103 Commits

Author SHA1 Message Date
45341a0bc8 feat(curator): switch Hermes Curator to DeepSeek V4-Pro via deepseek_local adapter
A/B test (2026-05-05) showed DeepSeek V4-Pro is 2-3x faster and ~20x cheaper
than Sonnet for style/lexicon pattern analysis, with comparable quality.
Adds adapters/deepseek-paperclip-adapter/ package, documents adapter requirements
(env injection, run-id headers), updates CLAUDE.md with adapter integration notes,
and records lessons from ערר 1200-25 (block order for 1xxx, "להלן מתוך" pattern,
expanded factual background, bridge planning analysis, flat heading structure).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 05:58:52 +00:00
d81c3c37ab fix(precedent-edit): translate appeal_subtype enum values to Hebrew
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 34s
The metadata extractor occasionally stuffs the practice_area enum
(``betterment_levy``, ``rishuy_uvniya``, ``compensation_197``) into
the free-text ``appeal_subtype`` column. The edit sheet then showed the
raw English string in the "תת-סוג" input.

When initialising the form, run the value through ``appealSubtypeLabel``
which maps known practice-area enum values to their Hebrew label and
returns anything else unchanged. The user can then edit normally; on
save the Hebrew sticks, so the next view is also clean.
2026-05-07 08:45:03 +00:00
fff2d1c859 fix(precedent-library): per-record extraction must drain the queue too
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m36s
reextract_metadata / reextract_halachot extract & apply but never cleared
metadata_extraction_requested_at / halacha_extraction_requested_at —
only the bulk worker (process_pending_extractions) did. Result: clicking
"חלץ מטא-דאטה" on the edit sheet (or calling precedent_extract_metadata
directly) left the row stuck in the queue forever, with the UI badge
showing "ממתין לחילוץ" even after extraction succeeded.

Mirror the worker's behaviour: on success ('completed' / 'no_changes' /
'no_halachot'), call db.clear_extraction_request to drain the queue.

Coolify deploy required for the FastAPI container; local MCP server
needs a process restart for the change to take effect (long-running).
2026-05-07 07:08:31 +00:00
36b78ea404 fix(precedent-library): queue listing must include internal_committee too
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m36s
Earlier commit afcc481 opened request_metadata_extraction and
request_halacha_extraction to all source kinds — but
list_pending_extraction_requests still hard-filtered to external_upload.

Result: stamping a queue request on an internal_committee row succeeded
silently, but the worker (and the queue badge) never saw it. Even with
the auto-wakeup added in c7132ba the CEO would wake, find 0 pending
items, and exit.

Drop the legacy filter so the queue listing matches the writer side.

Coolify deploy required for the FastAPI container to pick this up.
2026-05-07 06:51:19 +00:00
c7132ba0d2 feat(precedent-library): auto-trigger CEO wakeup on manual extract requests
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
The "חלץ מטא-דאטה" / "חלץ הלכות" buttons in the UI used to only stamp
the queue (set metadata_extraction_requested_at / halacha_extraction_requested_at)
and rely on a human running `mcp__legal-ai__precedent_process_pending` from
local Claude Code to drain it.

That left the user with an unintuitive two-step flow: click button → run
local MCP tool. Meanwhile, the upload endpoint already does the right
thing — after ingest succeeds it calls `pc_wake_for_precedent_extraction`,
which creates a Paperclip issue, assigns it to the CEO, and wakes them
to run `precedent_process_pending` automatically.

Add the same wakeup call to the manual request-metadata / request-halachot
endpoints. Now clicking the button is sufficient — the CEO picks it up
and drains the queue without manual intervention.

Best-effort: matches the upload flow's failure semantics. The queue stamp
still happens even if the wakeup fails, so the user can fall back to the
manual MCP tool when needed. The wakeup outcome is included in the
response under `wakeup` for observability.

Coolify deploy required for the FastAPI container to pick this up.
2026-05-07 06:48:51 +00:00
171da84680 feat(precedent-library): add halacha-extract button to library list rows
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m8s
When a precedent has not had successful halacha extraction yet, show a
small wand icon between the edit and delete buttons. Clicking it queues
the precedent for the local MCP worker (request-halachot endpoint).

Visibility rule (`needsHalachaExtraction`): show when text extraction is
complete AND halacha status is "pending without requested_at" (never
tried) or "failed" (allow retry). Hide while processing, after
completion, or when already queued — to avoid duplicate requests.

Pairs with the metadata-extract button on the edit sheet.
2026-05-07 06:30:03 +00:00
afcc4818a4 fix(precedent-library): allow re-extraction for internal_committee rows
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m13s
The "חלץ מטא-דאטה" / "חלץ הלכות" buttons in the UI were returning 404
for any precedent with `source_kind != 'external_upload'`. The original
restriction was meant to keep LLM extraction off internal-committee
imports (their metadata supposedly came from the case file system),
but the same precedent rows can still need re-extraction when ingest
produces broken data — e.g. the corrupted `subject_tags` value
`['[','"','ה','י',...]` that motivated this change (an early ingest
stored a JSON literal into a TEXT[] column, which Postgres split into
single chars).

Two changes here:

1. db.request_metadata_extraction / request_halacha_extraction:
   drop the `AND source_kind='external_upload'` filter. The extractor
   already preserves user values (only fills empty fields), so this
   is safe.

2. precedent_metadata_extractor.extract_and_apply: detect the
   character-by-character corruption above and treat it as empty so
   the freshly-extracted tags actually replace the broken ones.
   Heuristic: 3+ elements where every element is at most 2 chars
   (legitimate tags are multi-character Hebrew words).

Coolify deploy required for the FastAPI container to pick this up.
2026-05-06 19:44:13 +00:00
bd4b0ca766 feat(mcp): case_get_final_text — fall back to PDF/DOC/RTF/TXT/MD
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m58s
The Hermes Knowledge Curator's hermes-curator.md says it must be able to
read both DOCX and PDF final decisions. The original implementation
hardcoded the .docx extension only. Extend to try .docx → .pdf → .doc →
.rtf → .txt → .md, returning the first match. extractor.extract_text
already supports all six formats, so no extractor changes needed.

If none found, the not_found response now includes the tried_extensions
list so the caller knows what was attempted.

Verified on case 1130-25 (.docx still picked first) and tested via
`curator-cmp mcp test legal-ai`.
2026-05-05 19:18:57 +00:00
7c9582ed04 feat(mcp): case_get_final_text — let agents read the signed final DOCX
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m36s
The Knowledge Curator (Hermes) couldn't read סופי-{case}.docx because
document_get_text only works on rows in the documents table — the final
file is just a copy in the case's exports/ directory, not a tracked
document. CMP-71 hit this and produced an unproductive interaction
asking the user how to fix the access issue.

Add a new MCP tool that:
- Locates exports/סופי-{case_number}.docx via config.find_case_dir
- Extracts text using the existing extractor service (python-docx based)
- Returns JSON with status + text + page_count + truncation info
- Optional max_chars cap for large decisions

Smoke test on case 1130-25: 400-char preview returns proper Hebrew text
beginning with "לפנינו ערר על החלטת הוועדה המקומית...".

The local MCP server reloads on next Hermes spawn (stdio mode), so the
tool is immediately available — no Coolify deploy needed.

Curator's promptTemplate (DB-stored) updated to use the new tool as the
primary path for reading the final.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:57:10 +00:00
ea29778197 docs(hermes-curator): document interaction-driven conversation support
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 7s
The curator's promptTemplate (stored in DB) now teaches Hermes how to
post issue_thread_interactions instead of free-text comments. Three
patterns supported, curator picks per context:

- ask_user_questions for filtering findings (multi-select)
- request_confirmation for accept/reject of a single proposal
- suggest_tasks for proposing follow-up issues

Verified end-to-end on CMP-71: curator hit a real obstacle (couldn't
read the final DOCX from its container) and chose request_confirmation
on its own to ask the user how to proceed — exactly the conversational
behavior we want.

Paperclip auto-wakes the curator with $PAPERCLIP_APPROVAL_ID when the
user responds. The new prompt has a §B branch that handles the second
wake (read response → act → close).

The UI side was already built in d099470 (mirror Paperclip interactions
in case page) — now Hermes-side agents produce interactions too, not
just claude_local agents.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:24:57 +00:00
3be676e062 fix(api_mark_final): remove ingest_final_version call from container
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
ingest_final_version uses claude_session internally, which requires the
Claude CLI binary (not present in the legal-ai FastAPI container). The
call always failed with "Claude CLI not found" — caught by try/except
but noisy.

Replace with a static skipped status + comment pointing to the architectural
rule. Run ingest_final_version manually via Claude Code / MCP from the
local host when populating case_law is desired.

The curator wakeup hook remains and works correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:52:38 +00:00
799b950961 feat(curator): trigger Knowledge Curator from api_mark_final, drop CEO F2
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
The previous F2 stage in legal-ceo.md fired after the first DOCX export
— too early, since the user often iterates with עריכה-* uploads after
the first export. The true "this is dafna's chosen final" signal is the
"סמן כסופי" button in the UI, which calls api_mark_final.

This commit moves the curator wakeup from CEO's instructions to a
direct hook in api_mark_final:

- web/paperclip_client.py: add CURATOR_AGENTS dict (CMP + CMPA UUIDs)
  and wake_curator_for_final() helper. Looks up main case issue,
  creates a child issue assigned to the curator, tags plugin_state for
  case visibility, and triggers wakeup via Paperclip API.
- web/app.py: api_mark_final now calls workflow_tools.ingest_final_version
  (so case_law table finally gets populated for search_decisions) and
  pc_wake_curator_for_final. Both are best-effort — failure does not
  block marking final.
- legal-ceo.md: remove F2 stage, leave only the agents-table reference
  noting the curator runs from api_mark_final.
- hermes-curator.md: update activation description to reflect the new
  flow.

Result: curator runs only when chaim deliberately clicks "סמן כסופי",
on the actual final file, with no risk of analyzing a draft that will
later change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:47:03 +00:00
77e5996497 feat(agents): wire Hermes Knowledge Curator to CEO post-export (CMP + CMPA)
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m37s
Adds new sub-agent "מנהל ידע" (hermes_local adapter) that runs after
each successful export to analyze the final decision and suggest updates
to skills/decision/SKILL.md and lessons. Read-only on case data, write
only on a single comment per run.

- legal-ceo.md: new stage F2 after F (export). Looks up curator by name
  in current company, creates async sub-issue, no waiting. Falls back to
  silent skip if no curator configured.
- legal-ceo.md: agents table updated with both curator UUIDs (CMP + CMPA).
- hermes-curator.md: role instructions documenting CMP/CMPA split and
  what the curator does/does not do.

Stage 1 POC. End-to-end validated on CMP-68 (case 1130-25) with two
substantive findings on style patterns. CMPA agent created with separate
~/.hermes/profiles/curator-cmpa profile (own MEMORY.md focused on
היטל השבחה / פיצויים).

Known gaps to follow up: curator does not auto-close its issue, does
not auto-persist findings to MEMORY.md, comment attribution falls back
to chaim's user (install-key) — these are tracked separately and do
not block validation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 13:33:23 +00:00
69d4827f33 feat(migration): enrich internal committee entries — fix case_number + metadata + halachot
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m32s
- precedent_metadata_extractor: add case_number_clean extraction field
- apply_to_record: overwrite_case_number param for one-time migration
- internal_decisions: enrich_migrated_entries() — runs metadata then queues halachot
- server: expose as internal_decision_enrich MCP tool

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 18:59:20 +00:00
c0f67ab841 feat(precedents): split library into court rulings + appeals committee tables
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m34s
- /api/precedent-library now accepts source_kind param (default external_upload)
- list_external_case_law returns chair_name/district fields
- LibraryListPanel renders two separate tables with appropriate columns
- internal_decisions migration: added queue_halachot param to defer extraction
- Fixed practice_area mapping from style_corpus (appeals_committee → proper enum)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 18:49:32 +00:00
92a2763b86 feat: add internal committee decisions corpus (source_kind='internal_committee')
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m31s
Three-layer separation: style learning (style_corpus), appeals-committee decisions
(internal_committee), and court rulings (external_upload).

- SCHEMA_V10: chair_name + district columns on case_law and cases, partial indexes
- create_internal_committee_decision() DB upsert function
- search_precedent_library_semantic() now accepts source_kind/district/chair_name params
- search_precedent_library_hybrid() passes through new params
- services/internal_decisions.py: ingest_internal_decision, migrate_from_style_corpus,
  migrate_from_external_corpus (identifies rows via source_type='appeals_committee')
- search_internal_decisions() MCP tool (server.py + tools/search.py)
- internal_decision_migrate() MCP admin tool
- Web endpoints: POST /api/internal-decisions/upload, POST /api/internal-decisions/migrate,
  GET /api/internal-decisions
- ingest_final_version auto-ingests finalized decisions into internal corpus
- SKILL.md updated: agents now search internal + external in parallel, present separately

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 18:33:39 +00:00
1b14e04373 chore(skills): remove paperclip-dev, scope converting-plans-to-tasks
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 7s
paperclip-dev is for maintaining the Paperclip codebase itself — not
relevant to legal work. Removed from all 14 agents (was on CMPA mirror).

paperclip-converting-plans-to-tasks helps decompose a plan into assigned
issues. Useful for the planning-heavy agents (CEO, analyst). Now scoped
to those two — removed from the other 5 in CMPA where it had crept in.

Net effect: zero drift on paperclipai/* skills across all 7 master+mirror
pairs. Verified via the new Agents tab dashboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:47:05 +00:00
69e153b3db fix(settings/agents): exclude noise from drift detection
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 32s
Two false positives surfaced after the Agents tab went live:

1. status (running/idle/paused) is runtime state, not config — drops in
   and out as agents pick up issues. Removed from _DRIFT_FIELDS.

2. desiredSkills compared raw, but local/* and company/* skills carry
   per-company hashes/scopes by design (sync_agents_across_companies.py
   filters local skills with a warning). Comparing them flags every
   master+mirror pair that has any local skill on master.

Now compares only paperclipai/* skills (vendor-shipped, must match).
UI shows an inline note explaining the filter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:39:17 +00:00
702c01d678 chore(tasks): mark Task #29 done — Agents tab deployed to prod
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 36s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:29:30 +00:00
bd6a66e80d chore(types): regenerate OpenAPI types from prod
Some checks failed
Build & Deploy / build-and-deploy (push) Has been cancelled
Picks up the new GET /api/admin/paperclip-agents endpoint (Task #29) plus
any other endpoint changes accumulated since the last regeneration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:29:17 +00:00
af2dc0df2a chore(gitignore): ignore precedent-library data, .db files, .bak backups
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m36s
After committing the Paperclip gaps refactor, the .bak-pre-* sentinels
served their purpose. Add a wildcard so future similar backups won't be
tracked. Also ignore data/precedent-library/ (binary PDFs, 11MB) and
data/*.db (sqlite caches).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:26:20 +00:00
eab0ca906c feat(interim): include block-he opening in pre-ruling interim drafts
block-he (פתיחה ניטרלית) was previously emitted only in final decisions.
For interim drafts shown to the chair before ruling, including a neutral
opening helps the chair confirm framing before approving downstream blocks.
Skipped if empty, so legacy cases without block-he are unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:25:54 +00:00
cf5f6fe274 feat(paperclip): close 11 integration gaps (#16-#28)
Brings the legal-ai ↔ Paperclip integration in line with the official
Paperclip skill. Net effect: HEARTBEAT.md -47% (370→195 lines), all 14
agents on uniform runtime_config + budget + instructionsBundleMode, and
two cross-company helpers replacing manual SQL.

Highlights:
- HEARTBEAT.md refactor: project-specific only, delegates to the official
  paperclipai/paperclip skill (loaded per agent). Adds heartbeat-context
  fast-path (§1.7) and PAPERCLIP_WAKE_PAYLOAD_JSON shortcut (§1.5).
- Issue Thread Interactions API: legal-ceo.md now uses
  ask_user_questions / request_confirmation / suggest_tasks instead of
  free-text comments — gives chair structured UI with idempotency keys.
- pc.sh + paperclip_api.pc_request: every API call goes through helpers
  that inject Authorization + X-Paperclip-Run-Id (audit trail).
- sync_agents_across_companies.py: master(CMP)→mirror(CMPA) sync via
  Paperclip API, idempotent, with --verify and --apply modes.
- skills/new-company-setup: 11-step blueprint distilling all 11 gaps
  into a single onboarding runbook for the next company.
- .taskmaster: 12 tasks covering each gap (one already closed: #29).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:25:45 +00:00
6f713042b5 feat(settings): add Agents tab — read-only Paperclip agent config view
Task #29: surfaces all 14 agents (7 roles × 2 companies) in /settings as
master+mirror pairs with drift detection. Replaces ad-hoc psql + script
inspection with a single dashboard.

Backend: GET /api/admin/paperclip-agents — fetches via Paperclip API
(not direct DB), groups by name, computes drift across model/effort/
timeoutSec/maxTurnsPerRun/skills/runtime_config.heartbeat/budget/status.

Frontend: new AgentsTab card-per-pair with side-by-side compare,
drift highlighting, expandable details (skills list + instructions path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:23:48 +00:00
d0994704cf feat(agents): mirror Paperclip interactions in case page
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 47s
Surface issue_thread_interactions (ask_user_questions / request_confirmation /
suggest_tasks) directly inside legal-ai's case detail feed so the user can
answer agent prompts without switching to Paperclip's UI.

Backend (FastAPI):
- paperclip_client.py: 4 new helpers — get_issue_interactions (DB),
  respond_to_interaction / accept_interaction / reject_interaction (REST).
- app.py: extends GET /api/cases/{case_number}/agents to include
  `interactions`, and adds POST /api/cases/{case_number}/agents/interaction-response
  routing to /respond, /accept, /reject in Paperclip.
- paperclip_client.py: also pulls existing httpx calls onto the centralized
  pc_request helper (paperclip_api.py) for consistent auth + run-id headers.

Frontend (web-ui, Next.js 16 + TanStack Query):
- agents.ts: Interaction / InteractionPayload / InteractionStatus types,
  useSubmitInteraction mutation hook (invalidates the activity query).
- agent-activity-feed.tsx: InteractionCard renders radio (single) /
  checkbox (multi) for ask_user_questions, accept/reject + reason for
  request_confirmation, task selection for suggest_tasks. Resolved
  interactions show a read-only summary. Cards are interleaved with
  comments by created_at, so the feed reads chronologically.

Paperclip auto-wakes the issue assignee on a successful response
(queueResolvedInteractionContinuationWakeup) — no explicit wakeup needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 16:40:45 +00:00
82b29510f2 fix(settings): RTL Tabs + Hebrew labels (סביבה/כלים/בלוקים/רישומים)
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 34s
Radix Tabs defaults dir to 'ltr' if not set explicitly, which broke
RTL inside Tab content (cards flowing left-to-right). Set dir='rtl'
on the Tabs root and translate trigger labels to Hebrew (kept
Paperclip in English as a brand name).
2026-05-04 08:42:56 +00:00
e90faa9ba4 feat(settings): add Blocks tab — 12-block decision schema reference
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m35s
Read-only display of BLOCK_CONFIG from block_writer.py with CREAC role
and JWM functional-purpose annotations per block (sourced from
docs/block-schema.md).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 07:58:04 +00:00
ae35934383 feat(settings): wire frontend to Coolify SoT response shape
- McpEnvVar: infisical_value → coolify_value + has_duplicates
- McpEnvResponse: drop Infisical metadata fields
- EnvVarRow: 'Coolify:' label, 'ערוך ב-Coolify' external link
- DriftBadge: infisicalAvailable → coolifyAvailable
- EnvironmentTab: Coolify app badge, duplicates count
2026-05-04 07:53:27 +00:00
d1e12619d4 refactor(settings): pivot to Coolify env API as source of truth
Investigation showed legal-ai container has no INFISICAL_TOKEN and there
is no /legal-ai folder in Infisical — all env vars are stored in Coolify
and injected into os.environ at container start.

- Replace _read_infisical_values with _read_coolify_envs
- New: _coolify_authoritative_value picks among Coolify duplicates
- PATCH writes via Coolify API (upsert by key)
- Drift = Coolify-stored vs container-runtime (common: Coolify edited
  without redeploy)
- Response field renamed: infisical_value → coolify_value
- New 'has_duplicates' flag per row when Coolify has multiple entries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 07:50:02 +00:00
1cb832473c fix(settings): unknown drift state when Infisical unavailable + RTL drawer
- DriftBadge shows 'Unknown' (not 'Synced') when infisical_available=false
- Plumb infisicalAvailable from EnvironmentTab through EnvVarRow → DriftBadge
- Add dir='rtl' to ToolDetailDrawer SheetContent for Hebrew descriptions
2026-05-04 07:01:42 +00:00
89ce6c79d7 feat(settings): implement Registrations tab
Replaces stub RegistrationsTab with a full read-only view grouped by client.
Handles all 4 states: loading skeleton, fetch error, host_path_unavailable,
empty list, and populated data with per-registration detail rows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 06:50:12 +00:00
7e3c912899 feat(settings): implement Tools tab with detail drawer
Replaces stub ToolsTab with a grouped-by-module grid of clickable tool cards.
Adds ToolDetailDrawer (Sheet) showing name, description, module, source_location,
and params_schema for the selected tool.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 06:50:08 +00:00
f418686724 feat(settings): implement Environment tab with edit + drift detection
Add drift-badge, env-var-editor, env-var-row components and replace the
environment-tab stub; install shadcn Switch which was missing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 06:47:40 +00:00
8289b4d643 refactor(settings): split into tabs (paperclip + 3 stubs)
Extracts Paperclip companies + tag-mappings UI into PaperclipTab component,
adds stub tabs for Environment / Tools / Registrations, and replaces the flat
page.tsx with a shadcn Tabs layout to make room for Tasks 8-10.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 06:44:27 +00:00
6c129a1350 feat(settings): add MCP API hooks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 06:41:30 +00:00
320b9d3529 fix(settings): guard paperclip mcp.json type + sort registrations 2026-05-04 06:40:16 +00:00
394b971856 feat(settings): add MCP registrations endpoint + Coolify volume runbook
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 06:38:47 +00:00
1da3587334 fix(settings): log tool source resolution failures (no silent swallow) 2026-05-04 06:37:09 +00:00
272e49b6b0 feat(settings): add MCP tools introspection endpoint 2026-05-04 06:34:19 +00:00
69bdf7b30a fix(settings): harden PATCH/redeploy per code review
- Add infisicalsdk dependency
- Narrow update→create fallback to NotFound errors only (no silent swallow)
- Truncate Coolify error response text to 200 chars
- Add 60s cooldown to redeploy endpoint
- Move httpx to top-level import
2026-05-04 06:33:01 +00:00
2fe73fcce1 feat(settings): add PATCH env + Coolify redeploy endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 06:26:00 +00:00
c30c987ec2 fix(settings): suppress false drift when Infisical unreachable
- Add infisical_available flag to _build_env_var_row
- Stabilize error code (no exception text in API response)
- Document raw-comparison safety inline
2026-05-04 06:24:26 +00:00
562eae010a feat(settings): add GET /api/settings/mcp/env endpoint
Adds four helper functions (_infisical_client, _infisical_ctx,
_read_infisical_values, _build_env_var_row) and the /api/settings/mcp/env
endpoint that compares Infisical vs container env vars and reports drift.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 06:19:04 +00:00
a3ca32355a fix(settings): tighten coerce/normalize per code review
- reject non-integer floats in int coerce path
- document masking responsibility on to_public_dict
- use tuple for enum_values (immutable)
- treat empty string as None in normalize_for_compare
2026-05-04 06:17:22 +00:00
55a0eca070 feat(settings): add MCP env catalog with type validation
Static whitelist of 18 env vars (multimodal, rerank, halacha, general,
credentials, connection) with per-key type coercion, secret masking, and
drift-comparison helpers for the upcoming settings UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 06:11:32 +00:00
796f9d5f9c docs(plans): add implementation plan for MCP settings page
11 tasks across backend (catalog, env GET/PATCH, redeploy, tools introspection,
registrations) and frontend (tabs refactor, environment with drift detection,
tools drawer, registrations). Includes Coolify volume runbook.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 05:58:53 +00:00
70052b0133 docs(specs): add design for MCP settings page
Settings page extension to view and edit MCP server config (env vars,
tools, client registrations) — hybrid edit model: non-secrets editable
through Infisical, secrets read-only with drift detection vs container.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 05:44:31 +00:00
2f05cdea2e feat(precedents): add /precedents/[id] read-only detail page
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 34s
Global search rows linked to /precedents/<case_law_id> but no route
existed, so clicking a result hit a Next 404 and React threw hydration
error #418. New page reads /api/precedent-library/{id} and shows
metadata, summary/headnote/key_quote, subject tags, and the full
halachot roll-up. "ערוך פרטים" opens the existing PrecedentEditSheet
(no duplicate edit UX).

Extracted ExtractedHalachotSection + ReviewStatusPill from the edit
sheet into a shared component so both surfaces render the same block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 05:36:43 +00:00
bd1fb61655 feat(precedents): show extracted halachot in library edit sheet
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 35s
The "ספרייה" tab only exposed approved/total counts in a status pill;
to inspect the actual extracted halachot per case the chair had to use
the global "ממתין לאישור" tab, which only surfaces pending items, or
the MCP tool. Now the per-precedent edit sheet renders a read-only
roll-up of every halacha (approved + pending + rejected) with status
filter tabs and counts. Review actions intentionally stay in the
review tab to avoid duplicate approve/reject UX.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 05:24:25 +00:00
f6bb46dc4a fix(retrieval): restore _base(limit=) contract in hybrid precedent search
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m23s
`rerank.maybe_rerank` calls `base_search(limit=…, **base_kwargs)` on both
the rerank-on and rerank-off paths. Commit 242f668 moved the closure into
hybrid_search.py and renamed its parameter to `limit_inner`, so every call
to `/api/precedent-library/search` raised TypeError 500 regardless of the
VOYAGE_RERANK_ENABLED flag. Sibling `search_documents_hybrid` was unaffected
because it uses `lambda **kw:` which absorbs the kwarg.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 05:19:53 +00:00
36f21c815e fix(precedents): distinguish silent extraction failure from "no halachot"
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m5s
Observed 2026-05-03: a `precedent_process_pending(halacha)` run that
chained two precedents (1110/20 → 317/10) succeeded for the first
(9 halachot, 129 chunks) and produced status=`no_halachot` for the
second despite it being a 47KB Supreme Court ruling with rich legal
analysis. A manual single-precedent re-run on 317/10 immediately
extracted 53 halachot. Diagnosis: every chunk's claude_session call
in the back-to-back run silently failed (likely Anthropic rate-limit
storm after the 1110/20 token burn), and the empty list was reported
as "Claude looked and found nothing" — same code path as a real
0-halacha ruling. The user couldn't tell the difference.

Three changes:

1. Surface chunk-level failures (halacha_extractor.py)
   `_extract_chunk` now returns `(halachot, succeeded)` so the caller
   can count how many chunks crashed. `extract()` uses this to
   distinguish:
   - `no_halachot` — chunks ran cleanly, Claude found nothing
   - `extraction_failed` — ≥50% of chunks crashed AND zero halachot
     came back (rate limit, subprocess crash, etc.)
   When `extraction_failed`, DB status is left as 'processing' so the
   request stays in the queue for the caller to retry — instead of
   the old behaviour where it got marked 'completed' and silently
   dropped from the queue.

2. Inter-precedent cooldown (precedent_library.py)
   `process_pending_extractions` now sleeps 30s between precedents.
   Anthropic rate-limits per-org, and back-to-back large rulings
   (~4M tokens for 1110/20, immediately followed by another 2-3M)
   was the empirical trigger. 30s gives the per-minute counter time
   to drain.

3. Auto-retry on extraction_failed (precedent_library.py)
   When a precedent comes back as `extraction_failed`, retry once
   after a 60s cooldown before giving up. Rate-limit storms are
   transient — the manual re-run of 317/10 minutes later succeeded
   with 53 halachot and zero chunk failures, confirming a single
   retry is sufficient. Only retries `extraction_failed`; never
   `no_halachot` (Claude looked and there genuinely is no holding).

The DB status now ends up as 'failed' only after retries are
exhausted, matching the UI's terminal-failure chip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 05:13:10 +00:00
d4496b96f1 fix(mcp): eliminate "No such tool available" race at agent wakeup
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m26s
When Paperclip wakes the CEO and the model issues an mcp__legal-ai__*
call within ~10s of session init, Claude Code sometimes returns
"No such tool available" because the legal-ai MCP server hasn't
finished bringing up its tool catalog yet. Observed twice today on
CMPA precedent-extraction wakeups (sessions 9989fbaf and a9c61801);
the agent fell back to bash + .venv/bin/python and finished the work,
but the race needed fixing on the server side.

Three changes that close the window:

1. Lazy schema init (services/db.py + server.py)
   `init_schema()` was awaited inside the FastMCP lifespan, blocking
   the `initialize`/`tools/list` handshake until ~10 CREATE TABLE IF
   NOT EXISTS statements ran. Under contention (two CEOs waking at
   once for different companies) this stretched. Now the lifespan
   returns immediately and `get_pool()` runs the schema migrations
   exactly once on first DB access, guarded by an asyncio.Lock.
   tools/list is answered in milliseconds regardless of DB state.

2. Lazy heavy imports
   - services/embeddings.py: voyageai (~450ms) loaded only inside
     _get_client()
   - services/extractor.py: google.cloud.vision (~550ms) loaded only
     inside _get_vision_client() and _ocr_with_google_vision()
   These two were being imported at module top from
   legal_mcp.tools.documents -> services.processor -> services.{
   extractor,embeddings}, so the FastMCP server couldn't even start
   responding until both finished. Cold start dropped from 2.7s to
   1.17s end-to-end (init + tools/list response).

3. Agent-side warmup + retry guidance (.claude/agents/legal-ceo.md)
   Even with a fast server, the model can still race on the very
   first call. The precedent-extraction section now tells the CEO
   to call workflow_status as a warmup probe and to retry after a
   short sleep if it sees "No such tool available", before falling
   back to the python bypass.

Also expanded the precedent-tool whitelists on the sub-agents that
delegate halacha/library work (commits 4a9a6b7 + 7ee90dc added the
tools to the MCP server but only the CEO got them in its allowed
list). Added to: legal-researcher (full extraction set), legal-analyst
(library_get/list + halacha review), legal-writer (library lookups +
halacha_review), legal-qa (library_get + halacha_review), and the two
that the CEO was already missing (halacha_review, halachot_pending).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:23:14 +00:00
d12cdb1fad docs(voyage): mark stage C complete + record empirical fixes
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 10s
Stage C of the voyage-upgrades-plan shipped to production on
2026-05-03. The doc now leads with the final state and the two
empirical corrections vs the original plan:

1. Reciprocal Rank Fusion replaces weighted-sum hybrid merge.
   voyage-3 cosines (~0.4-0.5) systematically outscale
   voyage-multimodal-3 cosines (~0.20-0.25); a weighted sum lets
   text dominate even when image is the better signal. RRF is
   rank-based and robust to scale differences.

2. Chunker now propagates page_number end-to-end (extractor returns
   per-page offsets, chunker tags each chunk by its first character's
   page). A retrofit script backfills page_number on existing
   document_chunks without re-OCR — uses the stored
   documents.extracted_text plus PyMuPDF direct text reads as page
   anchors (linear interpolation for OCR-only pages).

Production state on cases 8174-24 + 8137-24: 419 page-image
embeddings, 819 chunks tagged with page_number, MULTIMODAL_ENABLED=true
in Coolify env, hybrid search verified A/B against text-only baseline.

The original stage C plan section is retained below for reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:16:13 +00:00
8a815ecff5 fix(retrieval): rewrite chunk-page retrofit to skip OCR
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 16s
The first-pass retrofit re-extracted via extractor.extract_text, which
re-runs Google Vision OCR on scanned pages. OCR is non-deterministic,
so the new text didn't match the chunk content stored in the DB
(produced by the original OCR run) — only ~7% of chunks were located.

New approach (no OCR cost):

1. Use the stored documents.extracted_text from the DB — the exact
   text the chunks were produced from, so chunk lookups match.
2. Anchor page boundaries via PyMuPDF direct text reads (free, no
   OCR). Pages with usable direct text are anchored by snippet match;
   OCR-only pages are linearly interpolated between anchors.
3. Search each chunk in extracted_text using a whitespace-tolerant
   helper — needed because the chunker joins paragraphs with single
   '\\n' while extracted_text uses '\\n\\n' as page separators.

Verified on 8174-24 (5 docs, 307 chunks) + 8137-24 (9 docs, 512
chunks): 100% chunks tagged, 13s total, $0 cost.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:04:33 +00:00
81ccf3a888 feat(retrieval): track page_number on text chunks for multimodal hybrid boost
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 6m33s
The legacy chunker did not track which PDF page each chunk came from.
Stored chunks had page_number=NULL, which blocked the multimodal
hybrid retriever's text+image boost — it joins (chunk, image) on
(document_id, page_number) and the join could never fire.

This change:

- extractor.extract_text now returns (text, page_count, page_offsets);
  page_offsets[i] is the start char offset of page (i+1) in the joined
  text. None for non-PDFs.
- chunker.chunk_document accepts an optional page_offsets and tags
  each chunk with the page that contains its first character (uses
  the existing chunker logic; pages assigned post-hoc by content
  search to keep the diff minimal).
- processor.process_document and precedent_library.ingest_precedent
  forward page_offsets through the chunker. New uploads now carry
  accurate page_number on every chunk.
- Other extract_text callers (tools/documents, tools/workflow,
  web/app.py) updated to unpack the third element (ignored).
- scripts/backfill_chunk_pages.py: per-case retrofit. Re-extracts each
  PDF (re-OCRs via Google Vision if needed, ~$0.0015/page), computes
  page_offsets, and updates page_number on every chunk by content
  search. Idempotent; --force re-runs on already-tagged docs.

Forward-only would leave the 419 image embeddings backfilled on
cases 8174-24 + 8137-24 unable to boost their corresponding text
chunks. The retrofit script closes that gap (cost ~$0.60).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:49:41 +00:00
5724ed8e5b chore: nudge Actions to build c31fe08 (RRF)
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 6m24s
Previous push to main did not trigger a workflow run; act-runner
went silent after task 112. Empty commit to re-fire the webhook.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:42:37 +00:00
c31fe0866b fix(retrieval): switch hybrid merge to Reciprocal Rank Fusion (RRF)
Some checks are pending
Build & Deploy / build-and-deploy (push) Waiting to run
Cosine scores in voyage-3 (~0.4-0.5) and voyage-multimodal-3
(~0.2-0.25) live on different scales. The previous weighted-sum
merge let text always dominate — verified empirically: 0 image-only
hits across 7 queries on case 8174-24, image side contributed nothing.

RRF combines by *rank* in each list rather than raw score, robust
to scale differences. Per-item score:

    rrf_score = text_weight / (k + text_rank)
              + image_weight / (k + image_rank)

A row that appears in both lists (joined on (id_field, page_number))
gets both terms — surfaced as match_type='text+image'.

After fix on 8174-24 (146 image rows): 2 image-only hits land in
top-5 across all 7 test queries, surfacing actual table/diagram/
signature pages (p12, p13 of שומת המשיבה for 'טבלת השוואת ערכי שומה',
p25 of שומת השגה for 'תרשים גוש וחלקה', etc).

On 8137-24 (273 image rows): 'חישוב היוון של דמי החכירה' goes from
0 baseline results → 5 hybrid results (3 text + 2 image), opening
recall on scanned content the OCR layer misses.

Default MULTIMODAL_TEXT_WEIGHT 0.65 → 0.5 (vanilla RRF) since the
prior 0.65 was tuned for raw cosine scales that no longer apply.
New env knob MULTIMODAL_RRF_K (default 60, standard literature).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:39:31 +00:00
242f668319 feat(retrieval): add voyage-multimodal-3 page-image embeddings (feature flag)
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m50s
Stage C: per-page image embeddings via voyage-multimodal-3 + hybrid
text+image search. Off by default; enable with MULTIMODAL_ENABLED=true.

- Schema V9: document_image_embeddings + precedent_image_embeddings
  (vector(1024), page_number, image_thumbnail_path)
- extractor.render_pages_for_multimodal renders PDF pages at
  MULTIMODAL_DPI (144) for embedding + JPEG thumbnails at
  MULTIMODAL_THUMB_DPI (96) for UI preview, in one pass
- embeddings.embed_images calls voyage-multimodal-3 in 50-page batches
- services/hybrid_search.py orchestrator: rerank applied to text side
  first (rerank-2 is text-only); image side cosine; weighted merge
  with text_weight 0.65 (env-tunable); image-only pages surface as
  match_type='image' so dense scanned content still appears
- processor.process_document and precedent_library.ingest_precedent
  gated by flag — non-fatal on multimodal failure
- scripts/multimodal_backfill.py — idempotent per-case CLI to embed
  existing documents without re-extracting text

Validated locally on a 5-page response brief: render 0.31s, embed 8.32s,
hybrid merge surfaces image rows correctly. Production rollout starts
with flag=false (no behavior change), then per-case A/B.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:24:52 +00:00
b9cdcf980d fix(precedents): translate practice_area slugs to Hebrew in halacha review
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 35s
The halacha-review panel was rendering raw slugs (`betterment_levy`,
`rishuy_uvniya`, `compensation_197`) as English badges. Pipe them through
the existing `practiceAreaLabel()` helper so the chair sees
"היטל השבחה", "רישוי ובניה", "פיצויים לפי ס' 197".

All other UI sites (library-list-panel, library-stats-panel,
precedent-edit-sheet) were already using the helper — this was the
sole place left rendering the raw slug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:13:48 +00:00
36e464f668 fix(halachot): exclude embedding from update_halacha RETURNING
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m26s
PATCH /api/halachot/{id} was returning 500 because the row included
``embedding`` as a numpy.ndarray of np.float32, which FastAPI's
jsonable_encoder cannot serialize (vars() and dict() both fail on it).

The bug had been latent — it triggered for the first time today after
the auto-approve batch left only low-confidence halachot for the chair
to review manually, and her first PATCH hit the unserializable response.

Replace ``RETURNING *`` with an explicit column list (everything except
``embedding``). Callers that need the embedding can re-fetch via
``get_halacha`` — but no current caller does.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:04:46 +00:00
4d1924c7e6 feat(halachot): auto-approve high-confidence halachot at insert
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m29s
Halachot extracted by halacha_extractor with confidence >= 0.80 are now
inserted with review_status='approved' instead of 'pending_review' —
they appear in search_precedent_library immediately. Halachot below the
threshold still require manual chair approval.

Threshold tunable via env (HALACHA_AUTO_APPROVE_THRESHOLD), defaults to
0.80. Rationale: 89% of historical extractions (356/400) score 0.80+,
spot-checks confirmed quality, and the manual review backlog was the
single biggest reason rerank-2 was returning passages-only on
ההבחנה-style queries.

After this change + the one-time backfill UPDATE, search now returns
9/10 halachot for "ההבחנה בין השבחה לפיצויים" instead of 0 — and the
top-3 are exact-match rules, not adjacent passages.

Reviewer field records "auto-approved (confidence ≥ X.XX)" with the
threshold value at insert time, for traceability.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:01:03 +00:00
26c3fddf41 feat(retrieval): add voyage rerank-2 cross-encoder stage (feature flag)
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m29s
Stage B of voyage-upgrades-plan rewritten: instead of context-3 (which
4 POCs showed inconsistent improvement), add a cross-encoder rerank
layer on top of voyage-3. Default off (VOYAGE_RERANK_ENABLED=false).

POC validation (785-doc corpus, 12 queries, claude-haiku-4-5 judge):
- mean@3 +4.5% (4.306 → 4.500)
- practical-category queries +11.6% (3.78 → 4.22)
- latency +702ms per query
- no schema change, no re-embed, no double storage

Plumbing:
- config: VOYAGE_RERANK_ENABLED / _MODEL / _FETCH_K env vars
- embeddings.voyage_rerank() wraps voyageai client.rerank
- services/rerank.py: maybe_rerank() helper — fetches FETCH_K candidates
  via the bi-encoder then reranks to top-K. Fail-open if Voyage rerank is
  unavailable.
- tools/search.py: search_decisions, search_case_documents,
  find_similar_cases all wrapped
- services/precedent_library.search_library wrapped

Smoke-tested locally with flag on/off — produces expected behaviour and
latency profile. Ready for production rollout via Coolify env flip after
deploy.

POCs (kept under scripts/ for reference):
- voyage_context3_poc{_long}.py — context-3 evaluation (rejected)
- voyage_multimodal_poc.py — multimodal-3 (stage C, deferred)
- voyage_rerank_judge_poc.py — single-case rerank benchmark
- voyage_rerank_corpus_poc.py — full-corpus rerank validation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:43:41 +00:00
688ba37d9c fix(ui): reorder + center the agent dropdown label
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 34s
In an RTL paragraph the bidi algorithm puts the *first* logical token
on the right, so "פתח דאשבורד Paperclip" rendered visually as
"Paperclip" on the LEFT — which reads as the *last* word in Hebrew
and looks like an afterthought rather than the brand name the menu
opens. Reorders to "Paperclip פתח דאשבורד" so Paperclip sits on the
right (read first) and centers the label so it sits above both items
instead of hugging the inline-start edge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:38:05 +00:00
b2985f88de fix(ui): use 3-column grid in header Row 1 for true viewport-centered search
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 33s
The previous flex layout used `flex-1` on the search wrapper, which
centers the search relative to the *remaining* space — so as the brand
subtitle grows ("עוזר משפטי · ערר 8137-24 · ניסוח") or the agent
trigger label changes, the search drifts off-center.

Switches Row 1 to `grid-cols-[minmax(0,1fr)_minmax(280px,460px)_minmax(0,1fr)]`:
brand on the right, search in the middle (anchored to the viewport
midpoint), agent dropdown on the left. The side cells flex equally so
the center stays put regardless of side content width.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:32:31 +00:00
01ea902156 fix(ui): stack agent dropdown items vertically to stop multi-line wrapping
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 34s
The previous layout used `justify-between` with the board name and the
prefix·hint hint on the same row. With Hebrew labels + the long hint
"תיקי 8xxx / 9xxx" the row overflowed the 220px content and wrapped the
hint into 2-3 lines, breaking visual alignment.

Stacks each item now: bold board name on top, dim prefix·hint underneath.
Adds whitespace-nowrap to both lines and bumps min-width to 240px so the
content drives the dropdown width instead of fighting it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:21:48 +00:00
cca17689de feat(ui): redesign header to two rows with grouped nav (Phase B)
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 32s
Splits the AppShell header into:
  Row 1 — brand: logo + dynamic context subtitle (route-aware) +
          global search + agent boards dropdown
  Row 2 — nav:   work group (בית · ארכיון) | knowledge group (ספריית
          פסיקה · אימון · מתודולוגיה) + admin dropdown (⚙) on the left

Three changes from the previous flat 8-item nav:

1. Grouping reflects intent. Daily-driver pages are in "work", corpus
   pages in "knowledge"; system pages (skills · diagnostics · settings)
   move into a single ⚙ dropdown so they stop competing for attention.

2. Subtitle is now dynamic. `headerSubtitle(pathname)` resolves the
   current section so the user always sees where they are without
   scanning the nav row. Case routes show the case number explicitly
   ("ערר 1234-24" / "ערר 1234-24 · ניסוח").

3. The gold-underline active state is preserved and the admin trigger
   inherits it whenever any admin route is active.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:15:20 +00:00
deb1a1eaf4 chore(api-types): regenerate after /api/search/cases
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 37s
Captures accumulated backend drift since last regeneration. Triggered
by the new /api/search/cases endpoint added for header global search,
but the diff also picks up many other endpoints that had been added
without re-running api:types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:10:57 +00:00
f722fa45bd feat(search): add header global search (Phase A) — cases + precedents + docs
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 41s
Adds an always-visible debounced search input in the AppShell header
that fans out to three independent sources in parallel and renders
per-source result groups with their own loading/empty/error states:

- /api/search/cases (NEW): SQL ILIKE on case_number, address, parties,
  title, subject. Returns small projections, no embeddings needed.
- /api/precedent-library/search (existing): semantic over case-law
  halachot + passages.
- /api/search (existing): semantic over case documents + past decisions.

Cmd/Ctrl+K focuses the input; Esc and click-outside close the panel.
This is Phase A of the header redesign — the bar layout itself is
unchanged; row grouping + dynamic context follow in Phase B.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:05:51 +00:00
cbdbc522a0 feat(ui): convert agent-mgmt link to dropdown for both Paperclip boards
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 35s
Replaces the hardcoded CMPA link with a dropdown listing both
Paperclip boards (CMP = רישוי ובניה, CMPA = היטלי השבחה). Fixes the
mislabeling where the original link pointed to the wrong board, and
gives the user a single entry point that scales if a third board is
added later.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:37:02 +00:00
6c727cb5d0 feat(ui): add CMPA agent dashboard link to header
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 32s
Adds a "ניהול סוכנים" link on the opposite side of the "עוזר משפטי"
title in the app shell header. Opens the Paperclip CMPA dashboard
(pc.nautilus.marcusgroup.org/CMPA/dashboard) in a new tab for quick
cross-tool navigation between the legal-ai workspace and agent ops.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:24:02 +00:00
923903217c feat(precedents): auto-trigger Claude extraction via Paperclip wakeup
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 7s
When a precedent is uploaded to the library, the FastAPI container now
fires a Paperclip wakeup so Claude (running locally as the CEO agent)
picks up the new row and runs `precedent_process_pending` for both
metadata and halacha extraction. The user no longer has to remember to
trigger it manually.

Mechanics:
- New `wake_for_precedent_extraction()` in paperclip_client.py creates
  (or reuses) a per-company "ספריית פסיקה — תור חילוץ" project, opens
  a fresh issue assigned to the company CEO with the case_law_id +
  citation in the description, and pings the Board API wakeup endpoint
  with `triggerDetail=precedent_library_upload`.
- ingest_precedent's _run() in app.py captures the returned case_law_id
  and best-effort calls the wake function (failures are logged, not
  surfaced — the upload itself stays clean).
- legal-ceo.md adds the precedent_process_pending tool family and a
  new "חילוץ פסיקה אוטומטי" section that tells the CEO to short-circuit
  past the heartbeat scan when woken with this trigger.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:49:25 +00:00
da0a385d9c docs: register reembed_voyage.py in SCRIPTS.md
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 7s
2026-05-03 16:44:07 +00:00
cb0b4b6a8b ops: switch embeddings to voyage-3 + plan for context-3 + multimodal-3.5
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 7s
Phase A — voyage-3 migration (executed):

- VOYAGE_MODEL=voyage-3 set in Coolify (legal-ai app) and ~/.env
- scripts/reembed_voyage.py: re-embeds document_chunks (6157),
  case_law_embeddings (9), precedent_chunks (385), and halachot (400)
  using the new model. paragraph_embeddings was empty. 6951 rows
  re-embedded in 93s, ~75 rows/sec.
- Same 1024 dim → no schema change needed.

Why voyage-3 over voyage-law-2: benchmark on 3 Hebrew legal queries
with real passages from the corpus gave voyage-3 perfect ordering on
3/3 tests AND the largest separation (+0.483 vs voyage-law-2's
+0.238). voyage-4 family had bigger separation but missed top-1 on
the hardest test.

Phase B (voyage-context-3) and Phase C (voyage-multimodal-3.5 for
scanned + appraiser docs) are designed in docs/voyage-upgrades-plan.md
but deferred — to be picked up in a fresh conversation. The plan
includes:
- Phase B: contextualized embeddings refactor (~49% recall lift on
  legal docs per Anthropic's research). Same dim, but ingestion
  pipeline must pass full doc context per chunk.
- Phase C: page-level image embeddings via voyage-multimodal-3.5,
  stored in a parallel *_image_embeddings table. Hybrid text+image
  search. Targets appraiser report tables and scanned PDFs where
  current OCR loses layout.

After this commit: MCP server needs a /mcp reconnect to pick up the
new VOYAGE_MODEL env, and the legal-ai container will pick it up on
its next redeploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:43:48 +00:00
72c4593e74 fix(precedents): auto-clear *_requested_at on terminal status
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m27s
set_case_law_extraction_status and set_case_law_halacha_status now NULL
the corresponding *_requested_at timestamp when status transitions to
"completed" or "failed". Without this, completed rows kept lingering in
the local-MCP work queue (which scans by `WHERE *_requested_at IS NOT NULL`)
and the UI's isPrecedentActive check, leaving them undeletable until a
manual SQL cleanup.

The pre-existing process_pending_extractions path already called
clear_extraction_request, but other paths (re-extraction, status set
during upload) didn't — so the cleanup belongs at the status setter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:39:24 +00:00
789cc273ee fix(precedents): allow delete when extraction completed but timestamp stale
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 32s
The local MCP worker is supposed to NULL `*_extraction_requested_at` after
a successful run, but in practice these timestamps linger. The previous
isPrecedentActive logic treated any non-null timestamp as "still active",
which left completed rows permanently undeletable.

Now only "processing" status (or genuinely queued: pending + timestamp)
counts as active. Once a row is "completed"/"failed", stale timestamps
no longer block the delete button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:24:16 +00:00
1f17419ee9 ui(precedents): live status pill with shimmer + auto-queue + auto-refresh
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m44s
The chair pointed out three UX gaps after uploading a new precedent:

1. The status said "מחלץ הלכות" but nothing was actually running — the
   field only meant "halacha_extraction_status != completed", which
   includes the post-upload "pending" state where the local MCP worker
   hasn't been told to drain anything yet. Misleading.

2. The page didn't refresh on its own. The chair had to F5 to see new
   counts after extraction completed.

3. Clicking the trash icon mid-extraction would cascade-delete the row
   while the extractor was still using it (FK errors, partial writes).

Fixes:

- ingest_precedent now auto-queues both metadata and halacha extraction
  on upload by stamping the request timestamps. The chair (or me) drains
  the queue with one `precedent_process_pending` call from chat —
  no need to click any button before that.

- StatusPill is now five-state with proper labels:
    "נכשל" (extraction_status=failed) — red
    "מעבד טקסט" — shimmer (extraction_status=processing)
    "בתור" — neutral (chunks queued, not yet running)
    "מחלץ הלכות" — shimmer (halacha_extraction_status=processing)
    "ממתין לחילוץ" — neutral (queued for local MCP worker)
    "לא חולץ" — neutral (pending without queue stamp — shouldn't happen)
    "X/Y מאושרות" — gold (done, with halachot count)
  The shimmer is a CSS-only sliding-stripe animation defined in globals.

- usePrecedents has a conditional refetchInterval — polls every 5s while
  any row is mid-extraction or queued, then stops once everything settles
  to completed/failed. New helper isPrecedentActive() centralises the
  "is this row mid-something" check so the UI and the destructive-action
  guard agree.

- Trash button is disabled (opacity 30%, tooltip explains) while the row
  is active. Pencil/edit stays enabled — editing metadata fields during
  extraction is safe (last write wins, low-stakes race).

Schema: list_external_case_law now exposes the two *_requested_at
timestamps so the UI can distinguish "queued" from "never asked".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:47:31 +00:00
4a9a6b7970 feat(precedents): UI button queues extraction for local MCP worker
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m27s
The chair wanted a one-click "extract metadata" button on the edit sheet.
The constraint stays the same — claude_session needs the local CLI which
the container doesn't have, so the button can't run the extractor itself.
Compromise: button stamps a queue marker; the local MCP server drains the
queue on demand.

DB (V8): two nullable timestamps on case_law,
metadata_extraction_requested_at and halacha_extraction_requested_at,
with partial indexes for cheap "find pending" scans.

API:
  POST /api/precedent-library/{id}/request-metadata   → stamp the row
  POST /api/precedent-library/{id}/request-halachot   → same for halacha
  GET  /api/precedent-library/queue/pending?kind=...  → read-only view

UI: Sparkles button in the edit sheet header. Click → toast tells the
chair what to run from Claude Code. The button never triggers the
extractor directly from the container.

MCP tool: precedent_process_pending(kind, limit) — runs from Claude Code
with the local CLI, picks up everything stamped, calls the extractor for
each, clears the timestamp on success. Failures keep the timestamp so the
next invocation retries them.

Architectural rule (claude_session local-only) is preserved end-to-end
and called out in the new endpoint comment + tool docstring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:32:25 +00:00
8e1384b897 fix(precedents): wrap citation column + extractor fills source_type
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m27s
Two follow-ups after running the metadata extractor on 403-17:

1. Library table: shadcn TableCell defaults to whitespace-nowrap and
   the table wrapper has overflow-x-auto, so the long citation forced
   a horizontal scrollbar inside the row. Override on the citation
   cell only — whitespace-normal + break-words + min/max-w to keep the
   column readable. Same for the case-name cell. Row aligns to top so
   wrapping doesn't push neighbours up.

2. Extractor now also fills source_type (court_ruling /
   appeals_committee). The previous round added decision_date_iso,
   precedent_level, and court but left source_type empty. Same
   closed-enum + merge-only-if-empty policy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:28:35 +00:00
6420fe4b0b feat(precedents): metadata extractor also fills date, level, court
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m26s
The first end-to-end run on 403-17 surfaced three fields the auto-fill
left blank because the chair didn't set them in the upload form: date,
precedent_level, and court. All three are right there in the ruling's
header text — there's no reason to require manual entry.

Prompt now asks for:
- decision_date_iso (YYYY-MM-DD parsed from "ניתנה היום, … 5 בספטמבר 2022"
  style signatures)
- precedent_level (closed enum: עליון/מנהלי/ועדת_ערר_ארצית/ועדת_ערר_מחוזית)
- court (the full court name from the title block)

Validation is unchanged: precedent_level only accepts the four enum
values; decision_date_iso is parsed into a Python date object before
being handed to update_case_law (asyncpg doesn't coerce strings to
DATE columns); court is stored verbatim.

Merge policy is unchanged — only fills empty fields. Anything the
chair typed in the upload form survives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:16:03 +00:00
fc3b6b6cae ui(precedents): collapsible groups by precedent + Hebrew labels + RTL fixes
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 33s
After running the dual-mode halacha extractor on a real appeals committee
decision (403-17), the pending-review tab surfaced 351 halachot in a
single flat list — the chair correctly pointed out that this is unusable
without grouping. Three fixes:

1. Group pending halachot by precedent (case_law_id). Each group shows
   the citation, court, date, level and item count; default state is
   collapsed so the chair picks one ruling at a time. Within a group,
   items still sort by confidence ascending so the doubtful ones surface
   first. J/K/A/R/E now scope to currently-expanded groups; toggling
   open auto-focuses the first item.

2. Translate the badges that were leaking English: rule_type values
   (`persuasive`, `interpretive`, `binding`, `application`, `procedural`,
   `obiter`) now render as Hebrew labels, and `confidence X.XX` becomes
   `ביטחון X.XX`. The card header no longer repeats the citation since
   it's already in the group header.

3. Strip Unicode bidi marks (U+200E/F/202A-E/2066-9) from displayed
   citations. Nevo PDFs and the upload form embed these in the
   case_number; they render as zero-width but visually push the text
   away from the right edge of the table cell. Also: hide the empty
   court line under the case name in the list (was rendering as a
   stray em-dash), and use a muted em-dash for empty date/level rather
   than blank/dash inconsistency across columns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:05:40 +00:00
2cfdf35191 refactor(precedents): keep all LLM calls on the local-MCP path
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m28s
Architectural correction: every claude_session caller in this project
runs through the local MCP server (~/.claude.json points at
/home/chaim/legal-ai/mcp-server/.venv/bin/python). The Coolify container
has no `claude` CLI and no claude.ai session, so any LLM call originating
from web/ FastAPI fails with "Claude CLI not found" — which is exactly
what we hit on 403-17.

The earlier Anthropic SDK fallback would have made it work, but at
direct API cost. The chair's preference is to stay on the claude.ai
session for everything. So:

- claude_session.py: removed the SDK fallback, restored CLI-only.
  The error message now points the next person at the architectural
  rule in the module docstring instead of papering over it.
- precedent_library.py:ingest_precedent (called from FastAPI on upload)
  now does only the non-LLM half: extract → chunk → embed → store.
  Sets halacha_extraction_status='pending' for the chair to act on.
- reextract_halachot / reextract_metadata kept, but lazy-import their
  extractors so the FastAPI path can't accidentally pull them in. They
  are reachable only via the MCP tools precedent_extract_halachot /
  precedent_extract_metadata, which run locally with CLI.
- Removed POST /api/precedent-library/{id}/extract-halachot and
  /extract-metadata — they were dead ends from the container.
- Dropped the `anthropic` Python dep that the SDK fallback required.
- UI: removed the "refresh halachot" and "sparkles metadata" buttons
  that called those endpoints. Edit sheet now points the chair at the
  MCP tool names instead.

Halacha and metadata extraction for an uploaded precedent now happen
when the chair (via Claude Code) runs:
  mcp__legal-ai__precedent_extract_metadata <case_law_id>
  mcp__legal-ai__precedent_extract_halachot <case_law_id>

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 11:06:08 +00:00
5d836ca414 fix(precedents): Anthropic SDK fallback, format() crash, UI refresh
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m31s
Three fixes to the precedent library after the first end-to-end test on
403-17 surfaced runtime issues:

1. Anthropic SDK fallback in claude_session. The legal-ai Docker container
   does not ship the `claude` CLI, so every halacha and metadata extraction
   was failing with "Claude CLI not found." Module now tries the CLI first
   (zero-cost local path) and falls back to the Anthropic SDK with
   ANTHROPIC_API_KEY when the binary is absent. Default model is
   claude-sonnet-4-6, overridable via CLAUDE_SDK_MODEL env. The system
   message gets cache_control: ephemeral so multi-chunk runs reuse the
   cached instruction prefix at ~10% read cost. Adds `anthropic` to
   pyproject deps.

2. precedent_metadata_extractor crashed with KeyError because the JSON
   example inside the prompt template contained literal { } characters
   that str.format() interpreted as placeholders. Switched to f-string
   concatenation; the prompt template no longer needs format() at all.

3. Library list query stays stale after upload because the upload
   mutation's onSuccess fires when the POST returns task_id, not when
   SSE reports completion. Added a second invalidate inside the SSE
   watcher in PrecedentUploadSheet so the new row appears with up-to-date
   chunk and halachot counts the moment processing finishes.

Halacha and metadata extractors now route the long static prompt through
the new `system=` parameter so the SDK path actually caches it; the CLI
path concatenates and behaves as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:52:31 +00:00
73a79ea7e8 feat(precedents): metadata auto-fill, edit sheet, persuasive extraction
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m28s
Three improvements to the precedent library based on usage feedback:

1. Auto-fill metadata at upload time. New service
   precedent_metadata_extractor reads the ruling's full_text and
   suggests case_name (short), summary, headnote, key_quote,
   subject_tags, appeal_subtype. The merge policy fills only empty
   fields, preserving everything the chair typed in the upload form.
   Wired into the ingest pipeline; also exposed as a re-run endpoint
   POST /api/precedent-library/{id}/extract-metadata for existing
   records.

2. Edit sheet in the UI. Pencil icon on each library row opens a
   pre-populated form covering every field. A Sparkles button on the
   sheet runs the metadata extractor on demand and refreshes the
   form. The case_number is read-only because halachot are FK'd to
   it; renaming requires delete + re-upload.

3. Halacha extractor branches on is_binding. Sources marked binding
   (Supreme/Administrative) keep the strict halacha prompt. Non-binding
   sources (other appeals committees, district courts on planning
   matters) get a different prompt that extracts applications,
   interpretive principles, and persuasive conclusions — labeled with
   new rule_types 'application' and 'persuasive'. The fallback also
   widens chunk selection: if the chunker labeled nothing as
   legal_analysis/ruling/conclusion, we now run on all chunks rather
   than returning zero halachot for a usable ruling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:19:35 +00:00
b51163b67c web-ui: shrink KPI card height on home dashboard
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 35s
Reduce vertical padding, number font size, and inter-element gaps so
the four counters take less vertical real estate. Width unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:46:27 +00:00
7ee90dce31 feat: external precedent library with auto halacha extraction
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m27s
Adds a third corpus of legal authority distinct from style_corpus
(Daphna's prior decisions for voice) and case_precedents (chair-attached
quotes per case). The new corpus holds chair-uploaded court rulings and
other appeals committee decisions, with binding rules (הלכות) extracted
automatically and queued for chair approval.

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:38:18 +00:00
a6edb75bbf web-ui: hide spurious horizontal scrollbar on case documents list
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m3s
The list's scroll container had only overflow-y:auto, which CSS computes
overflow-x to auto too. Combined with the row's -mx-2 hover-background
extension, this surfaced an unwanted horizontal scrollbar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:52:41 +00:00
e849285806 home: split cases table by appeal type + add appeal-type chart
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 32s
Backend (cases listing)
- /api/cases: also return updated_at, created_at, practice_area,
  appeal_subtype, subject. The detail-mode response was previously
  dropping these even though db.list_cases reads them, leaving the
  UI's "תחום" and "עודכן" columns blank.

Frontend
- Split the home table into two: רישוי (1xxx) and היטל השבחה ופיצויים
  (8xxx + 9xxx), bucketing on appeal_subtype with a case-number-prefix
  fallback. The "תחום" column is now redundant and removed.
- New AppealTypeBars chart in the right rail next to the existing
  status donut.
- Donut: switch to a vertical layout (donut on top, legend below in a
  3-col grid) so labels like "חדש / בעיבוד" no longer wrap inside the
  320px sidebar; counts now align in a tabular column.
- CasesTable accepts emptyText/searchPlaceholder so each split table
  has its own copy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:44:41 +00:00
f7249b7807 admin/skills: fail loud on DB error + read skills dir from env
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m8s
- Raise HTTPException(503) when Paperclip DB is unreachable instead of
  silently falling through to disk-only mode and returning [].
- Honor PAPERCLIP_SKILLS_DIR env var (falls back to ~/.paperclip/...).
  In the Coolify container the host's skills dir is bind-mounted at
  /paperclip-skills; without this, Path.home() resolved to /root/ and
  the disk inventory was always empty.

Both bugs together silently turned a Paperclip DB outage into "no skills
installed" on the /skills page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:24:39 +00:00
5deb38f5cf paperclip: assign CEO on issue creation so wakeup gate accepts run
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
Paperclip heartbeat staleness gate (heartbeat.js evaluateQueuedRunStaleness)
cancels queued runs when issue.assigneeAgentId !== run.agentId, with error
"issue assignee changed before the queued run could start". Older Paperclip
versions auto-assigned on wakeup; the current version does not, so issues
created with NULL assignee silently never run.

Set assignee_agent_id to the company's CEO at INSERT time. Affects both the
project setup issue and the "התחל תהליך ניסוח" workflow issue.
2026-05-01 15:32:22 +00:00
817d6e6d8d web-ui: raise proxy body limit to 100mb for large document uploads
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m9s
Default 10mb caused upload-tagged 500s on scanned PDFs in case 1027-26
(Next 16 truncates body, FastAPI sees broken multipart, socket hang up).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:12:41 +00:00
f256eddbb1 git_sync: full case-dir backup to Gitea (sweep + explicit commits)
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m25s
The case repo is the user's backup, so anything in the dir must end up
on Gitea. Two layers:

1. Periodic sweep (every 30s) — git_sync.sweep_loop runs as a FastAPI
   background task. It scans every case dir, runs git status --porcelain
   on each, and commit_and_push's any dirty changes with an auto-built
   Hebrew message ("אוטו: טיוטות (2) · מסמכים"). Catches files written
   outside the API path: agent research artefacts, manual edits, etc.

2. Explicit commits at known write paths — DOCX export, interim draft,
   apply_user_edit, revise_draft, mark-final, analysis DOCX export.
   These give immediate feedback with descriptive messages instead of
   waiting up to 30s for the sweep.

safe.directory injection added to _git_env so sweep + explicit commits
work even when the running uid differs from the case-dir owner (host
runs vs. uniform-root container).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 18:27:36 +00:00
6a38789379 docs+heartbeat: paperclip quirks + temp-file pattern + self-recovery
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 7s
Two latent issues surfaced today while watching the case 8174-24
end-to-end run, both worth documenting and engineering around because
they will recur on every future case.

Bug 1 — issue.released flips done→todo
  After an agent successfully PATCHes its issue to "done", Paperclip's
  internal issue.released action reverts the status to "todo" within
  ~30 seconds. This triggers a fresh wakeup of the same agent on a
  task that is already complete.
  Reproduced on CMPA-18 (30/04/26):
      18:14:57  agent PATCH → status: done
      18:15:35  Paperclip   → issue.released → status: todo
      18:15:54  new researcher run started
  The fix at the right altitude (Paperclip itself) is outside our repo.
  Mitigation in HEARTBEAT.md §3 — when an agent boots and finds the
  issue in `todo` while expected outputs (file, DB rows) already exist,
  it must short-circuit: post a "no change" comment, PATCH back to done,
  and exit. Costs ~$0.20 per false wakeup but breaks the loop.

Bug 2 — Bash backtick trap on long comment bodies
  Researcher agent built a curl pipeline like:
      curl ... -d "$(python3 -c "body = '''...
        📁 קובץ מחקר: `/path/to/file.md`
        '''")"
  The backticks around the file path (markdown convention) get
  evaluated by the OUTER bash $(...) as command substitution. Bash
  then tries to exec /path/to/file.md, which is not executable, and
  prints "Permission denied" — a misleading error since the actual
  file ownership is fine. The curl itself succeeded; only the bash
  prelude noised up the log.
  Fix in HEARTBEAT.md §4א: long bodies must go via Write→tempfile
  then `curl -d @file`. Avoids every shell quoting edge case.

Files:
  • docs/paperclip-quirks.md — new. Full writeup of both bugs plus
    two prior known-quirks (CEO auto-block in_progress, INSERT vs
    API for wakeups). Each section: what happens, empirical evidence
    from logs, impact, workaround, status.
  • .claude/agents/HEARTBEAT.md — added the self-recovery section to
    §3 and the temp-file pattern to §4א. The temp-file pattern is the
    canonical answer for any agent posting markdown comments —
    applies to all 7 agents in this skill set.
  • CLAUDE.md — referenced the new doc from the docs index.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 18:23:32 +00:00
fa70944ed4 case-create: surface Gitea repo result + UI retry button
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m29s
The auto-creation in case_create had two failure modes that combined to
make repos silently missing: a stale GITEA_TOKEN returning 401, and the
outer try/except in case_create that swallowed every exception with a
bare pass. Result: cases like 8174-24 ended up with a local git repo and
Paperclip project but no Gitea repo, with no signal anywhere.

_setup_gitea_remote now returns {ok, url, error} and never raises; the
result is attached to the case JSON and the FastAPI endpoint logs a
warning when ok=false. The UI gets a "צור ריפו ב-Gitea" button on the
case header that appears only when the repo or remote is missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 18:12:05 +00:00
7600810639 researcher: persist precedents to DB + save report to disk
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 14s
Two structural gaps in legal-researcher's "שלב 5: דיווח" surfaced while
auditing the case 8174-24 run:

  1. **No DB linkage.** The skill told the researcher to post a comment
     summarizing precedents but never to call mcp__legal-ai__precedent_attach.
     The MCP tool itself wasn't even in the tools frontmatter — so even
     a researcher that wanted to write to case_precedents physically
     couldn't. Result: 0 rows in case_precedents after a successful
     research run, even with 8 precedents identified and verified in
     the comment text. The writer then has to grep free-text instead
     of querying a structured table.

  2. **No persisted file.** Research output existed only as a Paperclip
     comment. The writer/QA can't `Read` it from disk; they have to go
     through Paperclip API to fetch comment bodies. Compare to the
     analyst, which is required to write `analysis-and-research.md`.

Fix:
  • Added precedent_attach, precedent_list, precedent_search_library
    to the tools frontmatter.
  • Rewrote step 5 with explicit ordering: save to disk → attach
    verified precedents to DB → update status → email → post comment.
  • Documented the precedent_attach call signature inline (case_number,
    citation, quote, section_id) so the agent doesn't have to reverse-
    engineer it. Includes guidance on which precedents to attach
    (verified with quote) vs which to leave for external verification.

Effect: future research runs will populate case_precedents and
data/cases/{N}/documents/research/precedent-research.md, both of which
the writer needs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:51:31 +00:00
47127f1e85 agents: close-own-issue PATCH for every agent (kill the retry loop)
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
The retry loop bug we fixed in legal-analyst yesterday existed in every
single sub-agent skill. They all post a comment + wake the CEO + exit,
leaving their own issue in `in_progress`. Paperclip's "in_progress with
no live execution" watchdog then re-wakes them, repeating until something
external transitions the issue. Watched it happen on CMPA-17 (researcher)
today — 4 iterations + manual SIGTERM + manual PATCH.

Same fix applied to all 5 remaining agents:
  • legal-researcher.md
  • legal-writer.md
  • legal-qa.md
  • legal-exporter.md
  • legal-proofreader.md (file was incomplete — also added the missing
    שלב 5: דיווח and wake-CEO sections to bring it to parity with the
    other agents)

Each gets a "סגור את ה-issue של עצמך — חובה!" section with two PATCH
templates: one for `done` after a successful run, one for `blocked` if
checks fail or output is incomplete. The section sits before the
wake-CEO block, with an explicit reference to the CMPA-17 incident so
the rule has a concrete anchor.

Result: every agent now has the same close-issue contract. No more
zombie in_progress issues, no more 4× wakeup loops.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:35:44 +00:00
a1969dd90d agents: fix analyst skill — appraiser_facts + close own issue
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 7s
Two structural bugs surfaced while monitoring the fresh end-to-end
run on case 8174-24:

1. **No appraiser_facts extraction.** legal-analyst.md's "what to
   extract" table didn't mention doc_type='appraisal' at all, and
   `extract_appraiser_facts` wasn't in its tools frontmatter. The
   CEO compounded this by writing in CMPA-16's body that all 3
   appraisals were "reference materials, do not extract" — which
   is correct for `extract_claims` but wrong for the appraisal-
   specific extractor. Result: 0 appraiser_facts in DB after a
   full run, even though the user had carefully tagged each
   appraisal's `appraiser_side` (committee/appellant) precisely
   so detect_conflicts could compare them.

2. **Issue stays in_progress, Paperclip retries forever.** Step 7
   ("שמירה ודיווח") instructed the analyst to update the *case*
   status, post a comment, send email, and wake the CEO — but
   never to PATCH the issue itself to `done`. Paperclip's
   "in_progress with no live execution" watchdog then re-woke the
   analyst, which posted "I'm done" again, which re-triggered
   another wakeup. We saw three iterations on CMPA-16 before the
   issue finally transitioned. The PATCH pattern was already
   documented in HEARTBEAT.md §4ב — the analyst skill just never
   referenced it.

Changes:
  • legal-analyst.md
    - Added mcp__legal-ai__extract_appraiser_facts to tools list.
    - Rewrote the "what to extract" table to use doc_type as the
      key column and added an `appraisal` row + a callout explaining
      why it goes through a different extractor.
    - Added explicit step 5 "חלץ עובדות שמאי" with the call.
    - Step 7 now PATCHes the issue to `done` (or `blocked` on
      failure) before waking the CEO. Refers to the actual incident
      so the rule has a concrete anchor.
    - Cleaned up the chunking guidance — phase 1 of claude_session
      already handles big docs automatically; no need to manually
      split.

  • legal-ceo.md (analyst issue template section)
    - Replaced the generic "list of docs not to extract from" with a
      per-doc_type action table that explicitly says
      `appraisal → extract_appraiser_facts (NOT extract_claims)`.
    - Added an explicit guard: "for every appraisal in the case,
      verify the issue body says to run extract_appraiser_facts —
      otherwise the writer gets a numbers-free block ז".
    - Added the close-the-issue-with-PATCH instruction so the CEO
      knows to write that into every analyst issue.

These edits don't affect the run currently in flight (the CEO's
prompt was already cached and the analyst already ran). They take
effect on the next analyst invocation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 16:57:49 +00:00
1fbcdd0d16 paperclip: auto-attach default workspace on project creation
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
Without a primary workspace on a project, the "סביבות עבודה" tab in
Paperclip stays hidden (gate: enableIsolatedWorkspaces && S0t list
non-empty), and agents wake with cwd=`/home/chaim` instead of the
legal-ai source tree. New helper inserts a primary workspace pointing at
LEGAL_AI_WORKSPACE_CWD (default /home/chaim/legal-ai) on both new and
legacy/existing-project paths. Idempotent — skips if any workspace row
already exists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:17:04 +00:00
cd4eed0045 docs: case-deletion runbook (legal-ai + Paperclip + Gitea)
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 7s
Captures the full deletion procedure we worked out empirically while
wiping case 8174-24 for a clean rerun. Covers all four systems where
case state lives, in dependency order:

  1. legal-ai DB + on-disk dir — DELETE /api/cases?remove_files=true
     (now actually works after 903fb4d added the missing db.delete_case)
  2. Paperclip DB — no API; raw SQL with explicit FK-blocker ordering
     (issue_comments, cost_events, finance_events, feedback_votes,
     issue_inbox_archives, issue_read_states must go before issues;
     heartbeat_runs.wakeup_request_id must be NULLed before
     agent_wakeup_requests can be deleted)
  3. Gitea — DELETE /api/v1/repos/cases/{N}
  4. Verification queries for each system

Two gotchas worth highlighting in the doc:
  • The case directory inside /data/cases is owned by root because the
    container runs as root — host-side rm needs sudo, or use the API
    (rmtree happens inside the container).
  • Paperclip projects are referenced via name LIKE '%{N}%' since
    there's no slug column. Stricter matching is recommended if N
    appears in multiple project names.

Linked from legal-ai/CLAUDE.md docs index. A future scripts/delete-case.sh
that automates the runbook with a confirmation prompt is noted as TODO
inside the runbook itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:54:21 +00:00
903fb4d140 db: add missing delete_case (cases_tools.case_delete was calling a ghost)
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m30s
The case_delete tool in tools/cases.py and the DELETE /api/cases endpoint
in web/app.py both invoke await db.delete_case(case_id), but no such
function existed in services/db.py — every call returned 500 with an
AttributeError. Discovered while wiping case 8174-24 for a clean rerun.

Implementation is straightforward because the FK graph already does the
work: 7 dependent tables CASCADE on cases.id (documents, document_chunks,
claims, appraiser_facts, decisions, qa_results, case_precedents) and 2
SET NULL (audit_log, chair_feedback). A single DELETE FROM cases is
enough — no manual ordering needed.

Documented in the docstring that this only touches the legal-ai DB —
Paperclip projects/issues and Gitea repos for the case are separate
systems and must be cleaned up by the caller.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:44:44 +00:00
28f49defff LLM session: async, 30min timeout, semantic chunking + parallel
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m28s
The claude_session bridge had two structural defects that made any
non-trivial document extraction unreliable:

  1. subprocess.run() blocks the asyncio event loop in the MCP server
     for the full duration of every LLM call (60-180s typical).
  2. The 120-second timeout was below the cold-cache cost of any
     document over ~12K Hebrew characters. Three back-to-back timeouts
     on case 8174-24 dropped 43 appellant claims on the floor.

Phase 1 of the remediation plan — keeps claude_session as the engine
(no Anthropic API switch) and restructures around it:

claude_session.py
  • query / query_json are now async — asyncio.create_subprocess_exec
    instead of subprocess.run, so MCP server can serve other coroutines
    while a call is in flight.
  • DEFAULT_TIMEOUT 120 → 1800 (30 min). High enough that no realistic
    document hits it; bounded so a runaway never zombifies forever.
  • LONG_TIMEOUT 300 → 3600 for opus block writing on full case context.
  • TimeoutError now actually kills the subprocess (asyncio.wait_for
    cancellation alone leaves the child running).

claims_extractor.py
  • _split_by_sections: chunks at numbered sections / Hebrew letter
    headings / "פרק" markers / markdown ##, falls back to paragraph
    breaks, then to hard splits. Targets 12K chars per chunk — small
    enough that each chunk reliably finishes inside the timeout.
  • _extract_chunk: per-chunk retry (1 attempt by default) with
    structured logging on failure. Failed chunks no longer crash the
    overall extraction; they're skipped with a partial-result warning.
  • extract_claims_with_ai now runs chunks in parallel via
    asyncio.gather bounded by a semaphore (CHUNK_CONCURRENCY=3).
    For a 25K-char appeal: was sequential 150-300s, now ~70-90s.

Updated all 9 callers (claims, appraiser facts, block writer, qa
validator, brainstorm, learning loop, style analyzer × 3) to await
the now-async API.

The one-shot scripts/extract_claims_8174.py used to recover 43
appellant claims on case 8174-24 has been moved to .archive/ — phase 1
makes it obsolete. SCRIPTS.md updated.

Phase 2 (background-task wrapper around LLM-bound MCP tools, persistent
llm_tasks table, SSE progress) is the structural follow-up — separate PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:21:35 +00:00
9bdfb05350 Upload progress: Redis-backed store + flushed SSE + client fallback
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m24s
The previous in-memory _progress dict + polling SSE handler had a 30s silent
tail after completion. HTTP/2 framing in the proxy chain (Traefik) buffered
the small chunks until the stream closed, so when a transient blip caused
EventSource to reconnect, the server returned 404 and the UI stuck on the
"מתחיל…" placeholder forever. Reproduced live: 445 bytes withheld 31s.

Changes:
  • web/progress_store.py — ProgressStore wraps Redis with TTL (5m), atomic
    GETDEL, dict-like API. Best-effort: Redis errors are logged and swallowed
    so observability outages don't break uploads.
  • web/app.py — _progress is now Redis-backed; every set/get/active/pop is
    awaited. SSE handler emits a heartbeat each tick (forces HTTP/2 flush),
    drops the 30s post-completion sleep, and returns a terminal
    {"status":"unknown"} payload instead of 404 when the task is gone — so
    EventSource closes cleanly instead of reconnect-looping. New _SSE_HEADERS
    set X-Accel-Buffering: no.
  • web-ui useProgress(taskId, caseNumber) — 10s fallback that invalidates
    the case detail if no SSE message arrived; treats "unknown" as terminal
    and triggers a refetch from the source of truth.
  • upload-sheet wires caseNumber through and renders "unknown" as completed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 12:53:23 +00:00
03e7d88aee DOCX exporter: 3-layer RTL + David font on all slots
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m30s
Hebrew was rendering LTR or in Times New Roman fallback in some Word
contexts. Root cause: incomplete RTL marking and missing font hints
on the run level.

Three layers of RTL are required (per skills/docx/SKILL.md):
1. Section: <w:bidi/> in sectPr (now inherited from template)
2. Paragraph: <w:bidi/> directly in pPr (paragraph direction)
3. Run: <w:rtl/> in rPr — tells Word to use cs (complex-script) font

Without an explicit font on the run, Hebrew renders in the ascii slot
(Times New Roman). Force David on all four slots (ascii / hAnsi / cs /
eastAsia) so every shaping path picks the correct font.

Changes:
- TEMPLATE_PATH now points to skills/docx/decision_template.docx
  (carries David, RTL, margins, styles); replaces hard-coded constants.
- _mark_run_rtl: writes rFonts on all four slots, then appends <w:rtl/>.
- _mark_paragraph_rtl: places <w:bidi/> directly in pPr (not nested in
  rPr — that was the bug), and adds <w:rtl/> to the paragraph-mark rPr.
- _set_paragraph_jc: forces explicit jc, overriding style-inherited.

Tests:
- test_mark_paragraph_rtl_adds_bidi_directly_in_pPr — guards against
  the regression where bidi was nested inside rPr.
- test_mark_run_rtl_forces_david_on_all_font_slots — ensures all four
  font slots are set, not just cs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:37:52 +00:00
4a297f910c Lessons from 1033-25 (clean acceptance — first in training corpus)
Comparison of our draft (טיוטה-v6, 2,126 words) against Dafna's final
decision (עריכה-v2, 2,299 words). 14 lessons (#20-#33) covering what
the draft got right and where she rebuilt the discussion.

Key findings:
- Lesson #20: Match doctrinal depth to legal uncertainty. In clean
  acceptance the committee's OWN conditions provide the anchor — no
  CREAC framework needed. The draft's 101-word "נבאר" doctrinal
  paragraph was deleted entirely.
- Lesson #21: Plant analytical seeds in the background ("ודוק"
  foreshadowing) for technical planning distinctions.
- Lesson #23: Concrete documentary evidence (specific permits in
  buildings 5, 7, 11) beats generic statements.
- Lesson #25: Counter-factual reasoning — "approved by mistake" gives
  the committee benefit of the doubt while strengthening reversal.
- Lesson #26: Engineer counter-factual — "had he known the shadow plan
  was not feasible, his opposition would have been even stronger".
- Lesson #27: "אכן...אולם" / "לא נעלם מעינינו" patterns are for
  rejection, NOT acceptance. Don't use prophylactically.
- Lesson #28: "ונפרט;" (ו prefix + semicolon), never "נפרט." with
  period.
- Lesson #33: Full acceptance against permit applicant → no expenses
  to either side.

New transition phrases catalogued: "דיון עקר", "אושרה מתוך טעות כי הרי
לא נוכל להניח כי אושרה למראית עין", "ועדת הערר אפשרה מרחב של זמן
בתקווה כי ההחלטה תתייתר", "להלן כדוגמא מתוך", "ברי כי הכוונה ל...".

Several of these lessons fed directly into daphna-acceptance-architecture.md
(template A) and daphna-decision-tree.md from the recent voice corpus
work; this file remains the case-study record.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:37:38 +00:00
132 changed files with 25899 additions and 1203 deletions

View File

@@ -1,160 +1,165 @@
# HEARTBEAT.md — רשימת ביצוע לכל ריצה # HEARTBEAT.md — רשימת ביצוע לכל ריצה (Project-Specific)
## שפה — כלל עליון > **🎯 קובץ זה — Project-specific only.** ה-skill הרשמי `paperclipai/paperclip/paperclip` (טעון אוטומטית בכל heartbeat דרך `paperclipSkillSync`) מכיל את כל ה-API patterns הגנריים: identity (`/api/agents/me`), `PAPERCLIP_WAKE_PAYLOAD_JSON`, `APPROVAL_ID`, inbox, comments, checkout, status updates, וכו'. **קובץ זה מתעד רק התאמות שלנו** — סינון חברה, helpers, workarounds, ו-quirks.
>
**כל הפלט שלך חייב להיות בעברית בלבד.** זה כולל: > **בקונפליקט:** קובץ זה גובר על ה-skill (project-specific מנצח default).
- Comments ב-Paperclip
- הודעות סטטוס
- תיאורי שגיאות
- סיכומים ודיווחים
- חשיבה פנימית (thinking)
אין יוצאים מן הכלל. גם שמות tools, פקודות, ונתיבי קבצים — ההסבר סביבם בעברית.
--- ---
הרץ את הרשימה הזו בכל heartbeat. ## שפה — כלל עליון
## 1. זיהוי וסינון חברה **כל הפלט שלך חייב להיות בעברית בלבד.** כולל: comments, סטטוס, שגיאות, סיכומים, ו-thinking פנימי. אין יוצאים מן הכלל. גם שמות tools, פקודות, ונתיבי קבצים — ההסבר סביבם בעברית. ה-skill הרשמי באנגלית — תרגם אם נדרש.
- וודא שאתה יודע מי אתה: `$PAPERCLIP_AGENT_ID` ---
- בדוק הקשר: `$PAPERCLIP_TASK_ID`, `$PAPERCLIP_WAKE_REASON`
- **זהה את החברה שלך**: `$PAPERCLIP_COMPANY_ID`
### ⚠️ סינון תיקים לפי חברה — כלל ברזל ## §0. כל קריאה ל-Paperclip API — דרך `pc.sh` בלבד
**אתה אחראי רק על תיקים ששייכים לחברה שלך.** הספרה הראשונה של מספר התיק קובעת: **ה-skill הרשמי משתמש ב-`curl` ישיר. אצלנו אסור.** משתמשים ב-helper שלנו:
| חברה | 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. בדוק תיבת דואר
```bash ```bash
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" "$PAPERCLIP_API_URL/api/agents/me/inbox-lite" ~/legal-ai/scripts/pc.sh <METHOD> <PATH> [BODY_JSON] [extra curl args...]
``` ```
- תעדוף: `in_progress` קודם, אחר כך `todo` מוסיף אוטומטית: `Authorization`, `X-Paperclip-Run-Id` (audit), `Content-Type`, base URL.
- אם `PAPERCLIP_TASK_ID` מוגדר — תעדף אותו
## 2b. קרא תגובות אחרונות על ה-issue
לפני שאתה מתחיל לעבוד, בדוק אם יש comments חדשים מחיים:
**דוגמאות:**
```bash ```bash
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ ~/legal-ai/scripts/pc.sh GET "/api/agents/me/inbox-lite"
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" | jq '[.[] | select(.authorUserId != null)] | .[-3:]' ~/legal-ai/scripts/pc.sh POST "/api/issues/$ISSUE_ID/checkout"
~/legal-ai/scripts/pc.sh PATCH "/api/issues/$ISSUE_ID" '{"status":"done"}'
``` ```
- אם יש comment מחיים (authorUserId, לא authorAgentId) שנכתב **אחרי** ה-comment האחרון שלך — **קרא אותו בתשומת לב** **ל-body גדול עם backticks**`Write` ל-temp file, אז `pc.sh ... "" -H "Content-Type: application/json" -d @/tmp/comment.json`. ראה §דיווח למה.
- אם ה-comment מכיל הוראות עבודה — **עקוב אחריהן**
- אם ה-comment מזכיר קובץ שהועלה — בדוק attachments (ראה 2c)
- אם ה-comment מבקש להעביר לסוכן אחר — **עצור**, פרסם comment שמאשר, והעֵר את ה-CEO
## 2c. בדוק קבצים מצורפים ---
אם comment מחיים מזכיר קובץ או טיוטה: ## §1. זיהוי וסינון חברה — כלל ברזל ⚠️
| חברה | COMPANY_ID | סוגי תיקים | טווח מספרים | CEO Agent ID |
|------|------------|-------------|---------------|---------------|
| ועדת ערר רישוי ובניה (CMP) | `42a7acd0-30c5-4cbd-ac97-7424f65df294` | רישוי ובניה | **1xxx** | `752cebdd-6748-4a04-aacd-c7ab0294ef33` |
| ועדת ערר היטלי השבחה (CMPA) | `8639e837-4c9d-47fa-a76b-95788d651896` | היטל השבחה + פיצויים ס' 197 | **8xxx, 9xxx** | `cdbfa8bc-3d61-41a4-a2e7-677ec7d34562` |
- אם `$PAPERCLIP_COMPANY_ID` = `42a7acd0...` → רק תיקים ש-**1xxx**
- אם `$PAPERCLIP_COMPANY_ID` = `8639e837...` → רק תיקים ש-**8xxx/9xxx**
- **אסור** ליצור פרויקט/issue/תוכן לתיק שלא בטווח שלך
- אם issue שהוקצה לך מכוון לתיק שלא בטווח — סרב בנימוס ב-comment, והעֵר את ה-CEO של החברה הנכונה
---
## §1.5. טיפול ב-wake (skill הרשמי + תוספות שלנו)
ה-skill מסביר `PAPERCLIP_WAKE_PAYLOAD_JSON`, `APPROVAL_ID`, ו-`heartbeat-context` (Step 6). הוסף עליו:
**1.5א. אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל comment חדש מחיים** — התייחס אליו ב-comment הראשון שלך ("ראיתי שביקשת X — מבצע Y") **לפני** עבודה רחבה. זה מבטיח שחיים יודע שקלטת.
**1.5ב. תמיד לקרוא `heartbeat-context`** — לא רק מה ש-skill ממליץ ("Prefer"). אצלנו ה-`attachments` המוחזרים חיוניים (חיים מעלה DOCX/PDF דרך comments). ראה §2.
```bash ```bash
PGPASSWORD="paperclip" psql -h 127.0.0.1 -p 54329 -U paperclip -d paperclip -c " CONTEXT=$(~/legal-ai/scripts/pc.sh GET "/api/issues/$ISSUE_ID/heartbeat-context?wakeCommentId=$LATEST_COMMENT_ID")
SELECT a.original_filename, a.content_type, a.object_key, a.byte_size ATTACHMENTS=$(echo "$CONTEXT" | jq '.attachments')
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}` **1.5ג. APPROVAL_ID flow** — אם חיים ענה על interaction (ראה `legal-ceo.md` §B/§C/§D), קרא תשובה דרך:
- קבצי DOCX — קרא אותם עם `Read` ```bash
- השתמש בתוכן הקובץ כקלט לעבודתך ~/legal-ai/scripts/pc.sh GET "/api/issues/$PAPERCLIP_TASK_ID/interactions/$PAPERCLIP_APPROVAL_ID" | jq '{status, kind, response}'
```
**אסור** לפענח טקסט מ-comment חופשי כשיש APPROVAL_ID — זה הקלט הסטרוקטורלי.
## 3. Checkout ועבודה ---
## §2. קבצים מצורפים — דרך `heartbeat-context`, **לא psql**
ה-attachments זמינים ב-`$CONTEXT.attachments` (מ-§1.5ב):
```bash ```bash
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ echo "$CONTEXT" | jq '.attachments[] | {filename, contentPath, contentType, byteSize}'
"$PAPERCLIP_API_URL/api/issues/{issue-id}/checkout"
# נתיב מלא לקובץ:
CONTENT_PATH=$(echo "$CONTEXT" | jq -r '.attachments[0].contentPath')
FULL_PATH="/home/chaim/.paperclip/instances/default/data/storage/$CONTENT_PATH"
``` ```
- עבוד על המשימה לפי ההוראות ב-AGENTS.md שלך קבצי DOCX/PDF — קרא עם `Read` tool ב-`$FULL_PATH`.
- השתמש בכלים המשפטיים (legal-ai MCP)
## 4. דיווח — חובה! ⚠️ **`psql` ישיר ל-`issue_attachments` — אסור.** ה-API הוא ה-source of truth (Gap #21).
**לפני שאתה מסיים, תמיד:** ---
### 4א. פרסם comment על ה-issue ## §3. self-recovery — `issue.released` bug
⚠️ **Paperclip quirk ידוע**: לאחר ש-issue מסומן `done`, מנגנון `issue.released` עלול להחזיר אותו ל-`todo` תוך ~30s, וגורם ל-wakeup חוזר על משימה שכבר בוצעה (תועד ב-`docs/paperclip-quirks.md §1`).
**לפני שמתחילים עבודה — בדוק שלא בוצעה כבר:**
1. **תוצרים בדיסק**: `Glob` על תיקיות output הצפויות (`{case_dir}/documents/research/*.md` לחוקר, `analysis-and-research.md` למנתח, וכו')
2. **תוצרים ב-DB**: דרך MCP — `precedent_list`, `get_claims`, `extract_appraiser_facts` (status=completed)
3. **comments קודמים** — חפש "הושלם בהצלחה" מסוף-מצב
**אם הכל קיים ותקין:** פרסם comment קצר ("אין שינוי — תוצרים קיימים מהריצה הקודמת"), `PATCH status=done`, צא נקי. **לא לעבוד פעמיים.**
**אם משהו חסר/שונה:** עבוד רק על מה שחסר.
---
## §4. דיווח — חובה!
**כל heartbeat שמסיים משימה:** comment + status + wake CEO. הסעיף הזה מתעד רק workarounds שלנו לא ב-skill.
### §4א. dual-comment workaround ל-`backtick trap`
**ל-body קצר (<500 תווים, בלי backticks/קוד/נתיבים)** — pattern רגיל:
```bash ```bash
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ ~/legal-ai/scripts/pc.sh POST "/api/issues/{issue-id}/comments" '{"body": "סיכום..."}'
-H "Content-Type: application/json" \
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" \
-d '{"body": "סיכום העבודה..."}'
``` ```
### 4ב. קבע סטטוס — done או blocked **ל-body ארוך עם markdown/backticks/נתיבים — חובה שתי פעולות נפרדות:**
**אם המשימה הושלמה בהצלחה** (כל המסמכים חולצו, כל הבדיקות עברו, אין חסימות): 1. כתוב את ה-JSON לקובץ זמני דרך **Write tool** (לא bash heredoc):
```bash ```
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ Write(file_path="/tmp/comment-{issue-id}.json",
-H "Content-Type: application/json" \ content=json.dumps({"body": markdown_body}, ensure_ascii=False))
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
-d '{"status": "done"}'
``` ```
**אם המשימה נכשלה או חסומה** (מסמך לא חולץ, timeout, חוסר מידע, שגיאה שלא ניתנת לפתרון): 2. אז `pc.sh` עם `-d @file` שקורא את הקובץ ישירות:
```bash ```bash
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ ~/legal-ai/scripts/pc.sh POST "/api/issues/{issue-id}/comments" "" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" -d @/tmp/comment-{issue-id}.json
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
-d '{"status": "blocked"}'
``` ```
**אסור** לסיים issue כ-"done" אם יש כשל שלא טופל. "done" = הכל הושלם בהצלחה. אם משהו נכשל — "blocked".
### 4ג. העֵר את העוזר המשפטי (CEO) — חובה! ⚠️ **למה לא bash heredoc / `python3 -c`:** backticks ב-markdown (`` `path/to/file` ``) ייפרשו על-ידי bash כ-command substitution גם בתוך מחרוזת Python. תקבל `Permission denied` מטעה. תועד ב-`docs/paperclip-quirks.md §2`.
אחרי כל סיום משימה (done או blocked), **העֵר את העוזר המשפטי של החברה שלך** כדי שיבדוק תוצאות ויחליט על הצעד הבא:
**⚠️ בחר CEO לפי חברה:** ### §4ב. סטטוס: `done` או `blocked` — לא ביניים
| חברה | COMPANY_ID | CEO Agent ID |
|------|------------|-------------| ```bash
| רישוי ובניה (CMP) | `42a7acd0-...` | `752cebdd-6748-4a04-aacd-c7ab0294ef33` | ~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}' # הצליח
| היטלי השבחה (CMPA) | `8639e837-...` | `cdbfa8bc-3d61-41a4-a2e7-677ec7d34562` | ~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}' # נכשל / חסום
```
**אסור** `done` עם כשל שלא טופל. אם משהו נכשל → `blocked` + comment עם פירוט.
### §4ג. wake CEO לפי חברה
**⚠️ CEO שונה לכל חברה** (ראה §1). UUID hardcoded **אסור** — תמיד דרך `$PAPERCLIP_COMPANY_ID`:
```bash ```bash
# קבע CEO_ID לפי חברה:
if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA
else else
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP
fi fi
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ ~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" \
-H "Content-Type: application/json" \ '{"source":"automation","triggerDetail":"system","reason":"סוכן [שם] סיים [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'
"$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:** ⚠️ **חובה `payload.issueId`** — בלי זה הסוכן מתעורר בלי הקשר (בלי תיק, בלי cwd).
1. **אסור** `INSERT INTO agent_wakeup_requests` — לא יוצר heartbeat_run, הסוכן לא יתעורר לעולם ⚠️ **wakeup לחברה אחרת נדחה** — `Agent key cannot access another company`.
2. **חובה** `payload.issueId` בכל wakeup — בלי זה הסוכן מתעורר בלי הקשר (בלי תיק, בלי cwd) ⚠️ **אסור** `INSERT INTO agent_wakeup_requests` ישיר — לא יוצר heartbeat_run, הסוכן לא מתעורר.
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. התראת מייל — כשנדרשת תשובה אנושית ## §5. התראת מייל — כשנדרשת תשובה אנושית
**כשהתוצאה דורשת החלטה או תשובה של חיים**, שלח מייל:
```bash ```bash
python3 /home/chaim/legal-ai/scripts/notify.py \ python3 /home/chaim/legal-ai/scripts/notify.py \
@@ -162,22 +167,29 @@ python3 /home/chaim/legal-ai/scripts/notify.py \
"תוכן ההודעה עם סיכום מה נדרש" "תוכן ההודעה עם סיכום מה נדרש"
``` ```
**מתי לשלוח תמיד:** **מתי לשלוח (תמיד):** סיום כל משימה (סיכום קצר), בקשת תוצאה/כיוון, QA fail, החלטה מוכנה לדפנה, מצב שדורש פעולה אנושית, שגיאה לא פתירה.
- **סיום כל משימה** — עם סיכום קצר של מה בוצע
- בקשה לקביעת תוצאה (דחייה/קבלה/חלקית)
- בקשה לאישור כיוון נימוק
- דוח QA שנכשל (צריך החלטה על תיקונים)
- החלטה מוכנה לביקורת דפנה
- כל מצב שדורש פעולה אנושית ולא יכול להתקדם לבד
- שגיאה שלא ניתן לפתור ללא התערבות
**מתי לא לשלוח:** **מתי לא:** עדכוני סטטוס ביניים, שגיאות טכניות שאפשר לפתור לבד.
- עדכוני סטטוס ביניים (רק בסיום)
- שגיאות טכניות שאפשר לפתור לבד
## 6. Release ---
## §6. Release
```bash ```bash
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ ~/legal-ai/scripts/pc.sh POST "/api/issues/{issue-id}/release"
"$PAPERCLIP_API_URL/api/issues/{issue-id}/release"
``` ```
---
## נתיבי API — הפניה ל-skill הרשמי
| פעולה | איפה ב-skill |
|--------|---------------|
| Identity, inbox, pick work | Step 1, 3, 4 |
| Wake payload + APPROVAL handling | Authentication + Step 2 |
| Heartbeat-context, comments, attachments | Step 6 |
| Checkout (with the `checkedOutByHarness` skip) | Step 5 |
| Comment, status update, exit | Step 7-8 |
| Routines, workflows, references | `references/` ב-skill |
**שינויים project-specific מה-skill:** תועדו בקובץ זה (§0 pc.sh, §1 חברה, §2 attachments, §3 quirk, §4 dual-comment + CEO wakeup, §5 notify).

View File

@@ -0,0 +1,146 @@
---
name: hermes-curator
description: Knowledge Curator (Hermes) — מנתח החלטות סופיות אחרי export, מציע עדכונים ל-skills/lessons. read-only על תוכן, write רק על comments.
adapter: deepseek_local
model: deepseek-v4-pro
profiles:
CMP: curator-cmp # רישוי ובניה (תיקים 1xxx)
CMPA: curator-cmpa # היטל השבחה + פיצויים (תיקים 8xxx, 9xxx)
---
> **Why DeepSeek**: A/B test 2026-05-05 הראה ש-DeepSeek V4-Pro חזק יותר מ-Sonnet
> על דפוסי סגנון/לקסיקון, פי 2-3 מהיר, פי ~20 זול. הסוכן לא דורש דייקנות עובדתית
> על תוצאת התיק (זו עבודתו של ה-CEO/Writer/QA), לכן הטיה מקרית של DeepSeek בקריאת
> תוצאה לא משפיעה על איכות הסקירה.
# מנהל ידע — Hermes Knowledge Curator
## רקע
אני סוכן Hermes Agent (לא Claude Code), מותקן בתור POC לבדיקה האם Hermes
מתאים יותר מ-Claude Code לתפקידי ניתוח עם זיכרון ארוך-טווח.
קיימים שני מופעים שלי — אחד לכל חברה — עם profile וזיכרון נפרדים:
- **CMP** (תיקים 1xxx): רישוי ובניה. profile=`curator-cmp`. UUID `60dce831-...`
- **CMPA** (תיקים 8xxx + 9xxx): היטלי השבחה ופיצויים. profile=`curator-cmpa`. UUID `d6f7c55d-...`
**איך אני מופעל:** דפנה לוחצת "סמן כסופי" בקובץ ב-UI של legal-ai →
`POST /api/cases/{case_number}/exports/{filename}/mark-final` רץ ב-`web/app.py`
הוא קורא ל-`pc_wake_curator_for_final()` ב-`web/paperclip_client.py` שיוצר
לי sub-issue ומעיר אותי. **לא דרך CEO** — חיבור ישיר מהאירוע ב-UI לסוכן.
זה מבטיח שאני מנתח את הגרסה האמיתית של דפנה, לא טיוטה אינטרמדיאטית.
ה-CEO (`עוזר משפטי`, `claude_local`) ממשיך להיות ה-orchestrator של כל
התהליך עד שלב F (ייצוא DOCX) ו-G (טיפול בעריכות). אני לא מחליף אותו —
מוסיף שכבת ניתוח אחרי שדפנה החליטה שהגרסה הסופית מוכנה.
**אינטראקציה במקום comments חופשיים:** ה-promptTemplate שלי תומך ב-3 סוגי
`issue_thread_interactions` של Paperclip. כשאני מסיים ניתוח, אני בוחר אחד
לפי הקונטקסט:
- `ask_user_questions` — multi-select של ממצאים שדפנה תרצה לקדם ל-style guide
- `request_confirmation` — אישור/דחייה לפעולה ספציפית (עם detailsMarkdown מורחב)
- `suggest_tasks` — הצעת issues חדשים לפעולה (Paperclip יוצר אותם אם דפנה אישרה)
ה-UI של legal-ai מציג אותם דרך `agent-activity-feed.tsx` (commit `d099470`):
רדיו / checkbox / accept-reject buttons. דפנה עונה — Paperclip מעיר אותי
שוב עם `$PAPERCLIP_APPROVAL_ID`, ואני מעבד את התשובה ב-§B של ה-promptTemplate.
## תפקיד
לאחר שכל החלטה סופית מיוצאת ל-DOCX, אני נקרא לסקור אותה. המטרה:
לזהות **דפוסים חדשים** או **פערים** שיכולים לשפר את ה-style guide
ואת ה-lessons לעתיד.
יו"ר הוועדה היא עו"ד דפנה תמיר. **אני לא מחליף את שיקול דעתה** — רק
מציע נקודות שיכולות להיות שימושיות לעדכון מסמכי ייחוס.
## מה אני עושה בכל wake
1. קורא את ה-issue body שב-`{{taskBody}}` — שם התיק + ID של ההחלטה הסופית
2. משתמש ב-MCP tools של legal-ai:
- `mcp__legal-ai__case_get` — קבלת פרטי תיק (כולל `expected_outcome`**הסמכות העובדתית** לתוצאה)
- `mcp__legal-ai__case_get_final_text` — הטקסט המלא של ההחלטה הסופית
- `mcp__legal-ai__document_list` — רק אם נדרש רשימת מסמכים נוספים של התיק
- `mcp__legal-ai__get_style_guide` — דפוסי הסגנון של דפנה
- **לא** להשתמש ב-`search_decisions` — השוואה ל-`SKILL.md` ו-`corpus-analysis.md` מספיקה ולא יקרה
3. קורא קבצים מקומיים (read-only):
- `/home/chaim/legal-ai/skills/decision/SKILL.md`
- `/home/chaim/legal-ai/docs/legal-decision-lessons.md`
- `/home/chaim/legal-ai/docs/corpus-analysis.md`
4. מעדכן את `~/.hermes/profiles/curator-cmp/memories/MEMORY.md` עם ממצאים
(Hermes שומר אוטומטית — אני יכול גם להשתמש ב-memory tool)
5. כותב comment על ה-issue הזה דרך Paperclip API:
```
POST {{paperclipApiUrl}}/issues/{{taskId}}/comments
Authorization: Bearer $PAPERCLIP_API_KEY
{ "body": "<my findings>" }
```
6. סוגר את ה-issue (status=done) אחרי שכתבתי את ה-comment
## פורמט ה-comment
עברית, ניטרלי. 3-5 ממצאים מובחנים. **כל ממצא חייב להיות מתויג** באחד מ-4 הסוגים:
```
[סגנון] — מילים, ביטויי מעבר, פתיחות, סיומים
[מבנה] — סדר בלוקים, יחסי אורך, מספור
[לקסיקון משפטי] — מינוח טכני (מגישי תכנית, ריפוי פגם, וכו')
[טבלאי] — דפוסים שמופיעים פעמיים+ ב-corpus
```
לכל ממצא:
- **מה ראיתי** — תיאור קצר של הדפוס/הפער
- **מה זה אומר** — למה זה חשוב
- **הצעה** — איך אפשר להוסיף ל-style guide / lessons (טקסט מוצע מילולי)
אם אין ממצאים חדשים → לציין במפורש בלי להמציא.
## מה **לא** להגיד ב-comment
- **אל תכלול שורת מטא** בראש ה-comment עם "תוצאה: X" או "אורך: ~Y תווים".
אתה לא בודק את התיק — אתה בודק את הסגנון. תוצאה מוטעית בראש ה-comment פוגעת באמינות.
- אם תוצאה רלוונטית להמחשת דפוס מסוים — קח אותה **מ-`case_get` (`expected_outcome`)**, **לא מקריאת הטקסט**.
אם השדה ריק או חסר ב-DB — סמן `[תוצאה: לא מאומתת]` או דלג עליה.
- **אל תפרש משפטית** את ההחלטה. דפנה כבר הכריעה. תפקידך זיהוי דפוסים בלבד.
## מה אני לא עושה
- **לא מעדכן** קבצים בעצמי (skills/, lessons.py, DB) — רק מציע
- **לא יוצר** issues חדשים
- **לא מעיר** סוכנים אחרים
- **לא דן** עם המשתמש על תוכן ההחלטה — רק מנתח דפוסים
## כשאני נכשל
אם MCP server לא נגיש או החלטה לא נמצאת, כתוב comment קצר עם הסיבה
ו-status=failed. אל תזייף ממצאים.
## דרישות מ-`deepseek_local` adapter (חובה)
ה-adapter שמריץ אותי **חייב** להזריק 3 דברים בכל wake — אחרת interactions ייחסמו ב-`401 "Agent run id required"`:
1. **env `PAPERCLIP_API_KEY`** — agent's own pcp_ key
2. **env `PAPERCLIP_RUN_ID`** — ה-`heartbeat_runs.id` של ה-wake הנוכחי
3. **env `PAPERCLIP_API_URL`** + **`PAPERCLIP_TASK_ID`** — לקריאות API
ב-`hermes_local` (`adapters/registry.ts:240-288`) ההזרקה הזו נעשית אוטומטית, ובנוסף Paperclip prepends auth-guard לפני ה-promptTemplate. ב-`deepseek_local` החדש — לוודא שמיושם.
ה-promptTemplate **כבר** כולל את ה-header `X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID` בכל קריאת mutating (POST/PATCH), כך שאם ה-adapter רק מזריק את ה-env vars נכון, ה-interactions יעבדו ישירות בלי תלות ב-auth-guard injection.
### Verification:
```bash
# על תיק חי, אחרי שדפנה לוחצת mark-final, ה-curator יקבל:
echo "PAPERCLIP_RUN_ID=$PAPERCLIP_RUN_ID" # חייב להיות UUID חוקי
echo "PAPERCLIP_API_KEY=${PAPERCLIP_API_KEY:0:8}..." # חייב להתחיל ב-pcp_
echo "PAPERCLIP_API_URL=$PAPERCLIP_API_URL" # חייב להיות http://localhost:3100/api
```
## קונטקסט קבוע (לא לשכוח)
- היו"ר: עו"ד דפנה תמיר
- חברה: ועדת ערר רישוי ובניה (CMP, תיקים 1xxx)
- שפה: עברית בלבד
- 24 החלטות במאגר האימון, 12-block architecture, סגנון דפנה
- אני קורא מ-MEMORY.md בכל wake — שם הקונטקסט שלי מצטבר

View File

@@ -14,9 +14,15 @@ tools:
- mcp__legal-ai__document_list - mcp__legal-ai__document_list
- mcp__legal-ai__document_get_text - mcp__legal-ai__document_get_text
- mcp__legal-ai__extract_claims - mcp__legal-ai__extract_claims
- mcp__legal-ai__extract_appraiser_facts
- mcp__legal-ai__get_claims - mcp__legal-ai__get_claims
- mcp__legal-ai__search_case_documents - mcp__legal-ai__search_case_documents
- mcp__legal-ai__search_decisions - mcp__legal-ai__search_decisions
- mcp__legal-ai__search_precedent_library
- mcp__legal-ai__precedent_library_get
- mcp__legal-ai__precedent_library_list
- mcp__legal-ai__halacha_review
- mcp__legal-ai__halachot_pending
- mcp__legal-ai__find_similar_cases - mcp__legal-ai__find_similar_cases
- mcp__legal-ai__workflow_status - mcp__legal-ai__workflow_status
- mcp__legal-ai__processing_status - mcp__legal-ai__processing_status
@@ -67,12 +73,15 @@ tools:
## סוגי מסמכים — מה לחלץ ומה לא ## סוגי מסמכים — מה לחלץ ומה לא
| סוג מסמך | מה לחלץ | claim_type | | סוג מסמך (doc_type) | מה לחלץ | באיזה כלי |
|-----------|----------|------------| |----------------------|----------|------------|
| כתב ערר | **טענות** — מה העוררים טוענים | claim | | `appeal` | **טענות** — מה העוררים טוענים | `extract_claims` (claim_type=claim) |
| כתב תשובה | **תשובות** — מה המשיבים/ועדה עונים | response | | `response` | **תשובות** — מה המשיבים/ועדה עונים | `extract_claims` (claim_type=response) |
| תגובה / השלמת טיעון | **תגובות** — תשובות לתשובות | reply | | `reply` / השלמת טיעון | **תגובות** — תשובות לתשובות | `extract_claims` (claim_type=reply) |
| פסיקה / תכנית / פרוטוקול / היתר | **אל תחלץ כלום** — מסמכי רקע בלבד | — | | `appraisal` | **עובדות שמאי** — מספרים, מקדמים, עסקאות השוואה, מסקנות שווי | `extract_appraiser_facts` |
| `reference` / `plan` / `protocol` / `permit` / `decision` / `court_decision` | **אל תחלץ כלום** — מסמכי רקע בלבד | — |
> **הבחנה קריטית — שומה אינה כתב טענות.** שומה (`appraisal`) היא חוות דעת מקצועית, לא טיעון משפטי. **לא** מריצים עליה `extract_claims` — מריצים `extract_appraiser_facts` שמחלץ נתונים כמותיים מובנים (שווי, מקדמים, עסקאות). זאת קלט מהותי לבלוקים ז ו-י של ההחלטה. **דילוג עליה = פלט חסר**.
## תהליך עבודה — 4 שלבים ## תהליך עבודה — 4 שלבים
@@ -85,9 +94,10 @@ tools:
- **הצדדים**: מי העורר, מי המשיב, מי צד ג' - **הצדדים**: מי העורר, מי המשיב, מי צד ג'
- **המסגרת הנורמטיבית**: חוקים, תקנות, תכניות רלוונטיות — **קרא את המסמכים הנורמטיביים במלואם** (לא רק הסעיף הנטען; מילה בסעיף אחד מתפרשת לאור סעיפים אחרים באותו מסמך) - **המסגרת הנורמטיבית**: חוקים, תקנות, תכניות רלוונטיות — **קרא את המסמכים הנורמטיביים במלואם** (לא רק הסעיף הנטען; מילה בסעיף אחד מתפרשת לאור סעיפים אחרים באותו מסמך)
4. חלץ טענות/תשובות/תגובות (`extract_claims` עם doc_type ו-party_hint מתאימים) 4. חלץ טענות/תשובות/תגובות (`extract_claims` עם doc_type ו-party_hint מתאימים)
- **מסמך גדול (>15,000 תווים):** פצל לחלקים לפי פרקים/סעיפים וחלץ מכל חלק בנפרד. אל תשלח מסמך שלם של 20K+ מילים בקריאה אחת — זה יגרום ל-timeout. - **מסמך גדול (>15,000 תווים):** מאז phase 1 של מערכת הניתוח, ה-chunking הסמנטי + מקבילות + retry מטופל אוטומטית. גם מסמך של 100K+ תווים ירוץ עד הסוף. אם בכל זאת נכשל — דווח ב-issue.
- **אם extract_claims נכשל (timeout):** נסה שוב עם חלק מהמסמך. אם עדיין נכשל — חלץ ידנית: קרא את הטקסט (`document_get_text`), זהה את הטענות המרכזיות, והכנס ל-DB. - **טיפול בכשל:** אם `extract_claims` החזיר `partial=true` או 0 טענות ממסמך לא ריק — נסה שוב פעם אחת. אם עדיין נכשל — סטטוס issue = `blocked`, פרסם comment עם הפירוט.
5. וודא שכל פריט מסווג ל-claim_type הנכון 5. **חלץ עובדות שמאי** — לכל מסמך `doc_type='appraisal'` בתיק, הרץ `extract_appraiser_facts(case_number)` (פעם אחת לתיק, מטפל בכל השומות). **חובה בכל ערר השבחה (8xxx) ופיצויים (9xxx) — בלי זה ה-writer לא יוכל לכתוב את בלוק ז עם מספרים מדויקים.**
6. וודא שכל פריט מסווג ל-claim_type הנכון
### שלב 2: ניתוח מעמיק ### שלב 2: ניתוח מעמיק
הצג במבנה הבא: הצג במבנה הבא:
@@ -160,11 +170,75 @@ tools:
- **לא להמציא פסיקה** — אם יש אזכור במסמכי התיק, ניתן להתייחס. אם לא — נסח ללא הפניה - **לא להמציא פסיקה** — אם יש אזכור במסמכי התיק, ניתן להתייחס. אם לא — נסח ללא הפניה
- שימוש במונחים מקובלים בפסיקה הישראלית (מתאים לחיפוש ב-nevo/law-mate) - שימוש במונחים מקובלים בפסיקה הישראלית (מתאים לחיפוש ב-nevo/law-mate)
## שלב 5: חיפוש פנימי בקורפוס ## שלב 5: חיפוש בשלושת הקורפוסים — חובה, עם תיעוד queries
חפש תקדימים רלוונטיים בקורפוס הפנימי:
- `search_decisions` — בהחלטות קודמות של דפנה **חובה לבצע** — לא הצעה. בלי השלב הזה הניתוח חסר תקדימי-עליון רלוונטיים, וה-writer לא יוכל לכתוב CREAC מלא. נבחן ב-QA.
- `find_similar_cases` — תיקים דומים
הוסף תוצאות רלוונטיות תחת כל סוגיה כ-"תקדימים מהקורפוס הפנימי". ### 5א. חיפוש בקורפוס הסמכותי (`search_precedent_library`) — חובה
לכל **טענת סף** ולכל **סוגיה מרכזית** שזיהית — הרץ לפחות שאילתה אחת ל-`search_precedent_library` עם פילטרים:
| סיווג תיק | practice_area |
|------------|---------------|
| 1xxx (רישוי ובניה) | `rishuy_uvniya` |
| 8xxx (היטל השבחה) | `histael_hashbacha` |
| 9xxx (פיצויים ס' 197) | `pitsuim_197` |
אם הסוגיה מאוזכרת ב-`appeal_subtype` ידוע (כמו "שימוש חורג", "חריגות בנייה", "סטייה ניכרת") — הוסף `appeal_subtype` לפילטר. צמצום מוקדם > הרחבה מאוחרת.
דוגמה:
```
search_precedent_library(
query="שימוש חורג מסחרי בייעוד נופש",
practice_area="rishuy_uvniya",
appeal_subtype="שימוש חורג",
limit=10
)
```
### 5ב. חיפוש בקאנון של דפנה (`search_decisions`)
לכל סוגיה — הרץ `search_decisions` כדי למצוא החלטות קודמות של דפנה באותה קטגוריה. אם דפנה כבר הכריעה בסוגיה דומה — תקדם אישי הוא חלק חובה מההנמקה (חיסכון או הבחנה).
### 5ג. תיקים דומים (`find_similar_cases`)
לכל סוגיה מרכזית — הרץ `find_similar_cases` לזיהוי דפוסים מבניים דומים בארכיון.
### 5ד. תיעוד מחייב — סעיף "שאילתות לקורפוסים" ב-`analysis-and-research.md`
ב-artifact הסופי, חובה להופיע סעיף חדש בשם **"7א. שאילתות לקורפוסים — log מלא"**, עם הפורמט הבא:
```markdown
## 7א. שאילתות לקורפוסים — log מלא
### קורפוס סמכותי (search_precedent_library)
#### Q1 — סוגיה: [שם הסוגיה]
- **שאילתה:** "..."
- **פילטרים:** practice_area=..., appeal_subtype=...
- **תוצאות:** N
- **נבחרו:**
- `[case_number]` — [למה רלוונטי, איזה headnote תומך]
- **נדחו:**
- `[case_number]` — [למה לא רלוונטי]
- **0 results?** ציין מפורש + נמק (אין מה למצוא, או הפילטר צר מדי)
#### Q2 — ...
### קאנון דפנה (search_decisions)
#### Q1 — סוגיה: [שם]
- **שאילתה:** "..."
- **תוצאות:** N
- **תקדים אישי שזוהה:** [שם תיק] — חיסכון/הבחנה?
### תיקים דומים (find_similar_cases)
- ...
```
**negative evidence חובה:** גם כששאילתה החזירה 0 תוצאות, חובה לתעד אותה. זה ההבדל בין "הקורפוס נסרק וריק" ל"הקורפוס לא נסרק". ה-QA יחזיר `needs_revision` אם הסעיף חסר או חסר queries.
**מינימום:** מספר queries ב-Q1+Q2+Q3 לקורפוס הסמכותי = מספר טענות סף + מספר סוגיות מרכזיות. אם זיהית 5 סוגיות + 2 טענות סף → לפחות 7 queries.
## שלב 6: בדיקת שלמות — לפני שמסיימים! ## שלב 6: בדיקת שלמות — לפני שמסיימים!
@@ -203,13 +277,25 @@ FROM documents d WHERE d.case_id = '{case_id}' AND d.doc_type IN ('appeal', 'res
2. **פרסם comment** ב-Paperclip עם סיכום: 2. **פרסם comment** ב-Paperclip עם סיכום:
- כמה טענות חולצו (מפורט: X טענות עוררים, Y תשובות משיבים, Z תגובות) - כמה טענות חולצו (מפורט: X טענות עוררים, Y תשובות משיבים, Z תגובות)
- **האם כל המסמכים חולצו בהצלחה** (כן/לא — אם לא, פרט מה נכשל) - **האם כל המסמכים חולצו בהצלחה** (כן/לא — אם לא, פרט מה נכשל)
- **כמה עובדות שמאי חולצו** (אם יש מסמכי `appraisal`)
- הסוגיות המרכזיות (3-5 כותרות) - הסוגיות המרכזיות (3-5 כותרות)
- כמה שאלות מחקר הופקו - כמה שאלות מחקר הופקו
- המלצה לשלב הבא - המלצה לשלב הבא
3. **עדכן סטטוס** (`case_update` עם status = `documents_ready`) 3. **עדכן סטטוס התיק** (`case_update` עם status = `documents_ready`)
4. **שלח מייל**: 4. **סגור את ה-issue של עצמך — חובה!** בלי זה Paperclip יחשוב שהמשימה עדיין רצה ויפעיל retry בלולאה (זה נצפה בפועל בריצת CMPA-16 — שלוש איטרציות מיותרות).
**אם הכל עבר בהצלחה (בדיקות שלב 6 + טענות + עובדות שמאי):**
```bash
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'```
**אם בדיקות שלב 6 נכשלו או חילוץ נכשל:**
```bash
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'```
**אסור** לסיים `done` עם פלט חסר — אם ניסיון חוזר נכשל, סטטוס = `blocked` + comment עם פירוט.
5. **שלח מייל**:
```bash ```bash
python3 /home/chaim/legal-ai/scripts/notify.py \ python3 /home/chaim/legal-ai/scripts/notify.py \
"ניתוח ומחקר הושלמו — ערר {case_number}" \ "ניתוח ומחקר הושלמו — ערר {case_number}" \
@@ -218,15 +304,16 @@ FROM documents d WHERE d.case_id = '{case_id}' AND d.doc_type IN ('appeal', 'res
### העֵר את העוזר המשפטי (CEO) — חובה! ### העֵר את העוזר המשפטי (CEO) — חובה!
```bash ```bash
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ # CEO לפי חברה — אסור לקבע UUID, חברות שונות = CEO שונה
-H "Content-Type: application/json" \ if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wakeup" \ CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
-d '{"reason": "מנתח משפטי סיים משימה [issue-id] בסטטוס [done/blocked]"}' else
``` CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
אם ה-API לא עובד: fi
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
**אם בדיקות שלב 6 נכשלו** — סטטוס issue = "blocked", פרסם comment עם פירוט מה נכשל, שלח מייל לחיים. ~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" '{"source":"automation","triggerDetail":"system","reason":"מנתח משפטי סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'```
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
## מבנה הפלט המלא — analysis-and-research.md ## מבנה הפלט המלא — analysis-and-research.md
@@ -302,8 +389,12 @@ X שאלות עומדות להכרעה:
- סעיף X לחוק... - סעיף X לחוק...
(הערה: התחל מלשון הטקסט הנורמטיבי. תקדים נדרש רק כשהטקסט עמום.) (הערה: התחל מלשון הטקסט הנורמטיבי. תקדים נדרש רק כשהטקסט עמום.)
**תקדימים מהקורפוס הפנימי:** **תקדימים מהקורפוס הסמכותי (search_precedent_library):**
- [אם נמצאו] - [תקדים שנבחר עם citation, headnote, רלוונטיות]
- (חובה לפחות שאילתה אחת ב-Q1 בסעיף 7א — גם אם 0 תוצאות, יש לתעד שם)
**תקדימים מהקאנון של דפנה (search_decisions):**
- [אם נמצאו — חיסכון או הבחנה?]
**עמדת ועדת הערר:** **עמדת ועדת הערר:**
[ימולא ע"י יו"ר הוועדה — עמדה/הנחיה לגבי סוגיה זו שתשמש את סוכן הכתיבה] [ימולא ע"י יו"ר הוועדה — עמדה/הנחיה לגבי סוגיה זו שתשמש את סוכן הכתיבה]
@@ -327,6 +418,9 @@ X שאלות עומדות להכרעה:
- **סדר דיון מומלץ**: הסדר המומלץ לדיון בסוגיות בהחלטה - **סדר דיון מומלץ**: הסדר המומלץ לדיון בסוגיות בהחלטה
- **תלויות**: סוגיות שהכרעתן תלויה בהכרעה בסוגיה אחרת - **תלויות**: סוגיות שהכרעתן תלויה בהכרעה בסוגיה אחרת
- **הערכה כללית**: לאן נוטה הניתוח ומהם הסיכויים הכלליים של הערר - **הערכה כללית**: לאן נוטה הניתוח ומהם הסיכויים הכלליים של הערר
## 7א. שאילתות לקורפוסים — log מלא
[סעיף חובה לפי שלב 5ד — log כל קריאה ל-search_precedent_library, search_decisions, find_similar_cases. גם 0 results.]
``` ```
## שלב 8: העמקת ניתוח (pass 2) — אחרי אישור כיוון ## שלב 8: העמקת ניתוח (pass 2) — אחרי אישור כיוון
@@ -338,10 +432,14 @@ X שאלות עומדות להכרעה:
### 8א. אימות פסיקה ### 8א. אימות פסיקה
סרוק את עמדות היו"ר וזהה כל אזכור פסיקה (בג"ץ, עע"מ, עת"מ, ע"א, ערר וכו'). סרוק את עמדות היו"ר וזהה כל אזכור פסיקה (בג"ץ, עע"מ, עת"מ, ע"א, ערר וכו').
לכל פסק דין שמוזכר: לכל פסק דין שמוזכר:
1. חפש בקורפוס הפנימי (`search_decisions`, `find_similar_cases`) 1. חפש ב**קורפוס הסמכותי** (`search_precedent_library`) — חובה ראשונה. שם נמצאות הלכות מאושרות עם supporting_quote מוכן לציטוט.
2. חפש במסמכי התיק (`search_case_documents`) — אולי מצוטט בכתבי הטענות 2. חפש בקאנון דפנה (`search_decisions`, `find_similar_cases`)
3. **אם נמצא** — חלץ ציטוט מדויק, הקשר, רלוונטיות 3. חפש במסמכי התיק (`search_case_documents`) — אולי מצוטט בכתבי הטענות
4. **אם לא נמצא** — סמן: "דורש אימות חיצוני" + נסח הנחיות חיפוש 4. **אם נמצא ב-precedent_library** — צטט citation+supporting_quote מדויקים מהקורפוס.
5. **אם נמצא רק במסמכי התיק** — סמן: "מקור: כתבי טענות, דורש אימות מול הקורפוס".
6. **אם לא נמצא בכלל** — סמן: "דורש אימות חיצוני" + נסח הנחיות חיפוש.
הוסף לסעיף "7א. שאילתות לקורפוסים" כל query נוסף שהורצה ב-pass 2.
הוסף לכל סוגיה תת-סעיף: הוסף לכל סוגיה תת-סעיף:
@@ -377,23 +475,15 @@ X שאלות עומדות להכרעה:
``` ```
6. **העֵר את ה-CEO — חובה!** 6. **העֵר את ה-CEO — חובה!**
```bash ```bash
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ # CEO לפי חברה — אסור לקבע UUID, חברות שונות = CEO שונה
-H "Content-Type: application/json" \ if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wakeup" \ CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
-d '{"reason": "מנתח משפטי סיים העמקת ניתוח (pass 2) [issue-id] בסטטוס [done/blocked]"}' else
``` CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
אם ה-API לא עובד: fi
```bash
PGPASSWORD="paperclip" psql -h 127.0.0.1 -p 54329 -U paperclip -d paperclip -c " ~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" '{"source":"automation","triggerDetail":"system","reason":"מנתח משפטי סיים העמקת ניתוח (pass 2) [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'```
INSERT INTO agent_wakeup_requests (company_id, agent_id, source, reason, status, requested_by_actor_type) **⚠️ אם ה-API מחזיר שגיאה — אל תיגע ב-DB.** `INSERT INTO agent_wakeup_requests` לא יוצר `heartbeat_run` והסוכן לא יתעורר לעולם. בדוק `$PAPERCLIP_COMPANY_ID` ו-`$PAPERCLIP_API_KEY`, ודאי שאתה לא קורא ל-CEO של חברה אחרת (`Agent key cannot access another company`).
VALUES (
(SELECT company_id FROM agents WHERE id = '\$PAPERCLIP_AGENT_ID'),
'752cebdd-6748-4a04-aacd-c7ab0294ef33',
'agent_completion',
'מנתח משפטי סיים העמקת ניתוח (pass 2) — נדרשת בדיקה',
'queued', 'agent'
);"
```
## כללים קריטיים ## כללים קריטיים

View File

@@ -17,6 +17,7 @@ tools:
- mcp__legal-ai__record_chair_feedback - mcp__legal-ai__record_chair_feedback
- mcp__legal-ai__list_chair_feedback - mcp__legal-ai__list_chair_feedback
- mcp__legal-ai__search_case_documents - mcp__legal-ai__search_case_documents
- mcp__legal-ai__search_precedent_library
- mcp__legal-ai__workflow_status - mcp__legal-ai__workflow_status
- mcp__legal-ai__processing_status - mcp__legal-ai__processing_status
- mcp__legal-ai__get_metrics - mcp__legal-ai__get_metrics
@@ -28,6 +29,16 @@ tools:
- mcp__legal-ai__apply_user_edit - mcp__legal-ai__apply_user_edit
- mcp__legal-ai__list_bookmarks - mcp__legal-ai__list_bookmarks
- mcp__legal-ai__revise_draft - mcp__legal-ai__revise_draft
- mcp__legal-ai__precedent_process_pending
- mcp__legal-ai__precedent_extract_halachot
- mcp__legal-ai__precedent_extract_metadata
- mcp__legal-ai__precedent_library_get
- mcp__legal-ai__precedent_library_list
- mcp__legal-ai__halacha_review
- mcp__legal-ai__halachot_pending
- mcp__legal-ai__extract_appraiser_facts
- mcp__legal-ai__write_interim_draft
- mcp__legal-ai__export_interim_draft
--- ---
# עוזר משפטי — מנהל תהליך כתיבת החלטות # עוזר משפטי — מנהל תהליך כתיבת החלטות
@@ -76,6 +87,7 @@ tools:
| כותב החלטה | 7ed8686f-24bc-49a3-bc02-67ca15b895a9 | כתיבת בלוקים ה-יב (Opus) | | כותב החלטה | 7ed8686f-24bc-49a3-bc02-67ca15b895a9 | כתיבת בלוקים ה-יב (Opus) |
| בודק איכות | 1a5b229e-9220-4b13-940c-f8eb7285fc29 | QA לפני ייצוא | | בודק איכות | 1a5b229e-9220-4b13-940c-f8eb7285fc29 | QA לפני ייצוא |
| מייצא טיוטה | d0dc703b-ca83-4883-bca7-c9449e8713cd | בדיקה סופית + ייצוא DOCX מגורסת | | מייצא טיוטה | d0dc703b-ca83-4883-bca7-c9449e8713cd | בדיקה סופית + ייצוא DOCX מגורסת |
| מנהל ידע (Hermes) | CMP: 60dce831-5c5b-4bae-bda9-5282d506f0dc · CMPA: d6f7c55d-570a-46b8-8d72-1286d07da0d8 | סקירת החלטות סופיות, הצעות לעדכון style guide / lessons. **לא קורא ישירות מ-CEO** — מופעל אוטומטית מ-`web/app.py:api_mark_final` כשדפנה לוחצת "סמן כסופי" ב-UI. |
## כלל: כל issue חדש = תת-משימה ## כלל: כל issue חדש = תת-משימה
@@ -84,10 +96,7 @@ tools:
```bash ```bash
# שלב 1: יצירת issue # שלב 1: יצירת issue
ISSUE_ID=$(curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ ISSUE_ID=$(~/legal-ai/scripts/pc.sh POST "/api/companies/$PAPERCLIP_COMPANY_ID/issues" '{"title": "[ערר CASE_NUMBER] ....", "description": "...", "parentId": "'$PAPERCLIP_TASK_ID'", "assigneeAgentId": "..."}' \
-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'])") | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
# שלב 2 (חובה!): קישור ל-case number בעוזר המשפטי # שלב 2 (חובה!): קישור ל-case number בעוזר המשפטי
@@ -151,8 +160,33 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
**לפני כל דבר אחר** — בדוק את סיבת ההתעוררות (`$PAPERCLIP_WAKE_REASON`): **לפני כל דבר אחר** — בדוק את סיבת ההתעוררות (`$PAPERCLIP_WAKE_REASON`):
- אם ה-reason מכיל `user_commented`**דלג ישירות לסעיף "טיפול בתגובות חדשות מחיים"**. אל תסרוק תיקים אחרים, אל תבדוק issues, אל תעשה heartbeat רגיל. **טפל רק בתגובה.** - אם ה-reason מכיל `user_commented`**דלג ישירות לסעיף "טיפול בתגובות חדשות מחיים"**. אל תסרוק תיקים אחרים, אל תבדוק issues, אל תעשה heartbeat רגיל. **טפל רק בתגובה.**
- אם ה-reason מכיל `agent_completion` → דלג לשלב E/F בהתאם לסוכן שסיים - אם ה-reason מכיל `agent_completion` → דלג לשלב E/F בהתאם לסוכן שסיים
- אם ה-reason מכיל `precedent_extraction_`**דלג לסעיף "חילוץ פסיקה אוטומטי"**. אל תיגע בתיקים — זו עבודת ספרייה.
- אחרת → המשך לשלב A (heartbeat רגיל) - אחרת → המשך לשלב A (heartbeat רגיל)
### חילוץ פסיקה אוטומטי
מופעל כשפסק דין חדש מועלה לספרייה. ה-issue נמצא בפרויקט "ספריית פסיקה — תור חילוץ" ומשויך אליך.
**⚠️ MCP startup race — חובה לקרוא לפני הקריאה הראשונה!**
ה-MCP server של legal-ai לוקח ~3-10 שניות לעלות בעת wakeup חדש (Python imports). אם הקריאה הראשונה ל-`mcp__legal-ai__*` תחזיר `"No such tool available"` — זה race, **לא bug אמיתי**. הפעולה הנכונה:
1. הרץ `Bash sleep 5` — תן ל-MCP server להתייצב.
2. נסה שוב את אותו כלי MCP.
3. אם עדיין נכשל אחרי 2 retries — fallback ל-Python ישיר (`Bash` עם `.venv/bin/python -c "from legal_mcp.tools.precedent_library import ..."`).
**מה לעשות:**
1. קרא את ה-description של ה-issue — מצוין שם `case_law_id` וה-citation.
2. **warmup**: קרא קודם `mcp__legal-ai__workflow_status(case_number="warmup")` (כלי קל שמאלץ MCP להתחבר). אם נכשל ב-"No such tool available" → `Bash sleep 5` ואז retry. רק אחרי שזה עובד, המשך:
3. הרץ פעמיים:
```
mcp__legal-ai__precedent_process_pending(kind="metadata")
mcp__legal-ai__precedent_process_pending(kind="halacha")
```
הכלי מעבד את **כל** הפסיקות שבתור — אם תוקיע אחת והגיעו עוד בינתיים, גם הן יעובדו.
4. כשמסתיים: כתוב comment קצר ב-issue (`mcp__legal-ai__precedent_process_pending` מחזיר את התוצאה — סכם בעברית: כמה הלכות חולצו, אילו שדות מטא-דאטה הושלמו, ו-status לכל פסיקה).
5. סמן את ה-issue כ-`done`.
**אל**: אל תיצור issues של ביצוע בתיקי ערר, אל תיכנס לתהליך כתיבת החלטה — זו רק עבודת תחזוקה של ספריית הפסיקה.
### שלב A: בדיקת מצב — שלמות, בדיקות שליליות, תאימות מתודולוגיה ### שלב A: בדיקת מצב — שלמות, בדיקות שליליות, תאימות מתודולוגיה
בכל heartbeat **רגיל** (לא comment routing): בכל heartbeat **רגיל** (לא comment routing):
@@ -190,7 +224,9 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
**מתי:** כשיש טענות מחולצות + מחקר תקדימים, אבל אין תוצאה עדיין **מתי:** כשיש טענות מחולצות + מחקר תקדימים, אבל אין תוצאה עדיין
פרסם comment ב-Paperclip: **שיטה — dual dispatch:** קודם פרסם comment עם הסיכום המלא (לתיעוד), ואז צור interaction עם כפתורים (לחיים).
#### B.1 פרסם comment עם הסיכום
``` ```
## סיכום תיק {case_number} — מוכן להחלטה ## סיכום תיק {case_number} — מוכן להחלטה
@@ -226,135 +262,151 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
- כלל: ... - כלל: ...
- עובדות: ... - עובדות: ...
- שאלה: ... - שאלה: ...
---
**מה התוצאה הצפויה?**
1. 🔴 **דחייה** — הערר נדחה
2. 🟡 **קבלה חלקית** — מתקבל עם תנאים
3. 🟢 **קבלה מלאה** — הערר מתקבל
@chaim — הגב עם מספר (1/2/3) + הערות אם יש
``` ```
**אחרי פרסום ה-comment:** עדכן את ה-issue הראשי ל-`status=in_review` (ראה "כלל קריטי: ניהול סטטוס issue" בראש הסעיף). #### B.2 צור interaction לבחירת תוצאה + טיפול בטענות
לאחר שחיים בחר תוצאה, שאל אותו לסמן טיפול בכל טענה: ```bash
~/legal-ai/scripts/pc.sh POST "/api/issues/$PAPERCLIP_TASK_ID/interactions" '{
``` "kind": "ask_user_questions",
## טיפול בטענות — {case_number} "idempotencyKey": "outcome:'"$PAPERCLIP_TASK_ID"':v1",
"title": "תוצאה וטיפול בטענות — {case_number}",
סמן לכל טענה את סוג הטיפול: "summary": "ראה את הסיכום ב-comment לעיל. שתי שאלות מובנות.",
"continuationPolicy": "wake_assignee",
| # | טענה | טיפול | "payload": {
|---|------|-------| "version": 1,
| 1 | {טענה 1} | דיון מלא / קיבוץ / דילוג | "submitLabel": "המשך לכיוונים",
| 2 | {טענה 2} | דיון מלא / קיבוץ / דילוג | "questions": [
| 3 | {טענה 3} | דיון מלא / קיבוץ / דילוג | {
| ... | ... | ... | "id": "outcome",
"prompt": "מה התוצאה?",
**הסבר:** "selectionMode": "single",
- **דיון מלא** — ניתוח סילוגיסטי מלא (כלל → עובדות → מסקנה) "required": true,
- **קיבוץ** — טענות שמכוונות לאותה נקודה ייאגדו יחד "options": [
- **דילוג** — "לא מצאנו ממש" או "אין צורך להכריע נוכח מסקנתנו" {"id":"reject", "label":"דחייה", "description":"הערר נדחה"},
{"id":"partial","label":"קבלה חלקית","description":"מתקבל עם תנאים"},
@chaim — סמן בטבלה והחזר {"id":"accept", "label":"קבלה מלאה","description":"הערר מתקבל"}
]
},
{
"id": "claims_treatment",
"prompt": "אילו טענות לדון בנפרד? (multi)",
"selectionMode": "multi",
"helpText": "סמן רק טענות שצריכות דיון מלא. השאר → קיבוץ או דילוג.",
"options": [
{"id":"claim_1","label":"{טענה 1 מקוצר}"},
{"id":"claim_2","label":"{טענה 2 מקוצר}"},
{"id":"claim_3","label":"{טענה 3 מקוצר}"}
]
}
]
}
}'
``` ```
**אחרי פרסום ה-comment:** עדכן את ה-issue הראשי ל-`status=in_review`. **אחרי יצירת ה-interaction:** עדכן את ה-issue הראשי ל-`status=in_review` (ראה "כלל קריטי: ניהול סטטוס issue" בראש הסעיף). חיים יקבל UI עם dropdowns וכפתורי radio במקום להקליד מספרים.
⚠️ **`idempotencyKey`** — חובה. אם תתעורר פעמיים, Paperclip לא יוצר 2 interactions זהים.
**מתי לחזור אחורה:** אם הסיכום לא מצליח לנסח שאלות כסילוגיזמים מכווצים — ייתכן שחסר מידע עובדתי או נורמטיבי. חזור למנתח/חוקר להשלמה. **מתי לחזור אחורה:** אם הסיכום לא מצליח לנסח שאלות כסילוגיזמים מכווצים — ייתכן שחסר מידע עובדתי או נורמטיבי. חזור למנתח/חוקר להשלמה.
### שלב C: קליטת תוצאה וכיוונים סילוגיסטיים ### שלב C: קליטת תוצאה וכיוונים סילוגיסטיים
**מתי:** חיים הגיב עם מספר תוצאה + טיפול בטענות **מתי:** התעוררת עם `$PAPERCLIP_APPROVAL_ID` שמצביע על interaction מ-§B (תשובת תוצאה+טענות).
0. **החזר את ה-issue הראשי ל-`status=in_progress`** (קיבלת קלט והמשכת לעבוד). 0. **החזר את ה-issue הראשי ל-`status=in_progress`** (קיבלת קלט והמשכת לעבוד).
1. קרא את ה-comment של חיים 1. **קרא את תשובת חיים מה-API** (לא מ-comment חופשי):
2. זהה את הבחירה (1=rejected, 2=partial, 3=accepted) ```bash
3. הרץ `set_outcome(case_number, outcome, reasoning)` ~/legal-ai/scripts/pc.sh GET "/api/issues/$PAPERCLIP_TASK_ID/interactions/$PAPERCLIP_APPROVAL_ID" \
4. **חשוב סילוגיסטית** על 2-3 כיוונים לנימוק — אתה כבר Claude, אתה יודע את הטענות והתקדימים. בנה כל כיוון כסילוגיזם מלא. | jq '{status, payload: .response}'
```
- תשובת `outcome`: `reject` / `partial` / `accept` (זהה ל-1/2/3 הישן)
- תשובת `claims_treatment`: array של claim IDs לדיון מלא
2. הרץ `set_outcome(case_number, outcome, reasoning)`
3. **חשוב סילוגיסטית** על 2-3 כיוונים לנימוק — אתה כבר Claude, אתה יודע את הטענות והתקדימים. בנה כל כיוון כסילוגיזם מלא.
> **הערה טכנית:** אל תקרא ל-`brainstorm_directions` — זה מפעיל Claude בתוך Claude ולוקח יותר מדי זמן. > **הערה טכנית:** אל תקרא ל-`brainstorm_directions` — זה מפעיל Claude בתוך Claude ולוקח יותר מדי זמן.
5. פרסם comment עם **סדר סוגיות מוצע**: 4. פרסם comment קצר עם **סדר סוגיות מוצע** (לתיעוד thread):
``` ```
## כיוונים אפשריים לנימוק — {outcome_hebrew} ## כיוונים לנימוק — {outcome_hebrew}
### סדר הסוגיות המוצע ### סדר הסוגיות המוצע
1. {שאלת סף — אם רלוונטית} 1. {שאלת סף — אם רלוונטית}
2. {הסוגיה המכריעה} 2. {הסוגיה המכריעה}
3. {סוגיות נוספות לפי חוזק} 3. {סוגיות נוספות לפי חוזק}
--- (הכיוונים המלאים — בinteraction למטה)
### כיוון 1: {title}
**כלל (הנחה עליונה):**
{הוראת תכנית / סעיף חוק / הלכה פסוקה}
**עובדות (הנחה תחתונה):**
{העובדות הספציפיות של הערר שנבחנות לאור הכלל}
**מסקנה:**
{התוצאה שנובעת מהחלת הכלל על העובדות}
**תקדימים תומכים:** {precedents}
---
### כיוון 2: {title}
**כלל (הנחה עליונה):**
{...}
**עובדות (הנחה תחתונה):**
{...}
**מסקנה:**
{...}
**תקדימים תומכים:** {precedents}
---
### כיוון 3: {title}
**כלל (הנחה עליונה):**
{...}
**עובדות (הנחה תחתונה):**
{...}
**מסקנה:**
{...}
**תקדימים תומכים:** {precedents}
---
@chaim — איזה כיוון מועדף? (1/2/3)
אפשר גם לשלב כיוונים או להוסיף הערות.
``` ```
**אחרי פרסום ה-comment:** עדכן את ה-issue הראשי ל-`status=in_review`. 5. צור **interaction לבחירת כיוון** עם detailsMarkdown מלא:
```bash
~/legal-ai/scripts/pc.sh POST "/api/issues/$PAPERCLIP_TASK_ID/interactions" '{
"kind": "ask_user_questions",
"idempotencyKey": "direction:'"$PAPERCLIP_TASK_ID"':v1",
"title": "בחירת כיוון לנימוק — {case_number}",
"summary": "3 כיוונים סילוגיסטיים. בחר אחד או שלב.",
"continuationPolicy": "wake_assignee",
"payload": {
"version": 1,
"submitLabel": "אישור כיוון — להעברה לכותב",
"questions": [
{
"id": "direction",
"prompt": "איזה כיוון מועדף?",
"selectionMode": "single",
"required": true,
"helpText": "ניתן לשלב כיוונים בהערות ב-comment נפרד אחרי הבחירה.",
"options": [
{
"id": "direction_1",
"label": "כיוון 1: {title}",
"description": "כלל: {הוראת תכנית/סעיף חוק/הלכה}\nעובדות: {ספציפיות הערר}\nמסקנה: {התוצאה}\nתקדימים: {precedents}"
},
{
"id": "direction_2",
"label": "כיוון 2: {title}",
"description": "כלל: {...}\nעובדות: {...}\nמסקנה: {...}\nתקדימים: {precedents}"
},
{
"id": "direction_3",
"label": "כיוון 3: {title}",
"description": "כלל: {...}\nעובדות: {...}\nמסקנה: {...}\nתקדימים: {precedents}"
}
]
}
]
}
}'
```
⚠️ ה-`description` של כל option בעברית. ה-`label` קצר (3-4 מילים), ה-`description` הוא הסילוגיזם המלא — חיים רואה הכל בלי להקליד.
**אחרי יצירת ה-interaction:** עדכן את ה-issue הראשי ל-`status=in_review`.
**מתי לחזור אחורה:** אם לא ניתן לבנות סילוגיזם מלא (חסר כלל, חסרות עובדות, או המסקנה לא נובעת) — חזור לחוקר תקדימים או למנתח להשלמת החסר. **מתי לחזור אחורה:** אם לא ניתן לבנות סילוגיזם מלא (חסר כלל, חסרות עובדות, או המסקנה לא נובעת) — חזור לחוקר תקדימים או למנתח להשלמת החסר.
### שלב D: אישור כיוון והפעלת כתיבה ### שלב D: אישור כיוון והפעלת כתיבה
**מתי:** חיים הגיב עם בחירת כיוון **מתי:** התעוררת עם `$PAPERCLIP_APPROVAL_ID` שמצביע על interaction מ-§C (תשובת כיוון).
0. **החזר את ה-issue הראשי ל-`status=in_progress`** (קיבלת קלט והמשכת לעבוד). 0. **החזר את ה-issue הראשי ל-`status=in_progress`** (קיבלת קלט והמשכת לעבוד).
1. קרא את ה-comment של חיים 1. **קרא את תשובת חיים מה-API:**
2. זהה כיוון (1/2/3) + הערות נוספות ```bash
~/legal-ai/scripts/pc.sh GET "/api/issues/$PAPERCLIP_TASK_ID/interactions/$PAPERCLIP_APPROVAL_ID" \
| jq '{status, response: .response}'
```
- `response.direction` יחזיר `direction_1` / `direction_2` / `direction_3`
- אם יש הערות נוספות — חיים יוסיף ב-comment נפרד; קרא את ה-comments האחרונים
2. זהה את הכיוון מהתשובה (1/2/3 → לפי המספר ב-id)
3. **אימות שלמות chair_directions** — לפני שליחה לכותב, ודא: 3. **אימות שלמות chair_directions** — לפני שליחה לכותב, ודא:
- [ ] טיפול בטענות (דיון מלא / קיבוץ / דילוג) מוגדר לכל טענה - [ ] טיפול בטענות (דיון מלא / קיבוץ / דילוג) מוגדר לכל טענה (מ-§B)
- [ ] כיוון סילוגיסטי נבחר ומאושר - [ ] כיוון סילוגיסטי נבחר ומאושר (מ-§C — interaction status=`answered`)
- [ ] סדר סוגיות מוגדר - [ ] סדר סוגיות מוגדר
- [ ] תקן ביקורת מצוין - [ ] תקן ביקורת מצוין
- אם חסר פריט כלשהו — **שאל את חיים** לפני שממשיכים - אם חסר פריט כלשהו — צור interaction חדש (`request_confirmation` או `ask_user_questions`) **לפני** שממשיכים. אסור לקרוא לחיים בcomment חופשי.
4. הרץ `approve_direction(case_number, direction_index, additional_notes)` 4. הרץ `approve_direction(case_number, direction_index, additional_notes)`
5. עדכן סטטוס: `case_update(status=direction_approved)` 5. עדכן סטטוס: `case_update(status=direction_approved)`
6. צור issue חדש ב-Paperclip: 6. צור issue חדש ב-Paperclip:
@@ -363,7 +415,7 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
- תיאור: "כיוון אושר. בצע pass 2: אמת פסיקה מעמדות היו"ר, העמק עובדות לאור הכיוון שנבחר." - תיאור: "כיוון אושר. בצע pass 2: אמת פסיקה מעמדות היו"ר, העמק עובדות לאור הכיוון שנבחר."
7. פרסם comment: "כיוון אושר. הועבר למנתח להעמקת ניתוח לפני כתיבה." 7. פרסם comment: "כיוון אושר. הועבר למנתח להעמקת ניתוח לפני כתיבה."
**מתי לחזור אחורה:** אם חיים שינה דעתו לגבי התוצאה או הכיוון, או אם חסר מידע — חזור לשלב B או C בהתאם. **מתי לחזור אחורה:** אם חיים דחה את ה-interaction (`status=rejected`) או שינה דעתו לגבי התוצאה או הכיוון, או אם חסר מידע — חזור לשלב B או C בהתאם וצור interaction חדש עם `idempotencyKey` מעודכן (לדוגמה `:v2`).
### שלב D2: אחרי העמקת ניתוח (pass 2) ### שלב D2: אחרי העמקת ניתוח (pass 2)
@@ -441,6 +493,72 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
- השתמש ב-`revise_draft` בלבד במצב ג'. - השתמש ב-`revise_draft` בלבד במצב ג'.
- אם המשתמש ביקש שינוי מאסיבי (שכתוב מלא של בלוק) — עדיף להציע לו לעבוד על זה בעריכה נוספת מצדו ולא לייצר revisions ארוכים. - אם המשתמש ביקש שינוי מאסיבי (שכתוב מלא של בלוק) — עדיף להציע לו לעבוד על זה בעריכה נוספת מצדו ולא לייצר revisions ארוכים.
### שלב H: טיוטת ביניים (לבקשת חיים, לפני דיון והכרעה)
**מתי:** חיים מבקש בקומנט "טיוטת ביניים" / "interim draft" / "טיוטה לפני דיון" / "תכין לי את הטיוטה עם טענות הצדדים". בכל שלב לפני שיש תוצאה (בד"כ כשהתיק ב-`research_complete` או `analyst_verified`).
**מטרה:** ייצור מסמך עבודה לחיים עם פתיחה ניטרלית, רקע, תכניות+היתרים, טענות הצדדים, והליכים — **בלי דיון והכרעה**. חיים יכתוב את בלוק י בעצמו ואז נמשיך לזרימה הרגילה (QA + ייצוא סופי).
**זה side-quest, לא חלק מהזרימה B-F.** אל תשנה `cases.status`. אל תייצר issues לסוכני משנה. הכלים `write_interim_draft` ו-`export_interim_draft` עושים הכל בעצמם.
**זרימה (~5-10 דקות):**
1. פרסם comment קצר: "מתחיל יצירת טיוטת ביניים — אעדכן בסיום." עדכן את ה-issue הראשי ל-`status=in_progress`.
2. **חילוץ עובדות שמאיות** (אם תיק 8xxx/9xxx ויש מסמכי שומה):
```
mcp__legal-ai__extract_appraiser_facts(case_number="...")
```
⚠️ אם מחזיר `status="sides_missing"` → דווח לחיים שאין תיוג `appraiser_side` במסמכי השומה (`document_update` עם `appraiser_side` בערכים `committee`/`appellant`/`deciding`). עצור עד שיתוקן.
אם הטבלה כבר מלאה — `write_interim_draft` ידלג על ההרצה אוטומטית, אז גם בלי הצעד הזה זה יעבוד.
3. **כתיבת 5 הבלוקים:**
```
mcp__legal-ai__write_interim_draft(
case_number="...",
instructions="לבלוק ה (פתיחה): נוסח ניטרלי לחלוטין — 'לפנינו ערר על שומה מכרעת...' + הגדרות 'להלן' בלבד. אין לרמוז על תוצאת הדיון, אין מילות שיפוט, אין אזכור 'דין הערר להידחות/להתקבל'. רק זיהוי הצדדים, השומה המכרעת, המקרקעין והגורם המחליט."
)
```
הכלי כותב ל-DB את בלוקים ה (פתיחה), ו (רקע), ט (תכניות+היתרים מורחב), ז (טענות), ח (הליכים). מחזיר `word_count` לכל בלוק.
4. **ייצוא DOCX:**
```
mcp__legal-ai__export_interim_draft(case_number="...")
```
מייצר `data/cases/{case_number}/exports/טיוטת-ביניים-v{N}.docx`, מעדכן `active_draft_path`.
5. **דווח לחיים** (כולל מייל דרך `scripts/notify.py`):
```
## טיוטת ביניים מוכנה — ערר {case_number}
📄 **קובץ:** `data/cases/{case_number}/exports/טיוטת-ביניים-v{N}.docx`
### מה כלול
| בלוק | כותרת | מילים |
|------|-------|-------|
| ה | פתיחה (ניטרלית) | {N} |
| ו | רקע עובדתי | {N} |
| ט | תכניות + היתרים | {N} |
| ז | טענות הצדדים | {N} |
| ח | הליכים | {N} |
| **סה"כ** | | **{N}** |
### סתירות שמאיות שזוהו
{אם יש — רשימה קצרה: "תכנית X — שמאי A קבע ..., שמאי B קבע ...". אם אין — "לא זוהו סתירות בין שמאים."}
### מה הלאה
הטיוטה מוכנה לעבודה. כשתסיים לכתוב את בלוק י, חזור ב-comment ונמשיך
לשלב F (QA + ייצוא סופי).
```
6. **סטטוס issue הראשי:** עדכן ל-`in_review` (ממתין לחיים שיכתוב את בלוק י).
**אזהרות:**
- אל תייצא DOCX סופי (`export_docx`) — זה לא תחליף לטיוטת ביניים.
- אל תפעיל את שלב B (סיכום + שאלת תוצאה) במקביל — חיים מחליט מתי לעבור לזרימה הראשית.
- אם בלוק ח חסר (אין פרוטוקול דיון/סיור) — ציין זאת בדוח. הכלי כותב מה שיש, אבל המשתמש צריך לדעת אם חסר.
## מפת סטטוסים ## מפת סטטוסים
**סטטוסים של התיק (`cases.status`) — כל סטטוס מתאים לפעולה אחת בדיוק:** **סטטוסים של התיק (`cases.status`) — כל סטטוס מתאים לפעולה אחת בדיוק:**
@@ -508,11 +626,15 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
--- ---
**תבנית issue למנתח — חובה בכל תיק:** **תבנית issue למנתח — חובה בכל תיק:**
1. **טבלת מיפוי מסמכים** — לכל מסמך: שם, claim_type, party_role. בנה מ-`document_list`. 1. **טבלת מיפוי מסמכים** — לכל מסמך: שם, doc_type, פעולה נדרשת:
2. **רשימת מסמכים שלא לחלץ מהם** (reference, plan, decision, court_decision) - `appeal` → `extract_claims` (claim_type=claim, party_role=appellant)
3. **הנחיה לפיצול מסמכים גדולים** — מעל 15,000 תווים → חלץ בחלקים - `response` → `extract_claims` (claim_type=response, party_role=respondent/committee)
4. **הנחיה לשלוח wakeup ל-CEO בסיום** - `reply` → `extract_claims` (claim_type=reply, party_role=permit_applicant/appellant)
5. **הנחיה לסיים כ-blocked אם מסמך נכשל** - **`appraisal` → `extract_appraiser_facts`** (לא extract_claims! שומה אינה כתב טענות. חובה בכל תיק 8xxx/9xxx)
- `reference`/`plan`/`protocol`/`permit`/`decision`/`court_decision` → אל תחלץ — חומר רקע בלבד
2. **בדיקת השלמה** — לכל doc_type='appraisal' בתיק, וודא שה-issue אומר במפורש להריץ `extract_appraiser_facts`. בלי זה ה-writer יקבל בלוק ז ריק ממספרים.
3. **הנחיה לסגור את ה-issue ב-PATCH** — סטטוס `done` בהצלחה, `blocked` בכשל. בלי זה Paperclip יפעיל retry בלולאה (נצפה בפועל ב-CMPA-16 / 30-04-26).
4. **הנחיה לשלוח wakeup ל-CEO בסיום** (כך שאתה תידע להמשיך)
## סינון תיקים לפי חברה — חובה! ## סינון תיקים לפי חברה — חובה!
@@ -555,22 +677,18 @@ case_prefix="${case_number:0:1}"
0. **החזר את ה-issue הראשי ל-`status=in_progress`** — אם ה-issue ב-`in_review` (כי המתנת לחיים) או ב-`blocked` (כי Paperclip חסם אוטומטית), הראשון דבר: עדכן ל-`in_progress` כדי לסמן שאתה עובד עליו. 0. **החזר את ה-issue הראשי ל-`status=in_progress`** — אם ה-issue ב-`in_review` (כי המתנת לחיים) או ב-`blocked` (כי Paperclip חסם אוטומטית), הראשון דבר: עדכן ל-`in_progress` כדי לסמן שאתה עובד עליו.
1. **קרא את ה-comments האחרונים** על ה-issue שצוין ב-prompt: 1. **קרא את ההקשר המלא** — issue + ancestors + project + goal + comments + attachments בקריאה אחת (ראה `HEARTBEAT.md §1.7`):
```bash ```bash
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ CONTEXT=$(~/legal-ai/scripts/pc.sh GET "/api/issues/$ISSUE_ID/heartbeat-context")
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" | jq '[.[] | select(.authorUserId != null)] | .[-3:]'
``` ```
2. **בדוק attachments** — אם חיים ציין קובץ שהועלה: 2. **בדוק attachments** — אם חיים ציין קובץ שהועלה, הוא כבר ב-`$CONTEXT.attachments`:
```bash ```bash
PGPASSWORD="paperclip" psql -h 127.0.0.1 -p 54329 -U paperclip -d paperclip -c " echo "$CONTEXT" | jq '.attachments[] | {filename, contentPath, contentType, byteSize}'
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}` נתיב מלא לקובץ: `/home/chaim/.paperclip/instances/default/data/storage/$(echo $CONTEXT | jq -r '.attachments[0].contentPath')`
⚠️ **אסור** psql ישיר ל-`issue_attachments` — ה-API הוא ה-source of truth.
3. **אם יש טיוטה/קובץ — קרא אותו מילה במילה.** חפש בתוכו: 3. **אם יש טיוטה/קובץ — קרא אותו מילה במילה.** חפש בתוכו:
- הוראות עריכה (טקסט כמו "צריך לערוך", "להוסיף", "חסר", "הוראות כתיבה") - הוראות עריכה (טקסט כמו "צריך לערוך", "להוסיף", "חסר", "הוראות כתיבה")
@@ -621,34 +739,35 @@ case_prefix="${case_number:0:1}"
## נתיבי API — חובה! ## נתיבי API — חובה!
```bash ```bash
# קרא comments על issue # קרא comments על issue (אבל בד"כ עדיף heartbeat-context — ראה HEARTBEAT.md §1.7)
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ ~/legal-ai/scripts/pc.sh GET "/api/issues/{issue-id}/comments" | jq '.[-1].body'
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" | jq '.[-1].body'
# פרסם comment # פרסם comment
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ ~/legal-ai/scripts/pc.sh POST "/api/issues/{issue-id}/comments" '{"body": "..."}'
-H "Content-Type: application/json" \
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" \
-d '{"body": "..."}'
# צור issue חדש (עם הקצאה לסוכן → מפעיל wakeup אוטומטי!) # צור issue חדש (עם הקצאה לסוכן → מפעיל wakeup אוטומטי!)
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ ~/legal-ai/scripts/pc.sh POST "/api/companies/42a7acd0-30c5-4cbd-ac97-7424f65df294/issues" \
-H "Content-Type: application/json" \ '{"title":"...","projectId":"25c1b4a1-2c0e-4a2d-9938-8ae56ccda6f1","assigneeAgentId":"{agent-id}","description":"...","status":"todo"}'
"$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 # עדכן issue
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ ~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'
-H "Content-Type: application/json" \
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \ # צור interaction מובנה לחיים (ראה §B/§C למעלה למבנה payload)
-d '{"status": "done"}' ~/legal-ai/scripts/pc.sh POST "/api/issues/{issue-id}/interactions" '{"kind":"...","payload":{...}}'
# קרא תשובת interaction (כשהתעוררת עם $PAPERCLIP_APPROVAL_ID)
~/legal-ai/scripts/pc.sh GET "/api/issues/{issue-id}/interactions/$PAPERCLIP_APPROVAL_ID" | jq '.'
``` ```
**⚠️ agent JWT לא יכול להעיר סוכנים אחרים ישירות.** כדי להעיר סוכן → **צור issue חדש + הקצה אליו** (Paperclip מפעיל wakeup אוטומטי על assignment). **⚠️ agent JWT לא יכול להעיר סוכנים אחרים ישירות.** כדי להעיר סוכן → **צור issue חדש + הקצה אליו** (Paperclip מפעיל wakeup אוטומטי על assignment).
חפש ב-comment של חיים: ## מתי להשתמש בinteraction לעומת comment
- מספר (1/2/3) → בחירה
- "כיוון" + מספר → אישור כיוון | מצב | פתרון |
- טבלת טיפול בטענות → סימון claim_handling |------|--------|
- שאלה → ענה | נדרשת בחירה מובנית מחיים (תוצאה, כיוון, אישור) | **interaction** (`ask_user_questions` / `request_confirmation`) — UI עם כפתורים |
- הערה → שלב בתהליך | הצעת עץ משימות לאישור | **interaction** (`suggest_tasks`) |
| עדכון סטטוס/תיעוד מסע (לא דורש פעולה) | **comment** רגיל |
| הסבר ארוך + שאלת בחירה | **dual** — comment עם הסבר + interaction עם options (ראה §B) |
**אסור:** "@chaim — ענה 1/2/3 בcomment". זה anti-pattern. תמיד interaction עם options.

View File

@@ -116,15 +116,31 @@ tools:
- ממצאי הבדיקה הסופית (אם היו הערות) - ממצאי הבדיקה הסופית (אם היו הערות)
- גודל הקובץ - גודל הקובץ
### סגור את ה-issue של עצמך — חובה!
בלי זה Paperclip יזהה "issue in_progress + אין execution חיה" ויפעיל auto-retry בלולאה (נצפה בפועל ב-CMPA-17 ב-30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):**
```bash
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'```
**אם בדיקות נכשלו, חסר פלט, או חסר מידע קריטי:**
```bash
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'```
**אסור** לסיים `done` עם פלט חסר — אם משהו נכשל, סטטוס = `blocked` + comment עם פירוט.
### העֵר את העוזר המשפטי (CEO) — חובה! ### העֵר את העוזר המשפטי (CEO) — חובה!
```bash ```bash
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ # CEO לפי חברה — אסור לקבע UUID, חברות שונות = CEO שונה
-H "Content-Type: application/json" \ if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wakeup" \ CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
-d '{"reason": "מייצא טיוטה סיים משימה [issue-id] בסטטוס [done/blocked]"}' else
``` CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
אם ה-API לא עובד: fi
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" '{"source":"automation","triggerDetail":"system","reason":"מייצא טיוטה סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'```
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.** **⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
## כללים קריטיים ## כללים קריטיים

View File

@@ -69,5 +69,46 @@ tools:
### שלב 4: שמירה ### שלב 4: שמירה
1. **גיבוי**: העתק את הקובץ המקורי מ-`extracted/` לתיקיית `documents/backup/` עם סיומת `.pre-proofread.txt` 1. **גיבוי**: העתק את הקובץ המקורי מ-`extracted/` לתיקיית `documents/backup/` עם סיומת `.pre-proofread.txt`
2. **כתוב** את הגרסה המתוקנת לתיקיית `documents/proofread/` (עם אותו שם קובץ כמו ב-`extracted/`) 2. **כתוב** את הגרסה המתוקנת לתיקיית `documents/proofread/` (עם אותו שם קובץ כמו ב-`extracted/`)
3. עדכן את מסד הנתונים — שנה `extraction_status` ל-`proofread`: 3. עדכן את מסד הנתונים — שנה `extraction_status` ל-`proofread`
### שלב 5: דיווח — חובה!
1. **פרסם comment ב-issue** עם סיכום:
- כמה מסמכים הוגהו
- כמה החלפות אוטומטיות בוצעו (לפי מילון ראשי תיבות)
- כמה תיקונים ידניים בוצעו
- אם נמצאו בעיות שלא ניתן היה לתקן — פרט (`[?]` markers)
2. **שלח מייל**:
```bash
python3 /home/chaim/legal-ai/scripts/notify.py \
"הגהה הושלמה — ערר {case_number}" \
"סיכום: X מסמכים הוגהו, Y החלפות, Z תיקונים. נדרשת ביקורתך."
```
### סגור את ה-issue של עצמך — חובה!
בלי זה Paperclip יזהה "issue in_progress + אין execution חיה" ויפעיל auto-retry בלולאה (נצפה בפועל ב-CMPA-17 ב-30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
**אם הכל עבר בהצלחה:**
```bash
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'```
**אם נכשלו תיקונים קריטיים או יש markers `[?]` רבים:**
```bash
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'```
**אסור** לסיים `done` עם פלט חסר — אם נכשל, סטטוס = `blocked` + comment עם פירוט.
### העֵר את העוזר המשפטי (CEO) — חובה!
```bash
# CEO לפי חברה — אסור לקבע UUID, חברות שונות = CEO שונה
if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
else
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
fi
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" '{"source":"automation","triggerDetail":"system","reason":"מגיה סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'```
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.** **⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.

View File

@@ -14,6 +14,9 @@ tools:
- mcp__legal-ai__get_metrics - mcp__legal-ai__get_metrics
- mcp__legal-ai__workflow_status - mcp__legal-ai__workflow_status
- mcp__legal-ai__search_case_documents - mcp__legal-ai__search_case_documents
- mcp__legal-ai__search_precedent_library
- mcp__legal-ai__precedent_library_get
- mcp__legal-ai__halacha_review
--- ---
# בודק איכות — סוכן QA להחלטות ועדת ערר # בודק איכות — סוכן QA להחלטות ועדת ערר
@@ -76,6 +79,29 @@ tools:
- סעיפים 1, 2, 3... ללא איפוס בין בלוקים - סעיפים 1, 2, 3... ללא איפוס בין בלוקים
- ללא כפילויות במספור - ללא כפילויות במספור
### 7א. שלמות חיפוש בקורפוסים (corpus_queries_logged) — critical
ה-analyst וה-researcher חייבים לתעד queries לקורפוסים שלהם. בלי תיעוד — אין דרך לוודא שתקדימי עליון רלוונטיים לא הוחמצו.
בדוק:
1. **קיום סעיף "שאילתות לקורפוסים"**:
- ב-`{case_dir}/documents/research/analysis-and-research.md` — סעיף **7א** (לפי שלב 5ד של ה-analyst)
- ב-`{case_dir}/documents/research/precedent-research.md` — סעיף **ז** (לפי שלב 2ב.4 של ה-researcher)
- אם חסר באחד מהם — `corpus_queries_logged = fail` (critical, חוסם המשך).
2. **מספר queries מינימלי לקורפוס הסמכותי (`search_precedent_library`):**
- `analyst >= (מספר טענות סף + מספר סוגיות מרכזיות)`
- `researcher >= מספר סוגיות מרכזיות`
- חישוב: ספור את הסוגיות בסעיף 6 של `analysis-and-research.md`. מתחת לסף → `fail`.
3. **negative evidence מתועד:** גם 0-result query חייבת להופיע. אם מצאת queries שכולן 0-result — לא fail; פשוט תיעוד שהקורפוס דליל בנושא.
4. **אצליבה הצלבה (cross-check):**
- הרץ `mcp__legal-ai__precedent_library_list(practice_area=X, search="<keyword מרכזי מהתיק>")` עם practice_area של התיק.
- אם החזיר תוצאות שלא מופיעות בסעיף "נבחרו" או "נדחו" של ה-analyst/researcher → `corpus_queries_logged = warning` (לא חוסם, אבל דווח לחיים).
חומרה: **critical** — בלי queries מתועדות אין דרך לאמת שלא הוחמצה הלכה מחייבת.
### 7. עמידה במתודולוגיה (methodology_compliance) ### 7. עמידה במתודולוגיה (methodology_compliance)
ראה `docs/decision-methodology.md` לעקרונות המלאים. בדוק: ראה `docs/decision-methodology.md` לעקרונות המלאים. בדוק:
- לכל סוגיה בבלוק י — ניתן לזהות מבנה סילוגיסטי: כלל + עובדות + מסקנה? - לכל סוגיה בבלוק י — ניתן לזהות מבנה סילוגיסטי: כלל + עובדות + מסקנה?
@@ -115,6 +141,7 @@ tools:
#### תקדמים (מ-`daphna-precedent-network.md`) #### תקדמים (מ-`daphna-precedent-network.md`)
- לכל סוגיה משפטית — האם נבחר התקדים המועדף של דפנה? - לכל סוגיה משפטית — האם נבחר התקדים המועדף של דפנה?
- האם יש תקדים אישי שלה רלוונטי? אם כן — האם הופנה אליו (חיסכון / דחייה / הבחנה)? - האם יש תקדים אישי שלה רלוונטי? אם כן — האם הופנה אליו (חיסכון / דחייה / הבחנה)?
- **ציטוטי פסיקה חיצונית בבלוק י** — לכל ציטוט (`citation` + `supporting_quote`) שמופיע, חפש ב-`search_precedent_library` (subject_tag הרלוונטי) וודא שהציטוט קיים בקורפוס ושהלכה אושרה. ציטוט שלא תואם להלכה מאושרת = critical.
#### תבנית קבלה (מ-`daphna-acceptance-architecture.md` — אם תוצאה = קבלה) #### תבנית קבלה (מ-`daphna-acceptance-architecture.md` — אם תוצאה = קבלה)
- האם הסיבה לקבלה ברורה: פגם פנימי / החזרה / תיקונים / 8xxx מהותית / שומה? - האם הסיבה לקבלה ברורה: פגם פנימי / החזרה / תיקונים / 8xxx מהותית / שומה?
@@ -133,6 +160,7 @@ tools:
| משקלות | warning | מדווח, לא חוסם | | משקלות | warning | מדווח, לא חוסם |
| כפילות | warning | מדווח, לא חוסם | | כפילות | warning | מדווח, לא חוסם |
| מספור | warning | מדווח, לא חוסם | | מספור | warning | מדווח, לא חוסם |
| **שאילתות לקורפוסים** | **critical** | **חוסם ייצוא** |
| מתודולוגיה | critical | חוסם ייצוא | | מתודולוגיה | critical | חוסם ייצוא |
| **קול דפנה** | **critical** | **חוסם ייצוא** | | **קול דפנה** | **critical** | **חוסם ייצוא** |
@@ -163,12 +191,28 @@ tools:
- האם מותר לייצא (כל הקריטיים pass?) - האם מותר לייצא (כל הקריטיים pass?)
- עדכן סטטוס ל-qa_review (אם נכשל) או drafted (אם עבר) - עדכן סטטוס ל-qa_review (אם נכשל) או drafted (אם עבר)
### סגור את ה-issue של עצמך — חובה!
בלי זה Paperclip יזהה "issue in_progress + אין execution חיה" ויפעיל auto-retry בלולאה (נצפה בפועל ב-CMPA-17 ב-30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):**
```bash
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'```
**אם בדיקות נכשלו, חסר פלט, או חסר מידע קריטי:**
```bash
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'```
**אסור** לסיים `done` עם פלט חסר — אם משהו נכשל, סטטוס = `blocked` + comment עם פירוט.
### העֵר את העוזר המשפטי (CEO) — חובה! ### העֵר את העוזר המשפטי (CEO) — חובה!
```bash ```bash
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ # CEO לפי חברה — אסור לקבע UUID, חברות שונות = CEO שונה
-H "Content-Type: application/json" \ if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wakeup" \ CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
-d '{"reason": "בודק איכות סיים משימה [issue-id] בסטטוס [done/blocked]"}' else
``` CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
אם ה-API לא עובד: fi
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" '{"source":"automation","triggerDetail":"system","reason":"בודק איכות סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'```
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.** **⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.

View File

@@ -16,6 +16,17 @@ tools:
- mcp__legal-ai__search_decisions - mcp__legal-ai__search_decisions
- mcp__legal-ai__find_similar_cases - mcp__legal-ai__find_similar_cases
- mcp__legal-ai__extract_references - mcp__legal-ai__extract_references
- mcp__legal-ai__precedent_attach
- mcp__legal-ai__precedent_list
- mcp__legal-ai__precedent_search_library
- mcp__legal-ai__search_precedent_library
- mcp__legal-ai__precedent_library_get
- mcp__legal-ai__precedent_library_list
- mcp__legal-ai__precedent_extract_halachot
- mcp__legal-ai__precedent_extract_metadata
- mcp__legal-ai__precedent_process_pending
- mcp__legal-ai__halacha_review
- mcp__legal-ai__halachot_pending
- mcp__legal-ai__workflow_status - mcp__legal-ai__workflow_status
--- ---
@@ -74,15 +85,76 @@ tools:
- **האם זה תקדם מהקאנון של דפנה?** (בדוק `docs/daphna-precedent-network.md` — אם כן, ציין שזה התקדם המועדף שלה לסוגיה) - **האם זה תקדם מהקאנון של דפנה?** (בדוק `docs/daphna-precedent-network.md` — אם כן, ציין שזה התקדם המועדף שלה לסוגיה)
4. הפק הפניות (`extract_references`) 4. הפק הפניות (`extract_references`)
### שלב 2ב: בדיקה מצטלבת מול הקאנון של דפנה ### שלב 2ב: חיפוש מובנה בשלושת הקורפוסים — חובה, עם תיעוד queries
אחרי שאספת את הפסיקה הרלוונטית בתיק:
1. **לכל סוגיה משפטית** בתיק — בדוק ב-`daphna-precedent-network.md`: **חובה לבצע** — לא הצעה. הניתוח קודם הראה (ערר 1200-25) שאם הקורפוס לא נסרק במפורש, מפספסים תקדימי עליון רלוונטיים שיושבים בו. ה-QA יחזיר `needs_revision` אם סעיף ה-queries חסר.
- האם יש תקדם מועדף של דפנה לסוגיה?
- האם הוא הוצג בכתבי הטענות? אם לא — סמן כתקדם שיש להוסיף **שלושת הקורפוסים — אל תבלבל:**
2. **תקדמים אישיים**: `search_decisions` בקטגוריה זהה לתיק. אם דפנה כבר הכריעה בסוגיה דומה: - `search_precedent_library` = פסיקה חיצונית סמכותית עם הלכות מאושרות (עליון/מנהלי/ועדות ערר אחרות) + supporting_quote מוכן.
- `search_decisions` = החלטות דפנה (style_corpus) — הקאנון האישי שלה.
- `precedent_search_library` = ציטוטים שדפנה צירפה ידנית לתיקים בעבר (case_precedents).
#### 2ב.1 — קורפוס סמכותי (`search_precedent_library`) — חובה
לכל **סוגיה משפטית מרכזית** בתיק — הרץ לפחות שאילתה אחת עם פילטרים:
| סיווג תיק | practice_area |
|------------|---------------|
| 1xxx (רישוי ובניה) | `rishuy_uvniya` |
| 8xxx (היטל השבחה) | `histael_hashbacha` |
| 9xxx (פיצויים ס' 197) | `pitsuim_197` |
אם הסוגיה ב-`appeal_subtype` ידוע (כמו "שימוש חורג", "סטייה ניכרת") — הוסף `appeal_subtype` לפילטר.
```
search_precedent_library(
query="...",
practice_area="rishuy_uvniya",
appeal_subtype="שימוש חורג",
limit=10
)
```
#### 2ב.2 — קאנון דפנה (`search_decisions`)
לכל סוגיה — בדוק אם דפנה כבר הכריעה:
- אם תוצאה דומה: תקדם לחיסכון דוקטרינרי ("כפי שקבענו ב-X") - אם תוצאה דומה: תקדם לחיסכון דוקטרינרי ("כפי שקבענו ב-X")
- אם תוצאה הפוכה: ציין כי **חובה** הבחנה (distinguishing) - אם תוצאה הפוכה: ציין כי **חובה** הבחנה (distinguishing)
3. **דווח** איזה תקדמים מהקאנון רלוונטיים, ואיזה תקדמים אישיים נמצאו
#### 2ב.3 — בדיקה מצטלבת מול `daphna-precedent-network.md`
לכל סוגיה — בדוק במסמך:
- האם יש תקדם מועדף של דפנה?
- האם הוצג בכתבי הטענות? אם לא — סמן כתקדם שיש להוסיף.
#### 2ב.4 — תיעוד מחייב — סעיף "שאילתות לקורפוסים" ב-`precedent-research.md`
חובה להופיע סעיף בשם **"ז. שאילתות לקורפוסים — log מלא"** עם:
```markdown
## ז. שאילתות לקורפוסים — log מלא
### קורפוס סמכותי (search_precedent_library)
#### Q1 — סוגיה: [שם]
- **שאילתה:** "..."
- **פילטרים:** practice_area=..., appeal_subtype=...
- **תוצאות:** N
- **נבחרו:** [case_number] — headnote/למה רלוונטי
- **נדחו:** [case_number] — למה לא
- **0 results?** ציין מפורש + נמק
#### Q2 — ...
### קאנון דפנה (search_decisions)
#### Q1 — ...
```
**negative evidence חובה:** גם 0 results נרשם. זה ההבדל בין "נסרק וריק" ל"לא נסרק".
**מינימום:** queries לקורפוס הסמכותי = מספר סוגיות מרכזיות שזוהו.
5. **דווח** איזה תקדמים מהקאנון רלוונטיים, איזה תקדמים אישיים נמצאו, ואילו הלכות מהקורפוס הסמכותי תומכות.
### שלב 3: מיפוי תכנית ### שלב 3: מיפוי תכנית
1. קרא הוראות התכנית **במלואן** — לא רק את הסעיף הנטען 1. קרא הוראות התכנית **במלואן** — לא רק את הסעיף הנטען
@@ -97,33 +169,69 @@ tools:
### שלב 5: דיווח — חובה! ### שלב 5: דיווח — חובה!
1. **עדכן סטטוס**: `case_update(case_number, status='research_complete')` 1. **שמור את הדוח לדיסק** (חובה — ה-writer וה-QA קוראים מהקובץ הזה ישירות):
```
{case_dir}/documents/research/precedent-research.md
```
המבנה המומלץ: רקע דיוני → מפת שומות (אם רלוונטי) → סוגיות + תקדימים מאומתים לכל אחת → המלצה לכיוון. כל תקדים עם citation מלא + ציטוט מדויק + הקשר.
2. **שלח מייל**: 2. **רשום ב-DB את התקדימים שאומתו** — חובה, אחרת ה-writer יקבל רשימה ריקה כשהוא קורא `precedent_list`.
לכל פסק דין שעבר את שלב 2 (ניתוח פסיקה) **ויש לו ציטוט מדויק מהמקור** — קרא `precedent_attach`:
```
mcp__legal-ai__precedent_attach(
case_number = "8174-24",
citation = "בר\"מ 3644/13 הוועדה המקומית גבעתיים נ' גלר (פורסם בנבו, 24.05.2017)",
quote = "ציטוט מדויק מפסק הדין — הקטע הספציפי שרלוונטי לסוגיה",
section_id = "issue_2" # או "threshold_1" לטענת סף; ריק אם כללי
)
```
תקדימים שלא הצלחת לאמת (ציטוט לא נמצא, רק "טוענים שמופיע בפסק") **אל תכתוב ל-DB** — סמן ב-comment כ"דורש אימות חיצוני" בלבד.
3. **עדכן סטטוס**: `case_update(case_number, status='research_complete')`
4. **שלח מייל**:
```bash ```bash
python3 /home/chaim/legal-ai/scripts/notify.py \ python3 /home/chaim/legal-ai/scripts/notify.py \
"מחקר תקדימים הושלם — ערר {case_number}" \ "מחקר תקדימים הושלם — ערר {case_number}" \
"סיכום: X פסקי דין נותחו, Y תכניות מופו. נדרשת ביקורתך לפני המשך." "סיכום: X פסקי דין נותחו ונרשמו ל-DB, Y תכניות מופו. נדרשת ביקורתך לפני המשך."
``` ```
3. פרסם comment ב-Paperclip עם: 5. **פרסם comment ב-Paperclip** עם:
- סיכום כל פסק דין (2-3 שורות לכל אחד) - סיכום כל פסק דין (2-3 שורות לכל אחד) — **ציין במפורש כמה תקדימים נרשמו ב-DB דרך `precedent_attach`**
- מיפוי הוראות תכנית רלוונטיות - מיפוי הוראות תכנית רלוונטיות
- ציר זמן ההליך - ציר זמן ההליך
- **המלצה מובנית לפי מקורות הנמקה:** - **המלצה מובנית לפי מקורות הנמקה:**
- **טקסט**: אילו סעיפי תכנית/חוק מרכזיים (ציטוט הנוסח) - **טקסט**: אילו סעיפי תכנית/חוק מרכזיים (ציטוט הנוסח)
- **תקדים**: אילו פסקי דין הכי חזקים (עם ציון היררכיה ומעמד — הלכה/אגב) - **תקדים**: אילו פסקי דין הכי חזקים (עם ציון היררכיה ומעמד — הלכה/אגב)
- **מדיניות**: אילו שיקולים תכנוניים עולים מהחומר - **מדיניות**: אילו שיקולים תכנוניים עולים מהחומר
- קישור למיקום הקובץ: `{case_dir}/documents/research/precedent-research.md`
### סגור את ה-issue של עצמך — חובה!
בלי זה Paperclip יזהה "issue in_progress + אין execution חיה" ויפעיל auto-retry בלולאה (נצפה בפועל ב-CMPA-17 ב-30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):**
```bash
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'```
**אם בדיקות נכשלו, חסר פלט, או חסר מידע קריטי:**
```bash
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'```
**אסור** לסיים `done` עם פלט חסר — אם משהו נכשל, סטטוס = `blocked` + comment עם פירוט.
### העֵר את העוזר המשפטי (CEO) — חובה! ### העֵר את העוזר המשפטי (CEO) — חובה!
```bash ```bash
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ # CEO לפי חברה — אסור לקבע UUID, חברות שונות = CEO שונה
-H "Content-Type: application/json" \ if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wakeup" \ CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
-d '{"reason": "חוקר תקדימים סיים משימה [issue-id] בסטטוס [done/blocked]"}' else
``` CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
אם ה-API לא עובד: fi
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" '{"source":"automation","triggerDetail":"system","reason":"חוקר תקדימים סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'```
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.** **⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
## כללים ## כללים
- **דיוק** — ציין מספרי סעיפים, תאריכים, שמות שופטים - **דיוק** — ציין מספרי סעיפים, תאריכים, שמות שופטים

View File

@@ -19,6 +19,10 @@ tools:
- mcp__legal-ai__save_block_content - mcp__legal-ai__save_block_content
- mcp__legal-ai__write_block - mcp__legal-ai__write_block
- mcp__legal-ai__search_decisions - mcp__legal-ai__search_decisions
- mcp__legal-ai__search_precedent_library
- mcp__legal-ai__precedent_library_get
- mcp__legal-ai__precedent_library_list
- mcp__legal-ai__halacha_review
- mcp__legal-ai__search_case_documents - mcp__legal-ai__search_case_documents
- mcp__legal-ai__get_style_guide - mcp__legal-ai__get_style_guide
- mcp__legal-ai__workflow_status - mcp__legal-ai__workflow_status
@@ -200,15 +204,31 @@ case_update(case_number, status="drafted")
- ספירת מילים לכל בלוק - ספירת מילים לכל בלוק
- יחסי משקל (% מהמסמך) - יחסי משקל (% מהמסמך)
### סגור את ה-issue של עצמך — חובה!
בלי זה Paperclip יזהה "issue in_progress + אין execution חיה" ויפעיל auto-retry בלולאה (נצפה בפועל ב-CMPA-17 ב-30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
**אם הכל עבר בהצלחה (כל בדיקות השלב הקודם עברו, אין כשל בפלט):**
```bash
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'```
**אם בדיקות נכשלו, חסר פלט, או חסר מידע קריטי:**
```bash
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'```
**אסור** לסיים `done` עם פלט חסר — אם משהו נכשל, סטטוס = `blocked` + comment עם פירוט.
### העֵר את העוזר המשפטי (CEO) — חובה! ### העֵר את העוזר המשפטי (CEO) — חובה!
```bash ```bash
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ # CEO לפי חברה — אסור לקבע UUID, חברות שונות = CEO שונה
-H "Content-Type: application/json" \ if [ "$PAPERCLIP_COMPANY_ID" = "8639e837-4c9d-47fa-a76b-95788d651896" ]; then
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wakeup" \ CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
-d '{"reason": "כותב החלטה סיים משימה [issue-id] בסטטוס [done/blocked]"}' else
``` CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
אם ה-API לא עובד: fi
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" '{"source":"automation","triggerDetail":"system","reason":"כותב החלטה סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'```
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.** **⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
**אם לא תעדכן סטטוס ל-drafted — בודק האיכות לא יוכל לרוץ!** **אם לא תעדכן סטטוס ל-drafted — בודק האיכות לא יוכל לרוץ!**
@@ -313,6 +333,20 @@ curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
זה לא קישוט. דפנה בונה ג'וריספרודנציה אישית מתמשכת. ראה דוגמה ב-1194-25 פס' 61, 64, 97, 98, 99 — חמש הפניות ל-1130-25. זה לא קישוט. דפנה בונה ג'וריספרודנציה אישית מתמשכת. ראה דוגמה ב-1194-25 פס' 61, 64, 97, 98, 99 — חמש הפניות ל-1130-25.
### חיפוש פסיקה סמכותית חיצונית (חובה)
אחרי `search_decisions`, חפש גם ב-**`search_precedent_library`** — הקורפוס של פסיקת ערכאות עליונות וועדות ערר אחרות, עם הלכות שדפנה אישרה. זה המקור היחיד לציטוטי פסיקה בבלוק י לפי CREAC:
- **rule (כלל)** — נסח את הכלל המחייב מתוך `rule_statement`. אל תמציא ניסוח חדש; השתמש בניסוח שאושר.
- **explanation (הרחבה)** — צטט את `supporting_quote` במלואו, מילה במילה. כל ציטוט חייב לכלול `case_number` + `court` + מראה מקום (`page_reference` כשיש).
**הבחנה בין כלים:**
- `search_decisions` = החלטות דפנה עצמה (סגנון, אסטרטגיה, ג'וריספרודנציה אישית).
- `search_precedent_library` = פסיקה חיצונית סמכותית (מחייבת או משכנעת — בית המשפט העליון, מנהלי, ועדות ערר אחרות).
- `precedent_search_library` (שונה!) = ציטוטים שדפנה צירפה ידנית לתיקים בעבר. לא לבלבל.
חפש לפי `practice_area` (rishuy_uvniya / betterment_levy / compensation_197) ולפי `subject_tag` רלוונטי. הלכות שלא אושרו ע"י דפנה לא מוחזרות מהכלי — אם החיפוש ריק, חזור ל-`search_decisions` בלבד.
### אנטי-דפוסים — בדיקה אחרי כתיבה (חובה) ### אנטי-דפוסים — בדיקה אחרי כתיבה (חובה)
- [ ] **אין רשימות ממוספרות בתוך פסקה** (`(1)... (2)... (3)...`) — דפנה מעולם לא משתמשת - [ ] **אין רשימות ממוספרות בתוך פסקה** (`(1)... (2)... (3)...`) — דפנה מעולם לא משתמשת

3
.gitignore vendored
View File

@@ -3,7 +3,10 @@ data/cases/
data/training/ data/training/
data/exports/ data/exports/
data/backups/ data/backups/
data/precedent-library/
data/.auto-sync.log data/.auto-sync.log
data/*.db
*.bak-pre-*
mcp-server/.venv/ mcp-server/.venv/
__pycache__/ __pycache__/
*.pyc *.pyc

View File

@@ -1,3 +1,6 @@
{ {
"migrationNoticeShown": true "migrationNoticeShown": true,
"currentTag": "legal-ai",
"lastSwitched": "2026-05-03T20:31:48.957Z",
"branchTagMapping": {}
} }

View File

@@ -2,7 +2,7 @@
"master": { "master": {
"tasks": [ "tasks": [
{ {
"id": "32", "id": 32,
"title": "הקמת סביבת פיתוח ותשתית בסיסית", "title": "הקמת סביבת פיתוח ותשתית בסיסית",
"description": "הקמת סביבת הפיתוח הבסיסית עם Python, FastAPI, PostgreSQL ו-Infisical לניהול סודות", "description": "הקמת סביבת הפיתוח הבסיסית עם Python, FastAPI, PostgreSQL ו-Infisical לניהול סודות",
"details": "יצירת פרויקט Python עם FastAPI כשרת API, PostgreSQL כמסד נתונים, ו-Infisical לניהול סודות. הגדרת Docker containers לפיתוח מקומי. יצירת מבנה תיקיות: /src, /tests, /docs, /data. הגדרת requirements.txt עם כל התלויות הנדרשות: fastapi, uvicorn, sqlalchemy, psycopg2, python-multipart, python-docx, PyPDF2, anthropic, infisical-python. הגדרת משתני סביבה דרך Infisical.", "details": "יצירת פרויקט Python עם FastAPI כשרת API, PostgreSQL כמסד נתונים, ו-Infisical לניהול סודות. הגדרת Docker containers לפיתוח מקומי. יצירת מבנה תיקיות: /src, /tests, /docs, /data. הגדרת requirements.txt עם כל התלויות הנדרשות: fastapi, uvicorn, sqlalchemy, psycopg2, python-multipart, python-docx, PyPDF2, anthropic, infisical-python. הגדרת משתני סביבה דרך Infisical.",
@@ -14,7 +14,7 @@
"updatedAt": "2026-04-03T08:53:33.842Z" "updatedAt": "2026-04-03T08:53:33.842Z"
}, },
{ {
"id": "33", "id": 33,
"title": "מודול קליטה ועיבוד מסמכים", "title": "מודול קליטה ועיבוד מסמכים",
"description": "פיתוח מודול לקליטת קבצי PDF, DOCX, MD וחילוץ טקסט כולל OCR", "description": "פיתוח מודול לקליטת קבצי PDF, DOCX, MD וחילוץ טקסט כולל OCR",
"details": "יצירת מחלקה DocumentProcessor שמטפלת בקבצים מסוגים שונים. עבור PDF: שימוש ב-PyPDF2 לטקסט רגיל ו-pytesseract לOCR של קבצים סרוקים. עבור DOCX: שימוש ב-python-docx. עבור MD: קריאה ישירה. הוספת זיהוי אוטומטי של קבצים סרוקים. יצירת API endpoint POST /documents/upload שמקבל קבצים ומחזיר טקסט מחולץ. שמירת מטא-דאטה של כל מסמך במסד הנתונים.", "details": "יצירת מחלקה DocumentProcessor שמטפלת בקבצים מסוגים שונים. עבור PDF: שימוש ב-PyPDF2 לטקסט רגיל ו-pytesseract לOCR של קבצים סרוקים. עבור DOCX: שימוש ב-python-docx. עבור MD: קריאה ישירה. הוספת זיהוי אוטומטי של קבצים סרוקים. יצירת API endpoint POST /documents/upload שמקבל קבצים ומחזיר טקסט מחולץ. שמירת מטא-דאטה של כל מסמך במסד הנתונים.",
@@ -28,7 +28,7 @@
"updatedAt": "2026-04-03T09:38:55.716Z" "updatedAt": "2026-04-03T09:38:55.716Z"
}, },
{ {
"id": "34", "id": 34,
"title": "מודול סיווג מסמכים וזיהוי צדדים", "title": "מודול סיווג מסמכים וזיהוי צדדים",
"description": "פיתוח מודול לסיווג מסמכים לסוגים (ערר, תשובה, פרוטוקול וכו') וזיהוי צדדים", "description": "פיתוח מודול לסיווג מסמכים לסוגים (ערר, תשובה, פרוטוקול וכו') וזיהוי צדדים",
"details": "יצירת מחלקה DocumentClassifier שמשתמשת ב-Claude API לסיווג מסמכים. הגדרת prompt מובנה שמזהה: סוג מסמך (ערר/תשובה/תגובה/פרוטוקול/תכנית/היתר/פסק דין/החלטה), צדדים (עוררים, משיבים, ועדה, מבקשי היתר), סוג ערר לפי מספר תיק (1xxx=רישוי, 8xxx=השבחה, 9xxx=פיצויים). יצירת מבנה נתונים מובנה לשמירת המידע המסווג. הוספת ולידציה לתוצאות הסיווג.", "details": "יצירת מחלקה DocumentClassifier שמשתמשת ב-Claude API לסיווג מסמכים. הגדרת prompt מובנה שמזהה: סוג מסמך (ערר/תשובה/תגובה/פרוטוקול/תכנית/היתר/פסק דין/החלטה), צדדים (עוררים, משיבים, ועדה, מבקשי היתר), סוג ערר לפי מספר תיק (1xxx=רישוי, 8xxx=השבחה, 9xxx=פיצויים). יצירת מבנה נתונים מובנה לשמירת המידע המסווג. הוספת ולידציה לתוצאות הסיווג.",
@@ -42,7 +42,7 @@
"updatedAt": "2026-04-03T09:43:02.411Z" "updatedAt": "2026-04-03T09:43:02.411Z"
}, },
{ {
"id": "35", "id": 35,
"title": "מודול חילוץ טענות", "title": "מודול חילוץ טענות",
"description": "פיתוח מודול לחילוץ וסיכום טענות מכתבי טענות לפי צד", "description": "פיתוח מודול לחילוץ וסיכום טענות מכתבי טענות לפי צד",
"details": "יצירת מחלקה ClaimsExtractor שמחלצת טענות מכתבי ערר ותשובה. שימוש ב-Claude API עם prompt מיוחד שמזהה טענות לפי צד ומסכם אותן בצורה נאמנה למקור. יצירת מבנה נתונים שמקשר בין טענה למסמך המקור ולמיקום בו. הוספת מנגנון לזיהוי טענות חוזרות או דומות. שמירת הטענות במסד הנתונים עם קישור לתיק ולצד.", "details": "יצירת מחלקה ClaimsExtractor שמחלצת טענות מכתבי ערר ותשובה. שימוש ב-Claude API עם prompt מיוחד שמזהה טענות לפי צד ומסכם אותן בצורה נאמנה למקור. יצירת מבנה נתונים שמקשר בין טענה למסמך המקור ולמיקום בו. הוספת מנגנון לזיהוי טענות חוזרות או דומות. שמירת הטענות במסד הנתונים עם קישור לתיק ולצד.",
@@ -56,7 +56,7 @@
"updatedAt": "2026-04-03T09:45:38.799Z" "updatedAt": "2026-04-03T09:45:38.799Z"
}, },
{ {
"id": "36", "id": 36,
"title": "מודול זיהוי תכניות ופסיקה", "title": "מודול זיהוי תכניות ופסיקה",
"description": "פיתוח מודול לזיהוי תכניות חלות על המקרקעין ופסיקה מצוטטת במסמכים", "description": "פיתוח מודול לזיהוי תכניות חלות על המקרקעין ופסיקה מצוטטת במסמכים",
"details": "יצירת מחלקה LegalReferencesExtractor שמזהה: תכניות (תב\"ע, תמ\"א, תכניות מקומיות), פסיקה מצוטטת (עם מספרי תיק ושנה), חקיקה רלוונטית. שימוש ב-regex patterns לזיהוי דפוסים נפוצים ו-Claude API לאימות ועידון. יצירת מאגר מקומי של תכניות ופסיקה שכבר זוהו. הוספת מנגנון לולידציה של הפניות שזוהו.", "details": "יצירת מחלקה LegalReferencesExtractor שמזהה: תכניות (תב\"ע, תמ\"א, תכניות מקומיות), פסיקה מצוטטת (עם מספרי תיק ושנה), חקיקה רלוונטית. שימוש ב-regex patterns לזיהוי דפוסים נפוצים ו-Claude API לאימות ועידון. יצירת מאגר מקומי של תכניות ופסיקה שכבר זוהו. הוספת מנגנון לולידציה של הפניות שזוהו.",
@@ -70,7 +70,7 @@
"updatedAt": "2026-04-03T09:48:16.636Z" "updatedAt": "2026-04-03T09:48:16.636Z"
}, },
{ {
"id": "37", "id": 37,
"title": "ממשק הזנת תוצאה וסיעור מוחות", "title": "ממשק הזנת תוצאה וסיעור מוחות",
"description": "פיתוח ממשק CLI להזנת תוצאה (דחייה/קבלה/חלקית) ומנגנון סיעור מוחות", "description": "פיתוח ממשק CLI להזנת תוצאה (דחייה/קבלה/חלקית) ומנגנון סיעור מוחות",
"details": "יצירת CLI interface עם typer שמאפשר לחיים להזין: סוג תוצאה (דחייה/קבלה/קבלה חלקית), נימוק (אופציונלי). אם לא הוזן נימוק - הפעלת מודול BrainstormingEngine שמציג טענות מרכזיות ומציע 2-3 כיוונים אפשריים. יצירת שיח אינטראקטיבי בין חיים למערכת עד הגעה לכיוון מוסכם. שמירת מסמך הכיוון הסופי. הוספת מנגנון מניעה מכתיבת דיון ללא כיוון מאושר.", "details": "יצירת CLI interface עם typer שמאפשר לחיים להזין: סוג תוצאה (דחייה/קבלה/קבלה חלקית), נימוק (אופציונלי). אם לא הוזן נימוק - הפעלת מודול BrainstormingEngine שמציג טענות מרכזיות ומציע 2-3 כיוונים אפשריים. יצירת שיח אינטראקטיבי בין חיים למערכת עד הגעה לכיוון מוסכם. שמירת מסמך הכיוון הסופי. הוספת מנגנון מניעה מכתיבת דיון ללא כיוון מאושר.",
@@ -85,7 +85,7 @@
"updatedAt": "2026-04-03T09:55:06.069Z" "updatedAt": "2026-04-03T09:55:06.069Z"
}, },
{ {
"id": "38", "id": 38,
"title": "מנוע כתיבת בלוק הפתיחה (בלוק ה)", "title": "מנוע כתיבת בלוק הפתיחה (בלוק ה)",
"description": "פיתוח מנוע לכתיבת בלוק הפתיחה בסגנון דפנה", "description": "פיתוח מנוע לכתיבת בלוק הפתיחה בסגנון דפנה",
"details": "יצירת מחלקה OpeningBlockWriter שכותבת את בלוק הפתיחה. ניתוח דפוסי הפתיחה מ-7 ההחלטות הקיימות (\"לפנינו\" vs \"עניינה של החלטה זו\"). יצירת prompt מובנה שמתאים את הפתיחה לסוג הערר ולמורכבות התיק. הוספת מנגנון לבחירת נוסח הפתיחה המתאים. שמירת תבניות פתיחה במסד הנתונים.", "details": "יצירת מחלקה OpeningBlockWriter שכותבת את בלוק הפתיחה. ניתוח דפוסי הפתיחה מ-7 ההחלטות הקיימות (\"לפנינו\" vs \"עניינה של החלטה זו\"). יצירת prompt מובנה שמתאים את הפתיחה לסוג הערר ולמורכבות התיק. הוספת מנגנון לבחירת נוסח הפתיחה המתאים. שמירת תבניות פתיחה במסד הנתונים.",
@@ -99,7 +99,7 @@
"updatedAt": "2026-04-03T09:58:34.296Z" "updatedAt": "2026-04-03T09:58:34.296Z"
}, },
{ {
"id": "39", "id": 39,
"title": "מנוע כתיבת בלוק הרקע (בלוק ו)", "title": "מנוע כתיבת בלוק הרקע (בלוק ו)",
"description": "פיתוח מנוע לכתיבת בלוק הרקע בצורה ניטרלית", "description": "פיתוח מנוע לכתיבת בלוק הרקע בצורה ניטרלית",
"details": "יצירת מחלקה BackgroundBlockWriter שכותבת רקע ניטרלי. הגדרת כללי ניטרליות: אין ציטוטים מצדדים, אין מילות שיפוט, הצגת עובדות בלבד. יצירת רשימת מילים אסורות ומנגנון ולידציה. שימוש במידע מהמסמכים המסווגים לבניית הרקע. הוספת מנגנון לקביעת אורך הרקע לפי מורכבות התיק (3%-18% מההחלטה).", "details": "יצירת מחלקה BackgroundBlockWriter שכותבת רקע ניטרלי. הגדרת כללי ניטרליות: אין ציטוטים מצדדים, אין מילות שיפוט, הצגת עובדות בלבד. יצירת רשימת מילים אסורות ומנגנון ולידציה. שימוש במידע מהמסמכים המסווגים לבניית הרקע. הוספת מנגנון לקביעת אורך הרקע לפי מורכבות התיק (3%-18% מההחלטה).",
@@ -113,7 +113,7 @@
"updatedAt": "2026-04-03T09:58:34.300Z" "updatedAt": "2026-04-03T09:58:34.300Z"
}, },
{ {
"id": "40", "id": 40,
"title": "מנוע כתיבת בלוק הטענות (בלוק ז)", "title": "מנוע כתיבת בלוק הטענות (בלוק ז)",
"description": "פיתוח מנוע לכתיבת סיכום טענות הצדדים בגוף שלישי", "description": "פיתוח מנוע לכתיבת סיכום טענות הצדדים בגוף שלישי",
"details": "יצירת מחלקה ClaimsBlockWriter שמסכמת טענות בגוף שלישי. שימוש בטענות שחולצו במודול חילוץ הטענות. הבטחת נאמנות מוחלטת למקור - אין שינוי מילים או קיצור ללא ציון. יצירת מבנה לוגי של הצגת הטענות לפי צד. הוספת מנגנון לקישור כל טענה למקור המדויק במסמך.", "details": "יצירת מחלקה ClaimsBlockWriter שמסכמת טענות בגוף שלישי. שימוש בטענות שחולצו במודול חילוץ הטענות. הבטחת נאמנות מוחלטת למקור - אין שינוי מילים או קיצור ללא ציון. יצירת מבנה לוגי של הצגת הטענות לפי צד. הוספת מנגנון לקישור כל טענה למקור המדויק במסמך.",
@@ -127,7 +127,7 @@
"updatedAt": "2026-04-03T09:58:34.303Z" "updatedAt": "2026-04-03T09:58:34.303Z"
}, },
{ {
"id": "41", "id": 41,
"title": "מנוע כתיבת בלוק ההליכים (בלוק ח)", "title": "מנוע כתיבת בלוק ההליכים (בלוק ח)",
"description": "פיתוח מנוע לכתיבת בלוק ההליכים (רק כשהיו הליכים מעבר לדיון פשוט)", "description": "פיתוח מנוע לכתיבת בלוק ההליכים (רק כשהיו הליכים מעבר לדיון פשוט)",
"details": "יצירת מחלקה ProceduresBlockWriter שכותבת תיעוד כרונולוגי של הליכים. זיהוי אוטומטי מתי נדרש הבלוק (סיור, השלמות טיעון, החלטות ביניים). יצירת ציר זמן של האירועים מהמסמכים. הבטחת דיוק עובדתי ומבנה כרונולוגי. הוספת מנגנון להחלטה אוטומטית האם הבלוק נדרש.", "details": "יצירת מחלקה ProceduresBlockWriter שכותבת תיעוד כרונולוגי של הליכים. זיהוי אוטומטי מתי נדרש הבלוק (סיור, השלמות טיעון, החלטות ביניים). יצירת ציר זמן של האירועים מהמסמכים. הבטחת דיוק עובדתי ומבנה כרונולוגי. הוספת מנגנון להחלטה אוטומטית האם הבלוק נדרש.",
@@ -141,7 +141,7 @@
"updatedAt": "2026-04-03T09:58:34.305Z" "updatedAt": "2026-04-03T09:58:34.305Z"
}, },
{ {
"id": "42", "id": 42,
"title": "מנוע כתיבת בלוק התכניות (בלוק ט)", "title": "מנוע כתיבת בלוק התכניות (בלוק ט)",
"description": "פיתוח מנוע לכתיבת בלוק התכניות והמסגרת הנורמטיבית", "description": "פיתוח מנוע לכתיבת בלוק התכניות והמסגרת הנורמטיבית",
"details": "יצירת מחלקה PlansBlockWriter שמטפלת ברישום תכניות. הגדרת כללי החלטה מתי נדרש פרק נפרד (מורכבות תכנונית, שאלה משפטית כמו ס' 152). שימוש במידע התכניות שזוהו במודול זיהוי התכניות. יצירת מבנה הירכי של התכניות (ארציות, מחוזיות, מקומיות). הוספת מנגנון לקביעת עומק הפירוט הנדרש.", "details": "יצירת מחלקה PlansBlockWriter שמטפלת ברישום תכניות. הגדרת כללי החלטה מתי נדרש פרק נפרד (מורכבות תכנונית, שאלה משפטית כמו ס' 152). שימוש במידע התכניות שזוהו במודול זיהוי התכניות. יצירת מבנה הירכי של התכניות (ארציות, מחוזיות, מקומיות). הוספת מנגנון לקביעת עומק הפירוט הנדרש.",
@@ -155,7 +155,7 @@
"updatedAt": "2026-04-03T09:58:34.308Z" "updatedAt": "2026-04-03T09:58:34.308Z"
}, },
{ {
"id": "43", "id": 43,
"title": "מנוע כתיבת בלוק הדיון (בלוק י) - ליבת המערכת", "title": "מנוע כתיבת בלוק הדיון (בלוק י) - ליבת המערכת",
"description": "פיתוח מנוע הכתיבה המרכזי לבלוק הדיון בשיטת CREAC", "description": "פיתוח מנוע הכתיבה המרכזי לבלוק הדיון בשיטת CREAC",
"details": "יצירת מחלקה DiscussionBlockWriter - הליבה של המערכת. יישום שיטת CREAC: מסקנה בפתיחה, כלל משפטי, הסבר, יישום על המקרה, מסקנה. הבטחת מענה לכל טענה מבלוק ז. שימוש בכיוון שנקבע בשלב סיעור המוחות. הוספת מנגנון למניעת כפילויות והפניות לבלוקים קודמים. יצירת מבנה לוגי של הנימוקים לפי סדר חשיבות.", "details": "יצירת מחלקה DiscussionBlockWriter - הליבה של המערכת. יישום שיטת CREAC: מסקנה בפתיחה, כלל משפטי, הסבר, יישום על המקרה, מסקנה. הבטחת מענה לכל טענה מבלוק ז. שימוש בכיוון שנקבע בשלב סיעור המוחות. הוספת מנגנון למניעת כפילויות והפניות לבלוקים קודמים. יצירת מבנה לוגי של הנימוקים לפי סדר חשיבות.",
@@ -169,7 +169,7 @@
"updatedAt": "2026-04-03T09:58:34.311Z" "updatedAt": "2026-04-03T09:58:34.311Z"
}, },
{ {
"id": "44", "id": 44,
"title": "מנוע כתיבת בלוק הסיכום (בלוק יא)", "title": "מנוע כתיבת בלוק הסיכום (בלוק יא)",
"description": "פיתוח מנוע לכתיבת בלוק הסיכום עם הוראות אופרטיביות", "description": "פיתוח מנוע לכתיבת בלוק הסיכום עם הוראות אופרטיביות",
"details": "יצירת מחלקה SummaryBlockWriter שכותבת הוראות אופרטיביות. גזירת ההוראות מהדיון שנכתב בבלוק י. הבטחת התאמה מדויקת להכרעה שנקבעה. יצירת מבנה ברור של ההוראות (מה מתקבל, מה נדחה, מה התנאים). הוספת מנגנון לולידציה של עקביות בין הדיון לסיכום.", "details": "יצירת מחלקה SummaryBlockWriter שכותבת הוראות אופרטיביות. גזירת ההוראות מהדיון שנכתב בבלוק י. הבטחת התאמה מדויקת להכרעה שנקבעה. יצירת מבנה ברור של ההוראות (מה מתקבל, מה נדחה, מה התנאים). הוספת מנגנון לולידציה של עקביות בין הדיון לסיכום.",
@@ -183,7 +183,7 @@
"updatedAt": "2026-04-03T09:58:34.313Z" "updatedAt": "2026-04-03T09:58:34.313Z"
}, },
{ {
"id": "45", "id": 45,
"title": "מנוע ייצוא DOCX מעוצב", "title": "מנוע ייצוא DOCX מעוצב",
"description": "פיתוח מנוע לייצוא ההחלטה לקובץ DOCX מעוצב בעברית RTL", "description": "פיתוח מנוע לייצוא ההחלטה לקובץ DOCX מעוצב בעברית RTL",
"details": "יצירת מחלקה DocxExporter שמייצרת DOCX מעוצב. הגדרת גופן David, כיוון RTL, כותרות מעוצבות, מספור סעיפים רציף. יצירת תבנית DOCX בסיסית עם הגדרות העיצוב. הוספת מנגנון לסימון מקומות תמונה (GIS, תשריט, סיור). הבטחת תמיכה מלאה בעברית ובכיוון RTL. יצירת מבנה היררכי של כותרות וסעיפים.", "details": "יצירת מחלקה DocxExporter שמייצרת DOCX מעוצב. הגדרת גופן David, כיוון RTL, כותרות מעוצבות, מספור סעיפים רציף. יצירת תבנית DOCX בסיסית עם הגדרות העיצוב. הוספת מנגנון לסימון מקומות תמונה (GIS, תשריט, סיור). הבטחת תמיכה מלאה בעברית ובכיוון RTL. יצירת מבנה היררכי של כותרות וסעיפים.",
@@ -197,7 +197,7 @@
"updatedAt": "2026-04-03T10:12:36.842Z" "updatedAt": "2026-04-03T10:12:36.842Z"
}, },
{ {
"id": "46", "id": 46,
"title": "מנגנון בקרת איכות ווולידציה", "title": "מנגנון בקרת איכות ווולידציה",
"description": "פיתוח מנגנון בקרת איכות לוולידציה של ההחלטה לפני הפלט", "description": "פיתוח מנגנון בקרת איכות לוולידציה של ההחלטה לפני הפלט",
"details": "יצירת מחלקה QualityController שבודקת: אפס הזיות (כל הפניה מול מסמכים שסופקו), מענה לכל טענה, רקע ניטרלי (ללא מילות שיפוט), משקלות בלוקים בטווח יחסי הזהב ±10%, ציטוטים נאמנים למקור. יצירת דוח ולידציה מפורט. הוספת מנגנון למניעת פלט במקרה של כשלון ולידציה קריטי.", "details": "יצירת מחלקה QualityController שבודקת: אפס הזיות (כל הפניה מול מסמכים שסופקו), מענה לכל טענה, רקע ניטרלי (ללא מילות שיפוט), משקלות בלוקים בטווח יחסי הזהב ±10%, ציטוטים נאמנים למקור. יצירת דוח ולידציה מפורט. הוספת מנגנון למניעת פלט במקרה של כשלון ולידציה קריטי.",
@@ -211,7 +211,7 @@
"updatedAt": "2026-04-03T10:14:00.311Z" "updatedAt": "2026-04-03T10:14:00.311Z"
}, },
{ {
"id": "47", "id": 47,
"title": "מודול לולאת למידה", "title": "מודול לולאת למידה",
"description": "פיתוח מודול לקליטת גרסה סופית והשוואה לטיוטה ללמידה", "description": "פיתוח מודול לקליטת גרסה סופית והשוואה לטיוטה ללמידה",
"details": "יצירת מחלקה LearningLoop שמקבלת את הגרסה הסופית שדפנה חתמה. השוואת הטיוטה לגרסה הסופית וזיהוי הבדלים. חילוץ לקחים: ביטויים חדשים, דפוסים שהשתנו, שגיאות חוזרות. עדכון מודל הסגנון על בסיס הלקחים. יצירת דוח למידה לחיים. שמירת הלקחים במסד הנתונים לשיפור עתידי.", "details": "יצירת מחלקה LearningLoop שמקבלת את הגרסה הסופית שדפנה חתמה. השוואת הטיוטה לגרסה הסופית וזיהוי הבדלים. חילוץ לקחים: ביטויים חדשים, דפוסים שהשתנו, שגיאות חוזרות. עדכון מודל הסגנון על בסיס הלקחים. יצירת דוח למידה לחיים. שמירת הלקחים במסד הנתונים לשיפור עתידי.",
@@ -225,7 +225,7 @@
"updatedAt": "2026-04-03T10:15:14.639Z" "updatedAt": "2026-04-03T10:15:14.639Z"
}, },
{ {
"id": "48", "id": 48,
"title": "מודול מדדי הצלחה ודשבורד", "title": "מודול מדדי הצלחה ודשבורד",
"description": "פיתוח מודול למדידת KPIs ויצירת דשבורד מעקב", "description": "פיתוח מודול למדידת KPIs ויצירת דשבורד מעקב",
"details": "יצירת מחלקה MetricsTracker שמודדת: אחוז שינוי (השוואת טיוטה לגרסה סופית), זמן לטיוטה (מקצה לקצה), אפס הזיות (ספירת הפניות לא תקינות), מענה לכל טענה, משקלות בלוקים, רקע ניטרלי. יצירת דשבורד פשוט עם הצגת המדדים לאורך זמן. הוספת התראות כשמדד יורד מתחת לסף המינימום.", "details": "יצירת מחלקה MetricsTracker שמודדת: אחוז שינוי (השוואת טיוטה לגרסה סופית), זמן לטיוטה (מקצה לקצה), אפס הזיות (ספירת הפניות לא תקינות), מענה לכל טענה, משקלות בלוקים, רקע ניטרלי. יצירת דשבורד פשוט עם הצגת המדדים לאורך זמן. הוספת התראות כשמדד יורד מתחת לסף המינימום.",
@@ -239,7 +239,7 @@
"updatedAt": "2026-04-03T10:16:10.708Z" "updatedAt": "2026-04-03T10:16:10.708Z"
}, },
{ {
"id": "49", "id": 49,
"title": "מנגנון ניהול סודות ואבטחה", "title": "מנגנון ניהול סודות ואבטחה",
"description": "יישום מנגנון אבטחה מלא עם Infisical וניהול סודות", "description": "יישום מנגנון אבטחה מלא עם Infisical וניהול סודות",
"details": "הגדרת Infisical לניהול כל הסודות: Anthropic API key, מחרוזות חיבור למסד נתונים, מפתחות הצפנה. יצירת מנגנון הצפנה לחומרי התיקים במסד הנתונים. הגדרת מדיניות גישה והרשאות. יצירת מנגנון audit log לכל הפעולות. הבטחת שחומרי התיקים לא נשלחים לשירותים חיצוניים מלבד Anthropic API.", "details": "הגדרת Infisical לניהול כל הסודות: Anthropic API key, מחרוזות חיבור למסד נתונים, מפתחות הצפנה. יצירת מנגנון הצפנה לחומרי התיקים במסד הנתונים. הגדרת מדיניות גישה והרשאות. יצירת מנגנון audit log לכל הפעולות. הבטחת שחומרי התיקים לא נשלחים לשירותים חיצוניים מלבד Anthropic API.",
@@ -253,7 +253,7 @@
"updatedAt": "2026-04-03T10:17:43.954Z" "updatedAt": "2026-04-03T10:17:43.954Z"
}, },
{ {
"id": "50", "id": 50,
"title": "מנגנון גיבוי ושחזור", "title": "מנגנון גיבוי ושחזור",
"description": "יישום מנגנון גיבוי יומי אוטומטי ושחזור מסד הנתונים", "description": "יישום מנגנון גיבוי יומי אוטומטי ושחזור מסד הנתונים",
"details": "יצירת סקריפט גיבוי יומי אוטומטי למסד הנתונים PostgreSQL. הגדרת cron job לביצוע הגיבוי בשעות הלילה. יצירת מנגנון שחזור מגיבוי. שמירת הגיבויים במיקום מאובטח. הוספת מנגנון לבדיקת תקינות הגיבויים. יצירת תיעוד לתהליכי גיבוי ושחזור.", "details": "יצירת סקריפט גיבוי יומי אוטומטי למסד הנתונים PostgreSQL. הגדרת cron job לביצוע הגיבוי בשעות הלילה. יצירת מנגנון שחזור מגיבוי. שמירת הגיבויים במיקום מאובטח. הוספת מנגנון לבדיקת תקינות הגיבויים. יצירת תיעוד לתהליכי גיבוי ושחזור.",
@@ -267,7 +267,7 @@
"updatedAt": "2026-04-03T10:18:18.247Z" "updatedAt": "2026-04-03T10:18:18.247Z"
}, },
{ {
"id": "51", "id": 51,
"title": "ממשק CLI מלא ותיעוד", "title": "ממשק CLI מלא ותיעוד",
"description": "פיתוח ממשק CLI מלא עם כל הפקודות הנדרשות ותיעוד מקיף", "description": "פיתוח ממשק CLI מלא עם כל הפקודות הנדרשות ותיעוד מקיף",
"details": "יצירת CLI מקיף עם typer שכולל: העלאת מסמכים, הזנת תוצאה, סיעור מוחות, יצירת טיוטה, הזנת גרסה סופית, הצגת מדדים. הוספת help מפורט לכל פקודה. יצירת תיעוד מקיף למשתמש עם דוגמאות שימוש. הוספת מנגנון לולידציה של קלטים. יצירת מנגנון לטיפול בשגיאות ומסרי שגיאה ברורים בעברית.", "details": "יצירת CLI מקיף עם typer שכולל: העלאת מסמכים, הזנת תוצאה, סיעור מוחות, יצירת טיוטה, הזנת גרסה סופית, הצגת מדדים. הוספת help מפורט לכל פקודה. יצירת תיעוד מקיף למשתמש עם דוגמאות שימוש. הוספת מנגנון לולידציה של קלטים. יצירת מנגנון לטיפול בשגיאות ומסרי שגיאה ברורים בעברית.",
@@ -282,7 +282,7 @@
"updatedAt": "2026-04-03T10:19:20.241Z" "updatedAt": "2026-04-03T10:19:20.241Z"
}, },
{ {
"id": "52", "id": 52,
"title": "בדיקות אינטגרציה ומבחן הסמכה", "title": "בדיקות אינטגרציה ומבחן הסמכה",
"description": "יצירת חבילת בדיקות מקיפה ומבחן הסמכה על תיק אמיתי", "description": "יצירת חבילת בדיקות מקיפה ומבחן הסמכה על תיק אמיתי",
"details": "יצירת בדיקות אינטגרציה לכל התהליך מקצה לקצה. בדיקה עם תיק הכט (תיק שכבר יש לו החלטה סופית) - השוואת הטיוטה שהמערכת מייצרת להחלטה הסופית. מדידת פער ווידוא שהוא קטן מ-10%. יצירת מבחן הסמכה מובנה לפני שימוש מבצעי. הוספת בדיקות ביצועים - וידוא שהמערכת מייצרת טיוטה תוך יום עבודה.", "details": "יצירת בדיקות אינטגרציה לכל התהליך מקצה לקצה. בדיקה עם תיק הכט (תיק שכבר יש לו החלטה סופית) - השוואת הטיוטה שהמערכת מייצרת להחלטה הסופית. מדידת פער ווידוא שהוא קטן מ-10%. יצירת מבחן הסמכה מובנה לפני שימוש מבצעי. הוספת בדיקות ביצועים - וידוא שהמערכת מייצרת טיוטה תוך יום עבודה.",
@@ -296,7 +296,7 @@
"updatedAt": "2026-04-04T07:50:59.998Z" "updatedAt": "2026-04-04T07:50:59.998Z"
}, },
{ {
"id": "53", "id": 53,
"title": "הוספת שלב 6 - הגהת דפנה לדרישות הפונקציונליות", "title": "הוספת שלב 6 - הגהת דפנה לדרישות הפונקציונליות",
"description": "הגדרת שלב הגהת דפנה החסר מהדרישות הפונקציונליות, כולל זרימת העבודה והממשקים", "description": "הגדרת שלב הגהת דפנה החסר מהדרישות הפונקציונליות, כולל זרימת העבודה והממשקים",
"details": "יש להגדיר בדרישות הפונקציונליות: (1) איך דפנה מקבלת את הטיוטה בפורמט DOCX, (2) איך מחזירה הערות ותיקונים (ממשק או פורמט מובנה), (3) מי מעלה את הגרסה הסופית ללולאת הלמידה. כולל הגדרת API endpoints לקבלת הטיוטה ולהחזרת הערות, ומנגנון עדכון המודל על בסיס הפידבק.", "details": "יש להגדיר בדרישות הפונקציונליות: (1) איך דפנה מקבלת את הטיוטה בפורמט DOCX, (2) איך מחזירה הערות ותיקונים (ממשק או פורמט מובנה), (3) מי מעלה את הגרסה הסופית ללולאת הלמידה. כולל הגדרת API endpoints לקבלת הטיוטה ולהחזרת הערות, ומנגנון עדכון המודל על בסיס הפידבק.",
@@ -308,7 +308,7 @@
"updatedAt": "2026-04-02T20:58:19.827Z" "updatedAt": "2026-04-02T20:58:19.827Z"
}, },
{ {
"id": "54", "id": 54,
"title": "החלפת דרישת 'אפס הזיות' במנגנון grounding ווולידציה", "title": "החלפת דרישת 'אפס הזיות' במנגנון grounding ווולידציה",
"description": "החלפת הדרישה הלא ריאלית של אפס הזיות במנגנון grounding מתקדם ומערכת וולידציה אוטומטית", "description": "החלפת הדרישה הלא ריאלית של אפס הזיות במנגנון grounding מתקדם ומערכת וולידציה אוטומטית",
"details": "יישום מנגנון grounding שמקשר כל הפניה למסמך מקור ספציפי עם citation tracking. פיתוח מערכת וולידציה אוטומטית שבודקת כל ציטוט/הפניה מול המסמכים שסופקו. הגדרת מדד: שיעור הפניות שלא עוברות וולידציה = 0. כולל מנגנון flagging של הפניות חשודות ודרישה לאישור ידני.", "details": "יישום מנגנון grounding שמקשר כל הפניה למסמך מקור ספציפי עם citation tracking. פיתוח מערכת וולידציה אוטומטית שבודקת כל ציטוט/הפניה מול המסמכים שסופקו. הגדרת מדד: שיעור הפניות שלא עוברות וולידציה = 0. כולל מנגנון flagging של הפניות חשודות ודרישה לאישור ידני.",
@@ -320,7 +320,7 @@
"updatedAt": "2026-04-02T20:58:55.741Z" "updatedAt": "2026-04-02T20:58:55.741Z"
}, },
{ {
"id": "55", "id": 55,
"title": "הוספת ניהול context window overflow", "title": "הוספת ניהול context window overflow",
"description": "פיתוח מנגנון לטיפול בתיקים מורכבים שחורגים מ-context window של המודל", "description": "פיתוח מנגנון לטיפול בתיקים מורכבים שחורגים מ-context window של המודל",
"details": "יישום מדידת גודל חומרים בטוקנים, אסטרטגיית chunking חכמה ו/או summarization של מסמכים ארוכים. הגדרת סף התראה כשמתקרבים לגבול context window. פיתוח אלגוריתם לסדר עדיפויות של מסמכים והחלטה איזה חלקים לכלול בהקשר הנוכחי.", "details": "יישום מדידת גודל חומרים בטוקנים, אסטרטגיית chunking חכמה ו/או summarization של מסמכים ארוכים. הגדרת סף התראה כשמתקרבים לגבול context window. פיתוח אלגוריתם לסדר עדיפויות של מסמכים והחלטה איזה חלקים לכלול בהקשר הנוכחי.",
@@ -332,7 +332,7 @@
"updatedAt": "2026-04-02T20:59:34.704Z" "updatedAt": "2026-04-02T20:59:34.704Z"
}, },
{ {
"id": "56", "id": 56,
"title": "הגדרה מתמטית מדויקת של 'אחוז שינוי'", "title": "הגדרה מתמטית מדויקת של 'אחוז שינוי'",
"description": "הגדרה ברורה ומתמטית של מדד אחוז השינוי עם דוגמאות קונקרטיות", "description": "הגדרה ברורה ומתמטית של מדד אחוז השינוי עם דוגמאות קונקרטיות",
"details": "הגדרת מדד אחוז שינוי מבוסס edit distance על מילים (לא תווים). ספירת שינויים: הוספה, מחיקה, החלפה של מילים. נוסחה: (מספר שינויים / סך מילים בטקסט המקורי) * 100. כולל דוגמאות מפורטות ומקרי קצה כמו שינוי סדר מילים, שינויי פיסוק, וטיפול בסעיפים חדשים.", "details": "הגדרת מדד אחוז שינוי מבוסס edit distance על מילים (לא תווים). ספירת שינויים: הוספה, מחיקה, החלפה של מילים. נוסחה: (מספר שינויים / סך מילים בטקסט המקורי) * 100. כולל דוגמאות מפורטות ומקרי קצה כמו שינוי סדר מילים, שינויי פיסוק, וטיפול בסעיפים חדשים.",
@@ -344,7 +344,7 @@
"updatedAt": "2026-04-02T21:00:03.477Z" "updatedAt": "2026-04-02T21:00:03.477Z"
}, },
{ {
"id": "57", "id": 57,
"title": "הוספת דרישות לבלוקים א-ד ויב", "title": "הוספת דרישות לבלוקים א-ד ויב",
"description": "הגדרת דרישות פונקציונליות לבלוקים החסרים: כותרת, הרכב, צדדים וחתימות", "description": "הגדרת דרישות פונקציונליות לבלוקים החסרים: כותרת, הרכב, צדדים וחתימות",
"details": "הגדרת דרישות מפורטות לבלוק א (כותרת התיק), בלוק ב (הרכב בית הדין), בלוק ג (זיהוי הצדדים), בלוק ד (פרטים נוספים על הצדדים), ובלוק יב (חתימות). כולל פורמט הפלט, מקורות המידע, וכללי עיבוד לכל בלוק. התאמה לתבנית הפסיקה הסטנדרטית.", "details": "הגדרת דרישות מפורטות לבלוק א (כותרת התיק), בלוק ב (הרכב בית הדין), בלוק ג (זיהוי הצדדים), בלוק ד (פרטים נוספים על הצדדים), ובלוק יב (חתימות). כולל פורמט הפלט, מקורות המידע, וכללי עיבוד לכל בלוק. התאמה לתבנית הפסיקה הסטנדרטית.",
@@ -358,7 +358,7 @@
"updatedAt": "2026-04-02T20:58:19.831Z" "updatedAt": "2026-04-02T20:58:19.831Z"
}, },
{ {
"id": "58", "id": 58,
"title": "יישום מנגנון שמירת מצב ביניים (persistence)", "title": "יישום מנגנון שמירת מצב ביניים (persistence)",
"description": "פיתוח מערכת לשמירת מצב העבודה ו-recovery מנפילות מערכת", "description": "פיתוח מערכת לשמירת מצב העבודה ו-recovery מנפילות מערכת",
"details": "יישום מנגנון auto-save שמשמר את מצב העבודה כל כמה דקות. שמירת גרסאות ביניים של כל בלוק, מעקב אחר השלב הנוכחי בתהליך, ומנגנון recovery שמאפשר המשך עבודה מהנקודה האחרונה שנשמרה. כולל ממשק למשתמש לבחירת נקודת שחזור.", "details": "יישום מנגנון auto-save שמשמר את מצב העבודה כל כמה דקות. שמירת גרסאות ביניים של כל בלוק, מעקב אחר השלב הנוכחי בתהליך, ומנגנון recovery שמאפשר המשך עבודה מהנקודה האחרונה שנשמרה. כולל ממשק למשתמש לבחירת נקודת שחזור.",
@@ -370,7 +370,7 @@
"updatedAt": "2026-04-02T21:01:07.799Z" "updatedAt": "2026-04-02T21:01:07.799Z"
}, },
{ {
"id": "59", "id": 59,
"title": "תיקון ספירת שלבים בטבלת מעקב", "title": "תיקון ספירת שלבים בטבלת מעקב",
"description": "עדכון טבלת המעקב להתאמה למספר השלבים בפועל", "description": "עדכון טבלת המעקב להתאמה למספר השלבים בפועל",
"details": "עדכון הטבלה לציון 7 שלבים במקום 6, כולל השלב החדש של הגהת דפנה. עדכון כל הרפרנסים למספר השלבים במסמכי הדרישות והתיעוד. וידוא עקביות בין כל המסמכים.", "details": "עדכון הטבלה לציון 7 שלבים במקום 6, כולל השלב החדש של הגהת דפנה. עדכון כל הרפרנסים למספר השלבים במסמכי הדרישות והתיעוד. וידוא עקביות בין כל המסמכים.",
@@ -384,7 +384,7 @@
"updatedAt": "2026-04-02T21:01:45.876Z" "updatedAt": "2026-04-02T21:01:45.876Z"
}, },
{ {
"id": "60", "id": 60,
"title": "הכרה ב-MVP לרישוי והשבחה בלבד", "title": "הכרה ב-MVP לרישוי והשבחה בלבד",
"description": "הגדרת גרסה ראשונה שמכסה רק רישוי והשבחה בשל חוסר נתוני אימון לפיצויים", "description": "הגדרת גרסה ראשונה שמכסה רק רישוי והשבחה בשל חוסר נתוני אימון לפיצויים",
"details": "הגדרת MVP שמתמקד ברישוי והשבחה בלבד. תיעוד המגבלות הנוכחיות בנוגע לפיצויים ותכנית לאיסוף נתוני אימון עתידיים. הגדרת קריטריונים להרחבה לפיצויים בגרסאות עתידיות. עדכון מטריקות הצלחה בהתאם למגבלות הגרסה הראשונה.", "details": "הגדרת MVP שמתמקד ברישוי והשבחה בלבד. תיעוד המגבלות הנוכחיות בנוגע לפיצויים ותכנית לאיסוף נתוני אימון עתידיים. הגדרת קריטריונים להרחבה לפיצויים בגרסאות עתידיות. עדכון מטריקות הצלחה בהתאם למגבלות הגרסה הראשונה.",
@@ -396,7 +396,7 @@
"updatedAt": "2026-04-02T21:01:45.879Z" "updatedAt": "2026-04-02T21:01:45.879Z"
}, },
{ {
"id": "61", "id": 61,
"title": "בחינה מחדש של יעד 98% שיעור שינוי", "title": "בחינה מחדש של יעד 98% שיעור שינוי",
"description": "הערכה מחדש של ריאליות יעד 98% בהתבסס על מחקר Endsley על התנהגות מומחים", "description": "הערכה מחדש של ריאליות יעד 98% בהתבסס על מחקר Endsley על התנהגות מומחים",
"details": "ניתוח מחקרי על התנהגות מומחים ונטייתם לבצע שינויים. הגדרת יעד ריאלי יותר המתחשב בגורמים פסיכולוגיים. הצעת מדדי הצלחה חלופיים כמו שיעור שינויים משמעותיים או שביעות רצון המומחים. כולל הגדרת baseline מתוך נתונים היסטוריים אם קיימים.", "details": "ניתוח מחקרי על התנהגות מומחים ונטייתם לבצע שינויים. הגדרת יעד ריאלי יותר המתחשב בגורמים פסיכולוגיים. הצעת מדדי הצלחה חלופיים כמו שיעור שינויים משמעותיים או שביעות רצון המומחים. כולל הגדרת baseline מתוך נתונים היסטוריים אם קיימים.",
@@ -408,7 +408,7 @@
"updatedAt": "2026-04-02T21:02:13.446Z" "updatedAt": "2026-04-02T21:02:13.446Z"
}, },
{ {
"id": "62", "id": 62,
"title": "הגדרת מנגנון לולאת למידה", "title": "הגדרת מנגנון לולאת למידה",
"description": "פיתוח מנגנון עדכון המודל על בסיס פידבק מדפנה ומשתמשים", "description": "פיתוח מנגנון עדכון המודל על בסיס פידבק מדפנה ומשתמשים",
"details": "הגדרת אסטרטגיית עדכון המודל: fine-tuning מול prompt engineering מול עדכון RAG. יישום מנגנון איסוף פידבק מובנה, עיבוד הנתונים לפורמט מתאים לאימון, ותהליך עדכון אוטומטי או חצי-אוטומטי. כולל מנגנון A/B testing לבדיקת שיפורים.", "details": "הגדרת אסטרטגיית עדכון המודל: fine-tuning מול prompt engineering מול עדכון RAG. יישום מנגנון איסוף פידבק מובנה, עיבוד הנתונים לפורמט מתאים לאימון, ותהליך עדכון אוטומטי או חצי-אוטומטי. כולל מנגנון A/B testing לבדיקת שיפורים.",
@@ -423,7 +423,7 @@
"updatedAt": "2026-04-02T21:02:32.651Z" "updatedAt": "2026-04-02T21:02:32.651Z"
}, },
{ {
"id": "63", "id": 63,
"title": "הוספת הגנה מפני prompt injection", "title": "הוספת הגנה מפני prompt injection",
"description": "יישום מנגנון הגנה מפני prompt injection ממסמכי מקור חיצוניים", "description": "יישום מנגנון הגנה מפני prompt injection ממסמכי מקור חיצוניים",
"details": "פיתוח מנגנון סינון וסניטיזציה של מסמכי קלט לזיהוי ניסיונות prompt injection. יישום validation של תוכן המסמכים, הפרדה בין הוראות המערכת לתוכן המסמכים, ומנגנון flagging של מסמכים חשודים. כולל רשימה שחורה של דפוסים מסוכנים.", "details": "פיתוח מנגנון סינון וסניטיזציה של מסמכי קלט לזיהוי ניסיונות prompt injection. יישום validation של תוכן המסמכים, הפרדה בין הוראות המערכת לתוכן המסמכים, ומנגנון flagging של מסמכים חשודים. כולל רשימה שחורה של דפוסים מסוכנים.",
@@ -437,7 +437,7 @@
"updatedAt": "2026-04-02T21:02:49.768Z" "updatedAt": "2026-04-02T21:02:49.768Z"
}, },
{ {
"id": "64", "id": 64,
"title": "הוספת מנגנון back-flows בתהליך", "title": "הוספת מנגנון back-flows בתהליך",
"description": "יישום יכולת חזרה אחורה בתהליך לעריכת בלוקים קודמים או שינוי כיוון", "description": "יישום יכולת חזרה אחורה בתהליך לעריכת בלוקים קודמים או שינוי כיוון",
"details": "פיתוח ממשק לחזרה לשלבים קודמים בתהליך. מנגנון לעריכת בלוקים שכבר הושלמו, עדכון אוטומטי של בלוקים תלויים, ומעקב אחר שינויים. כולל אזהרות למשתמש על השפעת שינויים על בלוקים אחרים ואפשרות לביטול פעולות.", "details": "פיתוח ממשק לחזרה לשלבים קודמים בתהליך. מנגנון לעריכת בלוקים שכבר הושלמו, עדכון אוטומטי של בלוקים תלויים, ומעקב אחר שינויים. כולל אזהרות למשתמש על השפעת שינויים על בלוקים אחרים ואפשרות לביטול פעולות.",
@@ -451,7 +451,7 @@
"updatedAt": "2026-04-02T21:01:07.801Z" "updatedAt": "2026-04-02T21:01:07.801Z"
}, },
{ {
"id": "65", "id": 65,
"title": "הוספת שלב QA/ולידציה לפני שליחה לדפנה", "title": "הוספת שלב QA/ולידציה לפני שליחה לדפנה",
"description": "יישום checklist אוטומטי ומנגנון QA לפני הפלט הסופי", "description": "יישום checklist אוטומטי ומנגנון QA לפני הפלט הסופי",
"details": "פיתוח checklist אוטומטי שבודק שלמות כל הבלוקים, תקינות הפורמט, נוכחות כל הרכיבים הנדרשים, ועקביות פנימית. מנגנון וולידציה של ציטוטים והפניות, בדיקת איכות השפה, ואזהרות על בעיות פוטנציאליות. כולל דוח QA מפורט למשתמש.", "details": "פיתוח checklist אוטומטי שבודק שלמות כל הבלוקים, תקינות הפורמט, נוכחות כל הרכיבים הנדרשים, ועקביות פנימית. מנגנון וולידציה של ציטוטים והפניות, בדיקת איכות השפה, ואזהרות על בעיות פוטנציאליות. כולל דוח QA מפורט למשתמש.",
@@ -466,7 +466,7 @@
"updatedAt": "2026-04-02T21:03:09.658Z" "updatedAt": "2026-04-02T21:03:09.658Z"
}, },
{ {
"id": "66", "id": 66,
"title": "יישום ניהול גרסאות של בלוקים", "title": "יישום ניהול גרסאות של בלוקים",
"description": "פיתוח מערכת ניהול גרסאות לכל בלוק בנפרד", "description": "פיתוח מערכת ניהול גרסאות לכל בלוק בנפרד",
"details": "יישום version control לכל בלוק בנפרד, שמירת היסטוריית שינויים, יכולת השוואה בין גרסאות, ואפשרות לחזרה לגרסה קודמת של בלוק ספציפי. כולל ממשק גרפי להצגת ההבדלים בין גרסאות ומטא-דאטה על כל שינוי (זמן, משתמש, סיבה).", "details": "יישום version control לכל בלוק בנפרד, שמירת היסטוריית שינויים, יכולת השוואה בין גרסאות, ואפשרות לחזרה לגרסה קודמת של בלוק ספציפי. כולל ממשק גרפי להצגת ההבדלים בין גרסאות ומטא-דאטה על כל שינוי (זמן, משתמש, סיבה).",
@@ -480,7 +480,7 @@
"updatedAt": "2026-04-02T21:04:33.961Z" "updatedAt": "2026-04-02T21:04:33.961Z"
}, },
{ {
"id": "67", "id": 67,
"title": "טיפול באיחוד תיקים", "title": "טיפול באיחוד תיקים",
"description": "פיתוח מנגנון לטיפול באיחוד תיקים כמו במקרה אריאלי 1078+1083", "description": "פיתוח מנגנון לטיפול באיחוד תיקים כמו במקרה אריאלי 1078+1083",
"details": "יישום לוגיקה לזיהוי תיקים הקשורים זה לזה ומנגנון איחוד אוטומטי או חצי-אוטומטי. טיפול בחפיפות מידע, פתרון קונפליקטים, ושמירת קישוריות בין התיקים המאוחדים. כולל ממשק למשתמש לאישור ועריכת האיחוד המוצע.", "details": "יישום לוגיקה לזיהוי תיקים הקשורים זה לזה ומנגנון איחוד אוטומטי או חצי-אוטומטי. טיפול בחפיפות מידע, פתרון קונפליקטים, ושמירת קישוריות בין התיקים המאוחדים. כולל ממשק למשתמש לאישור ועריכת האיחוד המוצע.",
@@ -495,7 +495,7 @@
"updatedAt": "2026-04-02T21:04:33.964Z" "updatedAt": "2026-04-02T21:04:33.964Z"
}, },
{ {
"id": "68", "id": 68,
"title": "תיקון LOA של סיעור מוחות", "title": "תיקון LOA של סיעור מוחות",
"description": "תיקון רמת האוטומציה של סיעור מוחות מרמה ג' לרמה ב'", "description": "תיקון רמת האוטומציה של סיעור מוחות מרמה ג' לרמה ב'",
"details": "עדכון הגדרת רמת האוטומציה (LOA) של תהליך סיעור המוחות מרמה ג' (אוטומציה מלאה) לרמה ב' (אוטומציה עם פיקוח אנושי). עדכון כל המסמכים והממשקים הרלוונטיים. הבטחת התאמה לרמת הביקורת הנדרשת.", "details": "עדכון הגדרת רמת האוטומציה (LOA) של תהליך סיעור המוחות מרמה ג' (אוטומציה מלאה) לרמה ב' (אוטומציה עם פיקוח אנושי). עדכון כל המסמכים והממשקים הרלוונטיים. הבטחת התאמה לרמת הביקורת הנדרשת.",
@@ -507,7 +507,7 @@
"updatedAt": "2026-04-02T21:04:33.967Z" "updatedAt": "2026-04-02T21:04:33.967Z"
}, },
{ {
"id": "69", "id": 69,
"title": "הגדרת סיעור מוחות כאופציונלי", "title": "הגדרת סיעור מוחות כאופציונלי",
"description": "שינוי הגדרת סיעור המוחות לאופציונלי גם במקרים שיש נימוק קיים", "description": "שינוי הגדרת סיעור המוחות לאופציונלי גם במקרים שיש נימוק קיים",
"details": "עדכון הלוגיקה כך שסיעור מוחות יהיה אופציונלי בכל המקרים, כולל כאשר קיים נימוק בסיסי. הוספת אפשרות למשתמש לבחור האם להפעיל סיעור מוחות או לדלג עליו. עדכון ממשק המשתמש והדרישות בהתאם.", "details": "עדכון הלוגיקה כך שסיעור מוחות יהיה אופציונלי בכל המקרים, כולל כאשר קיים נימוק בסיסי. הוספת אפשרות למשתמש לבחור האם להפעיל סיעור מוחות או לדלג עליו. עדכון ממשק המשתמש והדרישות בהתאם.",
@@ -521,7 +521,7 @@
"updatedAt": "2026-04-02T21:04:33.969Z" "updatedAt": "2026-04-02T21:04:33.969Z"
}, },
{ {
"id": "70", "id": 70,
"title": "הוספת ניטרליות מבנית", "title": "הוספת ניטרליות מבנית",
"description": "הרחבת דרישות הניטרליות מלקסיקלית למבנית", "description": "הרחבת דרישות הניטרליות מלקסיקלית למבנית",
"details": "הגדרת כללים לניטרליות מבנית בנוסף ללקסיקלית: סדר הצגת הטיעונים, אורך היחסי של סעיפים, מיקום המידע, ומבנה הפסיקה. פיתוח מנגנון בדיקה אוטומטית לזיהוי הטיה מבנית ואזהרות למשתמש. כולל הנחיות לכתיבה מאוזנת.", "details": "הגדרת כללים לניטרליות מבנית בנוסף ללקסיקלית: סדר הצגת הטיעונים, אורך היחסי של סעיפים, מיקום המידע, ומבנה הפסיקה. פיתוח מנגנון בדיקה אוטומטית לזיהוי הטיה מבנית ואזהרות למשתמש. כולל הנחיות לכתיבה מאוזנת.",
@@ -535,7 +535,7 @@
"updatedAt": "2026-04-02T21:04:33.973Z" "updatedAt": "2026-04-02T21:04:33.973Z"
}, },
{ {
"id": "71", "id": 71,
"title": "מיפוי פרסורמן 4 stages", "title": "מיפוי פרסורמן 4 stages",
"description": "הרחבת המיפוי מ-LOA בלבד לכלל 4 השלבים של מודל פרסורמן", "description": "הרחבת המיפוי מ-LOA בלבד לכלל 4 השלבים של מודל פרסורמן",
"details": "מיפוי מלא של התהליך לפי 4 השלבים של פרסורמן: Information acquisition, Information analysis, Decision selection, Action implementation. הגדרת רמת האוטומציה לכל שלב בנפרד ולא רק LOA כללי. עדכון התיעוד והדרישות בהתאם.", "details": "מיפוי מלא של התהליך לפי 4 השלבים של פרסורמן: Information acquisition, Information analysis, Decision selection, Action implementation. הגדרת רמת האוטומציה לכל שלב בנפרד ולא רק LOA כללי. עדכון התיעוד והדרישות בהתאם.",
@@ -549,7 +549,7 @@
"updatedAt": "2026-04-02T21:04:33.976Z" "updatedAt": "2026-04-02T21:04:33.976Z"
}, },
{ {
"id": "72", "id": 72,
"title": "הגדרת דרישות ביצועים per-block וסינכרוני/אסינכרוני", "title": "הגדרת דרישות ביצועים per-block וסינכרוני/אסינכרוני",
"description": "הגדרת דרישות ביצועים מפורטות לכל בלוק ובחירה בין עיבוד סינכרוני לאסינכרוני", "description": "הגדרת דרישות ביצועים מפורטות לכל בלוק ובחירה בין עיבוד סינכרוני לאסינכרוני",
"details": "הגדרת SLA ספציפי לכל בלוק: זמני תגובה מקסימליים, throughput נדרש, ושיעור זמינות. החלטה על ארכיטקטורת עיבוד: סינכרונית לבלוקים קריטיים, אסינכרונית לבלוקים כבדים. יישום מנגנון ניטור ביצועים ואזהרות על חריגה מהסטנדרטים.", "details": "הגדרת SLA ספציפי לכל בלוק: זמני תגובה מקסימליים, throughput נדרש, ושיעור זמינות. החלטה על ארכיטקטורת עיבוד: סינכרונית לבלוקים קריטיים, אסינכרונית לבלוקים כבדים. יישום מנגנון ניטור ביצועים ואזהרות על חריגה מהסטנדרטים.",
@@ -563,7 +563,7 @@
"updatedAt": "2026-04-02T21:04:33.980Z" "updatedAt": "2026-04-02T21:04:33.980Z"
}, },
{ {
"id": "73", "id": 73,
"title": "הרחבת DB schema לתהליך מלא", "title": "הרחבת DB schema לתהליך מלא",
"description": "הוספת שדות וטבלאות חסרים לתמיכה בתהליך המלא של כתיבת החלטות משפטיות", "description": "הוספת שדות וטבלאות חסרים לתמיכה בתהליך המלא של כתיבת החלטות משפטיות",
"details": "בקובץ db.py:\n1. הוספת שדות לטבלת decisions:\n - direction_doc JSONB - לשמירת מסמך הכיוון\n - outcome_reasoning TEXT - לנימוק התוצאה\n2. הרחבת enum של status בטבלת cases ל-13 ערכים:\n ['new', 'uploading', 'processing', 'documents_ready', 'outcome_set', 'brainstorming', 'direction_approved', 'drafting', 'qa_review', 'drafted', 'exported', 'reviewed', 'final']\n3. יצירת טבלת qa_results חדשה:\n - id SERIAL PRIMARY KEY\n - case_number VARCHAR REFERENCES cases\n - validation_type VARCHAR\n - passed BOOLEAN\n - errors JSONB\n - created_at TIMESTAMP\n4. יישום כ-migration עם Alembic", "details": "בקובץ db.py:\n1. הוספת שדות לטבלת decisions:\n - direction_doc JSONB - לשמירת מסמך הכיוון\n - outcome_reasoning TEXT - לנימוק התוצאה\n2. הרחבת enum של status בטבלת cases ל-13 ערכים:\n ['new', 'uploading', 'processing', 'documents_ready', 'outcome_set', 'brainstorming', 'direction_approved', 'drafting', 'qa_review', 'drafted', 'exported', 'reviewed', 'final']\n3. יצירת טבלת qa_results חדשה:\n - id SERIAL PRIMARY KEY\n - case_number VARCHAR REFERENCES cases\n - validation_type VARCHAR\n - passed BOOLEAN\n - errors JSONB\n - created_at TIMESTAMP\n4. יישום כ-migration עם Alembic",
@@ -575,7 +575,7 @@
"updatedAt": "2026-04-03T08:54:55.256Z" "updatedAt": "2026-04-03T08:54:55.256Z"
}, },
{ {
"id": "74", "id": 74,
"title": "הוספת 5 API endpoints חדשים ב-MCP server", "title": "הוספת 5 API endpoints חדשים ב-MCP server",
"description": "יצירת endpoints חדשים לתמיכה בתהליך כתיבת ההחלטות", "description": "יצירת endpoints חדשים לתמיכה בתהליך כתיבת ההחלטות",
"details": "בקובץ server.py או בקבצי API:\n1. POST /api/cases/{case_number}/outcome\n - קבלת: {outcome: string, reasoning: string}\n - שמירה ב-DB\n - עדכון סטטוס ל-outcome_set\n2. GET /api/cases/{case_number}/claims\n - החזרת טענות מחולצות מה-JSONB\n3. POST /api/cases/{case_number}/direction\n - קבלת מסמך כיוון כ-JSON\n - שמירה בשדה direction_doc\n - עדכון סטטוס ל-direction_approved\n4. POST /api/cases/{case_number}/qa\n - הרצת בדיקות QA\n - שמירה בטבלת qa_results\n - החזרת תוצאות\n5. POST /api/cases/{case_number}/learn\n - הפעלת לולאת למידה\n - עדכון מודלים/פרמטרים", "details": "בקובץ server.py או בקבצי API:\n1. POST /api/cases/{case_number}/outcome\n - קבלת: {outcome: string, reasoning: string}\n - שמירה ב-DB\n - עדכון סטטוס ל-outcome_set\n2. GET /api/cases/{case_number}/claims\n - החזרת טענות מחולצות מה-JSONB\n3. POST /api/cases/{case_number}/direction\n - קבלת מסמך כיוון כ-JSON\n - שמירה בשדה direction_doc\n - עדכון סטטוס ל-direction_approved\n4. POST /api/cases/{case_number}/qa\n - הרצת בדיקות QA\n - שמירה בטבלת qa_results\n - החזרת תוצאות\n5. POST /api/cases/{case_number}/learn\n - הפעלת לולאת למידה\n - עדכון מודלים/פרמטרים",
@@ -589,7 +589,7 @@
"updatedAt": "2026-04-03T08:55:56.839Z" "updatedAt": "2026-04-03T08:55:56.839Z"
}, },
{ {
"id": "75", "id": 75,
"title": "הוספת 8 tools חדשים לפלאגין Paperclip", "title": "הוספת 8 tools חדשים לפלאגין Paperclip",
"description": "הרחבת הפלאגין עם כלים חדשים לאינטראקציה עם המערכת המשפטית", "description": "הרחבת הפלאגין עם כלים חדשים לאינטראקציה עם המערכת המשפטית",
"details": "1. בקובץ src/worker.ts - הוספת 8 tools:\n - legal_document_upload: העלאת מסמך\n - legal_document_list: רשימת מסמכים\n - legal_document_text: קריאת טקסט ממסמך\n - legal_search_case: חיפוש תיק\n - legal_find_similar: מציאת תקדימים\n - legal_set_outcome: הגדרת תוצאה\n - legal_get_claims: קבלת טענות\n - legal_style_guide: קבלת הנחיות סגנון\n\n2. בקובץ src/legal-api.ts - יישום 8 methods:\n ```typescript\n async uploadDocument(caseNumber: string, file: File) {...}\n async listDocuments(caseNumber: string) {...}\n async getDocumentText(docId: string) {...}\n async searchCase(query: string) {...}\n async findSimilar(caseNumber: string) {...}\n async setOutcome(caseNumber: string, outcome: string, reasoning: string) {...}\n async getClaims(caseNumber: string) {...}\n async getStyleGuide() {...}\n ```\n\n3. בקובץ plugin.json - עדכון manifest", "details": "1. בקובץ src/worker.ts - הוספת 8 tools:\n - legal_document_upload: העלאת מסמך\n - legal_document_list: רשימת מסמכים\n - legal_document_text: קריאת טקסט ממסמך\n - legal_search_case: חיפוש תיק\n - legal_find_similar: מציאת תקדימים\n - legal_set_outcome: הגדרת תוצאה\n - legal_get_claims: קבלת טענות\n - legal_style_guide: קבלת הנחיות סגנון\n\n2. בקובץ src/legal-api.ts - יישום 8 methods:\n ```typescript\n async uploadDocument(caseNumber: string, file: File) {...}\n async listDocuments(caseNumber: string) {...}\n async getDocumentText(docId: string) {...}\n async searchCase(query: string) {...}\n async findSimilar(caseNumber: string) {...}\n async setOutcome(caseNumber: string, outcome: string, reasoning: string) {...}\n async getClaims(caseNumber: string) {...}\n async getStyleGuide() {...}\n ```\n\n3. בקובץ plugin.json - עדכון manifest",
@@ -603,7 +603,7 @@
"updatedAt": "2026-04-03T08:59:27.838Z" "updatedAt": "2026-04-03T08:59:27.838Z"
}, },
{ {
"id": "76", "id": 76,
"title": "שיפור status sync ב-Paperclip", "title": "שיפור status sync ב-Paperclip",
"description": "מיפוי מלא של 13 סטטוסים והוספת comments מפורטים", "description": "מיפוי מלא של 13 סטטוסים והוספת comments מפורטים",
"details": "1. עדכון מיפוי סטטוסים:\n ```javascript\n const statusMapping = {\n 'new': 'תיק חדש',\n 'uploading': 'העלאת מסמכים',\n 'processing': 'עיבוד מסמכים',\n 'documents_ready': 'מסמכים מוכנים',\n 'outcome_set': 'תוצאה הוגדרה',\n 'brainstorming': 'גיבוש כיוון',\n 'direction_approved': 'כיוון אושר',\n 'drafting': 'כתיבת החלטה',\n 'qa_review': 'בדיקת איכות',\n 'drafted': 'טיוטה מוכנה',\n 'exported': 'יוצאה ל-DOCX',\n 'reviewed': 'נבדקה ע\"י עו\"ד',\n 'final': 'סופית'\n }\n ```\n\n2. הוספת comments אוטומטיים ב-Paperclip:\n - בכל מעבר סטטוס\n - עם timestamp\n - עם פירוט הפעולה\n\n3. עדכון job sync-case-status", "details": "1. עדכון מיפוי סטטוסים:\n ```javascript\n const statusMapping = {\n 'new': 'תיק חדש',\n 'uploading': 'העלאת מסמכים',\n 'processing': 'עיבוד מסמכים',\n 'documents_ready': 'מסמכים מוכנים',\n 'outcome_set': 'תוצאה הוגדרה',\n 'brainstorming': 'גיבוש כיוון',\n 'direction_approved': 'כיוון אושר',\n 'drafting': 'כתיבת החלטה',\n 'qa_review': 'בדיקת איכות',\n 'drafted': 'טיוטה מוכנה',\n 'exported': 'יוצאה ל-DOCX',\n 'reviewed': 'נבדקה ע\"י עו\"ד',\n 'final': 'סופית'\n }\n ```\n\n2. הוספת comments אוטומטיים ב-Paperclip:\n - בכל מעבר סטטוס\n - עם timestamp\n - עם פירוט הפעולה\n\n3. עדכון job sync-case-status",
@@ -617,7 +617,7 @@
"updatedAt": "2026-04-03T09:00:19.243Z" "updatedAt": "2026-04-03T09:00:19.243Z"
}, },
{ {
"id": "77", "id": 77,
"title": "כתיבת SOUL.md לסוכנים", "title": "כתיבת SOUL.md לסוכנים",
"description": "יצירת קבצי הנחיות לסוכני AI בעברית", "description": "יצירת קבצי הנחיות לסוכני AI בעברית",
"details": "1. CEO Agent SOUL.md:\n ```markdown\n # CEO Agent - סוכן מנהל\n \n ## תפקיד\n ניהול תהליך כתיבת החלטה משפטית מקצה לקצה\n \n ## הנחיות\n - עבוד בעברית תמיד\n - נהל את התהליך לפי 13 הסטטוסים\n - התרע לחיים במקרים: תקלה טכנית, החלטה מורכבת, חריגה מזמנים\n - וודא שכל שלב הושלם לפני מעבר לבא\n \n ## מיפוי סטטוסים\n [רשימת 13 סטטוסים עם הסבר לכל אחד]\n ```\n\n2. Case Analyst Agent SOUL.md:\n ```markdown\n # Case Analyst - סוכן מנתח\n \n ## תפקיד\n ניתוח מסמכים משפטיים וחילוץ מידע\n \n ## הנחיות\n - נתח מסמכים בעברית\n - חלץ טענות מרכזיות\n - זהה תקדימים רלוונטיים\n - סכם עובדות מהותיות\n ```", "details": "1. CEO Agent SOUL.md:\n ```markdown\n # CEO Agent - סוכן מנהל\n \n ## תפקיד\n ניהול תהליך כתיבת החלטה משפטית מקצה לקצה\n \n ## הנחיות\n - עבוד בעברית תמיד\n - נהל את התהליך לפי 13 הסטטוסים\n - התרע לחיים במקרים: תקלה טכנית, החלטה מורכבת, חריגה מזמנים\n - וודא שכל שלב הושלם לפני מעבר לבא\n \n ## מיפוי סטטוסים\n [רשימת 13 סטטוסים עם הסבר לכל אחד]\n ```\n\n2. Case Analyst Agent SOUL.md:\n ```markdown\n # Case Analyst - סוכן מנתח\n \n ## תפקיד\n ניתוח מסמכים משפטיים וחילוץ מידע\n \n ## הנחיות\n - נתח מסמכים בעברית\n - חלץ טענות מרכזיות\n - זהה תקדימים רלוונטיים\n - סכם עובדות מהותיות\n ```",
@@ -629,7 +629,7 @@
"updatedAt": "2026-04-03T08:57:14.984Z" "updatedAt": "2026-04-03T08:57:14.984Z"
}, },
{ {
"id": "78", "id": 78,
"title": "יישום skill /brainstorm", "title": "יישום skill /brainstorm",
"description": "יצירת skill לגיבוש כיוון ההחלטה בשיתוף עם המשתמש", "description": "יצירת skill לגיבוש כיוון ההחלטה בשיתוף עם המשתמש",
"details": "בקובץ skills/brainstorm.ts:\n```typescript\nexport async function brainstorm(caseNumber: string) {\n // שלב 1: הצגת טענות מרכזיות\n const claims = await api.getClaims(caseNumber);\n displayClaims(claims);\n \n // שלב 2: הצעת 2-3 כיוונים\n const directions = generateDirections(claims);\n displayDirections(directions);\n \n // שלב 3: דיון אינטראקטיבי\n let approved = false;\n while (!approved) {\n const feedback = await getUserFeedback();\n if (feedback.type === 'approve') {\n approved = true;\n } else {\n directions = refineDirections(directions, feedback);\n }\n }\n \n // שלב 4: יצירת מסמך כיוון\n const directionDoc = {\n mainDirection: directions.selected,\n keyPoints: directions.keyPoints,\n precedents: directions.precedents,\n approvedBy: 'user',\n timestamp: new Date()\n };\n \n // שלב 5: שמירה ועדכון סטטוס\n await api.saveDirection(caseNumber, directionDoc);\n}\n```", "details": "בקובץ skills/brainstorm.ts:\n```typescript\nexport async function brainstorm(caseNumber: string) {\n // שלב 1: הצגת טענות מרכזיות\n const claims = await api.getClaims(caseNumber);\n displayClaims(claims);\n \n // שלב 2: הצעת 2-3 כיוונים\n const directions = generateDirections(claims);\n displayDirections(directions);\n \n // שלב 3: דיון אינטראקטיבי\n let approved = false;\n while (!approved) {\n const feedback = await getUserFeedback();\n if (feedback.type === 'approve') {\n approved = true;\n } else {\n directions = refineDirections(directions, feedback);\n }\n }\n \n // שלב 4: יצירת מסמך כיוון\n const directionDoc = {\n mainDirection: directions.selected,\n keyPoints: directions.keyPoints,\n precedents: directions.precedents,\n approvedBy: 'user',\n timestamp: new Date()\n };\n \n // שלב 5: שמירה ועדכון סטטוס\n await api.saveDirection(caseNumber, directionDoc);\n}\n```",
@@ -643,7 +643,7 @@
"updatedAt": "2026-04-03T10:16:24.667Z" "updatedAt": "2026-04-03T10:16:24.667Z"
}, },
{ {
"id": "79", "id": 79,
"title": "שיפור skill /draft-decision לכתיבה בלוק-אחרי-בלוק", "title": "שיפור skill /draft-decision לכתיבה בלוק-אחרי-בלוק",
"description": "שדרוג מ-stub לכתיבה מלאה עם 12 בלוקים", "description": "שדרוג מ-stub לכתיבה מלאה עם 12 בלוקים",
"details": "בקובץ skills/draft-decision.ts:\n```typescript\nconst BLOCKS = [\n {id: 'ה', name: 'כותרת', temperature: 0.3},\n {id: 'ו', name: 'פתיח', temperature: 0.5},\n {id: 'ז', name: 'רקע', temperature: 0.4},\n {id: 'ח', name: 'טענות הצדדים', temperature: 0.3},\n {id: 'ט', name: 'תמצית', temperature: 0.6},\n {id: 'י', name: 'דיון והכרעה', temperature: 0.7, model: 'opus'},\n {id: 'יא', name: 'סוף דבר', temperature: 0.5}\n];\n\nexport async function draftDecision(caseNumber: string) {\n const direction = await api.getDirection(caseNumber);\n const lastBlock = await getLastCompletedBlock(caseNumber);\n \n for (let i = getBlockIndex(lastBlock) + 1; i < BLOCKS.length; i++) {\n const block = BLOCKS[i];\n \n // כתיבת בלוק\n const content = await writeBlock(block, {\n direction,\n previousBlocks: await getPreviousBlocks(caseNumber, i),\n temperature: block.temperature,\n model: block.model || 'default'\n });\n \n // שמירה מיידית\n await saveBlock(caseNumber, block.id, content);\n \n // בלוק י - CREAC + thinking\n if (block.id === 'י') {\n await applyCREAC(content);\n await addThinkingTags(content);\n }\n }\n}\n\n// Recovery function\nexport async function recoverDraft(caseNumber: string) {\n const lastBlock = await getLastCompletedBlock(caseNumber);\n return draftDecision(caseNumber); // ממשיך מאיפה שנפל\n}\n```", "details": "בקובץ skills/draft-decision.ts:\n```typescript\nconst BLOCKS = [\n {id: 'ה', name: 'כותרת', temperature: 0.3},\n {id: 'ו', name: 'פתיח', temperature: 0.5},\n {id: 'ז', name: 'רקע', temperature: 0.4},\n {id: 'ח', name: 'טענות הצדדים', temperature: 0.3},\n {id: 'ט', name: 'תמצית', temperature: 0.6},\n {id: 'י', name: 'דיון והכרעה', temperature: 0.7, model: 'opus'},\n {id: 'יא', name: 'סוף דבר', temperature: 0.5}\n];\n\nexport async function draftDecision(caseNumber: string) {\n const direction = await api.getDirection(caseNumber);\n const lastBlock = await getLastCompletedBlock(caseNumber);\n \n for (let i = getBlockIndex(lastBlock) + 1; i < BLOCKS.length; i++) {\n const block = BLOCKS[i];\n \n // כתיבת בלוק\n const content = await writeBlock(block, {\n direction,\n previousBlocks: await getPreviousBlocks(caseNumber, i),\n temperature: block.temperature,\n model: block.model || 'default'\n });\n \n // שמירה מיידית\n await saveBlock(caseNumber, block.id, content);\n \n // בלוק י - CREAC + thinking\n if (block.id === 'י') {\n await applyCREAC(content);\n await addThinkingTags(content);\n }\n }\n}\n\n// Recovery function\nexport async function recoverDraft(caseNumber: string) {\n const lastBlock = await getLastCompletedBlock(caseNumber);\n return draftDecision(caseNumber); // ממשיך מאיפה שנפל\n}\n```",
@@ -658,7 +658,7 @@
"updatedAt": "2026-04-03T10:16:24.670Z" "updatedAt": "2026-04-03T10:16:24.670Z"
}, },
{ {
"id": "80", "id": 80,
"title": "יישום skill /qa-validate", "title": "יישום skill /qa-validate",
"description": "בדיקות איכות אוטומטיות על ההחלטה", "description": "בדיקות איכות אוטומטיות על ההחלטה",
"details": "בקובץ skills/qa-validate.ts:\n```typescript\nexport async function qaValidate(caseNumber: string) {\n const decision = await api.getDecision(caseNumber);\n const documents = await api.getDocuments(caseNumber);\n const claims = await api.getClaims(caseNumber);\n \n const checks = [\n {\n name: 'grounding_check',\n fn: () => validateGrounding(decision, documents),\n critical: true\n },\n {\n name: 'claims_coverage',\n fn: () => validateClaimsCoverage(decision, claims),\n critical: true\n },\n {\n name: 'neutral_background',\n fn: () => validateNeutrality(decision.background),\n critical: false\n },\n {\n name: 'weights_range',\n fn: () => validateWeightsInRange(decision),\n critical: true\n },\n {\n name: 'sequential_numbering',\n fn: () => validateNumbering(decision),\n critical: false\n },\n {\n name: 'definitions',\n fn: () => validateDefinitions(decision),\n critical: false\n }\n ];\n \n const results = [];\n let hasErrors = false;\n \n for (const check of checks) {\n const result = await check.fn();\n results.push({...result, name: check.name});\n if (!result.passed && check.critical) {\n hasErrors = true;\n }\n }\n \n // שמירת תוצאות\n await api.saveQAResults(caseNumber, results);\n \n // חסימת ייצוא אם יש שגיאות קריטיות\n if (hasErrors) {\n await api.blockExport(caseNumber);\n throw new Error('QA failed - export blocked');\n }\n \n return results;\n}\n```", "details": "בקובץ skills/qa-validate.ts:\n```typescript\nexport async function qaValidate(caseNumber: string) {\n const decision = await api.getDecision(caseNumber);\n const documents = await api.getDocuments(caseNumber);\n const claims = await api.getClaims(caseNumber);\n \n const checks = [\n {\n name: 'grounding_check',\n fn: () => validateGrounding(decision, documents),\n critical: true\n },\n {\n name: 'claims_coverage',\n fn: () => validateClaimsCoverage(decision, claims),\n critical: true\n },\n {\n name: 'neutral_background',\n fn: () => validateNeutrality(decision.background),\n critical: false\n },\n {\n name: 'weights_range',\n fn: () => validateWeightsInRange(decision),\n critical: true\n },\n {\n name: 'sequential_numbering',\n fn: () => validateNumbering(decision),\n critical: false\n },\n {\n name: 'definitions',\n fn: () => validateDefinitions(decision),\n critical: false\n }\n ];\n \n const results = [];\n let hasErrors = false;\n \n for (const check of checks) {\n const result = await check.fn();\n results.push({...result, name: check.name});\n if (!result.passed && check.critical) {\n hasErrors = true;\n }\n }\n \n // שמירת תוצאות\n await api.saveQAResults(caseNumber, results);\n \n // חסימת ייצוא אם יש שגיאות קריטיות\n if (hasErrors) {\n await api.blockExport(caseNumber);\n throw new Error('QA failed - export blocked');\n }\n \n return results;\n}\n```",
@@ -672,7 +672,7 @@
"updatedAt": "2026-04-03T10:16:24.673Z" "updatedAt": "2026-04-03T10:16:24.673Z"
}, },
{ {
"id": "81", "id": 81,
"title": "אינטגרציה E2E וחיבור Paperclip events", "title": "אינטגרציה E2E וחיבור Paperclip events",
"description": "חיבור מלא בין Paperclip ל-Claude Code עם trigger אוטומטי", "description": "חיבור מלא בין Paperclip ל-Claude Code עם trigger אוטומטי",
"details": "1. חיבור Paperclip events:\n```javascript\n// בקובץ paperclip-integration.js\npaperclip.on('issue.comment.created', async (event) => {\n if (event.comment.includes('/draft')) {\n await claudeCode.trigger('draft-decision', {\n caseNumber: event.issue.number\n });\n }\n});\n```\n\n2. E2E test על תיק הכט:\n```javascript\ntest('full flow - Hecht case', async () => {\n // העלאת חומרים\n await uploadDocuments('hecht', ['doc1.pdf', 'doc2.pdf']);\n \n // הזנת תוצאה\n await setOutcome('hecht', 'rejected', 'אין עילה');\n \n // כתיבה\n await triggerDraft('hecht');\n await waitForStatus('drafted');\n \n // QA\n const qaResults = await runQA('hecht');\n expect(qaResults.passed).toBe(true);\n \n // ייצוא\n const docx = await exportToDocx('hecht');\n \n // השוואה\n const similarity = await compareToFinal(docx, 'hecht-final.docx');\n expect(similarity).toBeGreaterThan(0.9);\n});\n```", "details": "1. חיבור Paperclip events:\n```javascript\n// בקובץ paperclip-integration.js\npaperclip.on('issue.comment.created', async (event) => {\n if (event.comment.includes('/draft')) {\n await claudeCode.trigger('draft-decision', {\n caseNumber: event.issue.number\n });\n }\n});\n```\n\n2. E2E test על תיק הכט:\n```javascript\ntest('full flow - Hecht case', async () => {\n // העלאת חומרים\n await uploadDocuments('hecht', ['doc1.pdf', 'doc2.pdf']);\n \n // הזנת תוצאה\n await setOutcome('hecht', 'rejected', 'אין עילה');\n \n // כתיבה\n await triggerDraft('hecht');\n await waitForStatus('drafted');\n \n // QA\n const qaResults = await runQA('hecht');\n expect(qaResults.passed).toBe(true);\n \n // ייצוא\n const docx = await exportToDocx('hecht');\n \n // השוואה\n const similarity = await compareToFinal(docx, 'hecht-final.docx');\n expect(similarity).toBeGreaterThan(0.9);\n});\n```",
@@ -691,7 +691,7 @@
"updatedAt": "2026-04-03T10:19:26.776Z" "updatedAt": "2026-04-03T10:19:26.776Z"
}, },
{ {
"id": "82", "id": 82,
"title": "מבחן הסמכה", "title": "מבחן הסמכה",
"description": "בדיקת המערכת על תיק עם החלטה קיימת והשוואת איכות", "description": "בדיקת המערכת על תיק עם החלטה קיימת והשוואת איכות",
"details": "שלב ב - בדיקה על תיק עם החלטה:\n```javascript\nexport async function certificationTest() {\n // בחירת תיק עם החלטה סופית\n const testCase = await selectTestCase();\n \n // הסתרת ההחלטה המקורית\n await hideOriginalDecision(testCase.number);\n \n // הרצת המערכת\n await runFullFlow(testCase.number);\n \n // השוואה\n const draft = await getDecision(testCase.number);\n const original = testCase.originalDecision;\n \n const comparison = {\n structure: compareStructure(draft, original),\n content: compareContent(draft, original),\n reasoning: compareReasoning(draft, original),\n outcome: compareOutcome(draft, original)\n };\n \n // חישוב ציון כולל\n const score = calculateScore(comparison);\n \n // בדיקת סף - 90%\n if (score < 0.9) {\n throw new Error(`Score ${score} is below threshold`);\n }\n \n return {score, comparison};\n}\n\n// שלב ג - תיק חי\nexport async function liveTest() {\n const liveCase = await getLiveCase();\n await runFullFlow(liveCase.number);\n \n // שליחה לדפנה לבדיקה\n await sendForReview('dafna@law.firm', liveCase.number);\n}\n```", "details": "שלב ב - בדיקה על תיק עם החלטה:\n```javascript\nexport async function certificationTest() {\n // בחירת תיק עם החלטה סופית\n const testCase = await selectTestCase();\n \n // הסתרת ההחלטה המקורית\n await hideOriginalDecision(testCase.number);\n \n // הרצת המערכת\n await runFullFlow(testCase.number);\n \n // השוואה\n const draft = await getDecision(testCase.number);\n const original = testCase.originalDecision;\n \n const comparison = {\n structure: compareStructure(draft, original),\n content: compareContent(draft, original),\n reasoning: compareReasoning(draft, original),\n outcome: compareOutcome(draft, original)\n };\n \n // חישוב ציון כולל\n const score = calculateScore(comparison);\n \n // בדיקת סף - 90%\n if (score < 0.9) {\n throw new Error(`Score ${score} is below threshold`);\n }\n \n return {score, comparison};\n}\n\n// שלב ג - תיק חי\nexport async function liveTest() {\n const liveCase = await getLiveCase();\n await runFullFlow(liveCase.number);\n \n // שליחה לדפנה לבדיקה\n await sendForReview('dafna@law.firm', liveCase.number);\n}\n```",
@@ -705,7 +705,7 @@
"updatedAt": "2026-04-03T10:19:26.779Z" "updatedAt": "2026-04-03T10:19:26.779Z"
}, },
{ {
"id": "83", "id": 83,
"title": "Phase 1 — Project setup (legal-ai UI rewrite)", "title": "Phase 1 — Project setup (legal-ai UI rewrite)",
"description": "הקמת scaffold של Next.js עם TypeScript + Tailwind v4 + App Router ב-web-ui/. התקנת כל התלויות: @tanstack/react-query, @tanstack/react-table, react-hook-form, @hookform/resolvers, zod, lucide-react, react-dropzone, openapi-typescript. העברת design-system.css tokens (navy/gold/parchment, Heebo) ל-Tailwind theme דרך @theme ו-CSS variables. הגדרת RTL עברית עם Heebo via next/font/google. בניית AppShell עם navy header + gold rule + nav.", "description": "הקמת scaffold של Next.js עם TypeScript + Tailwind v4 + App Router ב-web-ui/. התקנת כל התלויות: @tanstack/react-query, @tanstack/react-table, react-hook-form, @hookform/resolvers, zod, lucide-react, react-dropzone, openapi-typescript. העברת design-system.css tokens (navy/gold/parchment, Heebo) ל-Tailwind theme דרך @theme ו-CSS variables. הגדרת RTL עברית עם Heebo via next/font/google. בניית AppShell עם navy header + gold rule + nav.",
"status": "done", "status": "done",
@@ -801,7 +801,7 @@
"updatedAt": "2026-04-11T13:50:47.941Z" "updatedAt": "2026-04-11T13:50:47.941Z"
}, },
{ {
"id": "84", "id": 84,
"title": "Phase 2 — API client + generated TypeScript types", "title": "Phase 2 — API client + generated TypeScript types",
"description": "Add npm run api:types script that runs openapi-typescript against FastAPI's /openapi.json -> src/lib/api/types.ts. Build lib/api/client.ts (typed fetch wrapper + TanStack Query client with default retry/staleTime). Create one lib/api/<domain>.ts per endpoint category (cases, upload, compose, training, system), each exporting typed useQuery/useMutation hooks. Build lib/sse.ts as EventSource -> Query cache adapter. Plan: ~/.claude/plans/joyful-marinating-sutton.md.", "description": "Add npm run api:types script that runs openapi-typescript against FastAPI's /openapi.json -> src/lib/api/types.ts. Build lib/api/client.ts (typed fetch wrapper + TanStack Query client with default retry/staleTime). Create one lib/api/<domain>.ts per endpoint category (cases, upload, compose, training, system), each exporting typed useQuery/useMutation hooks. Build lib/sse.ts as EventSource -> Query cache adapter. Plan: ~/.claude/plans/joyful-marinating-sutton.md.",
"details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 2 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.", "details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 2 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
@@ -815,7 +815,7 @@
"updatedAt": "2026-04-11T15:51:34.020Z" "updatedAt": "2026-04-11T15:51:34.020Z"
}, },
{ {
"id": "85", "id": 85,
"title": "Phase 3 — Core read views (home, case detail, compose)", "title": "Phase 3 — Core read views (home, case detail, compose)",
"description": "Port the 3 highest-value screens. Use the frontend-design Claude Code skill to generate layout + composition, passing design tokens (navy/gold/parchment, Heebo), editorial voice, and typed API hooks. Use shadcn Card/Badge/Tabs/Sheet/ScrollArea as primitives. Port the custom donut chart into <DonutChart> component. TanStack Query staleTime:5000 for case detail replaces manual 5s polling. Plan: ~/.claude/plans/joyful-marinating-sutton.md.", "description": "Port the 3 highest-value screens. Use the frontend-design Claude Code skill to generate layout + composition, passing design tokens (navy/gold/parchment, Heebo), editorial voice, and typed API hooks. Use shadcn Card/Badge/Tabs/Sheet/ScrollArea as primitives. Port the custom donut chart into <DonutChart> component. TanStack Query staleTime:5000 for case detail replaces manual 5s polling. Plan: ~/.claude/plans/joyful-marinating-sutton.md.",
"details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 3 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.", "details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 3 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
@@ -829,7 +829,7 @@
"updatedAt": "2026-04-11T16:09:18.006Z" "updatedAt": "2026-04-11T16:09:18.006Z"
}, },
{ {
"id": "86", "id": 86,
"title": "Phase 4 — Forms and wizards (new case, upload, inline edits)", "title": "Phase 4 — Forms and wizards (new case, upload, inline edits)",
"description": "Port new case wizard, bulk upload, inline forms on case detail. Use react-hook-form + zod with schemas in lib/schemas/<entity>.ts. Build shared <WizardShell> from shadcn Card + Progress + Tabs. Build <DropZone> (react-dropzone + shadcn). Integrate SSE for upload progress via lib/sse.ts. Plan: ~/.claude/plans/joyful-marinating-sutton.md.", "description": "Port new case wizard, bulk upload, inline forms on case detail. Use react-hook-form + zod with schemas in lib/schemas/<entity>.ts. Build shared <WizardShell> from shadcn Card + Progress + Tabs. Build <DropZone> (react-dropzone + shadcn). Integrate SSE for upload progress via lib/sse.ts. Plan: ~/.claude/plans/joyful-marinating-sutton.md.",
"details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 4 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.", "details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 4 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
@@ -843,7 +843,7 @@
"updatedAt": "2026-04-11T16:25:55.569Z" "updatedAt": "2026-04-11T16:25:55.569Z"
}, },
{ {
"id": "87", "id": 87,
"title": "Phase 5 — Secondary screens (compare, training, style report, skills, diagnostics)", "title": "Phase 5 — Secondary screens (compare, training, style report, skills, diagnostics)",
"description": "Port the remaining 5 views. Use TanStack Table for training corpus and diagnostics lists. Port any charts/visualizations from current index.html. Plan: ~/.claude/plans/joyful-marinating-sutton.md.", "description": "Port the remaining 5 views. Use TanStack Table for training corpus and diagnostics lists. Port any charts/visualizations from current index.html. Plan: ~/.claude/plans/joyful-marinating-sutton.md.",
"details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 5 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.", "details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 5 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
@@ -857,7 +857,7 @@
"updatedAt": "2026-04-11T17:33:42.976Z" "updatedAt": "2026-04-11T17:33:42.976Z"
}, },
{ {
"id": "88", "id": 88,
"title": "Phase 6 — Polish & testing", "title": "Phase 6 — Polish & testing",
"description": "Accessibility pass (keyboard nav, aria-label on RTL icons, focus trap in modals). Error boundaries + toast notifications for failed mutations. Loading states for every query. Cross-browser smoke test (Chrome, Firefox, Safari) + mobile device test. Document E2E smoke test script in web-ui/README.md. Plan: ~/.claude/plans/joyful-marinating-sutton.md.", "description": "Accessibility pass (keyboard nav, aria-label on RTL icons, focus trap in modals). Error boundaries + toast notifications for failed mutations. Loading states for every query. Cross-browser smoke test (Chrome, Firefox, Safari) + mobile device test. Document E2E smoke test script in web-ui/README.md. Plan: ~/.claude/plans/joyful-marinating-sutton.md.",
"details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 6 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.", "details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 6 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
@@ -871,7 +871,7 @@
"updatedAt": "2026-04-11T17:44:08.337Z" "updatedAt": "2026-04-11T17:44:08.337Z"
}, },
{ {
"id": "89", "id": 89,
"title": "Phase 7 — Deployment & cutover", "title": "Phase 7 — Deployment & cutover",
"description": "Add multi-stage Dockerfile for web-ui/ (Node 20 build -> nginx serve of out/). Add web-ui as new app in Coolify project pointing to staging subdomain legal-ai-next.nautilus.marcusgroup.org. Run full smoke test against staging. Cutover: DNS flip legal-ai.nautilus.marcusgroup.org to new app, keep old on rollback subdomain for 1 week. Follow-up PR removes legal-ai/web/static/index.html + design-system.css once stable. Plan: ~/.claude/plans/joyful-marinating-sutton.md.", "description": "Add multi-stage Dockerfile for web-ui/ (Node 20 build -> nginx serve of out/). Add web-ui as new app in Coolify project pointing to staging subdomain legal-ai-next.nautilus.marcusgroup.org. Run full smoke test against staging. Cutover: DNS flip legal-ai.nautilus.marcusgroup.org to new app, keep old on rollback subdomain for 1 week. Follow-up PR removes legal-ai/web/static/index.html + design-system.css once stable. Plan: ~/.claude/plans/joyful-marinating-sutton.md.",
"details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 7 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.", "details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 7 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
@@ -884,7 +884,7 @@
"subtasks": [] "subtasks": []
}, },
{ {
"id": "90", "id": 90,
"title": "Phase 4.5 — Practice area integration", "title": "Phase 4.5 — Practice area integration",
"description": "Add practice_area + appeal_subtype to the wizard, types, schema, case header, and cases table. Gap identified after backend commit 26d09d6 (multi-tenant axis) — new Next.js UI has zero integration while vanilla UI is fully wired. Plan: ~/.claude/plans/woolly-cooking-graham.md", "description": "Add practice_area + appeal_subtype to the wizard, types, schema, case header, and cases table. Gap identified after backend commit 26d09d6 (multi-tenant axis) — new Next.js UI has zero integration while vanilla UI is fully wired. Plan: ~/.claude/plans/woolly-cooking-graham.md",
"details": "", "details": "",
@@ -898,7 +898,7 @@
"updatedAt": "2026-04-11T17:15:57.831Z" "updatedAt": "2026-04-11T17:15:57.831Z"
}, },
{ {
"id": "91", "id": 91,
"title": "Precedent attachment in compose screen", "title": "Precedent attachment in compose screen",
"description": "Add case_precedents table + FastAPI endpoints + MCP tools + Next.js compose UI for attaching legal precedents (quote + citation + optional archived PDF) to threshold_claims/issues and to the case as a whole. Plan: ~/.claude/plans/woolly-cooking-graham.md", "description": "Add case_precedents table + FastAPI endpoints + MCP tools + Next.js compose UI for attaching legal precedents (quote + citation + optional archived PDF) to threshold_claims/issues and to the case as a whole. Plan: ~/.claude/plans/woolly-cooking-graham.md",
"details": "", "details": "",
@@ -974,5 +974,413 @@
"updated": "2026-04-13T14:20:54.888Z", "updated": "2026-04-13T14:20:54.888Z",
"description": "Tasks for master context" "description": "Tasks for master context"
} }
},
"legal-ai": {
"tasks": [
{
"id": "1",
"title": "V7 schema: precedent library + halachot tables",
"description": "Add SCHEMA_V7_SQL to db.py: extend case_law with source_kind/document_id/extraction_status/halacha_extraction_status/practice_area (CHECK constraint for 3 areas)/appeal_subtype/headnote. Create precedent_chunks table with vector(1024). Create halachot table with vector(1024), review_status, practice_areas array. Add IVFFlat indexes. Register V7 in init_schema().",
"details": "",
"testStrategy": "",
"status": "done",
"dependencies": [],
"priority": "high",
"subtasks": [],
"updatedAt": "2026-05-03T08:17:59.928Z"
},
{
"id": "2",
"title": "Chunker: add court ruling section patterns",
"description": "Extend services/chunker.py SECTION_PATTERNS with 4 patterns for external court rulings: פסק דין→ruling, נימוקים→legal_analysis, סוף דבר→conclusion, העובדות הצריכות לעניין→facts",
"details": "",
"testStrategy": "",
"status": "done",
"dependencies": [
"1"
],
"priority": "medium",
"subtasks": [],
"updatedAt": "2026-05-03T08:18:33.239Z"
},
{
"id": "3",
"title": "Service: halacha_extractor.py",
"description": "New service that runs claude_session.query_json() over chunks where section_type IN (legal_analysis, ruling, conclusion). Concurrency=3, retry=1. Validates supporting_quote with substring check after Hebrew normalization. All halachot inserted with review_status=pending_review (no auto-publish). Embeds rule_statement+reasoning_summary via Voyage. Uses Hebrew prompt from plan appendix א. Idempotent on case_law_id.",
"details": "",
"testStrategy": "",
"status": "done",
"dependencies": [
"1",
"2"
],
"priority": "high",
"subtasks": [],
"updatedAt": "2026-05-03T08:22:12.392Z"
},
{
"id": "4",
"title": "Service: precedent_library.py orchestrator",
"description": "New service with ingest_precedent(file_path, citation, court, decision_date, source_type, precedent_level, practice_area, appeal_subtype, subject_tags, case_name, task_id) that orchestrates: extract_text → proofread → INSERT case_law (source_kind=external_upload) → chunk → embed → store precedent_chunks → halacha_extractor.extract → embed halachot → publish progress. Plus delete_precedent (cascading), list_precedents(filters), get_precedent(id), search_library(query, filters, limit) merging chunks+approved-halachot ranked.",
"details": "",
"testStrategy": "",
"status": "done",
"dependencies": [
"1",
"2",
"3"
],
"priority": "high",
"subtasks": [],
"updatedAt": "2026-05-03T08:23:33.235Z"
},
{
"id": "5",
"title": "MCP tools: precedent_library + halacha_review",
"description": "Create mcp-server/src/legal_mcp/tools/precedent_library.py with tools: precedent_library_upload, precedent_library_list, precedent_library_get, precedent_library_delete, precedent_extract_halachot, search_precedent_library (semantic, returns merged halachot+chunks), halacha_review (approve/reject). Register all in server.py. Do NOT modify existing precedent_search_library or search_decisions.",
"details": "",
"testStrategy": "",
"status": "done",
"dependencies": [
"4"
],
"priority": "high",
"subtasks": [],
"updatedAt": "2026-05-03T08:25:07.439Z"
},
{
"id": "6",
"title": "FastAPI endpoints under /api/precedent-library",
"description": "Add to web/app.py: POST /api/precedent-library/upload (multipart), GET /api/precedent-library (filters), GET /api/precedent-library/{id}, PATCH /api/precedent-library/{id}, DELETE /api/precedent-library/{id}, POST /api/precedent-library/{id}/extract-halachot, GET /api/precedent-library/search, GET /api/halachot?status=pending_review, PATCH /api/halachot/{id}, GET /api/precedent-library/stats. Reuse existing /api/progress/{task_id} SSE.",
"details": "",
"testStrategy": "",
"status": "done",
"dependencies": [
"5"
],
"priority": "high",
"subtasks": [],
"updatedAt": "2026-05-03T08:26:21.860Z"
},
{
"id": "7",
"title": "UI: /precedents page with 4 tabs",
"description": "New web-ui/src/app/precedents/page.tsx with tabs: Library (table+filters+upload), Semantic Search, Pending Review (PRIMARY - bulk approval UX with J/K nav, A/R/E shortcuts, side-by-side rule_statement vs supporting_quote, badge count), Stats. New components in web-ui/src/components/precedents/: precedent-upload-sheet, precedent-list-table, precedent-search-panel, precedent-detail-panel, halacha-review-card. New hooks in web-ui/src/lib/api/precedent-library.ts. Add nav link in app-shell.tsx.",
"details": "",
"testStrategy": "",
"status": "done",
"dependencies": [
"6"
],
"priority": "high",
"subtasks": [],
"updatedAt": "2026-05-03T08:34:00.548Z"
},
{
"id": "8",
"title": "Agent integration: legal-writer + 3 others",
"description": "Update .claude/agents/legal-writer.md (PRIMARY) — add mcp__legal-ai__search_precedent_library to tools and prompt section explaining when to use it for CREAC rule+explanation in block י. Update legal-researcher.md, legal-analyst.md, legal-ceo.md, legal-qa.md to add the tool. Update skills/decision/SKILL.md with section explaining the 3 corpora (style_corpus, case_precedents, precedent_library).",
"details": "",
"testStrategy": "",
"status": "done",
"dependencies": [
"5"
],
"priority": "medium",
"subtasks": [],
"updatedAt": "2026-05-03T08:36:24.711Z"
},
{
"id": "9",
"title": "Service: precedent_metadata_extractor.py",
"description": "LLM-based extractor that auto-fills empty metadata fields after upload: short case_name (e.g. 'אהרון ברק' from long citation), summary (2-3 sentences), headnote, key_quote, subject_tags array, appeal_subtype. Reuses claude_session.query_json. Returns dict; caller decides which empty fields to merge (never overrides user values).",
"details": "",
"testStrategy": "",
"status": "done",
"dependencies": [],
"priority": "high",
"subtasks": [],
"updatedAt": "2026-05-03T10:19:15.105Z"
},
{
"id": "10",
"title": "Halacha extractor: dual mode (binding vs persuasive)",
"description": "Update halacha_extractor.py prompt to branch on is_binding: binding=true → strict halacha extraction (current). binding=false → extract reasoning principles, applications of established halachot, persuasive conclusions. New rule_types: 'application' (applying known rule to facts), 'persuasive' (committee's reasoning citable as authority). Schema unchanged (rule_type already TEXT).",
"details": "",
"testStrategy": "",
"status": "done",
"dependencies": [],
"priority": "high",
"subtasks": [],
"updatedAt": "2026-05-03T10:19:15.117Z"
},
{
"id": "11",
"title": "Ingest pipeline: add metadata extraction stage",
"description": "In services/precedent_library.py:ingest_precedent, after halacha extraction, run metadata_extractor and PATCH the case_law row with auto-filled fields (only those left empty by user). Publish progress 'extracting_metadata'.",
"details": "",
"testStrategy": "",
"status": "done",
"dependencies": [
"9"
],
"priority": "high",
"subtasks": [],
"updatedAt": "2026-05-03T10:19:15.128Z"
},
{
"id": "12",
"title": "UI: precedent edit sheet",
"description": "Add edit button to library-list-panel rows that opens a Sheet with all editable fields (case_name, citation, court, date, practice_area, appeal_subtype, subject_tags, summary, headnote, key_quote, source_type, precedent_level, is_binding). Pre-populated from current values. Submit calls PATCH /api/precedent-library/{id} via useUpdatePrecedent. After save, invalidate library list query.",
"details": "",
"testStrategy": "",
"status": "done",
"dependencies": [],
"priority": "high",
"subtasks": [],
"updatedAt": "2026-05-03T10:19:15.134Z"
},
{
"id": "13",
"title": "Test on 403-17: fix metadata + re-extract",
"description": "After deploy: PATCH 403-17 to set case_name='ערר 403/17', then trigger precedent_extract_halachot to test the dual-mode extraction on a non-binding committee decision.",
"details": "",
"testStrategy": "",
"status": "pending",
"dependencies": [
"9",
"10",
"11",
"12"
],
"priority": "medium",
"subtasks": []
},
{
"id": "14",
"title": "Upgrade: speed up halacha+metadata extraction",
"description": "Halacha extraction on long rulings is slow (5-15 min for typical court ruling, 30-50 min for a 207-chunk appeals committee decision). Root cause: each chunk spawns a separate `claude -p` subprocess (5-10 sec startup overhead each), Hebrew prompts on cold cache run 30-90 sec, and there's no prompt-cache sharing between chunks. Acceleration options to evaluate later when speed becomes a real blocker.\n\nOptions (each can be combined):\n\n1. Concurrency 3 -> 6 in halacha_extractor.CHUNK_CONCURRENCY. ~2x faster wall-clock. Cost: 6x ~300MB RSS = 1.8GB peak — verify on Nautilus headroom.\n\n2. Larger chunks 12K -> 18-25K chars (CHUNK_TARGET_CHARS in claims_extractor.py / halacha_extractor.py). Fewer waves. Risk: timeout on cold cache (currently 1800s ceiling), and may degrade extraction precision for very long sections.\n\n3. Anthropic SDK direct with 5-min ephemeral prompt caching on the static instruction prefix (already wired the parameter as system= in claude_session.query). Estimated 5-10x faster because cache reads are ~10% of cold cost. Costs ~$0.30-2 per long ruling on Sonnet 4.6. Chair previously rejected this path for ALL traffic ('we work only with claude session'). Compromise: SDK only for the precedent-library corpus build (static, one-time), claude session for live decision drafting (interactive, frequent).\n\n4. Two-tier prompt: a short 'classification' pass with claude -p deciding which chunks contain halachot, then deep extraction only on positive chunks. Could cut total LLM time by 40-60% on rulings with lots of factual chapters.\n\n5. Already implemented (Apr 3, 2026): skip non-extractable sections — only run on chunks where section_type IN (legal_analysis, ruling, conclusion); fallback to all chunks when chunker labels nothing. So that win is already banked.\n\nRe-evaluate when: a chair drops a 200K+ char ruling into the queue and the wait becomes painful, OR when the precedent-library has 50+ pending entries and bulk processing matters.",
"details": "",
"testStrategy": "",
"status": "deferred",
"dependencies": [],
"priority": "low",
"subtasks": [],
"updatedAt": "2026-05-03T16:03:07.222Z"
},
{
"id": "15",
"title": "Backfill multimodal — החלטה על rollout מורחב לאחר A/B עם דפנה",
"description": "תזכורת לבדוק עם דפנה אם voyage-multimodal-3 על 8174-24 + 8137-24 עוזר בפועל, ולהחליט אם להריץ backfill על שאר הקורפוס (~236 docs, ~17,700 pages, ~2 שעות זמן API, ~350MB disk).",
"details": "תאריך יעד מומלץ: ~2026-05-10 (שבוע מהיום, 2026-05-03).\n\nקריטריונים להחלטה (אם מתקיים אחד — להריץ rollout):\n • דפנה זיהתה לפחות פעמיים ערך מוסף ב-8174-24 או 8137-24 (תקדים שלא הייתה מוצאת בלי image side, או חתימה/טבלה/תרשים שצף ב-top results)\n • היא ביקשה במפורש להפעיל על תיק נוסף ספציפי\n • היא מבקשת לעבור ל-search מצטלב (search_decisions, find_similar_cases) מעבר לתיק הנוכחי\n\nאם דפנה לא ראתה ערך — להחליט: לבטל / לכוונן MULTIMODAL_TEXT_WEIGHT (0.5 → 0.55-0.65) / לחכות עוד שבוע.\n\nאם החליטו להריץ — סדר עדיפויות:\n 1. שמאי-heavy: 8xxx (היטל השבחה) ו-9xxx (פיצויים) — שם הערך הגדול ביותר\n 2. תיקי 1xxx (רישוי ובניה) אחרון\n\nהרצה:\n CONTAINER=$(sudo docker ps --format '{{.Names}}' | grep gyjo | head -1)\n sudo docker cp scripts/multimodal_backfill.py $CONTAINER:/tmp/\n sudo docker cp scripts/backfill_chunk_pages.py $CONTAINER:/tmp/\n sudo docker exec $CONTAINER python /tmp/multimodal_backfill.py 8xxx-yy 9xxx-yy ...\n sudo docker exec $CONTAINER python /tmp/backfill_chunk_pages.py 8xxx-yy 9xxx-yy ...\n\nרפרנסים:\n • docs/voyage-upgrades-plan.md סעיף 'שלב C — voyage-multimodal-3 (✅ בוצע)'\n • commits 242f668..d12cdb1 על main\n • זיכרון: project_multimodal_stage_c.md, feedback_hybrid_retrieval_rrf.md",
"testStrategy": "",
"status": "pending",
"dependencies": [],
"priority": "low",
"subtasks": []
},
{
"id": "16",
"title": "[Paperclip Gap 1] runtime_config ריק — חסרים graceSec/cooldownSec/maxConcurrentRuns",
"description": "runtime_config = '{}' לכל 14 הסוכנים. מסתבר שעיקר ההגדרות החשובות (timeoutSec=3600, maxTurnsPerRun=500) יושבות ב-adapter_config ולא ב-runtime_config — אז המצב פחות חמור. אבל graceSec/cooldownSec/maxConcurrentRuns עדיין חסרים.",
"details": "תיקון לניתוח המקורי שגוי בעקבות בדיקה ב-DB:\n\nמה שכן יש לנו (ב-adapter_config, לא runtime_config):\n- timeoutSec: 3600 (לכל הסוכנים)\n- maxTurnsPerRun: 500 (לכל הסוכנים)\n- model + effort=high (לכל הסוכנים)\n- paperclipSkillSync.desiredSkills (5/7 סוכנים — חסר אצל הגהת מסמכים ומנתח משפטי)\n\nמה שבאמת חסר ב-runtime_config:\n- heartbeat.graceSec — זמן grace לפני SIGKILL אחרי timeout. מהקוד: Math.max(1, graceSec)*1000. אם לא מוגדר → 1ms grace. בעיה אם הסוכן נחתך באמצע commit ל-DB.\n- heartbeat.cooldownSec — default ביצירה חדשה: 10. אצלנו לא מוגדר.\n- heartbeat.maxConcurrentRuns — default מ-AGENT_DEFAULT_MAX_CONCURRENT_RUNS (כנראה 1).\n- heartbeat.wakeOnDemand — default=true בקוד. אצלנו לא מוגדר אבל בפועל true.\n- heartbeat.enabled — default=false (timer off). זה הרצוי אצלנו.\n\nפעולה (Phase 1):\n1. עדכון runtime_config של כל סוכן: { heartbeat: { graceSec: 60, cooldownSec: 10, maxConcurrentRuns: 1, wakeOnDemand: true } }\n2. בעיקר graceSec — בלעדיו commit באמצע יכול להיכשל\n3. cooldownSec=10 (זהה לdefault ב-UI ליצירת agent חדש)\n\nהשפעה: minimal — רוב המקרים עובדים עם defaults. graceSec הוא העיקר.",
"testStrategy": "1. SELECT name, runtime_config->'heartbeat' FROM agents → לראות שכל סוכן מקבל graceSec/cooldownSec/maxConcurrentRuns/wakeOnDemand.\n2. בדיקה: סוכן ארוך נחתך ב-timeout — לבדוק שהיתה הזדמנות לציין graceful shutdown ב-30-60 שניות",
"status": "done",
"dependencies": [],
"priority": "medium",
"subtasks": [],
"updatedAt": "2026-05-04T07:47:02.008Z"
},
{
"id": "17",
"title": "[Paperclip Gap 2] תקציבים = 0 לכל הסוכנים — אין budget enforcement",
"description": "budget_monthly_cents = 0 ו-spent_monthly_cents = 0 לכל 14 הסוכנים. Paperclip מציע cost control מובנה — אנחנו מתעלמים.",
"details": "ממצא: SELECT name, budget_monthly_cents, spent_monthly_cents FROM agents → הכל אפס.\n\nסיכון: לולאה חבויה יכולה לשרוף מאות $. אין auto-pause ב-80% spend (דפוס ש-CEO HEARTBEAT הרשמי מצפה לו).\n\nפעולה (Phase 3):\n1. מדידה: כמה כל סוכן באמת מוציא בחודש כיום (דרך לוגי claude-code, או Anthropic dashboard).\n2. הגדרת budget_monthly_cents סביר לכל סוכן (כותב Opus ≫ מנתח Sonnet).\n3. בדיקה שהמנגנון מפסיק כשמגיעים ל-100%.\n\nשאלה לחיים לפני ביצוע: באיזו רזולוציה למדוד? לפי Anthropic invoice, או לפי טוקנים בלוגים של claude_session?",
"testStrategy": "בדיקה ידנית: להגדיר budget קטן לסוכן ניסוי (1 cent), לעורר אותו על משימה, לוודא שמתעורר ונחסם. לעקוב ב-spent_monthly_cents.",
"status": "done",
"dependencies": [],
"priority": "high",
"subtasks": [],
"updatedAt": "2026-05-04T10:18:08.046Z"
},
{
"id": "18",
"title": "[Paperclip Gap 3] חסר X-Paperclip-Run-Id header בקריאות API",
"description": "ה-skill הרשמי קובע: 'You MUST include -H X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID on ALL API requests that modify issues'. ב-HEARTBEAT.md שלנו אין זכר לכך.",
"details": "ממצא: grep -n 'X-Paperclip-Run-Id' .claude/agents/ → 0 hits. כל curl ב-checkout/comments/PATCH issues — בלי הheader.\n\nסיכון: audit trail שבור. שאלה 'איזו ריצה שינתה את ה-issue X?' אין לה תשובה ב-DB.\n\nפעולה (Phase 1):\n1. עדכון .claude/agents/HEARTBEAT.md — דוגמאות ה-curl יכללו את הheader\n2. עדכון 6 קבצי הסוכנים (legal-ceo.md, legal-analyst.md, legal-researcher.md, legal-writer.md, legal-qa.md, legal-exporter.md) — כל מקום שיש curl POST/PATCH\n3. בדיקה שיש env var $PAPERCLIP_RUN_ID זמין בכל heartbeat",
"testStrategy": "בלוגי Paperclip (heartbeat_runs טבלה) לראות שהפעולות שלנו מקושרות ל-run_id. SELECT * FROM activity_log WHERE run_id IS NOT NULL ORDER BY created_at DESC LIMIT 10.",
"status": "done",
"dependencies": [],
"priority": "high",
"subtasks": [],
"updatedAt": "2026-05-04T08:49:44.646Z"
},
{
"id": "19",
"title": "[Paperclip Gap 4] לא משתמשים ב-/api/issues/{id}/interactions לאישורים",
"description": "Paperclip מציע API מובנה לאישור/שאלות (request_confirmation, ask_user_questions, suggest_tasks) עם idempotency keys ו-auto-wake. אנחנו עדיין כותבים 'חיים, מה לעשות?' כ-comment חופשי.",
"details": "סוגי interaction:\n- ask_user_questions — שאלות מובנות\n- request_confirmation — yes/no עם idempotency key (confirmation:{issueId}:plan:{revisionId})\n- suggest_tasks — הצעת עץ משימות\n- continuationPolicy: wake_assignee — wake אוטומטי על מענה\n- supersedeOnUserComment: true — בטל אם חיים עונה\n\nסיכון: אין UI מובנה לחיים (כפתורים), רק טקסט. אם הסוכן מתעורר פעמיים — שתי שאלות זהות.\n\nפעולה (Phase 2):\n1. בlegal-ceo.md — להחליף 'אם חיים לא הגדיר outcome: שאל בcomment' ב-request_confirmation\n2. בbrainstorm_directions — suggest_tasks במקום רשימת bullet\n3. בlegal-qa.md — request_confirmation לאישור export\n\nשאלה לחיים: האם תרצה לראות UI חדש או להישאר ב-Markdown comments?",
"testStrategy": "יצירת request_confirmation מסוכן ניסוי, בדיקה ב-UI שמופיעים כפתורי אישור/דחייה, בדיקה שהסוכן מתעורר אוטומטית עם PAPERCLIP_APPROVAL_ID env.",
"status": "done",
"dependencies": [
"16",
"17",
"18"
],
"priority": "medium",
"subtasks": [],
"updatedAt": "2026-05-04T11:18:59.050Z"
},
{
"id": "20",
"title": "[Paperclip Gap 5] לא משתמשים ב-PAPERCLIP_WAKE_PAYLOAD_JSON fast-path",
"description": "בwake שמכוון ל-issue ספציפי, ה-env var מכיל כבר issue summary + comments חדשים דחוסים. ה-skill הרשמי אומר 'skip Steps 1-4 entirely'. שלנו תמיד fetcher גם ה-API.",
"details": "ממצא: HEARTBEAT.md סעיפים 2-2c תמיד פונים ל-API גם אם ה-payload כבר מכיל את הכל.\n\nתועלת: חיסכון 3-4 קריאות API לכל ריצה. בwakeups תכופים (CEO על comments) — חיסכון ניכר.\n\nפעולה (Phase 2):\n1. הוספה ל-HEARTBEAT.md בראש הסעיפים: 'אם $PAPERCLIP_WAKE_PAYLOAD_JSON קיים — קרא אותו ראשון. רק אם fallbackFetchNeeded:true או חסר הקשר רחב — fetch'.\n2. דוגמה לפענוח JSON: jq עם key paths\n3. בדיקה איזה wake reasons בכלל מקבלים payload (כנראה comment-driven בלבד)",
"testStrategy": "בWakeup דרך API עם payload, לבדוק בלוגי הסוכן שאין fetch לcomments. timeit על מספר ריצות לפני/אחרי.",
"status": "done",
"dependencies": [
"18"
],
"priority": "medium",
"subtasks": [],
"updatedAt": "2026-05-04T09:15:46.339Z"
},
{
"id": "21",
"title": "[Paperclip Gap 6] שאילתות psql ישירות ל-issue_attachments — שובר אבסטרקציה",
"description": "HEARTBEAT.md סעיף 2c משתמש ב-psql ישיר ל-issue_attachments + assets. אם schema ישתנה (כפי שצפוי בעדכוני Paperclip) — כל הסוכנים נשברים.",
"details": "ממצא: 6 קבצי סוכן + HEARTBEAT.md מכילים PGPASSWORD=paperclip psql ... FROM issue_attachments ia JOIN assets a.\n\nסיכון: breakage בעדכון Paperclip. כפילות לוגיקה (copy-paste בכל סוכן).\n\nפעולה (Phase 2):\n1. בדיקה אם קיים endpoint רשמי /api/issues/{id}/attachments (curl + grep ב-server/src/routes)\n2. אם כן — להחליף את כל ה-psql\n3. אם לא — להעביר את ה-psql למקום יחיד: helper ב-mcp-server (mcp__legal-ai__list_issue_attachments tool)\n4. אופציה ג: לפתוח issue ב-paperclipai/paperclip לבקש endpoint\n\nתלוי במחקר API.",
"testStrategy": "אחרי החלפה: grep -rn 'issue_attachments' .claude/agents/ → 0 hits. בדיקה שסוכן עדיין רואה attachments בריצה.",
"status": "done",
"dependencies": [
"20"
],
"priority": "medium",
"subtasks": [],
"updatedAt": "2026-05-04T09:28:18.058Z"
},
{
"id": "22",
"title": "[Paperclip Gap 7] לא משתמשים ב-/api/issues/{id}/heartbeat-context",
"description": "Endpoint רשמי שמחזיר issue + ancestors + goal/project + comment cursor בקריאה אחת. אנחנו עושים 3 קריאות נפרדות.",
"details": "ה-skill הרשמי: 'Prefer GET /api/issues/{issueId}/heartbeat-context first. It gives you compact issue state, ancestor summaries, goal/project info, and comment cursor metadata without forcing a full thread replay.'\n\nשלנו: HEARTBEAT.md סעיפים 2 + 2b → שלוש קריאות (inbox-lite, issue, comments).\n\nפעולה (Phase 2):\n1. הוספת endpoint כצעד 6 ב-HEARTBEAT.md לפני 'Do the work'\n2. הסרת קריאות מיותרות שכבר ב-context\n3. שמירת comment cursor (after={last-seen-id}) לקריאות עוקבות",
"testStrategy": "בדיקה שהendpoint מחזיר את כל המידע הדרוש. ספירת קריאות API לפני/אחרי בריצה אמיתית.",
"status": "done",
"dependencies": [
"20"
],
"priority": "medium",
"subtasks": [],
"updatedAt": "2026-05-04T09:28:14.247Z"
},
{
"id": "23",
"title": "[Paperclip Gap 8+11] HEARTBEAT.md ארוך + אין שימוש ב-skills של Paperclip",
"description": "HEARTBEAT.md שלנו 220 שורות (vs upstream 85). Paperclip מציע 8 skills מוכנים (paperclip, paperclip-create-agent, וכו') שאנחנו לא משתמשים באף אחד.",
"details": "תיקון לניתוח: מסתבר ש-CEO + 4 סוכנים אחרים כן משתמשים ב-paperclipSkillSync עם 4 paperclip skills (paperclip, paperclip-create-agent, paperclip-create-plugin, para-memory-files). חסר אצל: הגהת מסמכים ומנתח משפטי (skills_count=0).\n\nממצא: ls skills/ ב-paperclip repo → 8 skills. שלנו: 0 skills של Paperclip בשימוש.\n\nרלוונטיים לנו:\n- paperclip — API patterns + heartbeat checklist (יכול להחליף חלק מ-HEARTBEAT.md)\n- paperclip-create-agent — אם נוסיף סוכן\n- paperclip-create-plugin — לעדכוני plugin-legal-ai\n- paperclip-converting-plans-to-tasks — יכול להחליף brainstorm_directions\n- diagnose-why-work-stopped — לתחזוקה\n\nפעולה (Phase 3):\n1. קריאת skills/paperclip/SKILL.md מלא\n2. הזרקת skill לסביבת הסוכנים (כנראה דרך CLI: paperclipai agent local-cli)\n3. שכתוב HEARTBEAT.md לפי הדפוס: project-specific only, delegation לskill הרשמי לכלל ה-API\n4. יעד: ~120 שורות ב-HEARTBEAT.md שלנו\n\nשאלה לחיים: האם להזריק skills כסימלינקים ל-symlinks קיימים, או דרך paperclipai CLI?",
"testStrategy": "אחרי שכתוב: סוכן ניסוי קורא את HEARTBEAT.md + paperclip skill, מבצע heartbeat מלא בלי שגיאות. השוואת אורך לפני/אחרי.",
"status": "done",
"dependencies": [
"16",
"17",
"18",
"19",
"20",
"21",
"22"
],
"priority": "medium",
"subtasks": [],
"updatedAt": "2026-05-04T16:44:27.553Z"
},
{
"id": "24",
"title": "[Paperclip Gap 9] לבדוק bootstrapPromptTemplate deprecated באף סוכן",
"description": "מ-docs/agents-runtime.md: 'bootstrapPromptTemplate is deprecated... should be migrated to the managed instructions bundle system.' לבדוק האם adapter_config שלנו משתמש בזה.",
"details": "פעולה (Phase 1):\n1. SELECT name, adapter_config->'promptTemplate' as pt, adapter_config->'bootstrapPromptTemplate' as bpt FROM agents WHERE adapter_type = 'claude_local';\n2. אם בשימוש אצל סוכן כלשהו — מיגרציה למבנה החדש\n3. ייעוד: לבדוק תיעוד managed instructions bundle ב-paperclip docs\n\nהערה: זה כנראה לא ישפיע אצלנו (אנחנו משתמשים ב-symlinks ל-AGENTS.md/HEARTBEAT.md ישירות) — אבל חובה לוודא.",
"testStrategy": "SELECT הנ\"ל. אם 0 שורות מחזירות bpt לא-NULL — סגור את המשימה.",
"status": "done",
"dependencies": [],
"priority": "high",
"subtasks": [],
"updatedAt": "2026-05-04T08:19:27.766Z"
},
{
"id": "25",
"title": "[Paperclip Gap 10] סוכנים מוכפלים בין 2 חברות — אין סנכרון",
"description": "14 שורות = 7 סוכנים × 2 חברות (1xxx, 8xxx). כל שינוי בהגדרות הסוכן צריך להיעשות פעמיים. אין מנגנון סנכרון או הורשה.",
"details": "ממצא: SELECT name, COUNT(*) FROM agents GROUP BY name → 2 לכל אחד.\n\nסיכון: drift בין החברות. שינוי runtime_config ל-CEO של 1xxx יכול לפספס את CEO של 8xxx.\n\nפעולה (Phase 3):\n1. בדיקה: האם Paperclip תומך ב-shared agents או chainOfCommand? (לקרוא docs/companies/)\n2. אם כן — מיגרציה למבנה משותף\n3. אם לא — סקריפט סנכרון: scripts/sync_agents_across_companies.py שמעתיק כל שינוי מחברה לחברה\n\nשאלה לחיים: בעתיד אם יהיו עוד סוגי ערר (10xxx?) — להוסיף עוד חברה או להשאיר 2?",
"testStrategy": "אם סקריפט: dry-run שמראה הבדלים בין 2 ה-CEOs. ואז apply ולוודא runtime_config זהה.",
"status": "done",
"dependencies": [
"16"
],
"priority": "medium",
"subtasks": [],
"updatedAt": "2026-05-04T09:52:14.263Z"
},
{
"id": "26",
"title": "[Paperclip Gap 12] עדכון @paperclipai/plugin-sdk + capabilities חדשות",
"description": "ה-plugin שלנו: @paperclipai/plugin-sdk@^2026.325.0, apiVersion: 1, minimumHostVersion: 2026.325.0. ה-host: 2026.428.0. ייתכן capabilities חדשות (issue.interactions.create, וכו').",
"details": "פעולה (Phase 4 — אחרי שדרוג Paperclip stable):\n1. cd /home/chaim/plugin-legal-ai && npm view @paperclipai/plugin-sdk version\n2. אם חדשה: npm install @paperclipai/plugin-sdk@latest\n3. קריאת adapter-plugin.md המעודכן ב-paperclip repo\n4. בדיקה אם apiVersion: 2 קיים\n5. הוספת capabilities חדשות אם רלוונטי (בעיקר issue.interactions.create אחרי gap #4)\n6. npm run build && reinstall plugin\n\nתלוי בgap #19 (interactions API) — אם אנחנו רוצים שהplugin יוכל ליצור interactions, חייב capability חדש.",
"testStrategy": "אחרי npm install: בדיקה ש-plugin עולה ב-Paperclip בלי last_error. SELECT status, last_error FROM plugins WHERE plugin_key='marcusgroup.legal-ai'.",
"status": "pending",
"dependencies": [
"27",
"19"
],
"priority": "low",
"subtasks": []
},
{
"id": "27",
"title": "[Paperclip Phase 4] שדרוג Paperclip לגרסה stable הבאה (לא 2026.428.0)",
"description": "כרגע אנחנו על 2026.428.0 — הגרסה היציבה האחרונה. כשיופיע stable חדש (כנראה 2026.5xx.x), לבצע שדרוג מבוקר.",
"details": "טריגר: npm view paperclipai dist-tags.latest מחזיר משהו ≠ 2026.428.0.\n\nפעולה:\n1. קריאת releases/v2026.5xx.x.md ב-GitHub\n2. בדיקת שינויים שעלולים להשפיע (CUSTOMIZATIONS.md סעיפים: hebrew, RTL, plugin driver, heartbeat)\n3. גיבוי: pg_dump של paperclip DB + cp -r ~/.npm/_npx/43414d9b790239bb /tmp/\n4. pm2 stop paperclip\n5. rm -rf ~/.npm/_npx/43414d9b790239bb\n6. npx paperclipai@latest run (יוריד גרסה חדשה)\n7. הרצה מחדש: ~/.paperclip/hebrew/apply-hebrew.sh && ~/.paperclip/issue-link-fix/apply-issue-link-fix.sh\n8. pm2 restart paperclip\n9. בדיקה ב-pc.nautilus.marcusgroup.org: עברית + plugin פעיל + סוכן מתעורר על comment\n\nתלוי בלי dependencies (יכול להיות מבוצע בכל עת אחרי שיש stable חדש).",
"testStrategy": "אחרי שדרוג: cat ~/.npm/_npx/43414d9b790239bb/node_modules/paperclipai/package.json | grep version → גרסה חדשה. UI עברית. test wakeup על issue.",
"status": "pending",
"dependencies": [],
"priority": "low",
"subtasks": []
},
{
"id": "28",
"title": "[Paperclip Auxiliary] להפעיל skill-sync ל-2 סוכנים שפיספסו",
"description": "הגהת מסמכים ומנתח משפטי לא קיבלו אף פעם revision מסוג skill-sync (לעומת 5 האחרים שכן). לבצע sync.",
"details": "ממצא: בדיקה ב-agent_config_revisions:\n- עוזר משפטי: 3 skill-sync revisions (יש 7 skills)\n- חוקר תקדימים: 3 (יש 5)\n- מייצא טיוטה: 5 (יש 5)\n- בודק איכות: 1 (יש 5)\n- כותב החלטה: 1 (יש 5)\n- הגהת מסמכים: 0 (יש 0) ❌\n- מנתח משפטי: 0 (יש 0) ❌\n\nאופציות:\n1. UI: agent settings → 'sync skills'\n2. API: POST /api/agents/{id}/skills-sync (לאתר)\n3. CLI: paperclipai agent skill-sync (לבדוק אם קיים)\n4. SQL ידני (לא מומלץ — דורף revision tracking)\n\nSkills להעתקה (לפי בודק איכות):\n- paperclipai/paperclip/paperclip\n- paperclipai/paperclip/paperclip-create-agent\n- paperclipai/paperclip/paperclip-create-plugin\n- paperclipai/paperclip/para-memory-files\n- (אופציונלי) local/eba6210d5a/legal-decision",
"testStrategy": "SELECT name, jsonb_array_length(adapter_config->'paperclipSkillSync'->'desiredSkills') FROM agents WHERE name IN ('הגהת מסמכים', 'מנתח משפטי') → 4-5. revision חדש ב-agent_config_revisions עם source='skill-sync'.",
"status": "done",
"dependencies": [],
"priority": "medium",
"subtasks": [],
"updatedAt": "2026-05-04T09:46:32.092Z"
},
{
"id": "29",
"title": "[legal-ai UI] מסך הגדרות סוכנים — הצגה + עריכה + שמירה",
"description": "מסך אדמין ב-legal-ai UI שמציג את כל הגדרות הסוכנים (model, timeout, runtime_config, skills, budget) ומאפשר עריכה ושמירה. מונע SQL ישיר.",
"details": "מטרה: ממשק אדמין מרכזי במקום שעריכה תהיה רק ב-UI של Paperclip + SQL ישיר + CUSTOMIZATIONS.md.\n\nשדות (לכל סוכן × 2 חברות):\n1. adapter_config: model, effort, timeoutSec, maxTurnsPerRun, extraArgs[], paperclipSkillSync.desiredSkills[]\n2. runtime_config.heartbeat: graceSec, cooldownSec, wakeOnDemand, maxConcurrentRuns, enabled, intervalSec\n3. budget_monthly_cents (לקראת gap #2)\n4. status / pause_reason (קריאה + כפתור pause/resume)\n\nאופציות מימוש:\nA. עמוד חדש ב-legal-ai/web-ui (Next.js 16) — קורא Paperclip DB דרך FastAPI endpoint חדש (/api/admin/paperclip-agents)\nB. קריאה ל-Paperclip API (/api/companies/{id}/agents) — REST טהור, פחות שדות זמינים\nC. iframe ל-Paperclip UI — שטחי\n\nהמלצה: A. שולט מלא + ולידציה משפטית (timeoutSec >= 1800 כי OCR).\n\nתלוי ב: gap #25 (סוכנים מוכפלים) — אם נעבור לshared, המסך יתאים.\n\nשאלות פתוחות לחיים:\n- auth: מי יכול לגשת? (כיום אין auth ב-legal-ai)\n- bulk edit ל-2 חברות יחד או נפרד?\n- חשיפת skill marketplace (להוסיף/להוריד skills) או רק קריאה?",
"testStrategy": "1. עמוד עולה ב-/admin/agents בlegal-ai UI. 2. עריכת timeoutSec ושמירה → SELECT ב-DB מאמת. 3. revision חדש ב-agent_config_revisions עם source מתאים.",
"status": "done",
"dependencies": [
"16",
"17",
"25"
],
"priority": "medium",
"subtasks": [],
"updatedAt": "2026-05-04T17:29:25.686Z"
}
],
"metadata": {
"version": "1.0.0",
"lastModified": "2026-05-04T17:29:25.687Z",
"taskCount": 29,
"completedCount": 24,
"tags": [
"legal-ai"
]
}
} }
} }

View File

@@ -48,8 +48,11 @@
| [`docs/corpus-analysis.md`](docs/corpus-analysis.md) | ניתוח שיטתי של 24 החלטות — מפת תוכן, דפוסי דיון תכנוני, פערים | **לפני כל כתיבת החלטה** | | [`docs/corpus-analysis.md`](docs/corpus-analysis.md) | ניתוח שיטתי של 24 החלטות — מפת תוכן, דפוסי דיון תכנוני, פערים | **לפני כל כתיבת החלטה** |
| [`docs/product-specification.md`](docs/product-specification.md) | איפיון מוצר מלא — personas, תהליכים עסקיים, דרישות | להתמצאות עסקית/מוצרית | | [`docs/product-specification.md`](docs/product-specification.md) | איפיון מוצר מלא — personas, תהליכים עסקיים, דרישות | להתמצאות עסקית/מוצרית |
| [`docs/new-company-setup-guide.md`](docs/new-company-setup-guide.md) | מדריך הקמת חברה חדשה (CMPA) — skills, corpus, style analysis | לפני הוספת חברה/סוג ערר חדש | | [`docs/new-company-setup-guide.md`](docs/new-company-setup-guide.md) | מדריך הקמת חברה חדשה (CMPA) — skills, corpus, style analysis | לפני הוספת חברה/סוג ערר חדש |
| [`skills/new-company-setup/SKILL.md`](skills/new-company-setup/SKILL.md) | **Blueprint טכני מלא להוספת חברה** — 11 שלבים מסודרים (companies, agents, runtime/adapter, skills, instructions, code, mappings) + checklist 10 מלכודות מ-Gap analysis #16-#28 | **חובה לפני הוספת חברה** (יותר actionable מ-doc) |
| [`docs/audit-report.md`](docs/audit-report.md) | דוח audit של המערכת | רקע כללי | | [`docs/audit-report.md`](docs/audit-report.md) | דוח audit של המערכת | רקע כללי |
| [`docs/case-migration-tracker.md`](docs/case-migration-tracker.md) | מעקב מיגרציה של תיקים קיימים | לצורך מעקב | | [`docs/case-migration-tracker.md`](docs/case-migration-tracker.md) | מעקב מיגרציה של תיקים קיימים | לצורך מעקב |
| [`docs/case-deletion-runbook.md`](docs/case-deletion-runbook.md) | runbook מלא למחיקת תיק — legal-ai DB + disk + Paperclip + Gitea, FK ordering, fallback ל-SQL ישיר | לפני reset שלם של תיק (מבחן, מחיקה בטעות) |
| [`docs/paperclip-quirks.md`](docs/paperclip-quirks.md) | מלכודות ידועות ב-Paperclip — `issue.released` ש-flips done→todo, bash backtick trap, CEO auto-block, wakeup דרך DB | לפני שמייחסים באג בסוכן ל-skill — לבדוק קודם אם זה Paperclip-side |
| [`docs/decision-block-mapping.md`](docs/decision-block-mapping.md) | מיפוי בלוקים להחלטות — איך 12 הבלוקים משתקפים ב-DOCX | להתמצאות במבנה | | [`docs/decision-block-mapping.md`](docs/decision-block-mapping.md) | מיפוי בלוקים להחלטות — איך 12 הבלוקים משתקפים ב-DOCX | להתמצאות במבנה |
| [`docs/memory.md`](docs/memory.md) | הקשר כללי — skills, פרויקטים שהושלמו, מבנה vault | להתמצאות כללית | | [`docs/memory.md`](docs/memory.md) | הקשר כללי — skills, פרויקטים שהושלמו, מבנה vault | להתמצאות כללית |
| [`skills/decision/SKILL.md`](skills/decision/SKILL.md) | מדריך סגנון מלא של דפנה — טון, מבנה, ביטויים, מתודולוגיה | **לפני כל כתיבת החלטה** | | [`skills/decision/SKILL.md`](skills/decision/SKILL.md) | מדריך סגנון מלא של דפנה — טון, מבנה, ביטויים, מתודולוגיה | **לפני כל כתיבת החלטה** |
@@ -115,6 +118,8 @@
├── web-ui/ ← Next.js frontend (TypeScript/React): ממשק המשתמש ├── web-ui/ ← Next.js frontend (TypeScript/React): ממשק המשתמש
│ └── next.config.ts ← proxy: /api/* → FastAPI :8000 │ └── next.config.ts ← proxy: /api/* → FastAPI :8000
├── mcp-server/ ← MCP server + services + tools ├── mcp-server/ ← MCP server + services + tools
├── adapters/ ← Paperclip external adapters (ראה למטה)
│ └── deepseek-paperclip-adapter/ ← `deepseek_local` (Hermes-pinned ל-DeepSeek profile)
└── scripts/ ← סקריפטים וכלי עזר (ראה scripts/SCRIPTS.md) └── scripts/ ← סקריפטים וכלי עזר (ראה scripts/SCRIPTS.md)
└── .archive/ ← סקריפטים שהושלמו (לא להריץ) └── .archive/ ← סקריפטים שהושלמו (לא להריץ)
``` ```
@@ -158,6 +163,34 @@
- ה-CEO קורא את ה-comment, מחליט על ניתוב, ויוצר issue לסוכן המתאים - ה-CEO קורא את ה-comment, מחליט על ניתוב, ויוצר issue לסוכן המתאים
- כל הסוכנים חייבים לקרוא comments אחרונים לפני שהם מתחילים לעבוד (HEARTBEAT שלבים 2b-2c) - כל הסוכנים חייבים לקרוא comments אחרונים לפני שהם מתחילים לעבוד (HEARTBEAT שלבים 2b-2c)
### קריאות API — תמיד דרך helper, לעולם לא `curl` ישיר
- **bash (סוכנים):** `~/legal-ai/scripts/pc.sh <METHOD> <PATH> [BODY_JSON]` — מוסיף Authorization, X-Paperclip-Run-Id, Content-Type, base URL. ראה `HEARTBEAT.md §0`.
- **Python (FastAPI):** `from web.paperclip_api import pc_request; await pc_request("POST", "/api/...", json={...})` — שימוש ב-board API key.
- **אסור** `curl ... $PAPERCLIP_API_URL` ישיר ב-bash; **אסור** `httpx.AsyncClient` ישיר ל-Paperclip ב-Python.
- **למה:** ה-skill הרשמי דורש `X-Paperclip-Run-Id` בכל קריאה משנה issue. אצלנו ה-audit trail עבד ממילא דרך JWT claims (`runId: runIdHeader || claims.run_id`), אבל ה-helper מבטיח עקביות + תאימות ל-board API keys (long-lived) שלא נושאות JWT claims.
### Cross-company agent sync — אחרי כל שינוי הגדרות
- יש 14 סוכנים = 7 × 2 חברות (CMP=1xxx, CMPA=8xxx). Paperclip מחייב `agents.company_id NOT NULL` — אין shared agents.
- **Master = CMP (1xxx)**, **Mirror = CMPA (8xxx)**.
- אחרי כל שינוי ב-`adapter_config`, `runtime_config`, `budget_monthly_cents`, או skills של סוכן ב-master (UI, SQL, או API), **חובה להריץ:**
```bash
PAPERCLIP_BOARD_API_KEY=$(...infisical...) \
python ~/legal-ai/scripts/sync_agents_across_companies.py --verify # לבדיקה
PAPERCLIP_BOARD_API_KEY=$(...) \
python ~/legal-ai/scripts/sync_agents_across_companies.py --apply # לסנכרן
```
- הסקריפט מסנן local skills שלא קיימים ב-CMPA (מציג אזהרה), משתמש ב-API (לא DB ישיר), יוצר revisions, idempotent.
- שאלות ה-skill הרשמי של Paperclip — `paperclip` skill תחת `paperclipai/paperclip`.
### External adapters — `deepseek_local`
- מיקום ה-package: [adapters/deepseek-paperclip-adapter/](adapters/deepseek-paperclip-adapter/) (לא ב-`node_modules`).
- רישום ב-Paperclip: רשומה ב-`~/.paperclip/adapter-plugins.json` (נטען אוטומטית ב-startup דרך `buildExternalAdapters`). אין צורך בעריכת `node_modules`.
- **מה ה-adapter עושה**: spawnל-`hermes chat` עם `HERMES_HOME=/home/chaim/.hermes/profiles/deepseek` כך שה-CLI טוען את `config.yaml` (`base_url=https://api.deepseek.com/v1`, `provider=custom`, `key_env=DEEPSEEK_API_KEY`) ואת `.env` (שמכיל את ה-key).
- **מודלים זמינים** (lookup ב-DeepSeek `/v1/models`): `deepseek-v4-pro` (default), `deepseek-v4-flash`. יופיעו כדרופ-דאון ב-UI.
- **התקנה מחדש / עדכון**: `curl -X POST -H "Authorization: Bearer pcapi_legal_install_key_2026" -H "Content-Type: application/json" -d '{"packageName":"/home/chaim/legal-ai/adapters/deepseek-paperclip-adapter","isLocalPath":true}' http://localhost:3100/api/adapters/install`. לעדכון hot — `POST /api/adapters/deepseek_local/reload`.
- **⚠ Cross-company sync**: `sync_agents_across_companies.py` **מדלג** על סוכנים עם `adapter_type` שונה בין CMP ל-CMPA. כשעוברים סוכן ל-`deepseek_local` חובה להחיל ידנית בשתי החברות לפני sync.
- **תוספת adapters עתידיים** (OpenAI ישיר, Anthropic ישיר, וכו'): אותו דפוס. ה-package הראשי חייב לייצא `createServerAdapter()` שמחזיר `{ type, label, models, agentConfigurationDoc, execute, testEnvironment, sessionCodec, listSkills, syncSkills, ... }`. ראה את [adapters/deepseek-paperclip-adapter/dist/index.js](adapters/deepseek-paperclip-adapter/dist/index.js) כתבנית.
--- ---
## עקרונות כתיבה קריטיים ## עקרונות כתיבה קריטיים

View File

@@ -0,0 +1,99 @@
/**
* DeepSeek (via Hermes) — external Paperclip adapter.
*
* Loaded by Paperclip's plugin-loader. Contract:
* The package's main module must export createServerAdapter() returning
* a single ServerAdapterModule object with all fields wired in.
*
* Runtime: spawns the local `hermes` CLI with HERMES_HOME pinned to a
* DeepSeek profile that defines model.base_url=https://api.deepseek.com/v1
* and model.key_env=DEEPSEEK_API_KEY.
*/
import {
ADAPTER_TYPE,
ADAPTER_LABEL,
DEEPSEEK_MODELS,
DEFAULT_PROFILE_HOME,
} from "./shared/constants.js";
import { execute } from "./server/execute.js";
import { testEnvironment } from "./server/test.js";
import { sessionCodec } from "./server/session-codec.js";
import { listSkills, syncSkills } from "./server/skills.js";
const AGENT_CONFIGURATION_DOC = `# DeepSeek (via Hermes) — Agent Configuration
DeepSeek-pinned variant of the Hermes adapter. Runs the local \`hermes\` CLI
with \`HERMES_HOME\` pointed at a DeepSeek profile (\`config.yaml\` declares
\`base_url=https://api.deepseek.com/v1\` and \`key_env=DEEPSEEK_API_KEY\`).
## Prerequisites
- Hermes Agent installed (\`pip install hermes-agent\`) — \`hermes --version\` works.
- DeepSeek profile dir exists (default: \`/home/chaim/.hermes/profiles/deepseek\`)
with \`config.yaml\` + \`.env\` (containing \`DEEPSEEK_API_KEY\`).
## Core Configuration
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| model | string | \`deepseek-v4-pro\` | DeepSeek model id (\`deepseek-v4-pro\` or \`deepseek-v4-flash\`). |
| provider | string | \`custom\` | Hermes provider name. The DeepSeek profile defines \`provider: custom\` so \`custom\` is the right value. |
| hermesProfileHome | string | \`/home/chaim/.hermes/profiles/deepseek\` | Absolute path to a Hermes profile dir. Set per-agent if you maintain multiple DeepSeek profiles. |
| timeoutSec | number | 1800 | Execution timeout in seconds. |
| graceSec | number | 30 | SIGTERM grace period in seconds. |
## Tools / Workspace
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| toolsets | string | (profile default) | Comma-separated toolsets to enable. |
| persistSession | boolean | true | Resume sessions across heartbeats via \`--resume\`. |
| worktreeMode | boolean | false | Use git worktree for isolated changes. |
| checkpoints | boolean | false | Enable filesystem checkpoints. |
## Advanced
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| hermesCommand | string | \`hermes\` | Path to the hermes binary. |
| verbose | boolean | false | Enable verbose Hermes logs. |
| extraArgs | string[] | [] | Extra CLI args appended after standard flags. |
| env | object | {} | Extra environment variables passed to Hermes. \`HERMES_HOME\` here overrides \`hermesProfileHome\`. |
| promptTemplate | string | (default) | Override the default Paperclip wakeup prompt. |
| paperclipApiUrl | string | \`http://127.0.0.1:3100/api\` | Paperclip API URL injected into the prompt template. |
## Available template variables
\`{{agentId}}\`, \`{{agentName}}\`, \`{{companyId}}\`, \`{{companyName}}\`,
\`{{runId}}\`, \`{{taskId}}\`, \`{{taskTitle}}\`, \`{{taskBody}}\`,
\`{{commentId}}\`, \`{{wakeReason}}\`, \`{{projectName}}\`, \`{{paperclipApiUrl}}\`.
`;
export function createServerAdapter() {
return {
type: ADAPTER_TYPE,
label: ADAPTER_LABEL,
models: DEEPSEEK_MODELS,
agentConfigurationDoc: AGENT_CONFIGURATION_DOC,
execute,
testEnvironment,
sessionCodec,
listSkills,
syncSkills,
// Capability flags
supportsLocalAgentJwt: true,
supportsInstructionsBundle: false,
requiresMaterializedRuntimeSkills: false,
};
}
// Also export the loose constants for any caller that wants to inspect
// the package without invoking createServerAdapter (e.g., test harnesses).
export const type = ADAPTER_TYPE;
export const label = ADAPTER_LABEL;
export const models = DEEPSEEK_MODELS;
export const agentConfigurationDoc = AGENT_CONFIGURATION_DOC;
export const defaultProfileHome = DEFAULT_PROFILE_HOME;

View File

@@ -0,0 +1,352 @@
/**
* Server-side execution for the DeepSeek-via-Hermes adapter.
*
* Spawns `hermes chat -q "..." -Q -m <model> --provider custom` with
* HERMES_HOME pinned to a DeepSeek-configured profile so the same machine
* can run other Hermes-based agents on different providers in parallel.
*
* The Hermes CLI loads model.base_url, model.key_env (DEEPSEEK_API_KEY),
* and toolsets from <HERMES_HOME>/config.yaml + <HERMES_HOME>/.env.
*/
import {
runChildProcess,
buildPaperclipEnv,
renderTemplate,
ensureAbsoluteDirectory,
} from "@paperclipai/adapter-utils/server-utils";
import {
HERMES_CLI,
DEFAULT_PROFILE_HOME,
DEFAULT_MODEL,
DEFAULT_PROVIDER,
DEFAULT_TIMEOUT_SEC,
DEFAULT_GRACE_SEC,
SESSION_ID_REGEX,
SESSION_ID_REGEX_LEGACY,
TOKEN_USAGE_REGEX,
COST_REGEX,
} from "../shared/constants.js";
function cfgString(v) {
return typeof v === "string" && v.length > 0 ? v : undefined;
}
function cfgNumber(v) {
return typeof v === "number" ? v : undefined;
}
function cfgBoolean(v) {
return typeof v === "boolean" ? v : undefined;
}
function cfgStringArray(v) {
return Array.isArray(v) && v.every((i) => typeof i === "string") ? v : undefined;
}
const DEFAULT_PROMPT_TEMPLATE = `You are "{{agentName}}", an AI agent employee in a Paperclip-managed company powered by DeepSeek.
IMPORTANT: Use the \`terminal\` tool with \`curl\` for ALL Paperclip API calls (web_extract and browser cannot access localhost).
Your Paperclip identity:
Agent ID: {{agentId}}
Company ID: {{companyId}}
API Base: {{paperclipApiUrl}}
{{#taskId}}
## Assigned Task
Issue ID: {{taskId}}
Title: {{taskTitle}}
{{taskBody}}
## Workflow
1. Work on the task using your tools.
2. When done, mark the issue completed:
\`curl -s -X PATCH "{{paperclipApiUrl}}/issues/{{taskId}}" -H "Content-Type: application/json" -d '{"status":"done"}'\`
3. Post a completion comment summarizing what you did:
\`curl -s -X POST "{{paperclipApiUrl}}/issues/{{taskId}}/comments" -H "Content-Type: application/json" -d '{"body":"DONE: <your summary here>"}'\`
{{/taskId}}
{{#commentId}}
## Comment on This Issue
Someone commented. Read it:
\`curl -s "{{paperclipApiUrl}}/issues/{{taskId}}/comments/{{commentId}}" | python3 -m json.tool\`
Address the comment, POST a reply if needed, then continue working.
{{/commentId}}
{{#noTask}}
## Heartbeat Wake — Check for Work
1. List your open issues:
\`curl -s "{{paperclipApiUrl}}/companies/{{companyId}}/issues?assigneeAgentId={{agentId}}"\`
2. Pick the highest priority and work on it. When done, follow steps 2-3 above.
3. If nothing to do, report briefly what you checked.
{{/noTask}}`;
function buildPrompt(ctx, config) {
const template = cfgString(config.promptTemplate) || DEFAULT_PROMPT_TEMPLATE;
const taskId = cfgString(ctx.context?.taskId);
const taskTitle = cfgString(ctx.context?.taskTitle) || "";
const taskBody = cfgString(ctx.context?.taskBody) || "";
const commentId = cfgString(ctx.context?.commentId) || "";
const wakeReason = cfgString(ctx.context?.wakeReason) || "";
const agentName = ctx.agent?.name || "DeepSeek Agent";
const companyName = cfgString(ctx.context?.companyName) || "";
const projectName = cfgString(ctx.context?.projectName) || "";
let paperclipApiUrl =
cfgString(config.paperclipApiUrl) ||
process.env.PAPERCLIP_API_URL ||
"http://127.0.0.1:3100/api";
if (!paperclipApiUrl.endsWith("/api")) {
paperclipApiUrl = paperclipApiUrl.replace(/\/+$/, "") + "/api";
}
const vars = {
agentId: ctx.agent?.id || "",
agentName,
companyId: ctx.agent?.companyId || "",
companyName,
runId: ctx.runId || "",
taskId: taskId || "",
taskTitle,
taskBody,
commentId,
wakeReason,
projectName,
paperclipApiUrl,
};
let rendered = template;
rendered = rendered.replace(/\{\{#taskId\}\}([\s\S]*?)\{\{\/taskId\}\}/g, taskId ? "$1" : "");
rendered = rendered.replace(/\{\{#noTask\}\}([\s\S]*?)\{\{\/noTask\}\}/g, taskId ? "" : "$1");
rendered = rendered.replace(/\{\{#commentId\}\}([\s\S]*?)\{\{\/commentId\}\}/g, commentId ? "$1" : "");
return renderTemplate(rendered, vars);
}
function cleanResponse(raw) {
return raw
.split("\n")
.filter((line) => {
const t = line.trim();
if (!t) return true;
if (t.startsWith("[tool]") || t.startsWith("[hermes]") || t.startsWith("[paperclip]") || t.startsWith("[deepseek]")) return false;
if (t.startsWith("session_id:")) return false;
if (/^\[\d{4}-\d{2}-\d{2}T/.test(t)) return false;
if (/^\[done\]\s*┊/.test(t)) return false;
if (/^┊\s*[\p{Emoji_Presentation}]/u.test(t) && !/^┊\s*💬/.test(t)) return false;
if (/^\p{Emoji_Presentation}\s*(Completed|Running|Error)?\s*$/u.test(t)) return false;
return true;
})
.map((line) => {
let t = line.replace(/^[\s]*┊\s*💬\s*/, "").trim();
t = t.replace(/^\[done\]\s*/, "").trim();
return t;
})
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function parseHermesOutput(stdout, stderr) {
const combined = stdout + "\n" + stderr;
const result = {};
const sessionMatch = stdout.match(SESSION_ID_REGEX);
if (sessionMatch?.[1]) {
result.sessionId = sessionMatch[1];
const sessionLineIdx = stdout.lastIndexOf("\nsession_id:");
if (sessionLineIdx > 0) {
result.response = cleanResponse(stdout.slice(0, sessionLineIdx));
}
} else {
const legacyMatch = combined.match(SESSION_ID_REGEX_LEGACY);
if (legacyMatch?.[1]) result.sessionId = legacyMatch[1];
const cleaned = cleanResponse(stdout);
if (cleaned.length > 0) result.response = cleaned;
}
const usageMatch = combined.match(TOKEN_USAGE_REGEX);
if (usageMatch) {
result.usage = {
inputTokens: parseInt(usageMatch[1], 10) || 0,
outputTokens: parseInt(usageMatch[2], 10) || 0,
};
}
const costMatch = combined.match(COST_REGEX);
if (costMatch?.[1]) result.costUsd = parseFloat(costMatch[1]);
if (stderr.trim()) {
const errorLines = stderr
.split("\n")
.filter((line) => /error|exception|traceback|failed/i.test(line))
.filter((line) => !/INFO|DEBUG|warn/i.test(line));
if (errorLines.length > 0) result.errorMessage = errorLines.slice(0, 5).join("\n");
}
return result;
}
export async function execute(ctx) {
const config = ctx.agent?.adapterConfig ?? {};
const hermesCmd = cfgString(config.hermesCommand) || HERMES_CLI;
const model = cfgString(config.model) || DEFAULT_MODEL;
const provider = cfgString(config.provider) || DEFAULT_PROVIDER;
const profileHome = cfgString(config.hermesProfileHome) || DEFAULT_PROFILE_HOME;
const timeoutSec = cfgNumber(config.timeoutSec) || DEFAULT_TIMEOUT_SEC;
const graceSec = cfgNumber(config.graceSec) || DEFAULT_GRACE_SEC;
const toolsets = cfgString(config.toolsets) || cfgStringArray(config.enabledToolsets)?.join(",");
const extraArgs = cfgStringArray(config.extraArgs);
const persistSession = cfgBoolean(config.persistSession) !== false;
const worktreeMode = cfgBoolean(config.worktreeMode) === true;
const checkpoints = cfgBoolean(config.checkpoints) === true;
const useQuiet = cfgBoolean(config.quiet) !== false;
const prompt = buildPrompt(ctx, config);
const args = ["chat", "-q", prompt];
if (useQuiet) args.push("-Q");
if (model) args.push("-m", model);
args.push("--provider", provider);
if (toolsets) args.push("-t", toolsets);
if (worktreeMode) args.push("-w");
if (checkpoints) args.push("--checkpoints");
if (cfgBoolean(config.verbose) === true) args.push("-v");
args.push("--source", "tool");
args.push("--yolo");
const prevSessionId = cfgString(ctx.runtime?.sessionParams?.sessionId);
if (persistSession && prevSessionId) args.push("--resume", prevSessionId);
if (extraArgs?.length) args.push(...extraArgs);
// Pin Hermes to the DeepSeek profile by default. The agent can override
// by setting adapter_config.hermesProfileHome or adapter_config.env.HERMES_HOME.
const env = {
...process.env,
...buildPaperclipEnv(ctx.agent),
HERMES_HOME: profileHome,
};
if (ctx.runId) env.PAPERCLIP_RUN_ID = ctx.runId;
const taskId = cfgString(ctx.context?.taskId);
if (taskId) env.PAPERCLIP_TASK_ID = taskId;
// Parity with hermes_local (paperclip-src/server/src/adapters/registry.ts:267):
// inject the per-run agent auth token so the agent can call the Paperclip API.
// Without this, every Paperclip API write from the running agent fails with 401.
//
// Resolve env from the runtime-resolved config (ctx.config.env contains plain
// strings — Paperclip's secrets service unwraps {type:"plain"|"secret_ref", ...}
// bindings before invocation in services/heartbeat.ts:5433-5437).
// Fall back to agent.adapterConfig.env with manual unwrapping for older paths.
function unwrapEnvValue(v) {
if (typeof v === "string") return v;
if (v && typeof v === "object" && !Array.isArray(v)) {
if (v.type === "plain" && typeof v.value === "string") return v.value;
}
return undefined; // skip secret_ref / unknown types — let resolver handle them
}
const resolvedUserEnv =
ctx.config && typeof ctx.config === "object" && ctx.config.env && typeof ctx.config.env === "object" && !Array.isArray(ctx.config.env)
? ctx.config.env
: null;
const rawUserEnv =
typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
? config.env
: {};
// Prefer pre-resolved values from ctx.config.env when available; fall back to
// unwrapping raw bindings from agent.adapterConfig.env.
const flattenedUserEnv = {};
for (const [k, v] of Object.entries(rawUserEnv)) {
const resolved = resolvedUserEnv && typeof resolvedUserEnv[k] === "string" ? resolvedUserEnv[k] : unwrapEnvValue(v);
if (typeof resolved === "string") flattenedUserEnv[k] = resolved;
}
const userEnvApiKey = flattenedUserEnv.PAPERCLIP_API_KEY;
const explicitApiKey =
typeof userEnvApiKey === "string" && userEnvApiKey.trim().length > 0;
if (ctx.authToken && !explicitApiKey) env.PAPERCLIP_API_KEY = ctx.authToken;
// Apply unwrapped user env (may override HERMES_HOME, OPENAI_API_KEY, etc.).
Object.assign(env, flattenedUserEnv);
const cwd = cfgString(config.cwd) || cfgString(ctx.config?.workspaceDir) || ".";
try {
await ensureAbsoluteDirectory(cwd);
} catch {
// non-fatal
}
await ctx.onLog(
"stdout",
`[deepseek] Starting Hermes (model=${model}, provider=${provider}, profileHome=${env.HERMES_HOME}, timeout=${timeoutSec}s)\n`,
);
if (prevSessionId) {
await ctx.onLog("stdout", `[deepseek] Resuming session: ${prevSessionId}\n`);
}
// Reclassify benign Hermes stderr lines as stdout so the UI doesn't paint them red.
const wrappedOnLog = async (stream, chunk) => {
if (stream === "stderr") {
const trimmed = chunk.trimEnd();
const isBenign =
/^\[?\d{4}[-/]\d{2}[-/]\d{2}T/.test(trimmed) ||
/^[A-Z]+:\s+(INFO|DEBUG|WARN|WARNING)\b/.test(trimmed) ||
/Successfully registered all tools/.test(trimmed) ||
/MCP [Ss]erver/.test(trimmed) ||
/tool registered successfully/.test(trimmed) ||
/Application initialized/.test(trimmed);
if (isBenign) return ctx.onLog("stdout", chunk);
}
return ctx.onLog(stream, chunk);
};
// Forward ctx.onSpawn so Paperclip persists processPid/processGroupId to the
// heartbeat_runs row. Without it, the reaper cannot verify the child is alive
// (run.processPid is null) and treats the run as orphaned during long quiet
// phases (DeepSeek V4-Pro thinking can be silent for 60-90s per turn).
const result = await runChildProcess(ctx.runId, hermesCmd, args, {
cwd,
env,
timeoutSec,
graceSec,
onLog: wrappedOnLog,
onSpawn: ctx.onSpawn,
});
const parsed = parseHermesOutput(result.stdout || "", result.stderr || "");
await ctx.onLog(
"stdout",
`[deepseek] Exit code: ${result.exitCode ?? "null"}, timed out: ${result.timedOut}\n`,
);
if (parsed.sessionId) {
await ctx.onLog("stdout", `[deepseek] Session: ${parsed.sessionId}\n`);
}
const executionResult = {
exitCode: result.exitCode,
signal: result.signal,
timedOut: result.timedOut,
provider,
model,
};
if (parsed.errorMessage) executionResult.errorMessage = parsed.errorMessage;
if (parsed.usage) executionResult.usage = parsed.usage;
if (parsed.costUsd !== undefined) executionResult.costUsd = parsed.costUsd;
if (parsed.response) executionResult.summary = parsed.response.slice(0, 2000);
executionResult.resultJson = {
result: parsed.response || "",
session_id: parsed.sessionId || null,
usage: parsed.usage || null,
cost_usd: parsed.costUsd ?? null,
};
if (persistSession && parsed.sessionId) {
executionResult.sessionParams = { sessionId: parsed.sessionId };
executionResult.sessionDisplayId = parsed.sessionId.slice(0, 16);
}
return executionResult;
}

View File

@@ -0,0 +1,29 @@
/**
* Session codec — Hermes uses a single sessionId for cross-heartbeat continuity
* via the --resume CLI flag. Same shape as the Hermes adapter.
*/
function readNonEmptyString(value) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
export const sessionCodec = {
deserialize(raw) {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
const sessionId =
readNonEmptyString(raw.sessionId) ?? readNonEmptyString(raw.session_id);
if (!sessionId) return null;
return { sessionId };
},
serialize(params) {
if (!params) return null;
const sessionId =
readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
if (!sessionId) return null;
return { sessionId };
},
getDisplayId(params) {
if (!params) return null;
return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
},
};

View File

@@ -0,0 +1,171 @@
/**
* Skill snapshot for the DeepSeek-via-Hermes adapter.
*
* Hermes manages its own skills under ~/.hermes/skills/ (global; not per-profile).
* Paperclip-managed skills declared in adapter config are surfaced as
* "company_managed" entries — same behavior as the upstream Hermes adapter.
*/
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
readPaperclipRuntimeSkillEntries,
resolvePaperclipDesiredSkillNames,
} from "@paperclipai/adapter-utils/server-utils";
import { ADAPTER_TYPE } from "../shared/constants.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
function asString(value) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function parseSkillFrontmatter(content) {
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
if (!match) return {};
const fm = {};
for (const line of match[1].split("\n")) {
const idx = line.indexOf(":");
if (idx === -1) continue;
const key = line.slice(0, idx).trim();
let val = line.slice(idx + 1).trim();
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
fm[key] = val;
}
return fm;
}
async function buildSkillEntry(key, skillMdPath, categoryPath) {
let description = null;
try {
const content = await fs.readFile(skillMdPath, "utf8");
description = parseSkillFrontmatter(content).description ?? null;
} catch {
// ignore
}
return {
key,
runtimeName: key,
desired: true,
managed: false,
state: "installed",
origin: "user_installed",
originLabel: "Hermes skill",
locationLabel: `~/.hermes/skills/${categoryPath}`,
readOnly: true,
sourcePath: skillMdPath,
targetPath: null,
detail: description,
};
}
async function scanHermesSkills(skillsHome) {
const entries = [];
try {
const cats = await fs.readdir(skillsHome, { withFileTypes: true });
for (const cat of cats) {
if (!cat.isDirectory()) continue;
const catPath = path.join(skillsHome, cat.name);
const topSkill = path.join(catPath, "SKILL.md");
if (await fs.stat(topSkill).catch(() => null)) {
entries.push(await buildSkillEntry(cat.name, topSkill, cat.name));
}
const items = await fs.readdir(catPath, { withFileTypes: true }).catch(() => []);
for (const item of items) {
if (!item.isDirectory()) continue;
const skillMd = path.join(catPath, item.name, "SKILL.md");
if (await fs.stat(skillMd).catch(() => null)) {
entries.push(await buildSkillEntry(item.name, skillMd, `${cat.name}/${item.name}`));
}
}
}
} catch {
// ~/.hermes/skills/ doesn't exist
}
return entries.sort((a, b) => a.key.localeCompare(b.key));
}
async function buildSnapshot(config) {
const homedir =
asString(config.env?.HOME) ??
process.env.HOME ??
"/home/chaim";
const hermesSkillsHome = path.join(homedir, ".hermes", "skills");
const paperclipEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredSkills = resolvePaperclipDesiredSkillNames(config, paperclipEntries);
const desiredSet = new Set(desiredSkills);
const availableByKey = new Map(paperclipEntries.map((e) => [e.key, e]));
const hermesSkillEntries = await scanHermesSkills(hermesSkillsHome);
const hermesKeys = new Set(hermesSkillEntries.map((e) => e.key));
const entries = [];
const warnings = [];
for (const entry of paperclipEntries) {
const desired = desiredSet.has(entry.key);
entries.push({
key: entry.key,
runtimeName: entry.runtimeName,
desired,
managed: true,
state: desired ? "configured" : "available",
origin: entry.required ? "paperclip_required" : "company_managed",
originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip",
readOnly: false,
sourcePath: entry.source,
targetPath: null,
detail: desired ? "Will be available on the next run via Hermes skill loading." : null,
required: Boolean(entry.required),
requiredReason: entry.requiredReason ?? null,
});
}
for (const entry of hermesSkillEntries) {
if (availableByKey.has(entry.key)) continue;
entries.push(entry);
}
for (const desired of desiredSkills) {
if (availableByKey.has(desired) || hermesKeys.has(desired)) continue;
warnings.push(`Desired skill "${desired}" is not available in Paperclip or Hermes skills.`);
entries.push({
key: desired,
runtimeName: null,
desired: true,
managed: true,
state: "missing",
origin: "external_unknown",
originLabel: "External or unavailable",
readOnly: false,
sourcePath: null,
targetPath: null,
detail: "Cannot find this skill in Paperclip or ~/.hermes/skills/.",
});
}
return {
adapterType: ADAPTER_TYPE,
supported: true,
mode: "persistent",
desiredSkills,
entries,
warnings,
};
}
export async function listSkills(ctx) {
return buildSnapshot(ctx.config);
}
export async function syncSkills(ctx, _desired) {
return buildSnapshot(ctx.config);
}
export function resolveDesiredSkillNames(config, availableEntries) {
return resolvePaperclipDesiredSkillNames(config, availableEntries);
}

View File

@@ -0,0 +1,164 @@
/**
* Environment test for the DeepSeek (via Hermes) adapter.
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import fs from "node:fs/promises";
import path from "node:path";
import {
HERMES_CLI,
ADAPTER_TYPE,
DEFAULT_PROFILE_HOME,
} from "../shared/constants.js";
const execFileAsync = promisify(execFile);
function asString(v) {
return typeof v === "string" ? v : undefined;
}
async function checkCliInstalled(command) {
try {
await execFileAsync(command, ["--version"], { timeout: 10_000 });
return null;
} catch (err) {
if (err && err.code === "ENOENT") {
return {
level: "error",
message: `Hermes CLI "${command}" not found in PATH`,
hint: "Install Hermes Agent: pip install hermes-agent",
code: "deepseek_hermes_cli_not_found",
};
}
return null;
}
}
async function checkProfile(profileHome) {
try {
const stat = await fs.stat(profileHome);
if (!stat.isDirectory()) {
return {
level: "error",
message: `Profile path is not a directory: ${profileHome}`,
hint: "Create the directory or override hermesProfileHome in adapter config.",
code: "deepseek_profile_not_dir",
};
}
} catch {
return {
level: "error",
message: `Hermes profile dir does not exist: ${profileHome}`,
hint: "Create the profile dir with config.yaml + .env (DEEPSEEK_API_KEY).",
code: "deepseek_profile_missing",
};
}
const configPath = path.join(profileHome, "config.yaml");
try {
await fs.stat(configPath);
} catch {
return {
level: "error",
message: `Profile is missing config.yaml: ${configPath}`,
hint: "Add config.yaml with model.default + model.base_url + model.key_env.",
code: "deepseek_profile_no_config",
};
}
return {
level: "info",
message: `Profile resolved: ${profileHome}`,
code: "deepseek_profile_ok",
};
}
async function checkApiKey(profileHome, configEnv) {
// 1. config.env (resolved by Paperclip from secrets)
if (configEnv && typeof configEnv === "object" && asString(configEnv.DEEPSEEK_API_KEY)) {
return {
level: "info",
message: "DEEPSEEK_API_KEY found in adapter env config",
code: "deepseek_api_key_in_config",
};
}
// 2. Profile-local .env
try {
const envFile = path.join(profileHome, ".env");
const text = await fs.readFile(envFile, "utf-8");
if (/^\s*DEEPSEEK_API_KEY=/m.test(text)) {
return {
level: "info",
message: `DEEPSEEK_API_KEY found in ${envFile}`,
code: "deepseek_api_key_in_profile",
};
}
} catch {
// ignore
}
// 3. Process env
if (process.env.DEEPSEEK_API_KEY) {
return {
level: "info",
message: "DEEPSEEK_API_KEY found in Paperclip process env",
code: "deepseek_api_key_in_process",
};
}
return {
level: "error",
message: "DEEPSEEK_API_KEY not found in adapter env, profile .env, or process env",
hint: "Add DEEPSEEK_API_KEY to <HERMES_HOME>/.env or to the agent's env secrets.",
code: "deepseek_api_key_missing",
};
}
export async function testEnvironment(ctx) {
const config = ctx.config ?? {};
const command = asString(config.hermesCommand) || HERMES_CLI;
const profileHome = asString(config.hermesProfileHome) || DEFAULT_PROFILE_HOME;
const checks = [];
const cliCheck = await checkCliInstalled(command);
if (cliCheck) {
checks.push(cliCheck);
if (cliCheck.level === "error") {
return {
adapterType: ADAPTER_TYPE,
status: "fail",
checks,
testedAt: new Date().toISOString(),
};
}
}
const profileCheck = await checkProfile(profileHome);
checks.push(profileCheck);
if (profileCheck.level === "error") {
return {
adapterType: ADAPTER_TYPE,
status: "fail",
checks,
testedAt: new Date().toISOString(),
};
}
const apiKeyCheck = await checkApiKey(profileHome, config.env);
checks.push(apiKeyCheck);
const model = asString(config.model);
checks.push({
level: "info",
message: model ? `Model: ${model}` : "Using profile default model",
code: "deepseek_model",
});
const hasErrors = checks.some((c) => c.level === "error");
const hasWarnings = checks.some((c) => c.level === "warn");
return {
adapterType: ADAPTER_TYPE,
status: hasErrors ? "fail" : hasWarnings ? "warn" : "pass",
checks,
testedAt: new Date().toISOString(),
};
}

View File

@@ -0,0 +1,36 @@
/**
* Shared constants for the DeepSeek (via Hermes) Paperclip adapter.
*/
export const ADAPTER_TYPE = "deepseek_local";
export const ADAPTER_LABEL = "DeepSeek (via Hermes)";
/** Default Hermes CLI binary name. */
export const HERMES_CLI = "hermes";
/** Default profile directory used as HERMES_HOME if the agent does not override it. */
export const DEFAULT_PROFILE_HOME = "/home/chaim/.hermes/profiles/deepseek";
/** Default model — V4-Pro is the strongest DeepSeek model currently exposed. */
export const DEFAULT_MODEL = "deepseek-v4-pro";
/** DeepSeek profiles in this stack use Hermes' "custom" provider (user-defined in profile config.yaml). */
export const DEFAULT_PROVIDER = "custom";
/** Default timeout (seconds) for one CLI invocation. */
export const DEFAULT_TIMEOUT_SEC = 1800;
/** Grace period (seconds) after SIGTERM before SIGKILL. */
export const DEFAULT_GRACE_SEC = 30;
/** Models that DeepSeek's API currently exposes (verified via /v1/models). */
export const DEEPSEEK_MODELS = [
{ id: "deepseek-v4-pro", label: "DeepSeek V4 Pro" },
{ id: "deepseek-v4-flash", label: "DeepSeek V4 Flash" },
];
/** Regex for extracting session_id from quiet-mode Hermes output. */
export const SESSION_ID_REGEX = /^session_id:\s*(\S+)/m;
export const SESSION_ID_REGEX_LEGACY = /session[_ ](?:id|saved)[:\s]+([a-zA-Z0-9_-]+)/i;
export const TOKEN_USAGE_REGEX = /tokens?[:\s]+(\d+)\s*(?:input|in)\b.*?(\d+)\s*(?:output|out)\b/i;
export const COST_REGEX = /(?:cost|spent)[:\s]*\$?([\d.]+)/i;

View File

@@ -0,0 +1,25 @@
{
"name": "deepseek-paperclip-adapter",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "deepseek-paperclip-adapter",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@paperclipai/adapter-utils": "^2026.325.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@paperclipai/adapter-utils": {
"version": "2026.428.0",
"resolved": "https://registry.npmjs.org/@paperclipai/adapter-utils/-/adapter-utils-2026.428.0.tgz",
"integrity": "sha512-kGHpE7rhePPCbnG3OwXbNuHZZuI+XyuFgNSiDnrEeiSbkI2c5XHM2WnWDCZ/NGHULfJW3lWhSxGMFoYqiy38vQ==",
"license": "MIT"
}
}
}

View File

@@ -0,0 +1,21 @@
{
"name": "deepseek-paperclip-adapter",
"version": "0.1.0",
"description": "Paperclip adapter for DeepSeek (V4-Pro / V4-Flash) — runs Hermes Agent locally pinned to a DeepSeek profile",
"type": "module",
"license": "MIT",
"private": true,
"main": "./dist/index.js",
"exports": {
".": "./dist/index.js"
},
"files": [
"dist"
],
"dependencies": {
"@paperclipai/adapter-utils": "^2026.325.0"
},
"engines": {
"node": ">=20.0.0"
}
}

View File

@@ -40,7 +40,7 @@ Local (developer machine, pm2):
External: External:
← Claude API (Opus 4.7 for agents) ← Claude API (Opus 4.7 for agents)
← Voyage AI (voyage-3-large, 1024-dim embeddings) ← Voyage AI (voyage-3, 1024-dim embeddings)
← Infisical (secret management) ← Infisical (secret management)
← Gmail SMTP (agent notifications) ← Gmail SMTP (agent notifications)
``` ```
@@ -59,7 +59,7 @@ External:
- מפעיל OCR (Google Vision) אם PDF ללא טקסט - מפעיל OCR (Google Vision) אם PDF ללא טקסט
- מריץ proofreader להסרת artifacts מ-Nevo - מריץ proofreader להסרת artifacts מ-Nevo
- מחלץ טקסט ל-`documents.extracted_text` - מחלץ טקסט ל-`documents.extracted_text`
- מפצל ל-chunks של ~500 מילים, מחשב embeddings (voyage-3-large, 1024D), שומר ב-`document_chunks` - מפצל ל-chunks של ~500 מילים, מחשב embeddings (voyage-3, 1024D), שומר ב-`document_chunks`
4. סטטוס תיק: `new``proofread` 4. סטטוס תיק: `new``proofread`
### שלב 2 — ניתוח משפטי (legal-researcher + analyst) ### שלב 2 — ניתוח משפטי (legal-researcher + analyst)
@@ -223,7 +223,7 @@ legal-qa מריץ 6 בדיקות איכות:
`case_law`, `statutory_provisions`, `transition_phrases`, `lessons_learned`, `style_corpus`, `style_patterns` `case_law`, `statutory_provisions`, `transition_phrases`, `lessons_learned`, `style_corpus`, `style_patterns`
### Layer 4: Semantic Search (RAG) ### Layer 4: Semantic Search (RAG)
`document_embeddings`, `paragraph_embeddings`, `case_law_embeddings` (pgvector 1024-dim, voyage-3-large) `document_embeddings`, `paragraph_embeddings`, `case_law_embeddings` (pgvector 1024-dim, voyage-3)
### Layer 5 — Multi-tenancy ### Layer 5 — Multi-tenancy
`companies`, `tag_company_mappings` (appeal_subtype → company_id) `companies`, `tag_company_mappings` (appeal_subtype → company_id)
@@ -283,7 +283,9 @@ legal-qa מריץ 6 בדיקות איכות:
## טכנולוגיות עיקריות ## טכנולוגיות עיקריות
- **Database**: PostgreSQL 15 + pgvector 0.8.1 - **Database**: PostgreSQL 15 + pgvector 0.8.1
- **Embeddings**: Voyage AI (`voyage-3-large`, 1024-dim) - **Embeddings**: Voyage AI (`voyage-3`, 1024-dim) + cross-encoder rerank (`rerank-2`)
- bi-encoder: voyage-3 לכל chunk (חד-פעמי בעת ingestion)
- cross-encoder: rerank-2 לכל query (top-50 → top-K), feature flag `VOYAGE_RERANK_ENABLED`
- **Agents**: Claude Opus 4.7 (via Paperclip pm2) - **Agents**: Claude Opus 4.7 (via Paperclip pm2)
- **DOCX manipulation**: `python-docx` 1.2+ ו-`lxml` 5.2+ (XML surgery) - **DOCX manipulation**: `python-docx` 1.2+ ו-`lxml` 5.2+ (XML surgery)
- **Frontend**: Next.js + TanStack Query + Tailwind - **Frontend**: Next.js + TanStack Query + Tailwind

View File

@@ -0,0 +1,179 @@
# מחיקת תיק — runbook
> **מתי להשתמש:** reset שלם של תיק (לבדיקות end-to-end), מחיקת תיק שנפתח בטעות, או ניקיון לפני העלאה חוזרת של מסמכים.
>
> **חשוב:** ה-API `DELETE /api/cases` בלבד **לא מספיק** — הוא מטפל רק בצד legal-ai (DB + on-disk dir). תיק חי במקביל ב-4 מערכות והכול חייב להתנקות יחד.
---
## איפה ה-state של תיק חי
| מערכת | מה נשמר | איך מנקים |
|---|---|---|
| **legal-ai DB** (port 5433) | `cases` + `documents` + `document_chunks` + `claims` + `appraiser_facts` + `decisions` + `qa_results` + `case_precedents` | API DELETE (cascade על FK) |
| **legal-ai disk** | `/data/cases/{N}/` בתוך ה-container — מכיל drafts/, documents/, .git/ | API עם `remove_files=true` (`shutil.rmtree` בתוך ה-container) |
| **Paperclip DB** (port 54329) | `projects` + `issues` + `issue_comments` + `agent_wakeup_requests` + `heartbeat_runs` (audit) + עוד 6+ טבלאות | SQL ידני (אין API) |
| **Gitea** | repo `cases/{N}` אם נוצר ב-case-create | Gitea API |
ה-API לא מטפל ב-Paperclip ו-Gitea כי אלה מערכות חיצוניות שלגמרי מחוץ ל-DB של legal-ai. תועד מפורשות ב-docstring של [`services/db.py:delete_case`](../mcp-server/src/legal_mcp/services/db.py).
---
## תהליך מחיקה מלא — שלב אחרי שלב
הצב את מספר התיק במשתנה לפני שמתחילים:
```bash
CASE_NUMBER=8174-24
```
### שלב 1 — legal-ai (DB + disk)
```bash
curl -s -X DELETE \
"https://legal-ai.nautilus.marcusgroup.org/api/cases?case_number=${CASE_NUMBER}&remove_files=true" \
-w "\nhttp=%{http_code}\n"
```
תוצאה צפויה: `200` עם `{"deleted": true, "removed_files": true, ...}`.
מה זה עושה מאחורי הקלעים:
1. `DELETE FROM cases` — מפעיל **CASCADE** ל-7 טבלאות, **SET NULL** ל-`audit_log` ו-`chair_feedback`.
2. `shutil.rmtree(/data/cases/{N})` — מסיר את כל הספרייה כולל `.git`.
> **הערה:** עד לפני [commit `903fb4d`](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/commit/903fb4d) ה-endpoint הזה החזיר 500 כי `db.delete_case` לא היה מוגדר. אם נתקלת ב-500 בגרסה ישנה, השתמש ב-SQL הישיר (ראה Fallback בסוף).
### שלב 2 — Paperclip
אין API. SQL ישיר:
```bash
PGPASSWORD=paperclip psql -h localhost -p 54329 -U paperclip -d paperclip <<SQL
BEGIN;
-- 1. מצא את כל ה-issues של הפרויקט (לפי שם)
CREATE TEMP TABLE _issue_ids AS
SELECT i.id, i.identifier
FROM issues i
JOIN projects p ON i.project_id = p.id
WHERE p.name LIKE '%${CASE_NUMBER}%';
SELECT identifier FROM _issue_ids ORDER BY identifier; -- וידוא לפני המחיקה
-- 2. מחק blockers ל-FK עם NO ACTION (אסור למחוק issue אם יש להם reference)
DELETE FROM issue_comments WHERE issue_id IN (SELECT id FROM _issue_ids);
DELETE FROM cost_events WHERE issue_id IN (SELECT id FROM _issue_ids);
DELETE FROM finance_events WHERE issue_id IN (SELECT id FROM _issue_ids);
DELETE FROM feedback_votes WHERE issue_id IN (SELECT id FROM _issue_ids);
DELETE FROM issue_inbox_archives WHERE issue_id IN (SELECT id FROM _issue_ids);
DELETE FROM issue_read_states WHERE issue_id IN (SELECT id FROM _issue_ids);
-- 3. מחק את ה-issues. CASCADE מטפל ב-7 טבלאות נוספות:
-- issue_approvals, issue_attachments, issue_documents,
-- issue_execution_decisions, issue_labels, issue_relations,
-- issue_work_products
DELETE FROM issues WHERE id IN (SELECT id FROM _issue_ids);
-- 4. שבור FK מ-heartbeat_runs כדי שאפשר יהיה למחוק wakeup_requests.
-- heartbeat_runs נשמרים כ-audit log לא משויך.
UPDATE heartbeat_runs
SET wakeup_request_id = NULL
WHERE wakeup_request_id IN (
SELECT id FROM agent_wakeup_requests
WHERE payload->>'issueId' IN (SELECT id::text FROM _issue_ids)
);
DELETE FROM agent_wakeup_requests
WHERE payload->>'issueId' IN (SELECT id::text FROM _issue_ids);
-- 5. מחק blockers ברמת ה-project (NO ACTION FK ל-projects)
DELETE FROM cost_events WHERE project_id IN (SELECT id FROM projects WHERE name LIKE '%${CASE_NUMBER}%');
DELETE FROM finance_events WHERE project_id IN (SELECT id FROM projects WHERE name LIKE '%${CASE_NUMBER}%');
-- 6. מחק את הפרויקט. CASCADE מטפל ב:
-- execution_workspaces, project_goals, project_workspaces, routines
DELETE FROM projects WHERE name LIKE '%${CASE_NUMBER}%' RETURNING id, name;
COMMIT;
SQL
```
> **למה Paperclip לא הוסיף API למחיקה?** כי זאת מערכת רב-משתמשית ומחיקה היא הרסנית מטבעה — Paperclip מעדיף `archive` (`projects.archived_at`). אנחנו אכן רוצים מחיקה אמיתית רק לסביבת בדיקות.
### שלב 3 — Gitea (אם repo נוצר)
```bash
GITEA_TOKEN=$(infisical secrets get GITEA__API_TOKEN --silent || \
echo "$GITEA_TOKEN") # סגדור מ-Infisical או ENV
curl -s -X DELETE \
-H "Authorization: token ${GITEA_TOKEN}" \
"https://gitea.nautilus.marcusgroup.org/api/v1/repos/cases/${CASE_NUMBER}" \
-w "http=%{http_code}\n"
```
תוצאה צפויה: `204` (deleted) או `404` (לא נוצר מעולם).
### שלב 4 — וידוא ניקיון
```bash
echo "=== legal-ai ==="
PGPASSWORD=$LEGAL_AI_PG psql -h localhost -p 5433 -U legal_ai -d legal_ai -t -c "
SELECT count(*) FROM cases WHERE case_number = '${CASE_NUMBER}';
" # → 0
ls /home/chaim/legal-ai/data/cases/${CASE_NUMBER} 2>&1 | head -1
# → "No such file or directory"
echo "=== Paperclip ==="
PGPASSWORD=paperclip psql -h localhost -p 54329 -U paperclip -d paperclip -t -c "
SELECT 'projects:'||count(*) FROM projects WHERE name LIKE '%${CASE_NUMBER}%'
UNION ALL SELECT 'issues:'||count(*) FROM issues WHERE title LIKE '%${CASE_NUMBER}%'
UNION ALL SELECT 'comments:'||count(*) FROM issue_comments WHERE body LIKE '%${CASE_NUMBER}%'
UNION ALL SELECT 'wakeups:'||count(*) FROM agent_wakeup_requests WHERE payload::text LIKE '%${CASE_NUMBER}%';
" # → all 0
echo "=== Gitea ==="
curl -s -H "Authorization: token ${GITEA_TOKEN}" \
"https://gitea.nautilus.marcusgroup.org/api/v1/repos/cases/${CASE_NUMBER}" \
| python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('full_name','NOT FOUND'))"
# → NOT FOUND
```
---
## Fallback — אם ה-API נשבר
אם משום מה ה-API DELETE לא עובד (ראינו את זה בעבר עם `delete_case` החסר), עשה DELETE ישיר ב-DB. ה-FK constraints יבצעו את העבודה:
```sql
PGPASSWORD=$LEGAL_AI_PG psql -h localhost -p 5433 -U legal_ai -d legal_ai -c "
DELETE FROM cases WHERE case_number = '${CASE_NUMBER}' RETURNING case_number, title;
"
```
לאחר מכן הסר את הספרייה מהדיסק. הספרייה בבעלות `root` כי ה-container רץ כ-root, אז תצטרך `sudo`:
```bash
sudo rm -rf /home/chaim/legal-ai/data/cases/${CASE_NUMBER}
```
---
## הערות שנלמדו תוך כדי
1. **`heartbeat_runs.wakeup_request_id`** הוא ה-trap היחיד. הוא NO ACTION FK, ולכן חוסם מחיקה של `agent_wakeup_requests`. הפתרון: `UPDATE ... SET wakeup_request_id = NULL` לפני המחיקה. ה-runs עצמם נשמרים כ-audit log (לא הפסד).
2. **פרויקט "name" ב-Paperclip** — לפי הקונבנציה הוא מתחיל ב-"ערר {N}" — לכן `LIKE '%{N}%'` מספיק. אם יש מספר תיקים שמכילים את אותו מספר, להחמיר עם match מלא או לפי `id`.
3. **Container ↔ host file ownership** — קבצים שיוצר ה-container (כולל ספריית התיק) שייכים ל-`root`. מחיקה מהמארח דורשת `sudo`, או דרך docker exec, או דרך ה-API (שמבצעת `rmtree` בתוך ה-container).
4. **`audit_log` ו-`chair_feedback` נשארים** — FK שלהם הוא SET NULL כדי לשמור היסטוריה גם אחרי שהתיק נמחק. אם אתה צריך מחיקה היסטרית מוחלטת, מחק שורות אלה ידנית.
---
## TODO — אוטומציה
ה-runbook הזה ניתן להמרה לסקריפט `scripts/delete-case.sh` שמקבל `CASE_NUMBER` ומבצע את 4 השלבים עם prompt confirmation. עדיין לא הוטמע — נכון להיום העבודה ידנית.
מי שמטמיע: שמור את הסקריפט כ-`destructive` ב-SCRIPTS.md ודרוש `--confirm` או prompt אינטראקטיבי. אסור שיעבוד בלי אישור מפורש.

View File

@@ -400,6 +400,54 @@
- **~30 תקדמים חיצוניים** ש**דפנה מצטטת באופן עקבי** (ראה precedent-network.md) - **~30 תקדמים חיצוניים** ש**דפנה מצטטת באופן עקבי** (ראה precedent-network.md)
- **~15 תקדמים אישיים** שלה עצמה — מהווים את הקאנון האישי שלה - **~15 תקדמים אישיים** שלה עצמה — מהווים את הקאנון האישי שלה
---
## 6.11 לקחים מערר 1200-25 (קרית ענבים, מאי 2026)
השוואה בין טיוטת הכותב לעריכת דפנה חשפה 7 דפוסי סגנון שלא היו מתועדים:
### א. סדר בלוקים — תכניות לפני טענות (1xxx)
בתיקי רישוי, דפנה מעדיפה שבלוק ט (תכניות חלות) יופיע **לפני** בלוק ז (טענות). הרציונל: הקורא צריך להכיר את המסגרת הנורמטיבית לפני שהוא קורא את טענות הצדדים.
**סדר נכון ל-1xxx:** ה → ו**ט**ו.ב (רקע מורחב) → ז → ח → י → יא → יב
### ב. תבנית "להלן מתוך" — חובה
כל התייחסות למסמך מקור מלווה ב-"להלן מתוך [שם המסמך]:" כ-placeholder לציטוט/צילום. **12 מופעים** בעריכה, **0** בטיוטה. זהו דפוס סגנוני מרכזי שחייב להיות אוטומטי.
דוגמאות:
- "להלן מתוך הוראות התכנית:"
- "להלן מתוך פרוטוקול הדיון בוועדה המקומית:"
- "להלן מתוך הבקשה להיתר:"
- "להלן מתוך מטרת התכנית:"
- "להלן מתוך תשריט מצב מוצע:"
### ג. רקע עובדתי מורחב — ציר זמן מלא
בלוק ו חייב לספר את "הסיפור" של התיק: הגשת בקשה → פרסום → מספר התנגדויות → ישיבות ועדה מקומית (תאריך + תוצאה לכל אחת) → החלטה סופית → הגשת ערר. הטיוטה נתנה שורה אחת (90 מילים); דפנה הרחיבה ל-3 ישיבות מפורטות (~420 מילים).
### ד. ניתוח "גשר תכנוני"
כשמבקש שימוש חורג גם מקדם תכנית — דפנה מנתחת: האם השימוש המבוקש **תואם** את התכנון העתידי (→ גשר לגיטימי, כמו בכוכבה תורן)? או **סותר** (→ סטייה כפולה)? מסגרת ניתוח שלמה (249 מילים) שלא הייתה בטיוטה.
### ה. עיגון כמותי
דפנה מוסיפה נתונים מספריים ספציפיים: "4,404.98 מ"ר לכלל היישוב vs 1,425 מ"ר מבוקש — 32%". המספרים מעגנים את ההחלטה במציאות ומקשים על ערעור.
### ו. כותרות שטוחות (Heading 2 בלבד)
דפנה השתמשה ב-Heading 2 לכל הסעיפים, כולל תת-נושאים בדיון. **אין Heading 3**. כל סעיף עומד בפני עצמו.
### ז. הבחנת תקדימים inline
במקום סעיף נפרד "הבחנה מתקדימי העוררת" — ההבחנות מנוסחות inline: "באשר ל-[שם פסק דין]" → מה ההבדל → סיכום. דוגמה: "באשר לבג"ץ 6525/15 עמק שווה... אולם ההבדל מהותי".
### ביטויי מעבר חדשים (מעריכה 1200-25)
| ביטוי | הקשר |
|-------|-------|
| "עינינו הרואות" | ממצא מתוך מסמך |
| "הנה כי כן" | לפיכך (פורמלי) |
| "נשוב כאן ונבחין" | חזרה להבחנת תקדים |
| "נוסיף ונבהיר" | הוספת הבהרה |
| "מסקנת הדברים" | סיכום סעיף |
| "משכבר קבענו" | הפניה לקביעה קודמת |
--- ---
## 7. מה עדיין לא ראינו ## 7. מה עדיין לא ראינו

View File

@@ -252,3 +252,197 @@ Total: ~340,000 words of source material.
Intermediate extraction documents also saved: Intermediate extraction documents also saved:
- `docs/fjc-principles-extraction.md` — 38 principles from FJC - `docs/fjc-principles-extraction.md` — 38 principles from FJC
- `docs/garner-methodology-extraction.md` — ~50 principles from Garner/Scalia - `docs/garner-methodology-extraction.md` — ~50 principles from Garner/Scalia
---
## Lessons from הר הבשן 1033-25 (April 2026)
### Source
- Final decision: `data/cases/1033-25/exports/עריכה-v2.docx`
- Our draft (v6): `data/cases/1033-25/exports/טיוטה-v6.docx`
- Intermediate edit (v1): `data/cases/1033-25/exports/עריכה-v1.docx`
- Date: April 2026
- Result: Full acceptance (קבלה מלאה)
- Word counts: Draft 2,126 → Final 2,299 (+8%)
- Discussion section: Draft 960 words (19 paras) → Final 1,099 words (23 paras) (+14%)
### What Our Draft Got Right
- **12-block structure preserved** — all blocks in correct order, headings identical
- **Opening formula** — bottom-line opening "מצאנו כי דין הערר להתקבל" (mode A adapted for acceptance) — used and kept
- **Threshold claims treatment** — all 3 threshold claims handled correctly with same reasoning
- **Central argument flow** — committee's own conditions → shadow plan → not feasible → appeal accepted — this was the exact structure Dafna kept
- **Background neutrality** — facts-only background passed final review (no party quotes, no value words)
- **Most paragraphs kept verbatim** — blocks ו (background), ז (claims), and most of ח (procedures) were kept nearly word-for-word
- **Transition phrases** — "ונוסיף", "הנה כי כן", "הדברים מתחדדים שעה שנזכיר כי" — all used correctly and retained
- **Direct quote from licensing rep** — "נכון, אני מסכימה, התבקשו הרחבות..." — kept verbatim
- **"מסקנת ביניים"** technique — used correctly and retained
- **"למען הסדר הטוב"** — correct usage for remaining claims section
### What the Final Version Changed — Critical Gaps
#### 20. Over-Doctrinal: Abstract Legal Framework Removed Entirely
- **Draft:** Had a 101-word "נבאר" paragraph explaining the general legal authority of committees to require uniform building plans, covering advisory vs. mandatory annexes and administrative review processes — pure CREAC doctrine.
- **Final:** Completely deleted. Went straight from conclusion ("מסקנתנו היא שהבקשה אינה עומדת") to factual evidence (shadow plan is theoretical).
- **Lesson:** In "clean acceptance" cases where the committee's OWN conditions provide the anchor for the decision, skip the doctrinal framework. The committee said "show us X", the applicant didn't show X — no need to explain WHY committees can require X. CREAC is for contested legal rules, not for applying a committee's own explicitly-stated conditions. This is the most important lesson from this case: **match doctrinal depth to legal uncertainty**.
#### 21. Background Enhanced with "ודוק" Foreshadowing
- **Draft:** Simple description of the permit application: "ופורסמה כנדרש לפי סעיף 149 לחוק"
- **Final:** Added 2 sentences after the permit description: "ודוק, בהתאם להוראות התכנית נספח הבינוי מחייב לגבי מספר הקומות המירבי ובכל הנוגע לדרישה להכנת תכנית אחידה הרי שזו מכח שלביות הביצוע של התכנית. על מנת לסטות מהוראות אלו התבקשו ההקלות."
- **Lesson:** Dafna plants analytical seeds in the background. This "ודוק" paragraph in the background isn't neutrality-violating — it's explaining how plan provisions work as a matter of technical fact. But it foreshadows the fulcrum of the entire analysis (the reliefs are from MANDATORY provisions, not from advisory guidance). The background reader already understands what's at stake before reaching the discussion. **Rule**: when the decision hinges on a technical planning distinction, explain that distinction in the background (as fact, not as argument).
#### 22. Procedures Section: Specific Dates → Summary Narrative
- **Draft:** Listed specific dates and documents: "ביום 05.02.2026 ניתנה החלטת ביניים... הודעת עמדה מטעם העוררת גלנסקי מיום 23.02.2026, תגובת גבי אינגרם מיום 08.02.2026, ותגובת מבקשת ההיתר מיום 25.02.2026"
- **Final:** Generalized: "לאחר מועד זה הוגשו בקשות, עדכונים ותגובות מטעם הצדדים לגבי ניסיון להגיע לידי הסכמות, וגם בניסיון לתכנן בקשה שונה ומכל מקום ועדת הערר אפשרה מרחב של זמן בתקווה כי ההחלטה תתייתר"
- **Lesson:** For post-hearing procedural history that didn't change the outcome, Dafna prefers summary narrative over chronological detail. The intermediate decisions, update letters, and their specific dates don't matter to the reader — what matters is the narrative arc: "we gave them time to agree, they didn't, now we decide." Also: "ועדת הערר אפשרה מרחב של זמן בתקווה כי ההחלטה תתייתר" — this signals judicial patience and good faith before ruling.
#### 23. Concrete Evidence Added: Specific Permits in Buildings 5, 7, 11
- **Draft:** General statement that expansions were done ("הרחבות אלו, שחלקן כבר בוצעו וחלקן אושרו...")
- **Final:** Added an entire new paragraph: "להלן כדוגמא מתוך היתרי הבניה בבתים מספר 5, 7, ו-11 (בניינים סמוכים ואף צמודים לזה מושא הערר), בהם התבקשו ואושרו תוספות בניה בהתאם להוראות התכנית בקומה ב' (מפלס 5.80+). משזכויות הבניה נוצלו כאמור, הרי שלא תהיה בידם האפשרות לנצל וליישם את הרחבת הבניה באופן דומה לזה המתבקש בענייננו, מה שיגרום לבית 13 להיות חריג לסביבתו" — with accompanying images of the permits.
- **Lesson:** In acceptance decisions where you're overturning a committee, provide specific factual evidence that makes the conclusion inevitable. Not "other buildings already expanded" but "HERE are permits 5, 7, 11 showing exactly what was approved at level +5.80, making it physically impossible for the shadow plan to be implemented." The word "חריג לסביבתו" appears here as factual consequence, not as value judgment.
#### 24. Plan-Provision Integration Paragraphs Added (נחדד + מקל וחומר)
- **Draft:** None of this content existed
- **Final:** Two new paragraphs:
- F13: "נחדד כי בהתאם להוראות התכנית נספח הבינוי מחייב לגבי מספר הקומות, ולכך מתווספת גם הוראת השלביות והדרישה להכנת תכנית אחידה לכל הבניין. ברי כי הכוונה לתכנית הממחישה ומבטיחה כי ההרחבות מושא התכנית יוכלו להתממש לגבי כלל בעלי הזכויות ובאופן המייצר מופע מקובל."
- F14: "הדברים מתחדדים ביתר שאת שעה שמבוקשת הקלה שמשמעותה חריגה מהוראות התכנית שאז בוודאי מקל וחומר נכון להכין תכנית אחידה."
- **Lesson:** Where the draft used abstract doctrine, Dafna uses specific plan provisions. The "מקל וחומר" argument is new and powerful: if a uniform plan is required even for plan-conforming construction, then all the more so for construction that deviates from the plan. This replaces the general legal framework with a specific, irrefutable logical argument anchored in THIS plan's provisions.
#### 25. Counter-Factual Reasoning: "Approved by Mistake" + "Barren Discussion"
- **Draft:** Simple statement: "לאחר שהתברר בדיון בפנינו כי תכנית הצל אינה ישימה" followed by intermediate conclusion
- **Final:** Added entirely new reasoning: "תכנית הצל אושרה מתוך טעות כי הרי לא נוכל להניח כי אושרה למראית עין וברי כי הועדה המקומית ביקשה להבטיח זכויות של אחרים והשתלבות בסביבה. במקום בו התכנית אינה ישימה דיון בה הינו דיון עקר."
- **Lesson:** The "benefit of the doubt" technique — assume the committee acted in good faith (they didn't knowingly approve a hollow document), then show that this good-faith assumption actually STRENGTHENS the reversal (if they thought it was real, and it's not, then they were misled). "דיון עקר" = "barren discussion" — a phrase that shuts down any further argument about the shadow plan's merits. This is a new rhetorical move not seen in previous decisions.
#### 26. Engineer Counter-Factual: "Had He Known..." (Two New Paragraphs)
- **Draft:** Nothing about the engineer after the discussion section
- **Final:** Two new paragraphs (F18-F19) adding meta-reasoning about the engineer's opinion:
- "חוות דעתו של מהנדס הוועדה כי התכנון המבוקש חורג לסביבתו נבחנה לאור תכנית הצל שהוגשה ומשזו הוגשה בחסר חוו"ד הגורם המקצועי נותרה גם היא בחסר."
- "ונציין כי חוו"ד מהנדס הוועדה ניתנה במקום בו היה סבור כי תכנית הצל ישימה ובהינתן כך קבע כי הינה עדיין חורגת לסביבה... היה והייתה מוצגת תכנית צל המאגדת את ההיתרים שאושרו וממחישה את חריגות הבניה במרחב, ניתן לשער כי חוו"ד המהנדס הייתה החלטית יותר"
- **Lesson:** In acceptance decisions where you're overturning a committee that had professional support, explain WHY the professional got it wrong (or rather, why his analysis was based on faulty premises). The counter-factual "had the engineer known the shadow plan was not feasible, his opposition would have been even stronger" turns the committee's own professional opinion into evidence FOR the reversal. This is Dafna's way of being respectful to professionals while still overturning their conclusions.
#### 27. "לא נעלם מעינינו" Acknowledge-Before-Reject Removed
- **Draft:** Had a 66-word paragraph: "לא נעלם מעינינו כי נספח הבינוי הוגדר כ'מנחה' ולא כ'מחייב'... אולם אף בנספח מנחה, סטייה מהותית... אינה עניין טכני אלא שינוי מהותי"
- **Final:** Completely removed
- **Lesson:** The "אכן...אולם" or "לא נעלם מעינינו" pattern is for REJECTING an appeal — you need to show you considered the losing side's best argument. In ACCEPTANCE, the losing side is the committee/permit applicant, and the analysis already shows their conditions weren't met. No need to acknowledge the other side's argument when the factual record speaks for itself. **Rule**: "acknowledge-before-reject" = only in rejection decisions or on specific issues where you rule against a party. Don't use it prophylactically.
#### 28. Committee Response: Personal Circumstances Added
- **Draft:** Missing entirely — no mention of "פסק הלכתי" or "נסיבות אישיות חריגות"
- **Final:** Added new paragraph in committee response section: "בין השיקולים ששקלו חברי הוועדה נלקחו בחשבון גם נסיבות אישיות חריגות של מבקשת ההיתר, ובכללן פסק הלכתי שהוצג בפני הוועדה, שלפיו בנות מתבגרות אינן יכולות להתגורר באותו מפלס עם שאר בני המשפחה"
- **Lesson:** If a committee considered unusual factors (religious rulings, personal hardship), document them in the claims section for completeness, even if they're not addressed in the discussion. Omitting them would create a gap for judicial review — a judge reading the protocol would wonder why the decision doesn't mention them. Including them in the claims section without addressing them in the discussion implicitly signals: "we noted this but it doesn't change the planning analysis."
#### 29. Opening Precision: Permit Number and Phrasing
- **Draft:** "בקשה להיתר שמספרה" (placeholder — number missing!), "בהקלה לתוספת קומה"
- **Final:** "בקשה להיתר מס' 20230614", "בקשה הכוללת הקלות 'הקלה לתוספת קומה ללא תכנית אחידה וללא אדריכלות חוץ'"
- **Lesson:** (a) Never leave placeholders — "שמספרה" without the actual number is a production error. (b) The permit number is a legal identifier that must appear in the opening. (c) The phrasing "בקשה הכוללת הקלות" (application that includes reliefs) is more precise than "בהקלה" (with a relief). Also: the relief description is quoted in quotation marks from the official publication.
#### 30. "ונפרט;" Not "נפרט."
- **Draft:** "נפרט." (period)
- **Final:** "ונפרט;" (ו prefix + semicolon)
- **Lesson:** The transition from conclusion to detail uses "ו" prefix (connecting) and semicolon (flowing into the detail), not a period (which creates a full stop). This was already documented in the voice fingerprint ("מעבר עם נקודה-פסיק") but the draft didn't apply it. This confirms: **semicolons before elaboration are not optional — they are Dafna's standard punctuation for transitions into detail**.
#### 31. Summary: No Forward-Looking Guidance to Losing Party
- **Draft:** Had a forward-looking paragraph: "ככל שמבקשת ההיתר תבקש להגיש בקשה מחודשת עליה לעמוד בדרישות התכנית, לרבות הצגת תכנית אחידה ישימה לכל הבניין כנדרש"
- **Final:** Replaced with simple restatement: "על כן, הבקשה להיתר לא עמדה בתנאים שהוועדה המקומית עצמה קבעה בהחלטתה מיום 8.7.2024."
- **Lesson:** Dafna does NOT give advice to the losing party in the summary. The decision says what was decided, not what the applicant should do next. Forward-looking guidance would be an advisory opinion outside the scope of the decision. Also note: the final added "ולמעשה היא אינה ממחישה את המצב הפיזי והתכנוני 'האמיתי'" — a new phrase capturing the essence of why the shadow plan fails (it doesn't reflect reality).
#### 32. Unit vs. Extension: Deference to Committee, Not Independent Analysis
- **Draft:** "ניתן לקבל בדוחק את עמדת מבקשת ההיתר כי מדובר בתוספת לדירה קיימת" — expressing the committee's own hesitant view
- **Final:** "עולה כי הועדה המקומית דנה בכך וקבעה כי מדובר ביחידת דיור אחת שבנייתה מיועדת לשימוש בן משפחה... אין אנו מוצאים להתערב בכך ראשית כי הדבר מקדים את זמנו... ושנית ככל שתאושר בניה זו יש לוודא כי לא תבנה יח"ד נוספת"
- **Lesson:** When a secondary issue was resolved by the committee and you're not overturning THAT specific finding, use deference ("אין אנו מוצאים להתערב") rather than expressing your own opinion ("ניתן לקבל בדוחק"). The final also adds a CONDITION ("יש לוודא כי לא תבנה יח"ד נוספת") — practical safeguard rather than theoretical analysis.
#### 33. No Expenses in Full Acceptance
- **Draft:** No mention of expenses
- **Final:** No mention of expenses
- **Lesson confirmed:** In full acceptance of an appeal by neighbor-appellants against a permit applicant, Dafna does not award expenses to either side. This contrasts with rejection (הכט: appellants pay expenses). The pattern emerges: expenses = only in rejection. Acceptance or partial acceptance = no expenses order.
### New Transition Phrases Discovered
- **"ונפרט;"** — correct form (ו + semicolon, not "נפרט.")
- **"דיון בה הינו דיון עקר"** — declaring a point moot
- **"אושרה מתוך טעות כי הרי לא נוכל להניח כי אושרה למראית עין"** — benefit-of-the-doubt construction
- **"ונציין כי חוו"ד... ניתנה במקום בו היה סבור כי..."** — counter-factual about professional opinion
- **"להלן כדוגמא מתוך"** — introducing specific documentary evidence
- **"ברי כי הכוונה ל..."** — explaining legislative intent of plan provisions
- **"מה שיגרום לבית 13 להיות חריג לסביבתו"** — factual consequence language
- **"ועדת הערר אפשרה מרחב של זמן בתקווה כי ההחלטה תתייתר"** — explaining judicial patience
### Meta-Lesson
This is the first "clean acceptance" in our training data (הכט = rejection, בית הכרם = partial acceptance). The key insight: **the draft was too careful**. It built a doctrinal framework (CREAC) as if it needed to justify overturning the committee from first principles, when in reality the committee's OWN conditions provided all the justification needed. Dafna's approach to acceptance:
1. **Anchor in the committee's own conditions** — no need for external legal authority
2. **Show concrete evidence** the conditions weren't met (specific permits, images)
3. **Explain WHY the committee was misled** (shadow plan approved by mistake)
4. **Counter-factual reasoning** about what professionals would have said with correct information
5. **No abstract doctrine needed** when the facts are clear
The draft's biggest structural error was adding the "נבאר" doctrinal paragraph and the "לא נעלם מעינינו" acknowledge-before-reject. Both are tools for CONTESTED or REJECTED cases. In a clean acceptance, the facts lead directly to the conclusion.
### Applied To
- [ ] Update SKILL.md: add "clean acceptance" track — skip doctrine, anchor in committee's conditions
- [ ] Update SKILL.md: "acknowledge-before-reject" only in rejection/contested issues
- [ ] Update SKILL.md: no forward-looking guidance in summary
- [ ] Update SKILL.md: "ודוק" foreshadowing in background for technical planning distinctions
- [ ] Update SKILL.md: counter-factual reasoning about professional opinions
- [ ] Update SKILL.md: procedures section — summary narrative for post-hearing history
- [ ] Update voice-fingerprint: add new transition phrases
- [ ] Update architecture-by-outcome: add "clean acceptance" archetype
- [ ] Fix agent opening punctuation: "ונפרט;" not "נפרט."
---
## Lessons from ערר 1200-25 (קרית ענבים — שימוש חורג, דחייה)
### Source
- Our draft: `data/cases/1200-25/exports/טיוטה-v1.docx` (3,181 words)
- Daphna's edit: `data/cases/1200-25/exports/עריכה-v1.docx` (4,313 words, +35%)
- Date: May 2026
### What the Edit Changed
#### 1. Block Order — Plans Before Claims
- **Draft:** ה→ו→ז→ח→ט→י→יא→יב (plans after procedures)
- **Edit:** ה→ו→**ט**→ו.ב→ז→ח→י→יא→יב (plans BEFORE claims)
- **Lesson:** In licensing cases (1xxx), the reader must understand the normative framework (plans) before reading the parties' arguments about those plans. Block ט should precede Block ז. The new order: opening → brief background → **applicable plans** → expanded background (application + committee proceedings) → claims → procedures → discussion.
#### 2. "להלן מתוך" Document Insertion Pattern
- **Draft:** 0 occurrences
- **Edit:** 12 occurrences of "להלן מתוך [document name]:"
- **Lesson:** Every reference to a source document must be accompanied by "להלן מתוך [שם המסמך]:" as a placeholder for a direct quote/image. This is a MANDATORY pattern, not optional. Examples: "להלן מתוך הוראות התכנית:", "להלן מתוך פרוטוקול הדיון:", "להלן מתוך הבקשה להיתר:"
#### 3. Expanded Factual Background (Block ו)
- **Draft:** ~90 words (3%), one paragraph
- **Edit:** ~420 words (10%), covering: (a) the application details, (b) 3 committee meetings with dates and outcomes, (c) the final decision
- **Lesson:** Block ו must tell the full "story" of the case: when the application was filed → when it was published → how many objections → when committee meetings were held → what was decided at each meeting → when the appeal was filed. Each meeting should have date + outcome.
#### 4. Bridge Planning Analysis ("גשר תכנוני")
- **Draft:** Not present
- **Edit:** 249 words — new analytical framework
- **Lesson:** When an applicant for deviation/variance is also promoting a plan for the same land, the decision must analyze: (a) is the pending plan harmonious with the requested use? If yes → the deviation can serve as a "bridge" until the plan is approved (cite כוכבה תורן). If no → the contradiction STRENGTHENS the rejection. The writer must check `search_case_documents` for pending plans and compare them with the requested use.
#### 5. Competing Plans Analysis
- **Draft:** Not present (1,033 words added)
- **Edit:** Detailed comparison of the site-specific plan (151-1382787) vs the comprehensive plan (151-1337534)
- **Lesson:** When there's a site-specific plan AND a comprehensive plan, the decision must: (a) describe each plan's scope, (b) compare the permitted uses, (c) show quantitative contradictions (e.g., "the comprehensive plan allocates 4,404 m² for ALL commerce in the settlement, while the request alone is for 1,425 m² — 32%"), (d) conclude whether there's harmony or contradiction. This is often the STRONGEST argument in the decision.
#### 6. Heading Level — Flat Structure
- **Draft:** Mixed Heading 2 + Heading 3 (nested subsections)
- **Edit:** All Heading 2 (flat structure)
- **Lesson:** Each section stands independently. No nesting. In the discussion, each analytical step is a separate Heading 2 section.
#### 7. Inline Precedent Distinguishing
- **Draft:** Separate section "הבחנה מתקדימי העוררת" (Heading 3)
- **Edit:** Each precedent distinguished inline with "באשר ל-[case name]" → what's different → conclusion
- **Lesson:** Don't create a separate "distinguishing" section. Address each precedent where it naturally comes up in the discussion, using "באשר ל..." as the opener.
### New Transition Phrases Identified
- **"עינינו הרואות"** — introducing a document-based finding ("our eyes see that...")
- **"הנה כי כן"** — therefore/accordingly (more formal than "לפיכך")
- **"נשוב כאן ונבחין"** — returning to distinguish a case
- **"נוסיף ונבהיר"** — adding clarification
- **"מסקנת הדברים"** — concluding a subsection
- **"משכבר קבענו"** — since we already established
### Applied To
- [x] Update legal-decision-lessons.md with lessons 1-7
- [x] Update daphna-voice-fingerprint.md with structural and style findings
- [ ] Update block-schema.md: block order for 1xxx cases (ט before ז)
- [ ] Update daphna-architecture-by-outcome.md: add "bridge planning" analysis for rejections
- [ ] Update writer system prompt: mandatory "להלן מתוך" pattern

157
docs/paperclip-quirks.md Normal file
View File

@@ -0,0 +1,157 @@
# Paperclip Quirks — מלכודות ידועות
> **הקשר:** מה ש-Paperclip עושה בעצמו, מתחת לרגליהם של הסוכנים שלנו, ושאנחנו צריכים לעקוף אותו או לחיות איתו.
>
> כל מלכודת מתועדת עם:
> 1. מה קורה בפועל
> 2. ראיה אמפירית מתוך לוגים
> 3. ההשפעה על הצינור שלנו
> 4. עקיפה / תיקון / קבלה
---
## 1. `issue.released` הופך `done` ל-`todo`
### מה קורה
לאחר שסוכן מבצע `PATCH /api/issues/{id}` עם `status: done`, **Paperclip מבצע פעולה נוספת בשם `issue.released`** מספר שניות מאוחר יותר. ל-`issue.released` יש side-effect לא-מתועד שמחזיר את ה-status ל-`todo`.
### ראיה אמפירית — תיק 8174-24, CMPA-18 (30/04/26)
מתוך `activity_log`:
```
ts | action | actor_type | details
----------+---------------------+------------+----------------------------------------
18:14:49 | issue.comment_added | agent | comment by researcher
18:14:57 | issue.updated | agent | {"status": "done", "_previous": {"status": "in_progress"}}
18:15:35 | issue.released | agent | ← here
```
מצב מ-`issues` table 38 שניות לאחר ה-`released`:
```
identifier | status | updated_at
CMPA-18 | todo | 18:15:35
```
ה-status חזר מ-`done` ל-`todo` למרות שאף סוכן או משתמש לא ביקש זאת.
### ההשפעה על הצינור שלנו
Paperclip מזהה issue ב-`todo` כ"יש עבודה לעשות" → מיד מפעיל wakeup לסוכן הרלוונטי → הסוכן רץ שוב עם prompt cache מלא (~$0.10-0.50 פר-ריצה) → מסתכל סביב ומבין שהעבודה כבר נעשתה → סוגר את ה-issue שוב → `issue.released` חוזר על עצמו ⇒ פוטנציאל ללולאה.
### עקיפה — בצד שלנו (ללא תיקון Paperclip)
הסוכן שלנו **עושה זאת כבר היום בהצלחה** במקרה שהוא רואה issue ב-`todo` עם תוצרים קיימים:
1. בודק שהקבצים הצפויים קיימים (`Glob /documents/research/*.md`)
2. בודק שה-DB מאוכלס (`mcp__legal-ai__precedent_list`, `get_claims`, וכו')
3. אם הכל קיים → לא מבצע עבודה כפולה → כותב comment "אין שינוי" → `PATCH issue → done`
**הראיה:** בריצה החוזרת (PID 309786 ב-30/04/26 18:15:54), המנתח של החוקר זיהה תוך 90 שניות שכל 9 התקדימים והקובץ קיימים, וסגר את ה-issue ב-`PATCH → done` שוב. הריצה הזאת עלתה כ-$0.20 — לא חינם, אבל לא לולאה.
### אם תרצה לחקור פנימה
ה-`issue.released` נרשם ב-`activity_log` עם `actor_type=agent` אבל בלי `agent_id` שמסביר מי. הוא לא נכתב על ידי הסקריפטים שלנו (אנחנו לא קוראים endpoint כזה). מקור אפשרי:
- מנגנון `executionLockedAt` / `executionWorkspaceId` של Paperclip שמשחרר משאבים אחרי שריצה מסתיימת ובמקביל מאפס status
האפשרות הנכונה לסגור את הבאג היא **ב-Paperclip עצמו** — לתקן את `issue.released` שלא ידרוס status מסוף-מצב כמו `done`. עד שזה נסגר אצלם, אנחנו חיים עם self-recovery.
### סטטוס
- **לא נסגר ב-Paperclip** (ידוע לפי 30/04/26)
- **טופל בצד שלנו** דרך self-recovery בסקייל של הסוכן (HEARTBEAT.md §4-recovery)
- **לתעד עלות**: כל ריצת self-recovery מוסיפה ~$0.20 לתיק
---
## 2. Bash backtick trap בעת בניית comment body דרך curl
### מה קורה
הסוכן בונה pipeline מורכב כדי לפרסם comment עם markdown ארוך:
```bash
curl ... -d "$(python3 -c "
body = '''## כותרת
📁 קובץ: \`/path/to/file.md\`
'''
print(json.dumps({'body': body}))")"
```
ה-`bash` שמריץ את ה-`$(...)` הראשון רואה את ה-backticks (` ` ` ) בתוך המחרוזת של Python ומפרש אותם **כ-command substitution של bash**. הוא מנסה להריץ את `/path/to/file.md` כפקודה, ומכיוון שהקובץ לא executable — מחזיר:
```
/bin/bash: line 56: /path/to/file.md: Permission denied
```
### ההטעיה
ההודעה `Permission denied` היא **לא** באמת בעיית הרשאות:
- `ls -la` מראה שהקובץ הוא `chaim:chaim` עם `-rw-r--r--`
- `touch` ידני באותו נתיב מצליח
- ה-Write tool כבר כתב את הקובץ הזה בהצלחה דקה קודם
### למה זה קורה דווקא בנתיבי מסמכים
Backticks הם תחביר markdown נפוץ לציטוט נתיבים: `` `/home/chaim/...` ``. בפלט markdown זה נכון, אבל כשהסוכן מטמיע את ה-markdown בתוך bash heredoc / command substitution, ה-backticks מפעילים את עצמם.
### תיקון — דפוס "כתוב לקובץ זמני אז curl -d @file"
במקום:
```bash
curl ... -d "$(python3 -c "...long body with backticks...")"
```
עשה:
```python
# 1. כתוב את ה-body לקובץ זמני דרך Write tool (בלי שום bash quoting)
Write("/tmp/comment.json", json.dumps({"body": markdown_body}))
```
```bash
# 2. אז curl קורא מהקובץ — אין shell expansion על התוכן
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" \
-d @/tmp/comment.json
```
הנתיב `-d @file` קורא את התוכן של הקובץ **בלי שום ניתוח** — אין shell, אין quoting, אין backticks-as-commands. זה גם מאפשר body של 10K+ תווים ללא הגבלת ARG_MAX.
### סטטוס
- **תיעוד ב-HEARTBEAT.md** עם הוראה מפורשת להשתמש ב-Write+`-d @file` ל-bodies מעל 500 תווים
- **השפעה היסטורית**: לפני התיקון, הריצה ב-CMPA-18 (30/04/26) הצליחה (curl באמת רץ) — אבל ה-`Permission denied` בלוג היה מבלבל וגרם לחקירה. עתה שהסיבה ידועה, אפשר להתעלם.
---
## 3. CEO main issue auto-block ב-`in_progress`
### מה קורה
CEO שמסיים turn (פרסם comment "ממתין לסיום של סוכן Y") ומשאיר את ה-issue ב-`in_progress` יקבל auto-block תוך דקה אחת מ-Paperclip ("live execution disappeared"). הסטטוס יקפוץ ל-`blocked` ויידרש wakeup ידני להמשיך.
### עקיפה
CEO צריך להעביר את ה-issue ל-`in_review` (לא `in_progress`) כשהוא ממתין למשאב חיצוני (סוכן אחר, יו"ר). זה מתועד ב-CLAUDE.md זיכרון: `feedback_paperclip_enums.md`.
### סטטוס
- **תיקון ב-`legal-ceo.md`** (commit a1969dd)
- נצפה עובד ב-CMPA-15 ב-30/04/26 — ה-CEO עבר ל-`in_review` נכון
---
## 4. Wakeup דרך DB ישיר ≠ wakeup דרך API
### מה קורה
`INSERT INTO agent_wakeup_requests` ידני בלי לעבור דרך `POST /api/agents/{id}/wakeup` יוצר רשומת wakeup אבל **לא יוצר `heartbeat_run`**. בלי `heartbeat_run`, ה-runtime של Paperclip לא מזהה שיש משהו להריץ → הסוכן לעולם לא מתעורר.
### עקיפה
תמיד להשתמש ב-API. כל הסקייל שלנו תועדו עם האזהרה הזאת.
### סטטוס
- **תיקון בכל הסקייל** (CLAUDE.md זיכרון: `reference_paperclip_wakeup.md`)

View File

@@ -0,0 +1,38 @@
<!-- docs/runbooks/coolify-mcp-settings-volumes.md -->
# Coolify Volume Mounts ל-MCP Settings Page
## רקע
טאב **Registrations** בדף `/settings` קורא רישומי MCP מתוך:
- `~/.claude.json` (host)
- `~/.paperclip/instances/*/mcp.json` (host)
הקונטיינר של legal-ai חייב גישת קריאה לקבצים אלה דרך volume mounts.
בלי המאונט, ה-endpoint יחזיר `error: "host_path_unavailable"` והטאב יציג הודעת אי-זמינות.
## הוראות
1. פתח Coolify UI: `http://158.178.131.193:8000`.
2. נווט לאפליקציה: legal-ai (UUID `gyjo0mtw2c42ej3xxvbz8zio`).
3. לשונית **Storages****Add Storage**.
4. הוסף שני mounts:
| Source path (host) | Destination path (container) | Mode |
|---|---|---|
| `/home/chaim/.claude.json` | `/host/.claude.json` | `ro` |
| `/home/chaim/.paperclip` | `/host/.paperclip` | `ro` |
5. שמור ולחץ **Redeploy**.
## אימות
אחרי ה-redeploy:
```bash
curl -s https://legal-ai.nautilus.marcusgroup.org/api/settings/mcp/registrations | jq
```
צריך להחזיר `"error": null` ורשימת רישומים.
## הערה אבטחה
המאונטים הם read-only. ה-endpoint לא מחזיר ערכי env (רק שמות keys),
ולא מאפשר לעדכן את הקבצים.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,336 @@
# דף הגדרות MCP — איפיון
**תאריך:** 2026-05-04
**מצב:** Draft → ממתין לאישור משתמש
**הקשר:** הרחבת `/settings` ב-web-ui עם מידע על MCP server של legal-ai (env vars, tools, registrations).
---
## 1. מטרה
לתת ליו"ר/מנהל המערכת מקום מרכזי לראות (ולערוך כשבטוח) את כל מצב התצורה של ה-MCP server, בלי לעבור בין Infisical UI, Coolify UI, וקבצי קונפיגורציה מקומיים.
## 2. גבולות (Scope)
**בתוך הסקופ:**
- תצוגה + עריכה של env vars לא-סודיים, שמירה ל-Infisical, redeploy ידני של Coolify.
- תצוגה (read-only) של env vars סודיים, עם indicator של drift בין Infisical לקונטיינר.
- תצוגה (read-only) של רשימת tools שה-MCP server חושף (introspection דינמי).
- תצוגה (read-only) של רישומי MCP בקבצי הקונפיגורציה של Claude Code ו-Paperclip.
**מחוץ לסקופ (אולי בעתיד):**
- Enable/disable של tools בודדים.
- עריכת `~/.claude.json` או `~/.paperclip/...` מ-UI.
- Auth/RBAC חדש (משתמש ב-auth קיים של הדף — אין כרגע).
- ניהול secrets — נשאר ב-Infisical UI.
- Auto-redeploy אחרי שמירה (משתמש לוחץ Redeploy ידנית).
## 3. ארכיטקטורה
### 3.1 מבנה דף (Frontend)
`/settings` הופך לדף מבוסס-טאבים (`shadcn/Tabs`):
| Tab | תוכן | מצב |
|---|---|---|
| Paperclip | התוכן הקיים: Tag mappings + Companies | קיים, ללא שינוי לוגי |
| Environment | env vars של MCP server, Infisical / Container | חדש, עריכה |
| Tools | רשימת tools של ה-MCP server | חדש, read-only |
| Registrations | רישומי MCP ב-Claude Code ו-Paperclip | חדש, read-only |
טאב ברירת מחדל: `Paperclip`.
### 3.2 שכבת Backend (FastAPI ב-`web/app.py`)
#### Endpoints חדשים
| Path | Method | תיאור |
|---|---|---|
| `/api/settings/mcp/env` | GET | מחזיר רשימת env vars מאוחדת |
| `/api/settings/mcp/env/{key}` | PATCH | מעדכן ערך ב-Infisical (רק לא-סודיים) |
| `/api/settings/mcp/env/redeploy` | POST | מפעיל Coolify redeploy |
| `/api/settings/mcp/tools` | GET | מחזיר רשימת tools של MCP server |
| `/api/settings/mcp/registrations` | GET | מחזיר רישומי MCP מ-`/host/.claude.json` ומ-`/host/.paperclip/instances/*/mcp.json` |
#### Catalog של env vars
קובץ חדש: `web/mcp_env_catalog.py`
```python
from dataclasses import dataclass
from typing import Literal, Any
EnvType = Literal["bool", "int", "float", "string", "enum"]
EnvCategory = Literal["multimodal", "rerank", "halacha", "credentials", "connection", "general"]
@dataclass(frozen=True)
class EnvSpec:
key: str
category: EnvCategory
type: EnvType
description: str
is_secret: bool
is_editable: bool
default: Any = None
min: float | None = None
max: float | None = None
enum_values: list[str] | None = None
ENV_CATALOG: dict[str, EnvSpec] = {
# multimodal
"MULTIMODAL_ENABLED": EnvSpec("MULTIMODAL_ENABLED", "multimodal", "bool",
"הפעלת page-image embeddings", False, True, default=False),
"MULTIMODAL_MODEL": EnvSpec("MULTIMODAL_MODEL", "multimodal", "string",
"מודל multimodal של Voyage", False, True, default="voyage-multimodal-3"),
"MULTIMODAL_DPI": EnvSpec("MULTIMODAL_DPI", "multimodal", "int",
"DPI ל-rendering של עמוד למודל", False, True, default=144, min=72, max=300),
"MULTIMODAL_THUMB_DPI": EnvSpec("MULTIMODAL_THUMB_DPI", "multimodal", "int",
"DPI ל-thumbnail בתצוגה", False, True, default=96, min=72, max=200),
"MULTIMODAL_TEXT_WEIGHT": EnvSpec("MULTIMODAL_TEXT_WEIGHT", "multimodal", "float",
"משקל text vs image ב-RRF", False, True, default=0.5, min=0.0, max=1.0),
"MULTIMODAL_RRF_K": EnvSpec("MULTIMODAL_RRF_K", "multimodal", "int",
"RRF damping constant", False, True, default=60, min=1, max=200),
# rerank
"VOYAGE_RERANK_ENABLED": EnvSpec("VOYAGE_RERANK_ENABLED", "rerank", "bool",
"הפעלת cross-encoder rerank", False, True, default=False),
"VOYAGE_RERANK_MODEL": EnvSpec("VOYAGE_RERANK_MODEL", "rerank", "string",
"מודל rerank", False, True, default="rerank-2"),
"VOYAGE_RERANK_FETCH_K": EnvSpec("VOYAGE_RERANK_FETCH_K", "rerank", "int",
"מספר candidates לפני rerank", False, True, default=50, min=10, max=200),
# halacha
"HALACHA_AUTO_APPROVE_THRESHOLD": EnvSpec("HALACHA_AUTO_APPROVE_THRESHOLD",
"halacha", "float", "סף confidence ל-auto-approve",
False, True, default=0.80, min=0.0, max=1.0),
# general
"VOYAGE_MODEL": EnvSpec("VOYAGE_MODEL", "general", "string",
"מודל embedding ראשי", False, True, default="voyage-law-2"),
"AUDIT_ENABLED": EnvSpec("AUDIT_ENABLED", "general", "bool",
"הפעלת audit log", False, True, default=True),
# credentials (read-only, masked)
"VOYAGE_API_KEY": EnvSpec("VOYAGE_API_KEY", "credentials", "string",
"Voyage AI API key", True, False),
"GOOGLE_CLOUD_VISION_API_KEY": EnvSpec("GOOGLE_CLOUD_VISION_API_KEY",
"credentials", "string", "Google Cloud Vision API key", True, False),
"INFISICAL_TOKEN": EnvSpec("INFISICAL_TOKEN", "credentials", "string",
"Infisical SDK token", True, False),
# connection (read-only — מסוכן לשנות runtime)
"POSTGRES_URL": EnvSpec("POSTGRES_URL", "connection", "string",
"PostgreSQL connection URL", True, False),
"REDIS_URL": EnvSpec("REDIS_URL", "connection", "string",
"Redis connection URL", False, False),
"DATA_DIR": EnvSpec("DATA_DIR", "connection", "string",
"Data directory path", False, False),
}
```
המקור: `mcp-server/src/legal_mcp/config.py`. כל מפתח שלא ב-catalog לא מוצג (whitelist policy).
#### Response shape של `GET /api/settings/mcp/env`
```json
{
"vars": [
{
"key": "MULTIMODAL_ENABLED",
"category": "multimodal",
"type": "bool",
"description": "הפעלת page-image embeddings",
"is_secret": false,
"is_editable": true,
"default": false,
"infisical_value": "true",
"container_value": "true",
"drift": false,
"min": null, "max": null, "enum_values": null
},
{
"key": "VOYAGE_API_KEY",
"category": "credentials",
"type": "string",
"description": "Voyage AI API key",
"is_secret": true,
"is_editable": false,
"infisical_value": "****",
"container_value": "****",
"drift": false
}
],
"infisical_environment": "dev",
"coolify_app_uuid": "gyjo0mtw2c42ej3xxvbz8zio",
"errors": []
}
```
- `infisical_value`: דרך `InfisicalSDKClient.get_secret(...)`. אם יש שגיאה → `null` ועדכון `errors`.
- `container_value`: `os.environ.get(key)`. אם לא מוגדר → `null`.
- `drift`: `infisical_value != container_value` (אחרי normalization של bool/int/float; secrets לא משווים ערכים גולמיים — רק hash).
- ל-secret: שני הערכים מוחזרים מטושטשים (`"****" + last_4`); השוואת drift על ה-hash בלבד.
#### Save flow ב-`PATCH /api/settings/mcp/env/{key}`
1. ולידציה: הקיי קיים ב-catalog ו-`is_editable=true`. אם לא → 400.
2. ולידציה לפי type: int/float ב-טווח, bool מוסב מ-string, enum בערכים מותרים.
3. כתיבה ל-Infisical:
```python
client.update_secret(
project_id=INFISICAL_PROJECT_ID,
environment_slug=INFISICAL_ENV, # "dev" כברירת מחדל
secret_path="/legal-ai",
secret_name=key,
secret_value=str(value),
)
```
4. Audit log: `logger.info("mcp_env_update", extra={"key": key, "value": value if not is_secret else "[masked]"})`.
5. Response: `{"ok": true, "requires_redeploy": true, "message": "נשמר ב-Infisical. נדרש redeploy."}`.
#### Redeploy flow ב-`POST /api/settings/mcp/env/redeploy`
1. קריאה ל-Coolify API: `POST /api/v1/deploy?uuid=gyjo0mtw2c42ej3xxvbz8zio&force=false`.
2. אסימון: `COOLIFY_API_TOKEN` (מ-Infisical).
3. Polling: קריאה ל-`/api/v1/deployments/{deployment_uuid}` כל 5 שניות, עד `status="finished"` או `status="failed"` (max 10 דקות).
4. UI מציג סטטוס מתעדכן (פשוט: spinner + הודעת סטטוס; לא נדרש streaming).
#### Tools introspection ב-`GET /api/settings/mcp/tools`
```python
from legal_mcp.server import mcp # FastMCP instance
async def api_mcp_tools():
tools = await mcp.list_tools() # FastMCP API
return {
"tools": [
{
"name": t.name,
"description": t.description,
"module": _module_for_tool(t.name), # מ-tools/__init__.py
"params_schema": t.inputSchema,
"source_location": _source_location(t), # f"{file}:{line}"
}
for t in tools
]
}
```
`_module_for_tool` ו-`_source_location` נכתבים ב-`web/mcp_introspection.py` עם קריאת `inspect.getfile()` ו-`inspect.getsourcelines()`.
#### Registrations ב-`GET /api/settings/mcp/registrations`
קורא:
1. `/host/.claude.json` — תחת `mcpServers` או `projects.<path>.mcpServers`.
2. `/host/.paperclip/instances/*/mcp.json` — לכל instance בנפרד.
לכל רישום: `{client, instance_name?, server_name, command, args, cwd, env_keys}`.
- `env_keys`: רק שמות, לא ערכים.
- אם command/args מכילים paths רגישים — מוצגים as-is (לא secrets).
#### Coolify config — volume mounts נדרשים
לפני שהפיצ'ר עולה לפרודקשן, יש לוודא ב-Coolify (UUID `gyjo0mtw2c42ej3xxvbz8zio`):
```yaml
volumes:
- /home/chaim/.claude.json:/host/.claude.json:ro
- /home/chaim/.paperclip:/host/.paperclip:ro
```
המימוש כולל סקריפט/הוראה אופרטיבית להוסיף את ה-mounts (לא חלק מקוד הפרויקט — שינוי תצורה).
### 3.3 שכבת Frontend
#### קובץ קיים: `web-ui/src/lib/api/settings.ts`
מורחב עם hooks חדשים:
```ts
// קריאות חדשות
export function useMcpEnv() { /* GET /api/settings/mcp/env */ }
export function useUpdateMcpEnv() { /* PATCH /api/settings/mcp/env/{key} */ }
export function useMcpRedeploy() { /* POST /api/settings/mcp/env/redeploy */ }
export function useMcpTools() { /* GET /api/settings/mcp/tools */ }
export function useMcpRegistrations() { /* GET /api/settings/mcp/registrations */ }
```
#### קבצי components חדשים תחת `web-ui/src/app/settings/_components/`
```
_components/
├── paperclip-tab.tsx ← העברת התוכן הקיים מ-page.tsx
├── environment-tab.tsx ← רשימת קבוצות + EnvVarRow
├── env-var-row.tsx ← שורה אחת של env var
├── env-var-editor.tsx ← input controls לפי type
├── tools-tab.tsx ← טבלה + drawer
├── tool-detail-drawer.tsx ← פרטי tool
├── registrations-tab.tsx ← כרטיסים לפי client
└── drift-badge.tsx ← badge ויזואלי
```
`page.tsx` הופך לאחראי רק על ה-Tabs ולעטיפה.
#### חוויית עריכת env var
לחיצה על שורה → התרחבות (accordion) → הצגת editor + שני ערכים (Infisical / Container) + כפתור "שמור".
לחיצה על "שמור":
1. PATCH → toast הצלחה: "נשמר ב-Infisical. לחץ Redeploy כדי להחיל בקונטיינר."
2. השורה מסומנת כ-"pending redeploy" עד ה-redeploy הבא.
3. כפתור "Redeploy now" קבוע בתחתית הטאב, מודגש כשיש שינויים pending.
#### חוויית Tools
טבלה לפי module. שורה → drawer מימין עם schema + תיאור + מיקום בקוד.
#### חוויית Registrations
כרטיס לכל client (Claude Code, Paperclip) → פירוט הרישום: command/args/cwd/env_keys.
## 4. טיפול בשגיאות
| תרחיש | התנהגות |
|---|---|
| Infisical לא זמין | `errors: ["infisical_unreachable"]` ב-GET. ערך infisical = null. UI מציג `?` במקום הערך + tooltip |
| Coolify redeploy נכשל | toast עם פרטי השגיאה. ערך נשמר ב-Infisical, מסומן pending |
| volume mount חסר ב-Coolify | endpoint registrations מחזיר `{registrations: [], error: "host_path_unavailable"}`. UI מציג הודעה |
| ניסיון עריכה של secret | 400 עם הודעה ברורה |
| ערך לא חוקי לפי type | 400 עם הודעת ולידציה ספציפית |
| FastMCP introspection נכשלת | 500. לוג שגיאה. UI מציג fallback |
## 5. בטיחות
- **לא להציג ערכי secret** — ה-API מחזיר תמיד `****<last_4>` עבור secrets.
- **Drift detection לא חושף** — השוואה על hash, לא על ערך גולמי.
- **PATCH על secret חסום ב-server** — לא רק ב-UI.
- **No raw `os.environ` dump** — ה-endpoint מחזיר רק keys ב-catalog.
- **Audit log** — כל PATCH מתועד ל-`logger.info` (key + ערך אם לא-סודי).
## 6. שלבי מימוש (overview ל-plan)
1. Catalog + endpoint `GET /api/settings/mcp/env` (ללא עריכה).
2. UI טאב Environment — read-only עם drift badges.
3. PATCH endpoint + UI editor.
4. Redeploy endpoint + UI button.
5. Tools introspection + UI.
6. Volume mounts הוראה (manual Coolify config) + Registrations endpoint + UI.
7. בדיקות ידניות end-to-end.
## 7. שאלות פתוחות (להבהרה לפני plan)
- **סביבת Infisical** — `dev`? `nautilus`? להחליט סופית. ברירת מחדל ב-spec: `dev`. ייתכן ויהיה ניתן לקבוע ב-env var (`INFISICAL_ENV`).
- **Path ב-Infisical** — `/legal-ai`? `/legal-ai/mcp`? להחליט לפי `_GUIDELINES/SAVE_SECRET_RULES`.
- **Auth** — אין כרגע על `/settings`. להוסיף לפחות "are you sure" dialog לפני PATCH של ערך משמעותי?
## 8. בדיקות
**ידני (אין test suite ל-frontend):**
- ✓ פתיחת `/settings` — Paperclip tab עובד כקודם.
- ✓ Environment tab — מציג env vars מקבץ catalog בלבד.
- ✓ Drift detection — שינוי ידני של env בקונטיינר → drift badge מופיע.
- ✓ עריכת `MULTIMODAL_TEXT_WEIGHT` ל-`0.7` → נשמר ב-Infisical.
- ✓ Redeploy → ערך חדש נכנס לתוקף בקונטיינר.
- ✓ ניסיון עריכת `VOYAGE_API_KEY` → חסום + הודעה.
- ✓ Tools tab — מציג את כל ה-tools של legal_mcp.
- ✓ Registrations tab — מציג את `~/.claude.json` ו-Paperclip instances.
**Backend tests** ב-`web/tests/` (אם קיימים — אחרת לדלג):
- catalog rejects unknown key
- PATCH על secret נחסם
- ולידציה של min/max

View File

@@ -0,0 +1,409 @@
# שדרוגי Voyage — תכנית מפורטת
תכנית 3-שלבית לשדרוג שכבת ה-retrieval של עוזר משפטי. שלב A מבוצע
בתאריך התכנית; שלבים B ו-C ממתינים לשיחה החדשה.
**הקשר**: Voyage = חיפוש (find), Claude = הבנה+כתיבה (read+write). שני
המנועים מנותקים ארכיטקטונית — שינוי שכבת ה-retrieval לא משפיע על קלוד
עצמו, רק על איזה chunks מגיעים אליו לקריאה.
---
## שלב A — מעבר ל-voyage-3 (✅ מבוצע)
### למה voyage-3 ולא voyage-law-2?
Benchmark על 3 שאילתות עברית-משפטית עם passages אמיתיים מהקורפוס:
| מודל | Perfect orderings | Total Separation |
|---|---|---|
| **voyage-3** | **3/3** | **+0.483** |
| voyage-3.5 | 3/3 | +0.278 |
| voyage-law-2 *(היה)* | 3/3 | +0.238 |
| voyage-4 | 2/3 | +0.423 |
| voyage-4-large | 2/3 | +0.353 |
voyage-3 **מנצח כפול** — דירוג מושלם + מרווחים גדולים פי-2 מ-voyage-law-2.
מימד נשאר 1024 → אין שינוי schema.
### מה בוצע
1. **Coolify env**: `VOYAGE_MODEL=voyage-3` בקונטיינר
2. **Local env (`~/.env`)**: `VOYAGE_MODEL=voyage-3`
3. **Re-embed של 5 טבלאות** באמצעות `scripts/reembed_voyage.py`:
- `document_chunks` — מסמכי תיקים (~6K rows)
- `paragraph_embeddings` — קורפוס סגנון (כעת ריק)
- `case_law_embeddings` — stubs מצוטטים אוטו'
- `precedent_chunks` — פסיקה שהועלתה (~385)
- `halachot.embedding` — 400 הלכות (rule_statement + reasoning)
4. **MCP server restart** — טעינה מחדש של `embeddings.py` עם המודל החדש
### Verification
- `search_precedent_library` על "תכנית רחביה" → 403/17 holding ראשון
- `search_decisions` על "השבחה" → תוצאות עקביות
- ה-counts בטבלאות לא ירדו (כל row עודכן, לא נמחק)
### Rollback אם משהו נשבר
- `VOYAGE_MODEL=voyage-law-2` ב-Coolify + `~/.env`
- הרצה מחדש של `scripts/reembed_voyage.py` (חוזרים לקודם)
- 10 דקות סך-הכל
---
## שלב B — voyage-rerank-2 (Cross-encoder reranking)
> **שינוי מהותי מהתכנית המקורית.** המקור היה ל-context-3. POC רחב
> (4 בנצ'מרקים) הראה ש-context-3 לא משפר עקבית, ובחלק מהמקרים מציג
> רגרסיה. במקום זאת, **rerank-2** (cross-encoder) הצליח לתת שיפור של
> +4.5% mean@3 על קורפוס מלא של 785 docs, **+11.6% על שאילתות
> מעשיות** (P-category — בדיוק התרחיש של legal-writer/legal-researcher),
> בלי שינוי schema, בלי re-embed, ובלי double storage.
### למה rerank-2 ולא context-3?
POC #4 (אהרון ברק, 18 שאילתות, claude-haiku-4-5 כ-judge):
| Retriever | mean@3 | mean@5 | MRR |
|---|---|---|---|
| voyage-3 (baseline) | 3.278 | 3.300 | 0.741 |
| **voyage-3 + rerank-2** | **3.574** | **3.467** | **0.769** |
| voyage-context-3 (windowed) | 3.481 | 3.378 | 0.685 |
POC #5 (קורפוס מלא 785 docs, 12 שאילתות):
| Retriever | mean@3 | קטגוריה P (practical) |
|---|---|---|
| voyage-3 | 4.306 | 3.78 |
| **voyage-3 + rerank-2** | **4.500 (+4.5%)** | **4.22 (+11.6%)** |
context-3 גם נכשל בקטגוריות keyword שהן 60%+ מהשאילתות בפועל אצל דפנה.
### איך rerank-2 עובד
Two-stage retrieval:
1. **שלב bi-encoder (כמו היום)**: voyage-3 מטמיע את ה-query, מחזיר
top-50 chunks דרך cosine similarity על `pgvector` (מהיר, ~390ms).
2. **שלב cross-encoder (חדש)**: rerank-2 מקבל `(query, document)` עבור
כל אחד מ-50 הdocuments, ומחזיר ציון רלוונטיות מדויק יותר.
הreranker רואה את ה-query ואת ה-doc ביחד דרך attention מלא,
לעומת bi-encoder שרק מחשב cosine בין שני embeddings בלתי-תלויים.
3. החזרה: top-K (10) המדורגים מחדש.
**עלות**: +702ms latency (bi-encoder=393ms → +rerank=1095ms).
**עלות tokens**: zero לאחסון (רק חישוב per-query).
### תכנית יישום
#### B.1 — `voyage_rerank()` ב-`embeddings.py`
```python
async def voyage_rerank(
query: str, documents: list[str], top_k: int = 10,
) -> list[tuple[int, float]]:
"""Cross-encoder rerank via Voyage. Returns [(orig_index, score), ...]."""
if not documents:
return []
client = _get_client()
result = client.rerank(
query=query, documents=documents,
model=config.VOYAGE_RERANK_MODEL, # "rerank-2"
top_k=top_k,
)
return [(r.index, r.relevance_score) for r in result.results]
```
#### B.2 — Feature flag ב-`config.py`
```python
VOYAGE_RERANK_MODEL = os.environ.get("VOYAGE_RERANK_MODEL", "rerank-2")
VOYAGE_RERANK_ENABLED = (
os.environ.get("VOYAGE_RERANK_ENABLED", "false").lower() == "true"
)
VOYAGE_RERANK_FETCH_K = int(os.environ.get("VOYAGE_RERANK_FETCH_K", "50"))
```
הdefault הוא `false` — הקוד יישמר אך לא יורץ עד שיופעל ידנית.
#### B.3 — אינטגרציה ב-3 search functions
ב-`db.py`:
- `search_similar` (document_chunks) — נוסיף פרמטר `rerank: bool = False`.
אם True: שולפים top-`VOYAGE_RERANK_FETCH_K` במקום `limit`,
מעבירים דרך rerank, מחזירים top-`limit`.
- `search_precedent_library_semantic` — אותו דבר. הuance: היום יש
boost של +0.05 ל-halachot. כש-rerank פעיל, ה-boost מתבטל ו-rerank
מוחל על המאוחד (chunks + halachot ביחד) — cross-encoder יבחר נכון
בלי boost מלאכותי.
- `search_similar_paragraphs` / `search_similar_case_law` (ב-style
corpus) — אותו דבר.
ב-`tools/search.py` — כל הtools (`search_decisions`, `search_case_documents`,
`find_similar_cases`, `precedent_search_library`) יעבירו
`rerank=config.VOYAGE_RERANK_ENABLED` לקריאות ה-DB.
#### B.4 — Schema
אין שינוי. אותם vectors, אותו pgvector.
#### B.5 — Rollout
1. שינוי קוד + push + deploy עם feature flag = `false`
2. אימות ש-baseline ממשיך לעבוד (לא רגרסיה)
3. הפעלה ידנית: `VOYAGE_RERANK_ENABLED=true` ב-Coolify env
4. שאילתות אמיתיות מדפנה / סוכנים — observation
5. אם רגרסיה — kill switch בשניות (`false` בחזרה)
6. אם כל מתעקפם — להגדיר `true` כdefault (in-code) אחרי שבוע יציב
#### B.6 — Tier check
Voyage Tier 1: 2M TPM, 2000 RPM ל-rerank-2. עומס שלנו (~עשרות
queries בשעה במקרה רגיל) — מתחת ל-1% מהמכסה.
---
## שלב C — voyage-multimodal-3 (✅ בוצע 2026-05-03)
> **תיקון שם המודל מהתכנית המקורית**: השם הסופי הוא
> `voyage-multimodal-3` (לא 3.5). הוצמד לזה ש-POC #3 הריץ.
### מצב סופי בייצור
- `MULTIMODAL_ENABLED=true` ב-Coolify env
- Schema V9 ב-DB (document_image_embeddings + precedent_image_embeddings)
- 419 page-image embeddings על 8174-24 (146) + 8137-24 (273)
- 819 text chunks קיבלו page_number (100% retrofit)
- RRF hybrid merge עם boost text+image פעיל
### שינויים מהתכנית המקורית — שני תיקונים אמפיריים
1. **Score scaling — Reciprocal Rank Fusion במקום weighted sum.**
ה-cosine של voyage-3 (~0.4-0.5) שיטתית גבוה מ-voyage-multimodal-3
(~0.20-0.25). A/B ראשון על 7 שאילתות הראה: עם 0.65/0.35 weighted
sum ו-MULTIMODAL_ENABLED=true, **0** image rows הופיעו ב-top-5,
image side פשוט הוצף. עברנו ל-RRF (`rrf_score = w / (k + rank)`)
שעמיד לסקיילים שונים. תוצאה: 5/5 results עם image contribution
בכל שאילתה.
2. **Page tracking — chunker חדש + retrofit ל-819 chunks קיימים.**
ה-chunker הישן זרק את ה-page_number של chunks. בלעדיו ה-boost
text+image (join על `(document_id, page_number)`) לא יכול לפעול.
נוסף `page_offsets` ל-`extractor.extract_text` (משלשה במקום זוג —
מעודכן ב-6 callers); chunker מקבל אותו ומסמן page לכל chunk לפי
offset של התווים הראשונים שלו. retrofit ל-chunks קיימים
(`scripts/backfill_chunk_pages.py`) עובד **בלי re-OCR**
משתמש ב-stored extracted_text כמקור (matches existing chunk
content verbatim) ו-PyMuPDF direct text reads כעיגוני page
boundaries; pages סרוקים ללא טקסט ישיר עוברים אינטרפולציה.
### למה NOT לעשות re-OCR ב-retrofit
ניסיון ראשון השתמש ב-`extractor.extract_text` להפיק page_offsets
חדשים. תוצאה: 1/29 chunks נמצאו (28 not found), כי OCR של Google
Vision לא דטרמיניסטי — ה-OCR החדש שונה מה-OCR שהפיק את ה-chunks
המקוריים. הגרסה החדשה משתמשת ב-stored `documents.extracted_text`
שמתאים לחלוטין לתוכן ה-chunks. עלות: $0 (לעומת ~$0.0015/page).
### Files שהשתנו (יחסית למה שהמסמך הזה תיכנן)
קוד שנכתב/שונה (5 commits, 242f668 → 8a815ec):
- `mcp-server/src/legal_mcp/config.py` — flags MULTIMODAL_*
- `mcp-server/src/legal_mcp/services/extractor.py` — render + page_offsets
- `mcp-server/src/legal_mcp/services/embeddings.py` — embed_images
- `mcp-server/src/legal_mcp/services/db.py` — schema V9 + 4 store/search funcs
- `mcp-server/src/legal_mcp/services/chunker.py` — page tracking
- `mcp-server/src/legal_mcp/services/processor.py` — ingest integration
- `mcp-server/src/legal_mcp/services/precedent_library.py` — same
- `mcp-server/src/legal_mcp/services/hybrid_search.py` — חדש, RRF orchestrator
- `mcp-server/src/legal_mcp/tools/search.py` — wired to hybrid
- `mcp-server/src/legal_mcp/tools/documents.py` + `tools/workflow.py` + `web/app.py` — extract_text triple unpack
- `scripts/multimodal_backfill.py` + `scripts/backfill_chunk_pages.py` — חדשים
### מה נשאר (deferred)
- UI thumbnails בתוצאות חיפוש (לא חוסם — דפנה מקבלת page numbers)
- Backfill על שאר הקורפוס (מעבר ל-2 התיקים): לא דחוף, אפשר per-case
- `text_weight` תיאום: כרגע 0.5 (vanilla RRF). אם דפנה תגיד שהיא רואה
יותר מדי image-influence, מעלים ל-0.55-0.6 דרך env בלי deploy.
---
## שלב C המקורי (תכנון, לרפרנס)
### הבעיה שהוא פותר
תיקים סרוקים ודוחות שמאי מאבדים מידע ב-OCR:
- ✗ פריסת טבלאות (שורות נתונים מתבלגנות)
- ✗ חתימות וחותמות
- ✗ דיאגרמות, מפות, תרשימים אדריכליים
- ✗ נוסחאות מתמטיות
OCR קיים (Google Cloud Vision) ממיר תמונות לטקסט אבל מטפל בעמוד כשורה-
אחר-שורה. תוצאה: בדוח שמאי "שווי לפני | שווי אחרי | ≈ 1.5M ש"ח" הופך
ל-"שווי לפני שווי אחרי 1.5M ש"ח" — חיפוש "שומה ל-1.5M" לא תמיד מוצא.
### מה voyage-multimodal-3.5 עושה
API: `client.multimodal_embed(inputs=[[image, text?], ...])`. מקבל
תמונה (PIL Image או URL) ומחזיר embedding שכולל:
- את הטקסט שעל העמוד
- את **המבנה הוויזואלי** (טבלה, חתימה, מיקומי גוש)
- תרשימים ודיאגרמות
Searchable יחד עם text embeddings — query טקסטואלית רגילה מוצאת גם
פסקאות עם טבלה רלוונטית.
### תכנית יישום
#### C.1 — Schema חדש
```sql
CREATE TABLE document_image_embeddings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
document_id UUID REFERENCES documents(id) ON DELETE CASCADE,
page_number INTEGER NOT NULL,
image_thumbnail_path TEXT, -- לסרגל תוצאות חיפוש
embedding vector(1024),
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_doc_img_emb_vec
ON document_image_embeddings USING ivfflat (embedding vector_cosine_ops);
CREATE TABLE precedent_image_embeddings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
case_law_id UUID REFERENCES case_law(id) ON DELETE CASCADE,
page_number INTEGER NOT NULL,
image_thumbnail_path TEXT,
embedding vector(1024),
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_prec_img_emb_vec
ON precedent_image_embeddings USING ivfflat (embedding vector_cosine_ops);
```
#### C.2 — Pipeline שינוי
חדש ב-`extractor.py`:
```python
async def render_pages_as_images(pdf_path: str) -> list[bytes]:
"""PyMuPDF render of each page → PNG bytes for multimodal embedding."""
import fitz
doc = fitz.open(pdf_path)
images = []
for page in doc:
pix = page.get_pixmap(dpi=144) # decent resolution for embeddings
images.append(pix.tobytes("png"))
return images
```
חדש ב-`embeddings.py`:
```python
async def embed_images(images: list[bytes], input_type: str = "document") -> list[list[float]]:
"""Embed page images via voyage-multimodal-3.5."""
from PIL import Image
import io
pil_images = [Image.open(io.BytesIO(img)) for img in images]
response = _get_client().multimodal_embed(
inputs=[[img] for img in pil_images],
model="voyage-multimodal-3.5",
input_type=input_type,
)
return response.embeddings
```
#### C.3 — Integration ב-ingest pipelines
`processor.py:process_document` (תיק):
```python
# אחרי extract+chunk+embed הטקסטואלי:
images = await extractor.render_pages_as_images(file_path)
img_embs = await embeddings.embed_images(images)
await db.store_document_image_embeddings(document_id, img_embs, thumbnails)
```
`precedent_library.py:ingest_precedent`: אותו pattern, על
`precedent_image_embeddings`.
#### C.4 — Hybrid search
חדש ב-`db.py:search_precedent_library_hybrid`:
```python
async def search_precedent_library_hybrid(query, limit=10):
query_emb = await embeddings.embed_query(query)
query_img_emb = await embeddings.embed_query_for_multimodal(query)
text_results = ... # cosine on precedent_chunks (top 30)
image_results = ... # cosine on precedent_image_embeddings (top 30)
# Merge: weighted score (text 0.6, image 0.4 — tunable)
merged = {}
for r in text_results: merged[r.case_law_id] = r.score * 0.6
for r in image_results:
merged[r.case_law_id] = merged.get(r.case_law_id, 0) + r.score * 0.4
return sorted(merged.items(), key=lambda x: -x[1])[:limit]
```
#### C.5 — UI: thumbnails בתוצאות חיפוש
ב-`/precedents` חיפוש סמנטי, התוצאות עם רכיב image יציגו thumbnail
קטן של העמוד. לחיצה תפתח את ה-PDF במקום הרלוונטי.
#### C.6 — סדר עדיפויות לדיגום
1. **דוחות שמאי** — הזכייה הגדולה (טבלאות = ערכים מספריים שכרגע
הולכים לאיבוד ב-OCR)
2. **תיקים סרוקים ישנים** — שיפור ה-recall של חיפוש
3. **פסיקה עם דיאגרמות** (תרשימי גוש/חלקה) — minor
#### C.7 — עלות + tier
voyage-multimodal-3.5 הוא מוצר נפרד. בdoc'ים פר-עמוד:
- תיק ממוצע: 50-200 עמודים
- 100 תיקים = 5,000-20,000 עמודים
- Free tier: 200M tokens/month — אבל multimodal נמדד ב-tokens שונה
(התמונה צורכת ~1000-2000 tokens לעמוד)
הערכה: 100 תיקים × 100 עמודים × 1500 tokens = 15M tokens. בthe
free tier בקלות. צריך לבדוק תקרת שימוש בפועל בdocs של voyage.
#### C.8 — שלבים מומלצים
1. **POC** — תיק אחד עם דו"ח שמאי. embed → search → השוואה לתוצאות
טקסט-בלבד.
2. **A/B test** — חצי מהתיקים החדשים עם multimodal, חצי בלי. 4
שבועות בדיקה — האם דפנה מוצאת תוצאות מדויקות יותר?
3. **Rollout** — אם המבחן חיובי, לעבד את הקורפוס הקיים ברקע
### החלטות שנשארו פתוחות
- ✋ DPI לרינדור: 144 (סביר), 200 (איכות), 96 (מהיר)?
- ✋ נשמור thumbnails ב-disk או רק את ה-embeddings?
- ✋ משקלות hybrid search: 0.6/0.4 או יותר נטוי לטקסט?
---
## רצף עבודה בשיחה החדשה
> 1. פתחי `docs/voyage-upgrades-plan.md` (זה המסמך)
> 2. אם A הצליח (verify ב-Coolify env), נמשיך ל-B (context-3)
> 3. **B.5 קודם** — benchmark לפני re-embed גדול
> 4. אם B מצליח, רץ ל-C — אבל ב-2 צעדים זהירים (POC → A/B → rollout)
---
## נספח: רשימה של קבצים שנגעו ב-Voyage היום
קוד שנכתב/שונה:
- `scripts/reembed_voyage.py` — חדש, סקריפט re-embed
- `~/.env``VOYAGE_MODEL=voyage-3`
- Coolify env (legal-ai app) — `VOYAGE_MODEL=voyage-3`
קבצים שלא צריכים שינוי (CONFIRM):
- `mcp-server/src/legal_mcp/services/embeddings.py` — קורא ל-config.VOYAGE_MODEL
- `mcp-server/src/legal_mcp/config.py` — default ל-voyage-law-2 אבל env
בקוולפיי + מקומית מנצח
- כל הסוכנים (legal-writer, etc.) — לא קוראים ל-Voyage ישירות
עבור B + C: השינויים במסמך הזה (לא מבוצעים עדיין).

View File

@@ -20,6 +20,7 @@ dependencies = [
"fastapi>=0.115.0", "fastapi>=0.115.0",
"uvicorn[standard]>=0.30.0", "uvicorn[standard]>=0.30.0",
"httpx>=0.27.0", "httpx>=0.27.0",
"infisicalsdk>=1.0.0",
] ]
[build-system] [build-system]

View File

@@ -47,6 +47,57 @@ VOYAGE_API_KEY = os.environ.get("VOYAGE_API_KEY", "")
VOYAGE_MODEL = os.environ.get("VOYAGE_MODEL", "voyage-law-2") VOYAGE_MODEL = os.environ.get("VOYAGE_MODEL", "voyage-law-2")
VOYAGE_DIMENSIONS = 1024 VOYAGE_DIMENSIONS = 1024
# Rerank — cross-encoder second-stage. Off by default; flip with env to
# enable across all semantic search tools (search_decisions,
# search_case_documents, find_similar_cases, search_precedent_library).
VOYAGE_RERANK_MODEL = os.environ.get("VOYAGE_RERANK_MODEL", "rerank-2")
VOYAGE_RERANK_ENABLED = (
os.environ.get("VOYAGE_RERANK_ENABLED", "false").lower() == "true"
)
# How many candidates to fetch from bi-encoder before reranking.
# 50 was the depth used in the POC; balances recall vs rerank cost.
VOYAGE_RERANK_FETCH_K = int(os.environ.get("VOYAGE_RERANK_FETCH_K", "50"))
# Multimodal — page-image embeddings via voyage-multimodal-3. Off by
# default; flip with env to enable per-page image embedding during
# ingestion + hybrid (text+image) ranking at search time. POC #3
# validated on a 89-page appraisal PDF (38s, 312K tokens, recovered
# table structure + image-only scanned pages that text-OCR misses).
MULTIMODAL_ENABLED = (
os.environ.get("MULTIMODAL_ENABLED", "false").lower() == "true"
)
MULTIMODAL_MODEL = os.environ.get("MULTIMODAL_MODEL", "voyage-multimodal-3")
# Render DPI for the image fed to the embedder. POC used 144 — sweet
# spot between embedding quality and tokens/page (144 ≈ 3.5K tok/page).
MULTIMODAL_DPI = int(os.environ.get("MULTIMODAL_DPI", "144"))
# Separate, lower DPI for the JPEG thumbnail saved to disk for UI
# preview. ~96dpi → ~20KB/page; ingestion-time, no re-render at view.
MULTIMODAL_THUMB_DPI = int(os.environ.get("MULTIMODAL_THUMB_DPI", "96"))
# Hybrid merge: Reciprocal Rank Fusion (RRF) bias for the *text* side.
# voyage-3 cosine scores (~0.4-0.5) and voyage-multimodal-3 scores
# (~0.20-0.25) live on different scales; a direct weighted sum lets
# text always dominate. RRF is rank-based and robust to that. The
# weight here biases the contribution of each side: 0.5 = balanced
# (vanilla RRF), >0.5 favours text, <0.5 favours image. Tunable per
# env without redeploy.
MULTIMODAL_TEXT_WEIGHT = float(
os.environ.get("MULTIMODAL_TEXT_WEIGHT", "0.5")
)
# RRF damping constant. Standard literature value is 60: lower values
# concentrate weight at top ranks; higher values flatten the curve.
MULTIMODAL_RRF_K = int(os.environ.get("MULTIMODAL_RRF_K", "60"))
# Halacha extraction — auto-approve threshold. Halachot with extractor
# confidence >= this value are inserted with review_status='approved'
# instead of 'pending_review' (so they immediately appear in
# search_precedent_library). Set to a value > 1.0 to disable auto-approval.
# 0.80 baseline: 89% of historical extractions land here, manual spot-check
# of 10 random samples confirmed quality. Tunable via env if drift is
# observed (e.g. raise to 0.90 if false-positives appear).
HALACHA_AUTO_APPROVE_THRESHOLD = float(
os.environ.get("HALACHA_AUTO_APPROVE_THRESHOLD", "0.80")
)
# Google Cloud Vision (OCR for scanned PDFs) # Google Cloud Vision (OCR for scanned PDFs)
GOOGLE_CLOUD_VISION_API_KEY = os.environ.get("GOOGLE_CLOUD_VISION_API_KEY", "") GOOGLE_CLOUD_VISION_API_KEY = os.environ.get("GOOGLE_CLOUD_VISION_API_KEY", "")

View File

@@ -23,12 +23,17 @@ logger = logging.getLogger("legal_mcp")
@asynccontextmanager @asynccontextmanager
async def lifespan(server: FastMCP) -> AsyncIterator[None]: async def lifespan(server: FastMCP) -> AsyncIterator[None]:
"""Initialize DB schema on startup, close pool on shutdown.""" """Server startup is now non-blocking.
from legal_mcp.services.db import close_pool, init_schema
logger.info("Initializing database schema...") Schema init was moved out of the lifespan to fix a race where Claude Code
await init_schema() would call a tool before `tools/list` had been answered — manifesting as
logger.info("Ezer Mishpati MCP server ready") "No such tool available". Lifespan now returns immediately so the MCP
handshake completes in milliseconds; the schema is initialized lazily on
the first DB access via services/db.get_pool().
"""
from legal_mcp.services.db import close_pool
logger.info("Ezer Mishpati MCP server ready (schema init deferred)")
try: try:
yield yield
finally: finally:
@@ -47,6 +52,7 @@ mcp = FastMCP(
from legal_mcp.tools import ( # noqa: E402 from legal_mcp.tools import ( # noqa: E402
cases, documents, search, drafting, workflow, precedents, cases, documents, search, drafting, workflow, precedents,
precedent_library as plib,
) )
@@ -110,6 +116,13 @@ async def case_delete(case_number: str, remove_files: bool = False) -> str:
return await cases.case_delete(case_number, remove_files) return await cases.case_delete(case_number, remove_files)
@mcp.tool()
async def case_get_final_text(case_number: str, max_chars: int = 0) -> str:
"""קליטת טקסט ההחלטה הסופית (`סופי-{case}.docx` בתיקיית exports).
max_chars: 0=הכל, אחרת חיתוך לאורך הנתון. שימושי ל-Hermes Knowledge Curator."""
return await cases.case_get_final_text(case_number, max_chars)
# Precedent attachments (user-supplied legal support for the compose phase) # Precedent attachments (user-supplied legal support for the compose phase)
@mcp.tool() @mcp.tool()
async def precedent_attach( async def precedent_attach(
@@ -142,10 +155,126 @@ async def precedent_remove(precedent_id: str) -> str:
async def precedent_search_library( async def precedent_search_library(
query: str, practice_area: str = "", limit: int = 10, query: str, practice_area: str = "", limit: int = 10,
) -> str: ) -> str:
"""חיפוש בספרייה הרוחבית של ציטוטים שנצברו בין תיקים.""" """חיפוש בציטוטים שדפנה צירפה ידנית לתיקים בעבר (case_precedents).
שונה מ-search_precedent_library שמחפש בקורפוס הפסיקה הסמכותית."""
return await precedents.precedent_search_library(query, practice_area, limit) return await precedents.precedent_search_library(query, practice_area, limit)
# ── External Precedent Library — authoritative case-law corpus ─────
# Distinct from precedent_search_library above (chair-attached quotes)
# and from search_decisions (Daphna's style corpus).
@mcp.tool()
async def precedent_library_upload(
file_path: str,
citation: str,
case_name: str = "",
court: str = "",
decision_date: str = "",
source_type: str = "",
precedent_level: str = "",
practice_area: str = "",
appeal_subtype: str = "",
subject_tags: list[str] | None = None,
is_binding: bool = True,
headnote: str = "",
summary: str = "",
) -> str:
"""העלאת פסיקה חיצונית (פס"ד / החלטה של ועדה אחרת) לקורפוס הסמכותי. מחלץ הלכות אוטומטית — כולן ממתינות לאישור היו"ר. practice_area: rishuy_uvniya / betterment_levy / compensation_197."""
return await plib.precedent_library_upload(
file_path, citation, case_name, court, decision_date,
source_type, precedent_level, practice_area, appeal_subtype,
subject_tags, is_binding, headnote, summary,
)
@mcp.tool()
async def precedent_library_list(
practice_area: str = "",
court: str = "",
precedent_level: str = "",
source_type: str = "",
search: str = "",
limit: int = 100,
) -> str:
"""רשימת הפסיקה בקורפוס הסמכותי, עם פילטרים."""
return await plib.precedent_library_list(
practice_area, court, precedent_level, source_type, search, limit,
)
@mcp.tool()
async def precedent_library_get(case_law_id: str) -> str:
"""פסיקה ספציפית בקורפוס + רשימת ההלכות שחולצו ממנה (כולל ממתינות לאישור)."""
return await plib.precedent_library_get(case_law_id)
@mcp.tool()
async def precedent_library_delete(case_law_id: str) -> str:
"""מחיקת פסיקה מהקורפוס (cascade: chunks + halachot)."""
return await plib.precedent_library_delete(case_law_id)
@mcp.tool()
async def precedent_extract_halachot(case_law_id: str) -> str:
"""הרצה מחדש של חילוץ הלכות לפסיקה קיימת. ההלכות הקיימות נמחקות, החדשות חוזרות לסטטוס pending_review."""
return await plib.precedent_extract_halachot(case_law_id)
@mcp.tool()
async def precedent_extract_metadata(case_law_id: str) -> str:
"""חילוץ מטא-דאטה (case_name קצר, summary, headnote, key_quote, subject_tags, appeal_subtype, date, level, court, source_type) מהטקסט. ממלא רק שדות ריקים."""
return await plib.precedent_extract_metadata(case_law_id)
@mcp.tool()
async def precedent_process_pending(kind: str = "metadata", limit: int = 20) -> str:
"""ריקון תור בקשות חילוץ שנשלחו מ-UI. kind: 'metadata' או 'halacha'. מריץ extractor מקומית עם CLI על כל פריט בתור, ומנקה את הסימון אחרי הצלחה."""
return await plib.precedent_process_pending(kind, limit)
@mcp.tool()
async def search_precedent_library(
query: str,
practice_area: str = "",
court: str = "",
precedent_level: str = "",
appeal_subtype: str = "",
subject_tag: str = "",
limit: int = 10,
include_halachot: bool = True,
) -> str:
"""חיפוש סמנטי בקורפוס הפסיקה הסמכותית. מחזיר הלכות (מאושרות בלבד) + קטעי טקסט. השתמש כש-legal-writer צריך לצטט פסיקה מחייבת בבלוק י (CREAC: rule + explanation)."""
return await plib.search_precedent_library(
query, practice_area, court, precedent_level, appeal_subtype,
None, subject_tag, limit, include_halachot,
)
@mcp.tool()
async def halacha_review(
halacha_id: str,
status: str,
reviewer: str = "דפנה",
rule_statement: str = "",
reasoning_summary: str = "",
subject_tags: list[str] | None = None,
practice_areas: list[str] | None = None,
) -> str:
"""אישור / דחייה / עריכה של הלכה שחולצה אוטומטית. status: pending_review / approved / rejected / published."""
return await plib.halacha_review(
halacha_id, status, reviewer, rule_statement, reasoning_summary,
subject_tags, practice_areas,
)
@mcp.tool()
async def halachot_pending(limit: int = 100) -> str:
"""תור ההלכות הממתינות לאישור."""
return await plib.halachot_pending(limit)
# Documents # Documents
@mcp.tool() @mcp.tool()
async def document_upload( async def document_upload(
@@ -268,6 +397,35 @@ async def find_similar_cases(
) )
@mcp.tool()
async def search_internal_decisions(
query: str,
practice_area: str = "",
appeal_subtype: str = "",
district: str = "",
chair_name: str = "",
limit: int = 10,
include_halachot: bool = True,
) -> str:
"""חיפוש בהחלטות ועדות ערר לתכנון ובנייה (כל המחוזות).
מחזיר החלטות מהקורפוס הפנימי של ועדות הערר — נפרד מפסיקת בתי המשפט.
השתמש בו במקביל ל-search_precedent_library להצגת שתי שכבות נפרדות.
Args:
query: שאילתת חיפוש בעברית
practice_area: rishuy_uvniya / betterment_levy / compensation_197
appeal_subtype: סינון לפי תת-סוג ערר
district: מחוז — ירושלים / מרכז / תל אביב / צפון / דרום / ארצי. ריק = כל המחוזות
chair_name: שם יו"ר הוועדה לסינון. ריק = כל היו"רים
limit: מספר תוצאות מקסימלי
include_halachot: האם לכלול הלכות שחולצו
"""
return await search.search_internal_decisions(
query, practice_area, appeal_subtype, district, chair_name, limit, include_halachot,
)
# Drafting # Drafting
@mcp.tool() @mcp.tool()
async def get_style_guide() -> str: async def get_style_guide() -> str:
@@ -451,6 +609,43 @@ async def ingest_final_version(
return await workflow.ingest_final_version(case_number, file_path, final_text) return await workflow.ingest_final_version(case_number, file_path, final_text)
@mcp.tool()
async def internal_decision_migrate(
source: str = "both",
dry_run: bool = True,
) -> str:
"""העברת החלטות ועדת ערר קיימות לקורפוס הפנימי (פעולת admin).
source: 'style_corpus' | 'external_corpus' | 'both'
dry_run: אם true — מציג מה יקרה ללא כתיבה
"""
import json as _json
from legal_mcp.services import internal_decisions as int_svc
if source not in {"style_corpus", "external_corpus", "both"}:
return "source חייב להיות style_corpus / external_corpus / both"
results: dict = {}
if source in {"style_corpus", "both"}:
results["style_corpus"] = await int_svc.migrate_from_style_corpus(dry_run=dry_run)
if source in {"external_corpus", "both"}:
results["external_corpus"] = await int_svc.migrate_from_external_corpus(dry_run=dry_run)
return _json.dumps(results, ensure_ascii=False, indent=2)
@mcp.tool()
async def internal_decision_enrich(
dry_run: bool = True,
) -> str:
"""העשרת החלטות שהומגרו (חד-פעמי): תיקון מספר ערר + שם + תאריך + תור להלכות.
dry_run=True — מציג כמה רשומות יטופלו ללא כתיבה.
dry_run=False — מריץ בפועל: metadata extraction (תיקון case_number/case_name/date) ואחר כך תור חילוץ הלכות.
"""
import json as _json
from legal_mcp.services import internal_decisions as int_svc
result = await int_svc.enrich_migrated_entries(dry_run=dry_run)
return _json.dumps(result, ensure_ascii=False, indent=2)
@mcp.tool() @mcp.tool()
async def record_chair_feedback( async def record_chair_feedback(
case_number: str, case_number: str,

View File

@@ -103,7 +103,7 @@ async def extract_facts_from_document(
f"שמאי: {appraiser_name}{chunk_label}\n\n" f"שמאי: {appraiser_name}{chunk_label}\n\n"
f"--- תחילת שומה ---\n{chunk}\n--- סוף שומה ---" f"--- תחילת שומה ---\n{chunk}\n--- סוף שומה ---"
) )
result = claude_session.query_json(prompt, timeout=180) result = await claude_session.query_json(prompt)
if not isinstance(result, list): if not isinstance(result, list):
logger.warning( logger.warning(
"extract_facts_from_document: chunk %d returned non-list (%s) for doc=%s", "extract_facts_from_document: chunk %d returned non-list (%s) for doc=%s",

View File

@@ -380,7 +380,7 @@ async def write_block(
# Call Claude via Claude Code session (no API) # Call Claude via Claude Code session (no API)
model_key = block_cfg["model"] model_key = block_cfg["model"]
timeout = claude_session.LONG_TIMEOUT if model_key == "opus" else claude_session.DEFAULT_TIMEOUT timeout = claude_session.LONG_TIMEOUT if model_key == "opus" else claude_session.DEFAULT_TIMEOUT
content = claude_session.query(prompt, timeout=timeout) content = await claude_session.query(prompt, timeout=timeout)
return _build_result(block_id, content, block_cfg) return _build_result(block_id, content, block_cfg)

View File

@@ -134,14 +134,14 @@ async def generate_directions(
{doc_context or '(אין מסמכים בתיק)'} {doc_context or '(אין מסמכים בתיק)'}
""" """
result = claude_session.query_json(user_content, timeout=120) result = await claude_session.query_json(user_content)
if result is None: if result is None:
logger.warning("Failed to parse brainstorm response: %s", raw[:300]) logger.warning("Failed to parse brainstorm response")
return { return {
"key_claims": [], "key_claims": [],
"directions": [], "directions": [],
"recommended_order": "", "recommended_order": "",
"raw_response": raw, "raw_response": "",
} }
return result return result

View File

@@ -7,14 +7,16 @@ from dataclasses import dataclass, field
from legal_mcp import config from legal_mcp import config
# Hebrew legal section headers # Hebrew legal section headers.
# Covers both appeals committee decisions and external court rulings —
# court rulings use slightly different vocabulary (פסק דין, נימוקים, סוף דבר).
SECTION_PATTERNS = [ SECTION_PATTERNS = [
(r"רקע\s*עובדתי|רקע\s*כללי|העובדות|הרקע", "facts"), (r"רקע\s*עובדתי|רקע\s*כללי|העובדות|הרקע", "facts"),
(r"טענות\s*העוררי[םן]|טענות\s*המערערי[םן]|עיקר\s*טענות\s*העוררי[םן]", "appellant_claims"), (r"טענות\s*העוררי[םן]|טענות\s*המערערי[םן]|עיקר\s*טענות\s*העוררי[םן]", "appellant_claims"),
(r"טענות\s*המשיבי[םן]|תשובת\s*המשיבי[םן]|עיקר\s*טענות\s*המשיבי[םן]", "respondent_claims"), (r"טענות\s*המשיבי[םן]|תשובת\s*המשיבי[םן]|עיקר\s*טענות\s*המשיבי[םן]", "respondent_claims"),
(r"דיון\s*והכרעה|דיון|הכרעה|ניתוח\s*משפטי|המסגרת\s*המשפטית", "legal_analysis"), (r"דיון\s*והכרעה|דיון|הכרעה|ניתוח\s*משפטי|המסגרת\s*המשפטית|נימוקים", "legal_analysis"),
(r"מסקנ[הות]|סיכום", "conclusion"), (r"מסקנ[הות]|סיכום|סוף\s*דבר", "conclusion"),
(r"החלטה|לפיכך\s*אני\s*מחליט|התוצאה", "ruling"), (r"פסק[- ]?דין|החלטה|לפיכך\s*אני\s*מחליט|התוצאה", "ruling"),
(r"מבוא|פתיחה|לפניי", "intro"), (r"מבוא|פתיחה|לפניי", "intro"),
] ]
@@ -31,8 +33,15 @@ def chunk_document(
text: str, text: str,
chunk_size: int = config.CHUNK_SIZE_TOKENS, chunk_size: int = config.CHUNK_SIZE_TOKENS,
overlap: int = config.CHUNK_OVERLAP_TOKENS, overlap: int = config.CHUNK_OVERLAP_TOKENS,
page_offsets: list[int] | None = None,
) -> list[Chunk]: ) -> list[Chunk]:
"""Split a legal document into chunks, respecting section boundaries.""" """Split a legal document into chunks, respecting section boundaries.
When ``page_offsets`` is supplied (from a PDF extraction), each chunk
is tagged with the page number of its first character — used by the
multimodal hybrid retriever to join (text chunk, image at same page)
and surface text+image matches.
"""
if not text.strip(): if not text.strip():
return [] return []
@@ -50,9 +59,34 @@ def chunk_document(
)) ))
idx += 1 idx += 1
if page_offsets:
_assign_pages(chunks, text, page_offsets)
return chunks return chunks
def _assign_pages(chunks: list[Chunk], text: str, page_offsets: list[int]) -> None:
"""Locate each chunk's first character in ``text`` and tag with the
page that contains that offset. Mutates chunks in-place.
Chunks have overlap so we search forward from a position slightly
past the previous chunk's start. Falls back to a global search if
the forward scan misses (rare — happens only when overlap is bigger
than the advance distance below).
"""
from legal_mcp.services.extractor import page_at_offset
pos = 0
for c in chunks:
idx = text.find(c.content, pos)
if idx < 0:
idx = text.find(c.content)
if idx < 0:
continue
c.page_number = page_at_offset(idx, page_offsets)
# advance past the chunk's halfway point — overlap is < 50% so
# the next chunk's starting point will be after this cursor.
pos = idx + max(1, len(c.content) // 2)
def _split_into_sections(text: str) -> list[tuple[str, str]]: def _split_into_sections(text: str) -> list[tuple[str, str]]:
"""Split text into (section_type, text) pairs based on Hebrew headers.""" """Split text into (section_type, text) pairs based on Hebrew headers."""
# Find all section headers and their positions # Find all section headers and their positions

View File

@@ -7,6 +7,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
import re import re
from uuid import UUID from uuid import UUID
@@ -17,6 +18,21 @@ from legal_mcp.services import db, claude_session
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Each chunk targets ~12K chars (≈3K tokens of Hebrew). Smaller than the
# previous 25K because:
# • A single ``claude -p`` call on a 25K-char Hebrew prompt with cold
# cache routinely hit ~150-180s. 12K chunks finish in ~60-90s.
# • Per-chunk retry costs less when chunks are smaller.
# • Parallel chunks benefit more — see CHUNK_CONCURRENCY.
CHUNK_TARGET_CHARS = 12000
# How many chunks to send to Claude in parallel. Each subprocess holds
# ~300 MB RSS plus its own MCP stack; concurrency=3 keeps the box usable.
CHUNK_CONCURRENCY = 3
# How many retry attempts per failed chunk before giving up on it.
CHUNK_RETRY_ATTEMPTS = 1
EXTRACT_CLAIMS_PROMPT = """אתה מנתח מסמכים משפטיים בתחום תכנון ובניה. תפקידך לחלץ טענות מכתב טענות. EXTRACT_CLAIMS_PROMPT = """אתה מנתח מסמכים משפטיים בתחום תכנון ובניה. תפקידך לחלץ טענות מכתב טענות.
@@ -43,6 +59,103 @@ EXTRACT_CLAIMS_PROMPT = """אתה מנתח מסמכים משפטיים בתחו
""" """
# Section markers we treat as natural chunk boundaries when present.
# Hebrew legal briefs almost always use numbered sections like "10." or
# letter-section headings (".א", ".ב"). Splitting between sections keeps
# every chunk a self-contained argumentative unit.
_SECTION_BOUNDARY_RE = re.compile(
r"\n\s*("
r"\d+\.\s+\S" # numbered section: "10. טענות"
r"|[א-ת]\.\s+\S" # Hebrew letter section: "א. רקע"
r"|##\s+\S" # markdown heading
r"|פרק\s+\S" # "פרק" headings
r")"
)
def _split_by_sections(text: str, target: int = CHUNK_TARGET_CHARS) -> list[str]:
"""Split a long document into roughly ``target``-sized chunks at section
boundaries. Falls back to paragraph breaks, then to hard splits if a
section happens to be larger than ``target`` on its own.
"""
if len(text) <= target:
return [text]
boundaries = [m.start() for m in _SECTION_BOUNDARY_RE.finditer(text)]
boundaries = [0, *boundaries, len(text)]
chunks: list[str] = []
start = 0
for cut in boundaries[1:]:
# Greedy: keep adding sections to the current chunk until adding
# the next one would push past ``target``.
if cut - start < target:
continue
end = cut
if end - start > target * 1.5:
# Section group exceeds 1.5× target — fall back to paragraph
# break inside it to avoid one chunk being far too big.
soft = text.rfind("\n\n", start, start + target)
if soft > start + target // 2:
end = soft
chunks.append(text[start:end].strip())
start = end
if start < len(text):
chunks.append(text[start:].strip())
# Hard splits for any chunk that is still too large (rare, but
# documents without any section markers can fall through).
final: list[str] = []
for c in chunks:
if len(c) <= target * 1.5:
final.append(c)
continue
for i in range(0, len(c), target):
final.append(c[i:i + target])
return [c for c in final if c.strip()]
async def _extract_chunk(
chunk: str,
chunk_index: int,
chunk_total: int,
context: str,
) -> tuple[int, list[dict] | None]:
"""Run extraction on one chunk with retry. Returns ``(chunk_index, claims_or_None)``.
None means the chunk failed both the initial call and every retry
(caller can use this to mark the result as partial).
"""
chunk_label = f" (חלק {chunk_index + 1}/{chunk_total})" if chunk_total > 1 else ""
prompt = (
f"{EXTRACT_CLAIMS_PROMPT}\n\n"
f"{context}{chunk_label}\n\n"
f"--- תחילת מסמך ---\n{chunk}\n--- סוף מסמך ---"
)
last_err: Exception | None = None
for attempt in range(CHUNK_RETRY_ATTEMPTS + 1):
try:
claims = await claude_session.query_json(prompt)
except Exception as e:
last_err = e
logger.warning(
"extract_claims chunk %d/%d attempt %d raised: %s",
chunk_index + 1, chunk_total, attempt + 1, e,
)
continue
if isinstance(claims, list):
return chunk_index, claims
logger.warning(
"extract_claims chunk %d/%d attempt %d returned non-list (%s)",
chunk_index + 1, chunk_total, attempt + 1, type(claims).__name__,
)
logger.error(
"extract_claims chunk %d/%d failed after %d attempts: %s",
chunk_index + 1, chunk_total, CHUNK_RETRY_ATTEMPTS + 1, last_err,
)
return chunk_index, None
async def extract_claims_with_ai( async def extract_claims_with_ai(
text: str, text: str,
doc_type: str = "appeal", doc_type: str = "appeal",
@@ -50,68 +163,62 @@ async def extract_claims_with_ai(
) -> list[dict]: ) -> list[dict]:
"""חילוץ טענות מכתב טענות באמצעות Claude. """חילוץ טענות מכתב טענות באמצעות Claude.
Splits ``text`` at section boundaries, runs every chunk through
Claude in parallel (bounded by ``CHUNK_CONCURRENCY``), retries each
failed chunk once, and merges the results in original document order.
Failed chunks are logged but don't block the overall extraction —
we return what we got and surface the gap via the logs.
Args: Args:
text: טקסט המסמך text: טקסט המסמך
doc_type: סוג המסמך (appeal/response) doc_type: סוג המסמך (appeal/response)
party_hint: רמז לזהות הצד (אם ידוע) party_hint: רמז לזהות הצד (אם ידוע)
Returns: Returns:
רשימת טענות עם party_role, claim_text, topic רשימת טענות עם party_role, claim_text, topic, claim_index.
""" """
context = f"סוג המסמך: {doc_type}" context = f"סוג המסמך: {doc_type}"
if party_hint: if party_hint:
context += f"\nהצד המגיש: {party_hint}" context += f"\nהצד המגיש: {party_hint}"
# For very long documents, split into chunks and merge results chunks = _split_by_sections(text)
max_chars_per_call = 25000 if len(chunks) > 1:
chunks = [] logger.info(
if len(text) > max_chars_per_call: "extract_claims: split %d chars into %d chunks (target=%d, concurrency=%d)",
# Split at paragraph boundaries len(text), len(chunks), CHUNK_TARGET_CHARS, CHUNK_CONCURRENCY,
pos = 0
while pos < len(text):
end = min(pos + max_chars_per_call, len(text))
if end < len(text):
# Find paragraph break near the limit
break_pos = text.rfind("\n\n", pos, end)
if break_pos > pos + max_chars_per_call // 2:
end = break_pos
chunks.append(text[pos:end])
pos = end
logger.info("Document split into %d chunks (%d chars total)", len(chunks), len(text))
else:
chunks = [text]
all_claims = []
for i, chunk in enumerate(chunks):
chunk_label = f" (חלק {i+1}/{len(chunks)})" if len(chunks) > 1 else ""
prompt = (
f"{EXTRACT_CLAIMS_PROMPT}\n\n"
f"{context}{chunk_label}\n\n"
f"--- תחילת מסמך ---\n{chunk}\n--- סוף מסמך ---"
) )
claims = claude_session.query_json(prompt, timeout=120)
if claims is None:
logger.warning("Failed to parse claims for chunk %d: %s", i, raw[:200])
continue
if isinstance(claims, list):
all_claims.extend(claims)
claims = all_claims sem = asyncio.Semaphore(CHUNK_CONCURRENCY)
async def _bounded(idx: int, c: str) -> tuple[int, list[dict] | None]:
async with sem:
return await _extract_chunk(c, idx, len(chunks), context)
results = await asyncio.gather(*[_bounded(i, c) for i, c in enumerate(chunks)])
# Merge in original order. Skip chunks that failed entirely.
failed = [i for i, r in results if r is None]
if failed:
logger.warning(
"extract_claims: %d/%d chunks failed (indices=%s) — returning partial result",
len(failed), len(chunks), failed,
)
merged: list[dict] = []
for idx, claims in sorted(results, key=lambda x: x[0]):
if not claims: if not claims:
return [] continue
merged.extend(claims)
if not isinstance(claims, list): # Add claim_index and drop entries missing required fields.
return [] cleaned: list[dict] = []
for i, claim in enumerate(merged):
# Add claim_index if not isinstance(claim, dict):
for i, claim in enumerate(claims): continue
claim["claim_index"] = i
# Validate required fields
if "party_role" not in claim or "claim_text" not in claim: if "party_role" not in claim or "claim_text" not in claim:
continue continue
claim["claim_index"] = i
return [c for c in claims if "party_role" in c and "claim_text" in c] cleaned.append(claim)
return cleaned
def _infer_claim_type(doc_type: str, source_name: str) -> str: def _infer_claim_type(doc_type: str, source_name: str) -> str:

View File

@@ -1,27 +1,53 @@
"""Claude Code session bridge — runs prompts via `claude -p` instead of API. """Claude Code session bridge — runs prompts via the local `claude` CLI.
All LLM calls in the project should use this module instead of calling All LLM calls in legal-ai go through this module. We shell out to the local
the Anthropic API directly. This uses the local Claude Code CLI which Claude Code CLI which uses the developer's claude.ai session — zero direct
runs on the user's claude.ai session — zero API cost. API cost.
**Architectural rule (do not violate):** this module only works when invoked
from the local MCP server (the Python process at
`/home/chaim/legal-ai/mcp-server/`, launched per `~/.claude.json`). It will
**not** work when called from the legal-ai Docker container — that container
has no `claude` CLI and no claude.ai session. Any code path under `web/`
(FastAPI) that calls this module — directly or via an extractor like
`halacha_extractor`, `claims_extractor`, `precedent_metadata_extractor`,
`block_writer`, `qa_validator`, `learning_loop`, `local_classifier`,
`appraiser_facts_extractor`, `brainstorm`, `style_analyzer` — is wrong.
LLM-dependent operations must be exposed as MCP tools and triggered from
agents (or the chair via Claude Code), where this module runs locally with
CLI access.
Async history: originally synchronous (``subprocess.run``) with a 120 s
timeout. That broke for large legal documents — sync subprocess stalled the
asyncio loop, and 120 s was far too short for cold-cache Hebrew prompts
(case 8174-24 hit three timeouts in a row). Fixed by going async with a
30-minute ceiling.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import logging import logging
import subprocess
from pathlib import Path
from legal_mcp.config import parse_llm_json from legal_mcp.config import parse_llm_json
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Default timeout for claude -p calls (seconds) # Default ceiling for any single ``claude -p`` invocation, in seconds.
DEFAULT_TIMEOUT = 120 # 30 min covers any single-document call we make in practice (chunking
LONG_TIMEOUT = 300 # For complex tasks like block writing # handles the rest); the bound exists only to prevent runaway zombies.
DEFAULT_TIMEOUT = 1800
LONG_TIMEOUT = 3600 # opus block writing on full case context
def query(prompt: str, timeout: int = DEFAULT_TIMEOUT, max_turns: int = 1) -> str: async def query(
prompt: str,
timeout: int = DEFAULT_TIMEOUT,
max_turns: int = 1,
*,
system: str | None = None,
) -> str:
"""Send a prompt to Claude Code headless and return the text response. """Send a prompt to Claude Code headless and return the text response.
Passes the prompt via stdin (not argv) to avoid the OS ARG_MAX limit — Passes the prompt via stdin (not argv) to avoid the OS ARG_MAX limit —
@@ -29,15 +55,23 @@ def query(prompt: str, timeout: int = DEFAULT_TIMEOUT, max_turns: int = 1) -> st
Args: Args:
prompt: The prompt to send. prompt: The prompt to send.
timeout: Max seconds to wait. timeout: Max seconds before the subprocess is killed.
max_turns: Max conversation turns (1 = single response). max_turns: Max conversation turns (1 = single response).
system: Optional repeated-instruction text. Prepended to ``prompt``
for the CLI; we don't pass it as a separate arg because the
CLI doesn't expose API-level caching. The parameter exists so
extractors can structure their calls cleanly today, and to make
a future SDK-backed path drop-in.
Returns: Returns:
The text response from Claude. The text response from Claude.
Raises: Raises:
RuntimeError: If claude CLI is not available or fails. RuntimeError: if the CLI is unavailable (e.g., called from the
container — see module docstring), or fails, or times out.
""" """
full_prompt = f"{system}\n\n{prompt}" if system else prompt
cmd = [ cmd = [
"claude", "-p", "claude", "-p",
"--output-format", "json", "--output-format", "json",
@@ -45,23 +79,40 @@ def query(prompt: str, timeout: int = DEFAULT_TIMEOUT, max_turns: int = 1) -> st
] ]
try: try:
result = subprocess.run( proc = await asyncio.create_subprocess_exec(
cmd, *cmd,
input=prompt, stdin=asyncio.subprocess.PIPE,
capture_output=True, stdout=asyncio.subprocess.PIPE,
text=True, stderr=asyncio.subprocess.PIPE,
timeout=timeout,
) )
except FileNotFoundError: except FileNotFoundError:
raise RuntimeError("Claude CLI not found. Install Claude Code or add 'claude' to PATH.") raise RuntimeError(
except subprocess.TimeoutExpired: "Claude CLI not found. This module only works when invoked "
"from the local MCP server — see the architectural rule in "
"the module docstring. If this error came from a FastAPI "
"endpoint in the container, refactor the call into an MCP "
"tool that the chair triggers from Claude Code."
)
try:
stdout_b, stderr_b = await asyncio.wait_for(
proc.communicate(input=full_prompt.encode("utf-8")),
timeout=timeout,
)
except asyncio.TimeoutError:
# wait_for cancellation alone leaves the child running.
try:
proc.kill()
await proc.wait()
except ProcessLookupError:
pass
raise RuntimeError(f"Claude CLI timed out after {timeout}s") raise RuntimeError(f"Claude CLI timed out after {timeout}s")
if result.returncode != 0: if proc.returncode != 0:
stderr = result.stderr.strip()[:500] if result.stderr else "unknown error" stderr = stderr_b.decode("utf-8", errors="replace").strip()[:500] or "unknown error"
raise RuntimeError(f"Claude CLI failed (exit {result.returncode}): {stderr}") raise RuntimeError(f"Claude CLI failed (exit {proc.returncode}): {stderr}")
stdout = result.stdout.strip() stdout = stdout_b.decode("utf-8", errors="replace").strip()
if not stdout: if not stdout:
raise RuntimeError("Claude CLI returned empty response") raise RuntimeError("Claude CLI returned empty response")
@@ -75,10 +126,15 @@ def query(prompt: str, timeout: int = DEFAULT_TIMEOUT, max_turns: int = 1) -> st
return stdout return stdout
def query_json(prompt: str, timeout: int = DEFAULT_TIMEOUT) -> dict | list | None: async def query_json(
prompt: str,
timeout: int = DEFAULT_TIMEOUT,
*,
system: str | None = None,
) -> dict | list | None:
"""Send a prompt and parse the response as JSON. """Send a prompt and parse the response as JSON.
Uses parse_llm_json for robust parsing (handles markdown wrapping, truncation). Uses parse_llm_json for robust parsing (handles markdown wrapping, truncation).
""" """
raw = query(prompt, timeout=timeout) raw = await query(prompt, timeout=timeout, system=system)
return parse_llm_json(raw) return parse_llm_json(raw)

File diff suppressed because it is too large Load Diff

View File

@@ -15,47 +15,112 @@ from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml import OxmlElement from docx.oxml import OxmlElement
from docx.oxml.ns import qn from docx.oxml.ns import qn
from docx.shared import Cm, Pt, RGBColor
from legal_mcp import config from legal_mcp import config
from legal_mcp.services import db from legal_mcp.services import db
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── Constants ───────────────────────────────────────────────────── # Path to the converted decision template. Carries David font, RTL, margins,
# and styles (Title / Heading 1-2 / Normal / Quote / List Paragraph).
FONT_NAME = "David" # Populated once by `scripts/convert_decision_template.py` from `.dotx`.
FONT_SIZE_BODY = Pt(12) TEMPLATE_PATH = (
FONT_SIZE_TITLE = Pt(16) Path(__file__).resolve().parents[4]
FONT_SIZE_HEADING = Pt(14) / "skills" / "docx" / "decision_template.docx"
LINE_SPACING = 1.5 )
PAGE_MARGIN = Cm(2.5)
# ── RTL helpers ─────────────────────────────────────────────────── # ── RTL helpers ───────────────────────────────────────────────────
# Three layers of RTL are required (per skills/docx/SKILL.md):
# 1. Section: <w:bidi/> in sectPr (inherited from template)
# 2. Paragraph: <w:bidi/> directly in pPr — paragraph direction
# 3. Run: <w:rtl/> in rPr — tells Word to use cs (complex-script) font
# Without explicit font on run, Hebrew can render in the ascii slot
# (Times New Roman) — so we also force David on all four font slots.
def _set_rtl_paragraph(paragraph) -> None: HEBREW_FONT = "David"
"""Set paragraph-level RTL properties."""
pPr = paragraph._element.get_or_add_pPr()
def _mark_run_rtl(run) -> None:
"""Force David font on all four slots, then add <w:rtl/>."""
rPr = run._r.get_or_add_rPr()
if rPr.find(qn("w:rFonts")) is None:
fonts = OxmlElement("w:rFonts")
fonts.set(qn("w:ascii"), HEBREW_FONT)
fonts.set(qn("w:hAnsi"), HEBREW_FONT)
fonts.set(qn("w:cs"), HEBREW_FONT)
fonts.set(qn("w:eastAsia"), HEBREW_FONT)
rPr.insert(0, fonts)
if rPr.find(qn("w:rtl")) is None:
rPr.append(OxmlElement("w:rtl"))
def _mark_paragraph_rtl(paragraph) -> None:
"""Add <w:bidi/> directly to pPr (paragraph direction) and <w:rtl/>
to the paragraph-mark rPr (affects trailing ¶ glyph)."""
pPr = paragraph._p.get_or_add_pPr()
# (2) <w:bidi/> directly in pPr — paragraph direction
if pPr.find(qn("w:bidi")) is None:
bidi = OxmlElement("w:bidi") bidi = OxmlElement("w:bidi")
bidi.set(qn("w:val"), "1") pstyle = pPr.find(qn("w:pStyle"))
pPr.append(bidi) if pstyle is not None:
pstyle.addnext(bidi)
else:
pPr.insert(0, bidi)
# paragraph-mark rPr gets <w:rtl/> so ¶ inherits RTL too
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"))
def _set_rtl_run(run) -> None: def _set_paragraph_jc(paragraph, value: str) -> None:
"""Set run-level RTL properties.""" """Force <w:jc w:val="..."/> on a paragraph, overriding style-inherited jc.
rPr = run._element.get_or_add_rPr()
rtl = OxmlElement("w:rtl") Needed because Heading 3 in the template ships with jc=center — we want
rtl.set(qn("w:val"), "1") body headings justified right (jc=both) like Normal.
rPr.append(rtl) """
pPr = paragraph._p.get_or_add_pPr()
existing = pPr.find(qn("w:jc"))
if existing is not None:
pPr.remove(existing)
jc = OxmlElement("w:jc")
jc.set(qn("w:val"), value)
pPr.append(jc)
def _set_rtl_section(section) -> None: def _suppress_paragraph_numbering(paragraph) -> None:
"""Set section-level RTL (bidi).""" """Kill any style-inherited auto-numbering on this paragraph.
sectPr = section._sectPr
bidi = OxmlElement("w:bidi") Heading styles linked to outline lists can auto-inject א./ב./ג. markers
bidi.set(qn("w:val"), "1") in some Word versions even when the style we read doesn't show numPr.
sectPr.append(bidi) Setting numId=0 explicitly removes the paragraph from any list.
"""
pPr = paragraph._p.get_or_add_pPr()
existing = pPr.find(qn("w:numPr"))
if existing is not None:
pPr.remove(existing)
numPr = OxmlElement("w:numPr")
ilvl = OxmlElement("w:ilvl")
ilvl.set(qn("w:val"), "0")
numId = OxmlElement("w:numId")
numId.set(qn("w:val"), "0")
numPr.append(ilvl)
numPr.append(numId)
pPr.append(numPr)
def _clear_body(doc) -> None:
"""Remove all paragraphs in the document body while keeping sectPr.
The template ships with sample paragraphs we don't want. Section
properties (page size, margins, bidi) stay intact.
"""
body = doc.element.body
for p in list(body.findall(qn("w:p"))):
body.remove(p)
# ── Bookmark helpers ────────────────────────────────────────────── # ── Bookmark helpers ──────────────────────────────────────────────
@@ -109,61 +174,109 @@ def _wrap_block_with_bookmarks(doc, block_name: str,
_insert_bookmark_end(last_new, bm_id) _insert_bookmark_end(last_new, bm_id)
def _add_paragraph(doc, text: str, style: str = "Normal", # ── Content cleanup ──────────────────────────────────────────────
bold: bool = False, font_size=None,
alignment=None, space_after: Pt | None = None) -> None:
"""Add an RTL paragraph with David font."""
para = doc.add_paragraph()
_set_rtl_paragraph(para)
if alignment: # Em-dash (—, U+2014) and en-dash (, U+2013) — per chair's no-dash policy,
# strip from body text. Surrounding spaces collapse.
_DASH_RE = re.compile(r"\s*[—–]\s*")
_MULTI_SPACE_RE = re.compile(r" {2,}")
def _strip_dashes(text: str) -> str:
"""Remove em/en-dashes and collapse surrounding whitespace."""
text = _DASH_RE.sub(" ", text)
return _MULTI_SPACE_RE.sub(" ", text).strip()
# Numbered paragraph: "1. content", "23. content" — auto-numbered via
# List Paragraph style so order reflects emission, not literal prefix.
_NUM_PREFIX_RE = re.compile(r"^(\d+)\.\s+(.*)$", re.DOTALL)
# Markdown inline bold — `**...**`
_INLINE_BOLD_RE = re.compile(r"\*\*([^\n*]+?)\*\*")
def _add_runs_with_inline_bold(paragraph, text: str, *, bold_all: bool = False) -> None:
"""Split text on `**...**` markers, alternating plain and bold runs.
Keeps `**טענה חשובה**` rendering as bold instead of leaving literal
asterisks. When bold_all is True, every run is bold (used for headings
that still carry inline-bold markup).
"""
pos = 0
for m in _INLINE_BOLD_RE.finditer(text):
if m.start() > pos:
plain = paragraph.add_run(text[pos:m.start()])
if bold_all:
plain.bold = True
_mark_run_rtl(plain)
run_bold = paragraph.add_run(m.group(1))
run_bold.bold = True
_mark_run_rtl(run_bold)
pos = m.end()
if pos < len(text):
tail = paragraph.add_run(text[pos:])
if bold_all:
tail.bold = True
_mark_run_rtl(tail)
def _add_styled_paragraph(doc, text: str, style: str = "Normal",
bold: bool = False,
alignment=None):
"""Add a paragraph using a template style.
Font, size, RTL direction and spacing all come from the style
definition in the template — we only pick the style by name.
Renders `**...**` markdown as inline bold runs.
Returns the paragraph so callers can apply further overrides.
"""
para = doc.add_paragraph(style=style)
_mark_paragraph_rtl(para)
if alignment is not None:
para.alignment = alignment para.alignment = alignment
else:
para.alignment = WD_ALIGN_PARAGRAPH.RIGHT
run = para.add_run(text) if text:
run.font.name = FONT_NAME _add_runs_with_inline_bold(para, text, bold_all=bold)
run.font.size = font_size or FONT_SIZE_BODY
run.bold = bold
_set_rtl_run(run)
# Line spacing return para
pf = para.paragraph_format
pf.line_spacing = LINE_SPACING
if space_after is not None:
pf.space_after = space_after
def _add_centered_paragraph(doc, text: str, bold: bool = True, def _add_centered_paragraph(doc, text: str, *, bold: bool = True,
font_size=None) -> None: style: str = "Normal") -> None:
"""Add centered RTL paragraph.""" _add_styled_paragraph(doc, text, style=style, bold=bold,
_add_paragraph(doc, text, bold=bold, font_size=font_size,
alignment=WD_ALIGN_PARAGRAPH.CENTER) alignment=WD_ALIGN_PARAGRAPH.CENTER)
def _add_heading(doc, text: str, *, style: str) -> None:
"""Heading with overrides: jc=both (overrides style-center / style-left)
and suppressed auto-numbering (so style-linked outline lists don't inject
א./ב./ג. — chair manages markers manually in content)."""
para = doc.add_paragraph(style=style)
_mark_paragraph_rtl(para)
_set_paragraph_jc(para, "both")
_suppress_paragraph_numbering(para)
if text:
_add_runs_with_inline_bold(para, text)
def _add_blockquote(doc, text: str) -> None: def _add_blockquote(doc, text: str) -> None:
"""Add indented blockquote paragraph.""" """Indented quote using the template's Quote style."""
para = doc.add_paragraph() _add_styled_paragraph(doc, text, style="Quote")
_set_rtl_paragraph(para)
para.alignment = WD_ALIGN_PARAGRAPH.RIGHT
run = para.add_run(text)
run.font.name = FONT_NAME
run.font.size = Pt(11)
run.italic = True
_set_rtl_run(run)
pf = para.paragraph_format
pf.left_indent = Cm(1.5)
pf.right_indent = Cm(1.5)
pf.line_spacing = LINE_SPACING
def _add_image_placeholder(doc, description: str) -> None: def _add_image_placeholder(doc, description: str) -> None:
"""Add image placeholder box.""" _add_styled_paragraph(doc, f"[{description}]", style="Normal",
_add_paragraph(doc, f"[{description}]", alignment=WD_ALIGN_PARAGRAPH.CENTER)
alignment=WD_ALIGN_PARAGRAPH.CENTER,
font_size=Pt(10))
def _add_spacer(doc) -> None:
"""Add an empty paragraph as a visual spacer."""
para = doc.add_paragraph(style="Normal")
_mark_paragraph_rtl(para)
# ── Main export ─────────────────────────────────────────────────── # ── Main export ───────────────────────────────────────────────────
@@ -178,6 +291,7 @@ _INTERIM_BLOCK_ORDER = [
"block-bet", # panel (skipped if empty) "block-bet", # panel (skipped if empty)
"block-gimel", # parties (skipped if empty) "block-gimel", # parties (skipped if empty)
"block-dalet", # "החלטה" title (skipped if empty) "block-dalet", # "החלטה" title (skipped if empty)
"block-he", # פתיחה ניטרלית (skipped if empty — opt-in for pre-ruling drafts)
"block-vav", # רקע עובדתי "block-vav", # רקע עובדתי
"block-tet", # תכניות + היתרים (extended) "block-tet", # תכניות + היתרים (extended)
"block-zayin", # טענות הצדדים "block-zayin", # טענות הצדדים
@@ -241,16 +355,14 @@ async def export_decision(
else: else:
ordered_blocks = list(rows) ordered_blocks = list(rows)
# Create document if not TEMPLATE_PATH.exists():
doc = Document() raise FileNotFoundError(
f"Template not found at {TEMPLATE_PATH}. "
"Run scripts/convert_decision_template.py first."
)
# Set page margins doc = Document(str(TEMPLATE_PATH))
for section in doc.sections: _clear_body(doc)
section.top_margin = PAGE_MARGIN
section.bottom_margin = PAGE_MARGIN
section.left_margin = PAGE_MARGIN
section.right_margin = PAGE_MARGIN
_set_rtl_section(section)
# Write blocks with bookmarks wrapping each block (anchors for revisions) # Write blocks with bookmarks wrapping each block (anchors for revisions)
bm_counter = [_BOOKMARK_ID_START] bm_counter = [_BOOKMARK_ID_START]
@@ -291,93 +403,132 @@ async def export_decision(
def _write_block_to_docx(doc, block_id: str, title: str, content: str) -> None: def _write_block_to_docx(doc, block_id: str, title: str, content: str) -> None:
"""Write a single block to the DOCX document.""" """Write a single block to the DOCX document using template styles."""
# Header blocks (א-ד) # Header blocks (א-ד)
if block_id == "block-alef": if block_id == "block-alef":
for line in content.split("\n"): for line in content.split("\n"):
if line.strip(): if line.strip():
_add_centered_paragraph(doc, line.strip(), bold=True, font_size=FONT_SIZE_HEADING) _add_styled_paragraph(doc, line.strip(), style="Heading 1",
alignment=WD_ALIGN_PARAGRAPH.CENTER)
return return
if block_id == "block-bet": if block_id == "block-bet":
_add_paragraph(doc, "", space_after=Pt(6)) # spacer _add_spacer(doc)
for line in content.split("\n"): for line in content.split("\n"):
if line.strip(): if line.strip():
_add_centered_paragraph(doc, line.strip(), bold=False, font_size=FONT_SIZE_BODY) _add_centered_paragraph(doc, line.strip(), bold=False)
return return
if block_id == "block-gimel": if block_id == "block-gimel":
_add_paragraph(doc, "", space_after=Pt(6)) _add_spacer(doc)
lines = content.split("\n") for line in content.split("\n"):
for line in lines:
stripped = line.strip() stripped = line.strip()
if not stripped: if not stripped:
continue continue
if stripped == "נגד": if stripped == "נגד":
_add_centered_paragraph(doc, "— נגד —", bold=True, font_size=FONT_SIZE_BODY) _add_centered_paragraph(doc, "— נגד —", bold=True)
else: else:
_add_centered_paragraph(doc, stripped, bold=False, font_size=FONT_SIZE_BODY) _add_centered_paragraph(doc, stripped, bold=False)
return return
if block_id == "block-dalet": if block_id == "block-dalet":
_add_paragraph(doc, "", space_after=Pt(12)) # spacer _add_spacer(doc)
_add_centered_paragraph(doc, "החלטה", bold=True, font_size=FONT_SIZE_TITLE) # Avoid style=Title: its rFonts use theme fonts (majorHAnsi / majorBidi)
_add_paragraph(doc, "", space_after=Pt(12)) # and 28pt size — renders Hebrew oversized and in the wrong face.
# Heading 1 carries David and proper RTL, bold + center gives the
# same visual weight.
para = _add_styled_paragraph(doc, "החלטה", style="Heading 1",
alignment=WD_ALIGN_PARAGRAPH.CENTER,
bold=True)
_suppress_paragraph_numbering(para)
_add_spacer(doc)
return return
if block_id == "block-yod-bet": if block_id == "block-yod-bet":
_add_paragraph(doc, "", space_after=Pt(24)) # spacer _add_spacer(doc)
for line in content.split("\n"): for line in content.split("\n"):
if line.strip(): if line.strip():
_add_centered_paragraph(doc, line.strip(), bold=False, font_size=FONT_SIZE_BODY) _add_centered_paragraph(doc, line.strip(), bold=False)
return return
# Content blocks (ה-יא) — parse paragraphs # Content blocks (ה-יא) — parse paragraphs
paragraphs = content.split("\n") for para_text in content.split("\n"):
for para_text in paragraphs: stripped = _strip_dashes(para_text.strip())
stripped = para_text.strip()
if not stripped: if not stripped:
continue continue
# Section headings (e.g., "תמצית טענות הצדדים", "טענות העוררים") # Markdown H1/H2/H3 → template heading styles
if _is_section_heading(stripped): md_heading = re.match(r"^(#{1,6})\s+(.*)$", stripped)
_add_paragraph(doc, stripped, bold=True, font_size=FONT_SIZE_HEADING, if md_heading:
space_after=Pt(6)) level = len(md_heading.group(1))
heading_text = md_heading.group(2).strip()
style = "Heading 1" if level == 1 else f"Heading {min(level, 3)}"
_add_heading(doc, heading_text, style=style)
continue
# Standalone `**...**` line — treat as a sub-heading (Heading 3)
stand_bold = re.match(r"^\*\*([^\n*]+?)\*\*$", stripped)
if stand_bold:
_add_heading(doc, stand_bold.group(1).strip(), style="Heading 3")
continue
if _is_section_heading(stripped):
_add_heading(doc, stripped, style="Heading 2")
continue continue
# Blockquotes (indented quotes from protocols/rulings)
if stripped.startswith('"') or stripped.startswith("״") or stripped.startswith(">"): if stripped.startswith('"') or stripped.startswith("״") or stripped.startswith(">"):
clean = stripped.lstrip(">").strip().strip('"').strip("״").strip('"') clean = stripped.lstrip(">").strip().strip('"').strip("״").strip('"')
_add_blockquote(doc, clean) _add_blockquote(doc, clean)
continue continue
# Image placeholders if "📷" in stripped or (stripped.startswith("[") and "תמונה" in stripped):
if "📷" in stripped or stripped.startswith("[") and "תמונה" in stripped:
_add_image_placeholder(doc, stripped.strip("[]📷 ")) _add_image_placeholder(doc, stripped.strip("[]📷 "))
continue continue
# Regular numbered paragraph or plain text # Numbered body paragraph ("1. text") → List Paragraph with auto-num.
_add_paragraph(doc, stripped) # The literal prefix is dropped; Word renders "1. 2. 3. ..." via numId.
num_match = _NUM_PREFIX_RE.match(stripped)
if num_match:
body_text = num_match.group(2).strip()
_add_styled_paragraph(doc, body_text, style="List Paragraph")
continue
_add_styled_paragraph(doc, stripped, style="Normal")
def _is_section_heading(text: str) -> bool: _SECTION_HEADING_PATTERNS = [
"""Detect section headings in decision text.""" re.compile(p) for p in (
heading_patterns = [ # Block-level titles
r"^פתח\s+דבר",
r"^רקע\s+עובדתי",
r"^תמצית\s+טענות", r"^תמצית\s+טענות",
r"^טענות\s+הצדדים",
r"^טענות\s+העוררי", r"^טענות\s+העוררי",
r"^טענות\s+המשיב",
r"^עמדת\s+הוועדה", r"^עמדת\s+הוועדה",
r"^עמדת\s+מבקשי", r"^עמדת\s+מבקשי",
r"^ההליכים\s+בפני", r"^ההליכים\s+בפני",
r"^הליכים\s+בפני",
r"^דיון\s+והכרעה", r"^דיון\s+והכרעה",
r"^סוף\s+דבר", r"^סוף\s+דבר",
r"^סיכום", r"^סיכום",
r"^פתח\s+דבר", # Subsection titles produced by legal-writer inside block-vav/block-tet
r"^המצב\s+התכנוני",
r"^הליכי\s+הרישוי",
r"^שומת\s+ההשבחה",
r"^הליך\s+השומה",
r"^הגשת\s+הערר",
r"^תכניות\s+מתאר",
r"^תכניות\s+מפורטות",
r"^תכניות\s+חלות", r"^תכניות\s+חלות",
r"^תכניות\s+החלות",
r"^מדיניות\s+מהנדס",
r"^היתרי\s+בני",
r"^היתר\s+בני",
)
] ]
for pattern in heading_patterns:
if re.search(pattern, text):
return True def _is_section_heading(text: str) -> bool:
# Short bold-like lines (under 60 chars, not numbered) """Detect legal-decision section headings — mapped to Heading 2 style."""
if len(text) < 60 and not re.match(r"^\d+\.", text): return any(p.search(text) for p in _SECTION_HEADING_PATTERNS)
return False
return False

View File

@@ -3,19 +3,31 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import TYPE_CHECKING
import voyageai
from legal_mcp import config from legal_mcp import config
if TYPE_CHECKING:
import voyageai
from PIL import Image as PILImage
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_client: voyageai.Client | None = None # voyageai is imported lazily inside _get_client to keep MCP server startup
# fast — loading voyageai eagerly costs ~450ms and Claude Code's first tool
# call can hit a "No such tool available" race if the server isn't ready yet.
_client: "voyageai.Client | None" = None
# Per-call cap for multimodal_embed. POC ran 89 pages (~312K tokens)
# in a single call comfortably; 50 leaves safe headroom for densely-
# OCR'd legal pages where tokens/page can exceed 4K.
_MULTIMODAL_BATCH_SIZE = 50
def _get_client() -> voyageai.Client: def _get_client() -> "voyageai.Client":
global _client global _client
if _client is None: if _client is None:
import voyageai
_client = voyageai.Client(api_key=config.VOYAGE_API_KEY) _client = voyageai.Client(api_key=config.VOYAGE_API_KEY)
return _client return _client
@@ -53,3 +65,65 @@ async def embed_query(query: str) -> list[float]:
"""Embed a single search query.""" """Embed a single search query."""
results = await embed_texts([query], input_type="query") results = await embed_texts([query], input_type="query")
return results[0] return results[0]
async def embed_images(
images: "list[PILImage.Image]",
input_type: str = "document",
) -> list[list[float]]:
"""Embed page images via voyage-multimodal-3.
Each input is a single PIL.Image (one page = one embedding).
Returns a list of 1024-dim vectors, one per input image, in order.
Batches at ``_MULTIMODAL_BATCH_SIZE`` to stay within Voyage's
per-request limits on dense legal pages.
"""
if not images:
return []
client = _get_client()
out: list[list[float]] = []
for i in range(0, len(images), _MULTIMODAL_BATCH_SIZE):
batch = images[i : i + _MULTIMODAL_BATCH_SIZE]
result = client.multimodal_embed(
inputs=[[img] for img in batch],
model=config.MULTIMODAL_MODEL,
input_type=input_type,
truncation=True,
)
out.extend(result.embeddings)
return out
async def embed_query_for_multimodal(query: str) -> list[float]:
"""Embed a text query in the multimodal vector space, so it can be
cosine-compared against page-image embeddings."""
client = _get_client()
result = client.multimodal_embed(
inputs=[[query]],
model=config.MULTIMODAL_MODEL,
input_type="query",
)
return result.embeddings[0]
async def voyage_rerank(
query: str, documents: list[str], top_k: int | None = None,
) -> list[tuple[int, float]]:
"""Cross-encoder rerank via Voyage. Returns [(orig_index, score), ...]
sorted by relevance. Each tuple's index refers to the position in the
*input* documents list (not a DB row id) — caller maps it back.
Used as a second stage after bi-encoder retrieval: fetch top-N
candidates with cosine, then rerank to get top-K with cross-encoder
attention over (query, doc).
"""
if not documents:
return []
client = _get_client()
result = client.rerank(
query=query,
documents=documents,
model=config.VOYAGE_RERANK_MODEL,
top_k=top_k,
)
return [(r.index, float(r.relevance_score)) for r in result.results]

View File

@@ -9,29 +9,35 @@ Post-processing: Hebrew abbreviation quote fixer.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import io
import logging import logging
import re import re
import subprocess import subprocess
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING
import fitz # PyMuPDF import fitz # PyMuPDF
from PIL import Image
from docx import Document as DocxDocument from docx import Document as DocxDocument
from google.cloud import vision
from striprtf.striprtf import rtf_to_text from striprtf.striprtf import rtf_to_text
from legal_mcp import config from legal_mcp import config
if TYPE_CHECKING:
from google.cloud import vision
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── Google Cloud Vision client ─────────────────────────────────── # ── Google Cloud Vision client (imported lazily — saves ~550ms at MCP startup) ──
_vision_client: vision.ImageAnnotatorClient | None = None _vision_client: "vision.ImageAnnotatorClient | None" = None
def _get_vision_client() -> vision.ImageAnnotatorClient: def _get_vision_client() -> "vision.ImageAnnotatorClient":
global _vision_client global _vision_client
if _vision_client is None: if _vision_client is None:
from google.cloud import vision
_vision_client = vision.ImageAnnotatorClient( _vision_client = vision.ImageAnnotatorClient(
client_options={"api_key": config.GOOGLE_CLOUD_VISION_API_KEY} client_options={"api_key": config.GOOGLE_CLOUD_VISION_API_KEY}
) )
@@ -118,12 +124,22 @@ def _fix_hebrew_quotes(text: str) -> str:
# ── Extraction ─────────────────────────────────────────────────── # ── Extraction ───────────────────────────────────────────────────
async def extract_text(file_path: str) -> tuple[str, int]: # Separator used when joining per-page text. Constant so chunker /
# retrofit can reproduce the join when computing page offsets.
PAGE_SEPARATOR = "\n\n"
async def extract_text(file_path: str) -> tuple[str, int, list[int] | None]:
"""Extract text from a document file. """Extract text from a document file.
Returns: Returns:
Tuple of (extracted_text, page_count). ``(text, page_count, page_offsets)`` where:
page_count is 0 for non-PDF files. - ``text``: concatenated extracted text
- ``page_count``: number of pages (0 for non-PDF)
- ``page_offsets``: ``page_offsets[i]`` = char start offset of
page (i+1) inside ``text``. ``None`` for non-PDFs (where the
notion of pages doesn't apply). Used by the chunker to assign
a ``page_number`` to each chunk.
""" """
path = Path(file_path) path = Path(file_path)
suffix = path.suffix.lower() suffix = path.suffix.lower()
@@ -131,18 +147,34 @@ async def extract_text(file_path: str) -> tuple[str, int]:
if suffix == ".pdf": if suffix == ".pdf":
return await _extract_pdf(path) return await _extract_pdf(path)
elif suffix == ".docx": elif suffix == ".docx":
return _extract_docx(path), 0 return _extract_docx(path), 0, None
elif suffix == ".doc": elif suffix == ".doc":
return _extract_doc(path), 0 return _extract_doc(path), 0, None
elif suffix == ".rtf": elif suffix == ".rtf":
return _extract_rtf(path), 0 return _extract_rtf(path), 0, None
elif suffix in (".txt", ".md"): elif suffix in (".txt", ".md"):
return path.read_text(encoding="utf-8"), 0 return path.read_text(encoding="utf-8"), 0, None
else: else:
raise ValueError(f"Unsupported file type: {suffix}") raise ValueError(f"Unsupported file type: {suffix}")
async def _extract_pdf(path: Path) -> tuple[str, int]: def _join_pages(pages_text: list[str]) -> tuple[str, list[int]]:
"""Join per-page text with PAGE_SEPARATOR while recording the start
offset of each page in the joined output."""
offsets: list[int] = []
parts: list[str] = []
cursor = 0
for i, pg in enumerate(pages_text):
offsets.append(cursor)
parts.append(pg)
cursor += len(pg)
if i < len(pages_text) - 1:
parts.append(PAGE_SEPARATOR)
cursor += len(PAGE_SEPARATOR)
return "".join(parts), offsets
async def _extract_pdf(path: Path) -> tuple[str, int, list[int]]:
"""Extract text from PDF. """Extract text from PDF.
Try direct text first, fall back to Google Cloud Vision for scanned Try direct text first, fall back to Google Cloud Vision for scanned
@@ -170,11 +202,32 @@ async def _extract_pdf(path: Path) -> tuple[str, int]:
pages_text.append(ocr_text) pages_text.append(ocr_text)
doc.close() doc.close()
return "\n\n".join(pages_text), page_count joined, offsets = _join_pages(pages_text)
return joined, page_count, offsets
def page_at_offset(offset: int, page_offsets: list[int]) -> int:
"""Look up the page number containing a given char offset.
page_offsets[i] is the start of page (i+1) in the joined text;
a chunk starting at ``offset`` belongs to the highest-indexed page
whose start is ``<= offset``. Returns 1-based page number.
"""
if not page_offsets:
return 1
# Linear scan is fine — page_offsets is short (≤ ~200 for our PDFs).
page = 1
for i, start in enumerate(page_offsets):
if start <= offset:
page = i + 1
else:
break
return page
def _ocr_with_google_vision(image_bytes: bytes, page_num: int) -> str: def _ocr_with_google_vision(image_bytes: bytes, page_num: int) -> str:
"""OCR a single page image using Google Cloud Vision API.""" """OCR a single page image using Google Cloud Vision API."""
from google.cloud import vision # lazy: keeps MCP startup fast
client = _get_vision_client() client = _get_vision_client()
image = vision.Image(content=image_bytes) image = vision.Image(content=image_bytes)
@@ -220,6 +273,65 @@ def _extract_rtf(path: Path) -> str:
return rtf_to_text(rtf_content) return rtf_to_text(rtf_content)
# ── Multimodal page rendering (V9) ───────────────────────────────
def _pixmap_to_pil(pix: fitz.Pixmap) -> Image.Image:
"""Convert a PyMuPDF pixmap to PIL.Image (RGB) without going through
PNG bytes. Faster than tobytes('png') → Image.open()."""
if pix.alpha:
# Drop alpha channel — voyage multimodal expects RGB.
pix = fitz.Pixmap(pix, 0)
return Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
def render_pages_for_multimodal(
pdf_path: str | Path,
embed_dpi: int,
thumb_dpi: int | None = None,
thumbnail_dir: Path | None = None,
) -> list[tuple[Image.Image, Path | None]]:
"""Render each PDF page as PIL.Image at ``embed_dpi`` for the
multimodal embedder, and optionally save a smaller JPEG thumbnail
at ``thumb_dpi`` to ``thumbnail_dir`` for UI preview.
Returns ``[(pil_image, thumb_path_or_None), ...]`` in page order.
The full-DPI image stays in memory only — only the thumbnail is
persisted to disk.
"""
src = Path(pdf_path)
if not src.is_file():
raise FileNotFoundError(f"PDF not found: {src}")
if thumbnail_dir is not None:
thumbnail_dir.mkdir(parents=True, exist_ok=True)
out: list[tuple[Image.Image, Path | None]] = []
doc = fitz.open(str(src))
try:
for page_idx, page in enumerate(doc):
page_num = page_idx + 1
pix = page.get_pixmap(dpi=embed_dpi)
img = _pixmap_to_pil(pix)
thumb_path: Path | None = None
if thumbnail_dir is not None and thumb_dpi:
thumb_path = thumbnail_dir / f"p{page_num:03d}.jpg"
# Downsample the same render rather than re-rendering
# with PyMuPDF — far faster.
ratio = thumb_dpi / embed_dpi
thumb_size = (
max(1, int(img.width * ratio)),
max(1, int(img.height * ratio)),
)
thumb = img.resize(thumb_size, Image.Resampling.LANCZOS)
thumb.save(thumb_path, "JPEG", quality=75, optimize=True)
out.append((img, thumb_path))
finally:
doc.close()
return out
# ── Nevo preamble stripping ────────────────────────────────────── # ── Nevo preamble stripping ──────────────────────────────────────
_NEVO_MARKERS = ("ספרות:", "חקיקה שאוזכרה:", "מיני-רציו:", "פסקי דין שאוזכרו:", _NEVO_MARKERS = ("ספרות:", "חקיקה שאוזכרה:", "מיני-רציו:", "פסקי דין שאוזכרו:",

View File

@@ -6,15 +6,23 @@ rotated in Infisical, repos created with the old token will fail to
push silently — only logged at WARNING level. ``commit_and_push`` push silently — only logged at WARNING level. ``commit_and_push``
re-injects the *current* token into the existing origin URL on every re-injects the *current* token into the existing origin URL on every
call, so push survives token rotation. call, so push survives token rotation.
This module also runs a periodic ``sweep_loop`` that catches files
written outside the API path (most importantly: agents writing research
artefacts directly to the case dir). The full case repo is the user's
backup, so anything in the dir must end up on Gitea.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
import os import os
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from legal_mcp import config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -22,8 +30,8 @@ def _gitea_token() -> str:
return os.environ.get("GITEA_ACCESS_TOKEN") or os.environ.get("GITEA_TOKEN", "") return os.environ.get("GITEA_ACCESS_TOKEN") or os.environ.get("GITEA_TOKEN", "")
def _git_env() -> dict: def _git_env(case_dir: str | Path | None = None) -> dict:
return { env = {
"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_NAME": "Ezer Mishpati",
"GIT_AUTHOR_EMAIL": "legal@local", "GIT_AUTHOR_EMAIL": "legal@local",
"GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_NAME": "Ezer Mishpati",
@@ -31,6 +39,13 @@ def _git_env() -> dict:
"PATH": os.environ.get("PATH", "/usr/bin:/bin"), "PATH": os.environ.get("PATH", "/usr/bin:/bin"),
"GIT_TERMINAL_PROMPT": "0", "GIT_TERMINAL_PROMPT": "0",
} }
if case_dir is not None:
# Trust the case dir even when the running uid differs from the
# owner (prod container is uniform-root, but host runs may not be).
env["GIT_CONFIG_COUNT"] = "1"
env["GIT_CONFIG_KEY_0"] = "safe.directory"
env["GIT_CONFIG_VALUE_0"] = str(case_dir)
return env
def _refresh_remote_url(case_dir: Path, env: dict) -> bool: def _refresh_remote_url(case_dir: Path, env: dict) -> bool:
@@ -68,7 +83,7 @@ def commit_and_push(case_dir: str | Path, message: str) -> bool:
if not (case_dir / ".git").exists(): if not (case_dir / ".git").exists():
return False return False
env = _git_env() env = _git_env(case_dir)
subprocess.run(["git", "add", "."], cwd=case_dir, capture_output=True, env=env) subprocess.run(["git", "add", "."], cwd=case_dir, capture_output=True, env=env)
commit = subprocess.run( commit = subprocess.run(
@@ -90,3 +105,104 @@ def commit_and_push(case_dir: str | Path, message: str) -> bool:
logger.warning("Git push failed in %s: %s", case_dir, push.stderr) logger.warning("Git push failed in %s: %s", case_dir, push.stderr)
return False return False
return True return True
# ── Periodic sweep ────────────────────────────────────────────────
#
# The user's expectation is that "anything I or an agent puts into a case
# dir ends up on Gitea". Explicit commit_and_push calls cover the API
# write paths, but agents write research/draft files directly to disk.
# A short periodic sweep is the safety net.
_SWEEP_INTERVAL_SEC = 30
def _porcelain_changes(case_dir: Path, env: dict) -> list[str]:
"""Return list of `git status --porcelain` lines, or [] if clean/error."""
res = subprocess.run(
["git", "status", "--porcelain"],
cwd=case_dir, capture_output=True, text=True, env=env,
)
if res.returncode != 0:
return []
return [ln for ln in res.stdout.splitlines() if ln.strip()]
def _auto_message(changes: list[str]) -> str:
"""Build a Hebrew commit message from porcelain output.
Groups by top-level subdir under the case dir so a sweep that picks up
one DOCX export plus one research file produces a useful summary
instead of "auto-sync".
"""
groups: dict[str, int] = {}
sample: dict[str, str] = {}
for line in changes:
path = line[3:].strip().strip('"')
if "->" in path: # rename
path = path.split("->", 1)[1].strip().strip('"')
first = path.split("/", 1)[0]
groups[first] = groups.get(first, 0) + 1
sample.setdefault(first, path)
label_map = {
"documents": "מסמכים",
"drafts": "טיוטות",
"exports": "גרסאות",
"case.json": "מטא",
"notes.md": "הערות",
}
parts: list[str] = []
for top, count in groups.items():
label = label_map.get(top, top)
parts.append(f"{label} ({count})" if count > 1 else label)
summary = " · ".join(parts) or "שינויים"
return f"אוטו: {summary}"
def sweep_once() -> dict:
"""Walk every case dir and commit+push any dirty changes.
Synchronous (subprocess-based) but cheap — `git status --porcelain` on
a clean dir is a sub-millisecond operation. Returns a small report
suitable for logging.
"""
base: Path = config.CASES_DIR
if not base.exists():
return {"checked": 0, "synced": 0, "errors": 0}
checked = synced = errors = 0
for case_dir in base.iterdir():
if not case_dir.is_dir() or not (case_dir / ".git").exists():
continue
checked += 1
changes = _porcelain_changes(case_dir, _git_env(case_dir))
if not changes:
continue
msg = _auto_message(changes)
ok = commit_and_push(case_dir, msg)
if ok:
synced += 1
logger.info("auto-sync committed %d change(s) in %s", len(changes), case_dir.name)
else:
errors += 1
return {"checked": checked, "synced": synced, "errors": errors}
async def sweep_loop(interval_sec: int = _SWEEP_INTERVAL_SEC) -> None:
"""Background task: run sweep_once forever every interval_sec.
Cancellation-safe; logs and continues on transient errors.
"""
logger.info("git_sync.sweep_loop started (interval=%ds)", interval_sec)
while True:
try:
await asyncio.sleep(interval_sec)
# Run the sync subprocess work in a thread to avoid blocking
# the FastAPI event loop.
await asyncio.to_thread(sweep_once)
except asyncio.CancelledError:
logger.info("git_sync.sweep_loop cancelled")
raise
except Exception as exc:
logger.warning("git_sync sweep iteration failed: %s", exc)

View File

@@ -0,0 +1,473 @@
"""Extract binding legal rules (הלכות) from external court rulings.
Runs Claude (via the local headless ``claude -p`` bridge) over the
legal_analysis / ruling / conclusion chunks of a precedent, returns a
structured list of halachot, validates each one against the source text,
embeds the rule statement, and stores everything as ``pending_review`` in
the ``halachot`` table.
All extraction is idempotent — calling ``extract(case_law_id)`` twice
deletes prior rows for that precedent first.
Trust model:
Per chair decision, NO halacha is auto-published. Every extracted
halacha enters with ``review_status='pending_review'``. The chair
approves/rejects via the UI, and only ``approved`` (or ``published``)
rows are visible to ``search_precedent_library`` and the writing
agents.
"""
from __future__ import annotations
import asyncio
import logging
import re
from uuid import UUID
from legal_mcp import config
from legal_mcp.config import parse_llm_json
from legal_mcp.services import claude_session, db, embeddings, proofreader
logger = logging.getLogger(__name__)
# Concurrency model mirrors claims_extractor — each ``claude -p`` subprocess
# holds ~300 MB RSS, so we cap parallel chunks to keep the box healthy.
CHUNK_CONCURRENCY = 3
CHUNK_RETRY_ATTEMPTS = 1
# If at least this fraction of chunks crash and the precedent yields zero
# halachot, treat the run as `extraction_failed` rather than `no_halachot`.
# Picked at 0.5 so a precedent that genuinely has no holdings (e.g. a remand
# ruling that just sends the case back) isn't misflagged just because a few
# chunks timed out, while a real rate-limit storm — which kills nearly every
# call — is correctly distinguished and re-tried by the caller.
EXTRACTION_FAILURE_THRESHOLD = 0.5
# Sections from which to extract. facts/intro/appellant_claims/respondent_claims
# never contain holdings, only positions, so we skip them.
EXTRACTABLE_SECTIONS = ("legal_analysis", "ruling", "conclusion")
# Two prompts — choose by source's is_binding flag.
#
# The binding prompt extracts strict halachot (rules a future panel MUST
# follow). It rejects obiter dicta, factual findings, and citations of
# other rulings that the present court only mentioned in passing.
#
# The persuasive prompt is for sources that don't establish binding law
# (most appeals committee decisions, district courts on planning matters,
# etc.). For those, the value is in **how the panel reasoned and applied**
# established law to facts — not in new halachot. The user explicitly
# wants to be able to cite "another committee reached the same conclusion"
# even though it is not binding.
#
# The schema's rule_type field accepts six values:
# binding | interpretive | procedural | obiter | application | persuasive
HALACHA_EXTRACTION_PROMPT_BINDING = """אתה משפטן בכיר המתמחה בדיני תכנון ובניה (ועדות ערר, היטל השבחה, פיצויים לפי סעיף 197 לחוק התכנון והבניה). תפקידך: לחלץ הלכות מחייבות מתוך פסק דין/החלטה משפטית של ערכאה עליונה (עליון / מנהלי).
## הגדרות מחייבות
הלכה (binding rule) = כלל משפטי שהפסק קובע או מאמץ ומיישם, באופן שניתן להסתמך עליו בהחלטות עתידיות.
לא-הלכה (אין לחלץ):
- אמרת אגב (obiter dicta) — הערות שאינן הכרחיות להכרעה.
- ממצאים עובדתיים ספציפיים לתיק ("העורר לא הוכיח X").
- ציטוטי הלכות מפסקי דין אחרים שלא אומצו במפורש בפסק זה.
- הצהרות על דין קיים שאינן מיושמות בהכרעה.
הבחנה קריטית: כאשר הפסק מצטט הלכה מפסק קודם, חלץ אותה רק אם בית המשפט בפסק הנוכחי **מאמץ ומחיל** אותה (לא רק מזכיר אותה ברקע).
## תחומים אפשריים (practice_areas) — תחומי ועדת הערר בלבד
- rishuy_uvniya — רישוי ובניה (תיקי 1xxx: היתרים, שימוש חורג, תכניות, קווי בניין, גובה, חניה)
- betterment_levy — היטל השבחה (תיקי 8xxx: שומה, מערכות, תכניות המקנות בה, מועד קובע, סופיות ההחלטה)
- compensation_197 — פיצויים לפי ס' 197 (תיקי 9xxx: פגיעה במקרקעין, ירידת ערך, ס' 200/פטור)
הלכה אחת יכולה לחול על כמה תחומים — practice_areas הוא array ולא string יחיד.
## סוגי הלכה (rule_type)
- binding — הלכה מחייבת שהוחלה על התיק.
- interpretive — פרשנות סעיף חוק/תכנית שאומצה.
- procedural — כלל פרוצדורלי (סמכות, מועדים, הליכי שמיעה).
- obiter — אמרת אגב חשובה (חלץ רק אם משמעותית; סמן confidence נמוך).
## פלט נדרש
החזר JSON array בלבד, ללא markdown, ללא הסברים. דוגמה:
[
{
"rule_statement": "ניסוח הכלל בלשון משפטית מדויקת בגוף שלישי, 1-3 משפטים.",
"rule_type": "binding",
"reasoning_summary": "תמצית ההיגיון: למה בית המשפט הגיע לכלל הזה (1-2 משפטים).",
"supporting_quote": "ציטוט מילולי מדויק מהפסק התומך בכלל. חייב להופיע מילה במילה בטקסט הקלט.",
"page_reference": "פס' 12 / עמ' 8 — ככל שניתן לזהות מהקלט.",
"practice_areas": ["betterment_levy"],
"subject_tags": ["מועד_קביעת_שומה", "סופיות_ההחלטה"],
"cites": ["עע\\"מ 3975/22"],
"confidence": 0.85
}
]
## כללי איכות
1. **נאמנות מוחלטת לציטוט** — supporting_quote חייב להיות הדבקה מדויקת מהקלט. אם אין ציטוט מתאים — אל תמציא הלכה.
2. **מספר הלכות** — פסק רגיל מכיל 1-4 הלכות מחייבות. אל תמתח את הרשימה. אם אין הלכה — החזר [].
3. **לא לפצל יתר על המידה** — אם שני סעיפים מבטאים את אותו עיקרון, אחד את הניסוח.
4. **שפה** — rule_statement בעברית משפטית מקצועית, לא צמצום מילולי של הציטוט.
5. **subject_tags** — 2-5 תגיות בעברית, snake_case (חניה, קווי_בניין, שיקול_דעת, פגם_פרוצדורלי, סמכות, מועדים, פגיעה_במקרקעין, ירידת_ערך).
6. **confidence** — 0..1. מתחת ל-0.7 = ספק לגבי היות זה הלכה מחייבת.
"""
HALACHA_EXTRACTION_PROMPT_PERSUASIVE = """אתה משפטן בכיר המתמחה בדיני תכנון ובניה. תפקידך: לחלץ עקרונות, יישומים ומסקנות מתוך החלטה של ועדת ערר אחרת או של בית משפט שאינו ערכאה עליונה לסוגיה.
## חשוב — מה לחלץ ומה לא
המקור הזה **אינו** מקור להלכות מחייבות חדשות (binding rules). הלכות מחייבות מגיעות מהעליון/מנהלי. עם זאת, יש כאן ערך משמעותי שצריך לחלץ — איך הפנל הזה ניתח ויישם את הדין הקיים. כשנכתוב החלטה עתידית, נצטט מהמקור הזה כ"גם ועדת הערר ב-X הגיעה למסקנה דומה" — לא כסמכות מחייבת, אלא כתמיכה משכנעת.
**יש לחלץ:**
- **יישום של הלכה ידועה** (rule_type=`application`) — הפנל החיל הלכה ידועה (של עליון/מנהלי) על עובדות הנידונות. תצטט את ניסוח הכלל **כפי שהוצג כאן** (לא בהכרח כפי שנקבע במקור) ואת התוצאה.
- **עקרון פרשני שאומץ** (rule_type=`interpretive`) — איך הפנל פירש סעיף חוק / תכנית, באופן שניתן לאמץ.
- **כלל פרוצדורלי** (rule_type=`procedural`) — קביעות בנושאי סמכות, מועדים, הליך.
- **מסקנה מנומקת ומשכנעת** (rule_type=`persuasive`) — מסקנה שלמה של הפנל בסוגיה, עם ההיגיון התומך, ניתנת לציטוט כאסמכתא משכנעת.
**אין לחלץ:**
- ממצאים עובדתיים ספציפיים לתיק ("העורר לא הוכיח X").
- ציטוטים מפסקי דין אחרים ללא ניתוח של הפנל.
- אמרות אגב חסרות חשיבות.
## תחומים אפשריים (practice_areas) — תחומי ועדת הערר בלבד
- rishuy_uvniya — רישוי ובניה (תיקי 1xxx: היתרים, שימוש חורג, תכניות, קווי בניין, גובה, חניה)
- betterment_levy — היטל השבחה (תיקי 8xxx: שומה, מערכות, תכניות המקנות בה, מועד קובע, סופיות ההחלטה)
- compensation_197 — פיצויים לפי ס' 197 (תיקי 9xxx: פגיעה במקרקעין, ירידת ערך, ס' 200/פטור)
## פלט נדרש
החזר JSON array בלבד, ללא markdown, ללא הסברים:
[
{
"rule_statement": "ניסוח הכלל / המסקנה / היישום בלשון משפטית מדויקת, 1-3 משפטים.",
"rule_type": "application",
"reasoning_summary": "תמצית ההיגיון של הפנל (1-2 משפטים).",
"supporting_quote": "ציטוט מילולי מדויק מהקלט שתומך בכלל. חייב להופיע מילה במילה.",
"page_reference": "פס' 12 / עמ' 8 — ככל שניתן לזהות.",
"practice_areas": ["betterment_levy"],
"subject_tags": ["מועד_קביעת_שומה", "תכנית_רחביה"],
"cites": ["עע\\"מ 3975/22"],
"confidence": 0.85
}
]
## כללי איכות
1. **נאמנות מוחלטת לציטוט** — supporting_quote חייב להיות הדבקה מדויקת מהקלט. אם אין ציטוט מתאים — אל תוסיף את ההלכה.
2. **מספר הלכות** — החלטה ארוכה של ועדת ערר יכולה להניב 2-8 פריטים (יישומים + מסקנות). אם אין מה לחלץ — החזר [].
3. **rule_type מדויק** — application = יישום הלכה ידועה. interpretive = פרשנות. procedural = פרוצדורה. persuasive = מסקנה כללית בעלת ערך כאסמכתא.
4. **לא לפצל יתר על המידה** — שני סעיפים זהים מבחינה רעיונית = פריט אחד.
5. **שפה** — עברית משפטית מקצועית, גוף שלישי.
6. **subject_tags** — 2-5 תגיות בעברית, snake_case.
7. **confidence** — 0..1. דייק.
"""
_VALID_PRACTICE_AREAS = {"rishuy_uvniya", "betterment_levy", "compensation_197"}
_VALID_RULE_TYPES = {
"binding", "interpretive", "procedural", "obiter",
"application", "persuasive",
}
def _normalize_for_comparison(text: str) -> str:
"""Normalize Hebrew text for substring matching.
Collapses whitespace and unifies the half-dozen Hebrew quote-mark
variants. Use ``proofreader._fix_hebrew_quotes`` for the quote part
so we stay consistent with the proofreader pipeline.
"""
fixed = proofreader._fix_hebrew_quotes(text)
# Collapse all whitespace (newlines, tabs, multiple spaces) to a single space.
return re.sub(r"\s+", " ", fixed).strip()
def _verify_quote(supporting_quote: str, full_text: str) -> bool:
"""Return True if ``supporting_quote`` appears verbatim in ``full_text``
after Hebrew quote/whitespace normalization.
The LLM occasionally trims a leading/trailing word from the quote;
we accept the quote if at least 90% of its characters match a
contiguous substring of the source.
"""
if not supporting_quote.strip():
return False
normalized_quote = _normalize_for_comparison(supporting_quote)
normalized_text = _normalize_for_comparison(full_text)
if not normalized_quote:
return False
if normalized_quote in normalized_text:
return True
# Fallback: try the inner 90% of the quote (drops boundary trim).
if len(normalized_quote) >= 30:
trim = max(2, len(normalized_quote) // 20)
inner = normalized_quote[trim:-trim]
if inner and inner in normalized_text:
return True
return False
def _coerce_halacha(raw: dict, is_binding: bool = True) -> dict | None:
"""Validate and normalize one LLM-returned halacha dict.
Returns ``None`` if the entry is missing required fields. ``is_binding``
only affects the default rule_type when the LLM returned an unknown
value — for binding sources we default to ``binding``, otherwise to
``persuasive`` (never pretend an appeals committee created halacha).
"""
if not isinstance(raw, dict):
return None
rule_statement = (raw.get("rule_statement") or "").strip()
supporting_quote = (raw.get("supporting_quote") or "").strip()
if not rule_statement or not supporting_quote:
return None
default_rule_type = "binding" if is_binding else "persuasive"
rule_type = (raw.get("rule_type") or default_rule_type).strip().lower()
if rule_type not in _VALID_RULE_TYPES:
rule_type = default_rule_type
# Guard: don't let a non-binding source produce 'binding' rule_type
if not is_binding and rule_type == "binding":
rule_type = "persuasive"
practice_areas_raw = raw.get("practice_areas") or []
if isinstance(practice_areas_raw, str):
practice_areas_raw = [practice_areas_raw]
practice_areas = [p for p in practice_areas_raw if p in _VALID_PRACTICE_AREAS]
subject_tags_raw = raw.get("subject_tags") or []
if isinstance(subject_tags_raw, str):
subject_tags_raw = [subject_tags_raw]
subject_tags = [str(t).strip() for t in subject_tags_raw if str(t).strip()]
cites_raw = raw.get("cites") or []
if isinstance(cites_raw, str):
cites_raw = [cites_raw]
cites = [str(c).strip() for c in cites_raw if str(c).strip()]
try:
confidence = float(raw.get("confidence", 0.0))
except (TypeError, ValueError):
confidence = 0.0
confidence = max(0.0, min(1.0, confidence))
return {
"rule_statement": rule_statement,
"rule_type": rule_type,
"reasoning_summary": (raw.get("reasoning_summary") or "").strip(),
"supporting_quote": supporting_quote,
"page_reference": (raw.get("page_reference") or "").strip(),
"practice_areas": practice_areas,
"subject_tags": subject_tags,
"cites": cites,
"confidence": confidence,
}
async def _extract_chunk(
chunk_text: str,
section_type: str,
chunk_index: int,
chunk_total: int,
context: str,
is_binding: bool,
) -> tuple[list[dict], bool]:
"""Run the halacha extractor on one chunk with retry.
Returns ``(halachot, succeeded)`` so the caller can distinguish "Claude
said there are no halachot here" (`(_, True)`) from "every attempt
crashed/timed out" (`(_, False)`). Without this distinction a precedent
that hit a rate-limit storm looks identical to one that genuinely has no
halachot — and gets silently marked `no_halachot`.
The prompt branches on ``is_binding`` so non-binding sources (other
appeals committees, district courts) yield application/persuasive
entries rather than a forced 0-result strict halacha pass.
"""
base_prompt = (
HALACHA_EXTRACTION_PROMPT_BINDING if is_binding
else HALACHA_EXTRACTION_PROMPT_PERSUASIVE
)
chunk_label = f" (חלק {chunk_index + 1}/{chunk_total})" if chunk_total > 1 else ""
# Pass the static instruction prompt as `system` so the SDK path can cache
# it (5-min ephemeral). Only the per-chunk content varies via `prompt`.
user_msg = (
f"## הקלט\n"
f"סוג קטע: {section_type}\n"
f"{context}{chunk_label}\n\n"
f"--- תחילת הטקסט ---\n{chunk_text}\n--- סוף הטקסט ---"
)
last_err: Exception | None = None
for attempt in range(CHUNK_RETRY_ATTEMPTS + 1):
try:
result = await claude_session.query_json(user_msg, system=base_prompt)
except Exception as e:
last_err = e
logger.warning(
"halacha_extractor chunk %d/%d attempt %d raised: %s",
chunk_index + 1, chunk_total, attempt + 1, e,
)
continue
if isinstance(result, list):
return result, True
logger.warning(
"halacha_extractor chunk %d/%d attempt %d returned non-list (%s)",
chunk_index + 1, chunk_total, attempt + 1, type(result).__name__,
)
logger.error(
"halacha_extractor chunk %d/%d failed after %d attempts: %s",
chunk_index + 1, chunk_total, CHUNK_RETRY_ATTEMPTS + 1, last_err,
)
return [], False
async def extract(case_law_id: UUID | str) -> dict:
"""Extract halachot from an uploaded precedent and store them.
Idempotent: replaces any existing halachot for this case_law_id.
All inserted rows start as ``review_status='pending_review'``.
Returns:
``{"status": "...", "extracted": N, "verified": M, "stored": K, ...}``
"""
if isinstance(case_law_id, str):
case_law_id = UUID(case_law_id)
record = await db.get_case_law(case_law_id)
if not record:
return {"status": "not_found", "extracted": 0, "stored": 0}
is_binding = bool(record.get("is_binding"))
# Try the targeted sections first (legal_analysis / ruling / conclusion).
# If the chunker labeled everything as 'other' (common when a ruling
# uses non-standard headings or the section markers aren't bracketed
# cleanly), fall back to ALL chunks — better to over-include than to
# silently skip a ruling that has reasoning under an unexpected label.
chunks = await db.list_precedent_chunks(
case_law_id, section_types=EXTRACTABLE_SECTIONS,
)
if not chunks:
chunks = await db.list_precedent_chunks(case_law_id)
if chunks:
logger.info(
"halacha_extractor: case_law=%s — no targeted sections, "
"falling back to all %d chunks",
case_law_id, len(chunks),
)
if not chunks:
await db.set_case_law_halacha_status(case_law_id, "completed")
return {"status": "no_chunks", "extracted": 0, "stored": 0}
await db.set_case_law_halacha_status(case_law_id, "processing")
await db.delete_halachot(case_law_id)
citation = record.get("case_number", "")
court = record.get("court", "")
date_str = str(record.get("date") or "")
context = f"מקור: {citation}{court}, {date_str}"
sem = asyncio.Semaphore(CHUNK_CONCURRENCY)
async def _bounded(idx: int, chunk_row: dict) -> tuple[list[dict], bool]:
async with sem:
return await _extract_chunk(
chunk_row["content"], chunk_row["section_type"],
idx, len(chunks), context, is_binding,
)
chunk_results = await asyncio.gather(
*[_bounded(i, c) for i, c in enumerate(chunks)]
)
raw_halachot: list[dict] = []
failed_chunks = 0
for items, ok in chunk_results:
raw_halachot.extend(items)
if not ok:
failed_chunks += 1
# If most chunks failed (rate limit storm, claude_session crash, etc.)
# do NOT touch the DB status — leave it 'processing' so the caller can
# retry without the request falling out of the queue. The caller
# (`process_pending_extractions`) is responsible for either retrying or
# finalising the status as 'failed' after retries are exhausted. This
# is the bug that produced 317/10's silent `no_halachot` after a
# 129-chunk neighbour saturated the API.
failure_rate = failed_chunks / len(chunks) if chunks else 0
if failure_rate >= EXTRACTION_FAILURE_THRESHOLD and not raw_halachot:
logger.error(
"halacha_extractor: case_law=%s extraction_failed — "
"%d/%d chunks failed (rate=%.0f%%), no halachot retrieved. "
"DB status left as 'processing' for caller-level retry.",
case_law_id, failed_chunks, len(chunks), failure_rate * 100,
)
return {
"status": "extraction_failed",
"extracted": 0,
"stored": 0,
"failed_chunks": failed_chunks,
"total_chunks": len(chunks),
}
if not raw_halachot:
await db.set_case_law_halacha_status(case_law_id, "completed")
return {
"status": "no_halachot",
"extracted": 0,
"stored": 0,
"failed_chunks": failed_chunks,
"total_chunks": len(chunks),
}
# Validate against the full text of the precedent for the quote check.
full_text = record.get("full_text") or ""
cleaned: list[dict] = []
for raw in raw_halachot:
coerced = _coerce_halacha(raw, is_binding=is_binding)
if coerced is None:
continue
coerced["quote_verified"] = _verify_quote(
coerced["supporting_quote"], full_text,
)
cleaned.append(coerced)
if not cleaned:
await db.set_case_law_halacha_status(case_law_id, "completed")
return {"status": "no_valid_halachot", "extracted": len(raw_halachot), "stored": 0}
# Embed rule_statement + reasoning_summary so semantic search hits the
# rule directly rather than the surrounding chunk centroid.
embed_inputs = [
f"{h['rule_statement']}{h['reasoning_summary']}".strip("")
for h in cleaned
]
try:
vectors = await embeddings.embed_texts(embed_inputs, input_type="document")
except Exception as e:
logger.error("halacha_extractor: embeddings failed: %s", e)
vectors = [None] * len(cleaned)
for halacha, vec in zip(cleaned, vectors):
halacha["embedding"] = vec
stored = await db.store_halachot(case_law_id, cleaned)
verified = sum(1 for h in cleaned if h["quote_verified"])
await db.set_case_law_halacha_status(case_law_id, "completed")
logger.info(
"halacha_extractor: case_law=%s extracted=%d cleaned=%d verified=%d stored=%d",
case_law_id, len(raw_halachot), len(cleaned), verified, stored,
)
return {
"status": "completed",
"extracted": len(raw_halachot),
"valid": len(cleaned),
"verified": verified,
"stored": stored,
}

View File

@@ -0,0 +1,225 @@
"""Hybrid (text + image) search wrappers.
Layered on top of ``rerank.maybe_rerank``. When ``MULTIMODAL_ENABLED`` is
true the result comes from a weighted merge of:
• text side: cosine on chunks → optional rerank-2 cross-encoder
• image side: cosine on per-page voyage-multimodal-3 embeddings
rerank-2 is a *text* cross-encoder, so image-side rows are NOT passed
through it; they keep their cosine score and merge alongside the
(possibly reranked) text rows. Image-only pages with no overlapping
text chunk are surfaced as ``match_type='image'`` so scanned-only or
visual-heavy content still appears in results.
When ``MULTIMODAL_ENABLED`` is false this module degenerates to plain
``rerank.maybe_rerank`` — callers can wrap unconditionally and let env
control behaviour.
"""
from __future__ import annotations
import logging
from typing import Any
from uuid import UUID
from legal_mcp import config
from legal_mcp.services import db, embeddings, rerank
logger = logging.getLogger(__name__)
async def search_documents_hybrid(
query: str,
query_text_embedding: list[float],
*,
limit: int,
case_id: UUID | None = None,
section_type: str | None = None,
practice_area: str | None = None,
appeal_subtype: str | None = None,
) -> list[dict]:
"""Hybrid wrapper for document-chunk search (search_decisions /
search_case_documents / find_similar_cases)."""
fetch_k = max(limit, config.VOYAGE_RERANK_FETCH_K) if config.MULTIMODAL_ENABLED else limit
text_results = await rerank.maybe_rerank(
query=query,
base_search=lambda **kw: db.search_similar(
query_embedding=query_text_embedding, **kw,
),
limit=fetch_k,
case_id=case_id,
section_type=section_type,
practice_area=practice_area,
appeal_subtype=appeal_subtype,
)
if not config.MULTIMODAL_ENABLED:
return text_results[:limit]
try:
query_img_emb = await embeddings.embed_query_for_multimodal(query)
img_rows = await db.search_document_images_similar(
query_img_emb,
limit=fetch_k,
case_id=case_id,
practice_area=practice_area,
appeal_subtype=appeal_subtype,
)
except Exception as e:
logger.warning("Hybrid: image side failed, returning text only: %s", e)
return text_results[:limit]
merged = _merge(
text_results, img_rows,
id_field="document_id",
text_weight=config.MULTIMODAL_TEXT_WEIGHT,
)
return merged[:limit]
async def search_precedent_library_hybrid(
query: str,
query_text_embedding: list[float],
*,
limit: int,
practice_area: str = "",
court: str = "",
precedent_level: str = "",
appeal_subtype: str = "",
is_binding: bool | None = None,
subject_tag: str = "",
include_halachot: bool = True,
source_kind: str = "external_upload",
district: str = "",
chair_name: str = "",
) -> list[dict]:
"""Hybrid wrapper for precedent-library search.
source_kind='external_upload' → court rulings (default)
source_kind='internal_committee' → appeals-committee decisions
"""
fetch_k = max(limit, config.VOYAGE_RERANK_FETCH_K) if config.MULTIMODAL_ENABLED else limit
async def _base(limit: int) -> list[dict]:
return await db.search_precedent_library_semantic(
query_embedding=query_text_embedding,
practice_area=practice_area,
court=court,
precedent_level=precedent_level,
appeal_subtype=appeal_subtype,
is_binding=is_binding,
subject_tag=subject_tag,
limit=limit,
include_halachot=include_halachot,
source_kind=source_kind,
district=district,
chair_name=chair_name,
)
text_results = await rerank.maybe_rerank(
query=query, base_search=_base, limit=fetch_k,
)
if not config.MULTIMODAL_ENABLED:
return text_results[:limit]
try:
query_img_emb = await embeddings.embed_query_for_multimodal(query)
img_rows = await db.search_precedent_images_similar(
query_img_emb,
limit=fetch_k,
practice_area=practice_area,
court=court,
precedent_level=precedent_level,
appeal_subtype=appeal_subtype,
is_binding=is_binding,
)
except Exception as e:
logger.warning("Hybrid: image side failed, returning text only: %s", e)
return text_results[:limit]
merged = _merge(
text_results, img_rows,
id_field="case_law_id",
text_weight=config.MULTIMODAL_TEXT_WEIGHT,
)
return merged[:limit]
def _merge(
text_rows: list[dict],
img_rows: list[dict],
id_field: str,
text_weight: float,
) -> list[dict]:
"""Reciprocal Rank Fusion of text + image rows.
Why RRF: voyage-3 cosine scores (~0.4-0.5) and voyage-multimodal-3
scores (~0.2-0.25) live on different scales — a direct weighted
sum lets text always dominate. RRF combines by *rank* in each list,
making the merge robust to score-scale differences.
Per item::
rrf_score = text_weight / (k + text_rank)
+ image_weight / (k + image_rank)
A row that appears in only one list contributes that list's term
only. Rows joined at ``(id_field, page_number)`` get both terms —
surfaced as ``match_type='text+image'`` with the thumbnail attached.
Halachot in precedent rows have no page_number; they remain
text-only under RRF (the case-level image boost is dropped — RRF
works on rank, not raw scores).
"""
from legal_mcp import config as _cfg
img_weight = 1.0 - text_weight
k = _cfg.MULTIMODAL_RRF_K
# Index image rows by their join key for boost detection.
img_rank_by_key: dict[tuple, int] = {}
img_row_by_key: dict[tuple, dict] = {}
for rank, r in enumerate(img_rows, 1):
key = (str(r[id_field]), r.get("page_number"))
img_rank_by_key[key] = rank
img_row_by_key[key] = r
seen_image_keys: set = set()
merged: list[dict] = []
for rank, r in enumerate(text_rows, 1):
rid = str(r[id_field])
page = r.get("page_number")
key = (rid, page) if page is not None else None
img_rank = img_rank_by_key.get(key) if key else None
text_term = text_weight / (k + rank)
image_term = img_weight / (k + img_rank) if img_rank else 0.0
d = dict(r)
d["text_score"] = float(r.get("score", 0.0))
d["text_rank"] = rank
if img_rank:
img_hit = img_row_by_key[key]
d["image_score"] = float(img_hit.get("score", 0.0))
d["image_rank"] = img_rank
d["image_thumbnail_path"] = img_hit.get("image_thumbnail_path")
d["match_type"] = "text+image"
seen_image_keys.add(key)
else:
d["image_score"] = 0.0
d["match_type"] = "text"
d["score"] = text_term + image_term
merged.append(d)
for rank, r in enumerate(img_rows, 1):
key = (str(r[id_field]), r.get("page_number"))
if key in seen_image_keys:
continue
d = dict(r)
d["text_score"] = 0.0
d["image_score"] = float(r.get("score", 0.0))
d["image_rank"] = rank
d["score"] = img_weight / (k + rank)
d["match_type"] = "image"
d["content"] = ""
d["section_type"] = "image"
merged.append(d)
merged.sort(key=lambda x: -float(x["score"]))
return merged

View File

@@ -0,0 +1,376 @@
"""Orchestrator for the Internal Committee Decisions corpus.
Ingest pipeline:
text/file → INSERT case_law (source_kind='internal_committee')
→ chunk → embed → store precedent_chunks
→ queue halacha extraction
Migration helpers:
migrate_from_style_corpus() — re-index style_corpus entries as searchable
migrate_from_external_corpus() — reclassify external appeals-committee rows
All ועדות ערר (any district) belong here.
Judicial decisions (Supreme Court, Administrative Court) stay in external_upload.
"""
from __future__ import annotations
import logging
import re
import shutil
from datetime import date
from pathlib import Path
from uuid import UUID, uuid4
from legal_mcp import config
from legal_mcp.services import chunker, db, embeddings, extractor
logger = logging.getLogger(__name__)
INTERNAL_DECISIONS_DIR = Path(config.DATA_DIR) / "internal-decisions"
_VALID_DISTRICTS = {"", "ירושלים", "מרכז", "תל אביב", "צפון", "דרום", "ארצי"}
_COURT_TO_DISTRICT = [
("ירושלים", "ירושלים"),
("תל אביב", "תל אביב"),
('ת"א', "תל אביב"),
("מרכז", "מרכז"),
("חיפה", "צפון"),
("צפון", "צפון"),
("דרום", "דרום"),
("ארצי", "ארצי"),
("ארצית", "ארצי"),
]
def _coerce_date(value) -> date | None:
if value is None or value == "":
return None
if isinstance(value, date):
return value
if isinstance(value, str):
try:
return date.fromisoformat(value[:10])
except ValueError:
return None
return None
def _safe_filename(name: str) -> str:
base = Path(name).name
return re.sub(r"[^\w.\-+א-ת ]", "_", base) or f"internal-{uuid4().hex[:8]}"
def _district_from_court(court: str) -> str:
for keyword, district in _COURT_TO_DISTRICT:
if keyword in court:
return district
return ""
async def ingest_internal_decision(
*,
case_number: str,
case_name: str = "",
court: str = "",
decision_date=None,
chair_name: str = "",
district: str = "",
practice_area: str = "",
appeal_subtype: str = "",
subject_tags: list[str] | None = None,
summary: str = "",
is_binding: bool = True,
file_path: str | Path | None = None,
text: str | None = None,
document_id: UUID | None = None,
queue_halachot: bool = True,
) -> dict:
"""Ingest an appeals-committee decision into the internal corpus.
Either file_path or text must be provided.
If district is empty, it is inferred from court.
Returns: {"status": "completed", "case_law_id": "...", "chunks": N}
"""
if not file_path and not text:
raise ValueError("either file_path or text is required")
if not case_number.strip():
raise ValueError("case_number is required")
resolved_district = district.strip() or _district_from_court(court)
if file_path:
src = Path(file_path)
if not src.is_file():
raise FileNotFoundError(f"file not found: {src}")
dest_dir = INTERNAL_DECISIONS_DIR / (resolved_district or "other")
dest_dir.mkdir(parents=True, exist_ok=True)
staged = dest_dir / f"{uuid4().hex[:8]}_{_safe_filename(src.name)}"
shutil.copy2(src, staged)
raw_text, page_count, page_offsets = await extractor.extract_text(str(staged))
raw_text = extractor.strip_nevo_preamble(raw_text or "").strip()
if not raw_text:
raise ValueError("no extractable text in file")
else:
raw_text = (text or "").strip()
if not raw_text:
raise ValueError("text is empty")
page_count = 0
page_offsets = None
record = await db.create_internal_committee_decision(
case_number=case_number.strip(),
case_name=(case_name.strip() or case_number.strip()),
full_text=raw_text,
court=court.strip(),
decision_date=_coerce_date(decision_date),
chair_name=chair_name.strip(),
district=resolved_district,
practice_area=practice_area,
appeal_subtype=appeal_subtype.strip(),
subject_tags=list(subject_tags or []),
summary=summary.strip(),
is_binding=is_binding,
document_id=document_id,
)
case_law_id = UUID(str(record["id"]))
try:
chunks = chunker.chunk_document(raw_text, page_offsets=page_offsets)
if not chunks:
await db.set_case_law_extraction_status(case_law_id, "completed")
await db.set_case_law_halacha_status(case_law_id, "completed")
return {"status": "completed", "case_law_id": str(case_law_id), "chunks": 0}
chunk_texts = [c.content for c in chunks]
chunk_vectors = await embeddings.embed_texts(chunk_texts, input_type="document")
chunk_dicts = [
{
"chunk_index": c.chunk_index,
"content": c.content,
"section_type": c.section_type,
"page_number": c.page_number,
"embedding": v,
}
for c, v in zip(chunks, chunk_vectors)
]
stored = await db.store_precedent_chunks(case_law_id, chunk_dicts)
await db.set_case_law_extraction_status(case_law_id, "completed")
await db.set_case_law_halacha_status(case_law_id, "pending")
if queue_halachot:
await db.request_halacha_extraction(case_law_id)
return {
"status": "completed",
"case_law_id": str(case_law_id),
"chunks": stored,
"halachot_pending": True,
}
except Exception:
logger.exception("ingest_internal_decision failed for %s", case_number)
await db.set_case_law_extraction_status(case_law_id, "failed")
raise
async def migrate_from_style_corpus(dry_run: bool = False, queue_halachot: bool = True) -> dict:
"""Re-index all style_corpus entries as searchable internal committee decisions.
Does NOT delete style_corpus rows — they remain for style analysis.
Skips entries that already exist in case_law as internal_committee.
"""
pool = await db.get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""SELECT decision_number, decision_date, full_text,
practice_area, appeal_subtype, subject_categories
FROM style_corpus
ORDER BY decision_date NULLS LAST"""
)
results = {"total": len(rows), "ingested": 0, "skipped": 0, "failed": 0, "dry_run": dry_run}
for row in rows:
case_number = (row["decision_number"] or "").strip()
if not case_number:
results["skipped"] += 1
continue
if not dry_run:
existing = await pool.fetchval(
"SELECT id FROM case_law WHERE case_number = $1 AND source_kind = 'internal_committee'",
case_number,
)
if existing:
results["skipped"] += 1
continue
if dry_run:
results["ingested"] += 1
continue
try:
subject_tags = list(row["subject_categories"] or [])
raw_pa = row["practice_area"] or ""
subtype = row["appeal_subtype"] or ""
# style_corpus stores 'appeals_committee' (source_type) instead of practice_area
_subtype_to_pa = {
"building_permit": "rishuy_uvniya",
"betterment_levy": "betterment_levy",
"compensation_197": "compensation_197",
}
practice_area = raw_pa if raw_pa in ("rishuy_uvniya", "betterment_levy", "compensation_197") \
else _subtype_to_pa.get(subtype, "")
await ingest_internal_decision(
case_number=case_number,
court="ועדת הערר לתכנון ובנייה — מחוז ירושלים",
decision_date=row["decision_date"],
chair_name="דפנה תמיר",
district="ירושלים",
practice_area=practice_area,
appeal_subtype=subtype,
subject_tags=subject_tags,
text=row["full_text"],
queue_halachot=queue_halachot,
)
results["ingested"] += 1
logger.info("Migrated style_corpus entry: %s", case_number)
except Exception as e:
logger.error("Failed to migrate %s: %s", case_number, e)
results["failed"] += 1
return results
async def migrate_from_external_corpus(dry_run: bool = False) -> dict:
"""Reclassify external appeals-committee decisions to source_kind='internal_committee'.
Identifies rows by source_type='appeals_committee' and updates source_kind + district.
Existing precedent_chunks remain — no re-embedding needed.
"""
pool = await db.get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""SELECT id, case_number, court
FROM case_law
WHERE source_kind = 'external_upload'
AND source_type = 'appeals_committee'"""
)
results = {"total": len(rows), "updated": 0, "dry_run": dry_run}
if dry_run:
results["updated"] = len(rows)
results["preview"] = [
{"case_number": r["case_number"], "court": r["court"], "district": _district_from_court(r["court"] or "")}
for r in rows
]
return results
async with pool.acquire() as conn:
for row in rows:
district = _district_from_court(row["court"] or "")
await conn.execute(
"""UPDATE case_law
SET source_kind = 'internal_committee',
district = CASE WHEN $2 <> '' THEN $2 ELSE district END
WHERE id = $1""",
row["id"], district,
)
results["updated"] = len(rows)
logger.info("Migrated %d external appeals-committee rows to internal_committee", len(rows))
return results
async def enrich_migrated_entries(dry_run: bool = False) -> dict:
"""One-time enrichment: run metadata extraction + halacha extraction on all
internal_committee entries that are waiting (halacha_status='pending',
metadata never requested).
Metadata extraction will:
- Fix case_number from the decision header text
- Fill case_name from the parties line
- Fill date if missing
Halacha extraction queues the LLM-based halacha extraction job.
"""
from legal_mcp.services import precedent_metadata_extractor, db as _db
pool = await _db.get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""SELECT id, case_number
FROM case_law
WHERE source_kind = 'internal_committee'
AND halacha_extraction_status = 'pending'
AND metadata_extraction_requested_at IS NULL
ORDER BY created_at"""
)
results = {
"total": len(rows),
"metadata_updated": 0,
"halachot_queued": 0,
"failed": 0,
"dry_run": dry_run,
}
if dry_run:
return results
for row in rows:
case_law_id = row["id"]
try:
meta = await precedent_metadata_extractor.extract_and_apply(
case_law_id, overwrite_case_number=True
)
if meta.get("status") in ("completed", "no_changes"):
results["metadata_updated"] += 1
logger.info(
"enrich_migrated: %s → fields=%s",
row["case_number"], meta.get("fields"),
)
except Exception as e:
logger.error("enrich_migrated metadata failed for %s: %s", row["case_number"], e)
results["failed"] += 1
continue
try:
await _db.request_halacha_extraction(case_law_id)
results["halachot_queued"] += 1
except Exception as e:
logger.error("enrich_migrated halacha queue failed for %s: %s", row["case_number"], e)
return results
async def search_internal(
query: str,
*,
practice_area: str = "",
appeal_subtype: str = "",
district: str = "",
chair_name: str = "",
limit: int = 10,
include_halachot: bool = True,
) -> list[dict]:
"""Semantic search over internal committee decisions."""
from legal_mcp.services import hybrid_search
if not query.strip():
return []
query_vec = await embeddings.embed_query(query)
return await hybrid_search.search_precedent_library_hybrid(
query=query,
query_text_embedding=query_vec,
limit=limit,
practice_area=practice_area,
appeal_subtype=appeal_subtype,
include_halachot=include_halachot,
source_kind="internal_committee",
district=district,
chair_name=chair_name,
)

View File

@@ -90,10 +90,10 @@ async def analyze_changes(draft_text: str, final_text: str) -> dict:
--- גרסה סופית --- --- גרסה סופית ---
{final_sample} {final_sample}
""" """
result = claude_session.query_json(prompt, timeout=120) result = await claude_session.query_json(prompt)
if result is None: if result is None:
logger.warning("Failed to parse lessons response") logger.warning("Failed to parse lessons response")
return {"changes": [], "new_expressions": [], "overall_assessment": raw[:200]} return {"changes": [], "new_expressions": [], "overall_assessment": ""}
return result return result

View File

@@ -0,0 +1,552 @@
"""Orchestrator for the External Precedent Library.
Ingest pipeline (one upload):
file → extract_text → proofread → INSERT case_law (source_kind='external_upload')
→ chunk → embed → store precedent_chunks
→ halacha_extractor.extract → embed halachot → store halachot
→ set extraction_status='completed'
Progress is reported via a caller-supplied async callback so the
web layer can pipe updates into the existing Redis ProgressStore /
SSE plumbing without this module knowing about Redis.
"""
from __future__ import annotations
import asyncio
import logging
import re
import shutil
from datetime import date
from pathlib import Path
from typing import Awaitable, Callable
from uuid import UUID, uuid4
from legal_mcp import config
from legal_mcp.services import chunker, db, embeddings, extractor, hybrid_search, rerank # noqa: F401
# Note: halacha_extractor and precedent_metadata_extractor are NOT imported
# at module load. They are imported lazily inside the dedicated re-extract
# entry points so that `ingest_precedent` (called from the FastAPI container,
# where `claude` CLI is unavailable) cannot accidentally pull them in. See
# the architectural rule in services/claude_session.py.
logger = logging.getLogger(__name__)
ProgressCb = Callable[[str, int, str], Awaitable[None]]
PRECEDENT_LIBRARY_DIR = Path(config.DATA_DIR) / "precedent-library"
_VALID_PRACTICE_AREAS = {"", "rishuy_uvniya", "betterment_levy", "compensation_197"}
_VALID_SOURCE_TYPES = {"", "court_ruling", "appeals_committee"}
_VALID_PRECEDENT_LEVELS = {
"", "עליון", "מנהלי", "ועדת_ערר_ארצית", "ועדת_ערר_מחוזית",
"supreme", "administrative", "national_appeals_committee", "district_appeals_committee",
}
async def _noop_progress(_status: str, _percent: int, _msg: str) -> None:
return None
def _safe_filename(name: str) -> str:
"""Strip path separators and unsafe chars from a user-provided name."""
base = Path(name).name
return re.sub(r"[^\w.\-+א-ת ]", "_", base) or f"upload-{uuid4().hex[:8]}"
def _stage_file(src_path: Path, source_type: str) -> Path:
"""Copy the uploaded file into data/precedent-library/<source_type>/.
Returns the destination path. Source file is not deleted (caller decides).
"""
sub = source_type if source_type in {"court_ruling", "appeals_committee"} else "other"
dest_dir = PRECEDENT_LIBRARY_DIR / sub
dest_dir.mkdir(parents=True, exist_ok=True)
safe_name = _safe_filename(src_path.name)
dest = dest_dir / f"{uuid4().hex[:8]}_{safe_name}"
shutil.copy2(src_path, dest)
return dest
def _coerce_date(value) -> date | None:
if value is None or value == "":
return None
if isinstance(value, date):
return value
if isinstance(value, str):
try:
return date.fromisoformat(value[:10])
except ValueError:
return None
return None
async def ingest_precedent(
*,
file_path: str | Path,
citation: str,
case_name: str = "",
court: str = "",
decision_date=None,
source_type: str = "",
precedent_level: str = "",
practice_area: str = "",
appeal_subtype: str = "",
subject_tags: list[str] | None = None,
is_binding: bool = True,
headnote: str = "",
summary: str = "",
document_id: UUID | None = None,
progress: ProgressCb | None = None,
) -> dict:
"""Ingest a single uploaded precedent through the full pipeline.
Required: file_path + citation. Everything else has a sensible default.
Returns:
``{"status": "...", "case_law_id": "...", "chunks": N, "halachot": M}``
"""
progress = progress or _noop_progress
src = Path(file_path)
if not src.is_file():
raise FileNotFoundError(f"file not found: {src}")
if not citation.strip():
raise ValueError("citation is required")
if practice_area not in _VALID_PRACTICE_AREAS:
raise ValueError(f"invalid practice_area: {practice_area!r}")
if source_type not in _VALID_SOURCE_TYPES:
raise ValueError(f"invalid source_type: {source_type!r}")
await progress("staging", 5, "מעתיק את הקובץ לאחסון")
staged = _stage_file(src, source_type)
await progress("extracting", 15, "מחלץ טקסט מהקובץ")
try:
text, page_count, page_offsets = await extractor.extract_text(str(staged))
except Exception as e:
await progress("failed", 100, f"כשל בחילוץ טקסט: {e}")
raise
text = (text or "").strip()
if not text:
await progress("failed", 100, "לא נמצא טקסט בקובץ")
raise ValueError("no extractable text in file")
# Strip any Nevo preamble that might wrap court rulings downloaded from Nevo.
text = extractor.strip_nevo_preamble(text)
await progress("storing_metadata", 25, "שומר את הפסיקה במסד הנתונים")
record = await db.create_external_case_law(
case_number=citation.strip(),
case_name=case_name.strip() or citation.strip(),
full_text=text,
court=court.strip(),
decision_date=_coerce_date(decision_date),
practice_area=practice_area,
appeal_subtype=appeal_subtype.strip(),
subject_tags=list(subject_tags or []),
summary=summary.strip(),
headnote=headnote.strip(),
source_type=source_type,
precedent_level=precedent_level,
is_binding=is_binding,
document_id=document_id,
)
case_law_id = UUID(str(record["id"]))
try:
await progress("chunking", 40, f"מחלק את הטקסט ל-chunks ({page_count} עמ')")
chunks = chunker.chunk_document(text, page_offsets=page_offsets)
if not chunks:
await db.set_case_law_extraction_status(case_law_id, "completed")
await db.set_case_law_halacha_status(case_law_id, "completed")
await progress("completed", 100, "אין טקסט לעיבוד")
return {
"status": "completed",
"case_law_id": str(case_law_id),
"chunks": 0,
"halachot": 0,
}
await progress("embedding", 55, f"מייצר embeddings ל-{len(chunks)} chunks")
chunk_texts = [c.content for c in chunks]
chunk_vectors = await embeddings.embed_texts(chunk_texts, input_type="document")
chunk_dicts = [
{
"chunk_index": c.chunk_index,
"content": c.content,
"section_type": c.section_type,
"page_number": c.page_number,
"embedding": v,
}
for c, v in zip(chunks, chunk_vectors)
]
stored_chunks = await db.store_precedent_chunks(case_law_id, chunk_dicts)
# Multimodal page-image embeddings (V9). Gated by feature flag.
# Non-fatal: text path already succeeded. Only PDFs.
if config.MULTIMODAL_ENABLED and page_count > 0 and staged.suffix.lower() == ".pdf":
try:
await progress(
"embedding_images", 70,
f"מטמיע {page_count} עמודי תמונה (multimodal)",
)
await _embed_precedent_pages(case_law_id, staged, page_count)
except Exception as e:
logger.warning("Precedent multimodal embedding failed (non-fatal): %s", e)
# Pipeline split: the container does the non-LLM half (extract +
# chunk + embed + store). LLM-driven extraction (metadata, halachot)
# runs separately via the MCP tool `precedent_process_pending` from
# local Claude Code, where `claude` CLI is available.
#
# We auto-queue both extractions so the chair doesn't need to click
# any button — the moment they (or me) run `precedent_process_pending`
# in chat, both kinds get processed.
await db.set_case_law_extraction_status(case_law_id, "completed")
await db.set_case_law_halacha_status(case_law_id, "pending")
await db.request_metadata_extraction(case_law_id)
await db.request_halacha_extraction(case_law_id)
await progress(
"completed",
100,
f"הוכנס לספרייה: {stored_chunks} chunks. "
f"חילוץ הלכות ומטא-דאטה ממתינים בתור — "
f"להפעיל מ-Claude Code: precedent_process_pending.",
)
return {
"status": "completed",
"case_law_id": str(case_law_id),
"chunks": stored_chunks,
"halachot": 0,
"halachot_pending": True,
"metadata_filled": [],
"pages": page_count,
}
except Exception as e:
logger.exception("precedent_library.ingest_precedent failed: %s", e)
await db.set_case_law_extraction_status(case_law_id, "failed")
await progress("failed", 100, f"כשל בעיבוד: {e}")
raise
async def reextract_halachot(
case_law_id: UUID | str,
progress: ProgressCb | None = None,
) -> dict:
"""Re-run the halacha extractor on an existing precedent. Idempotent.
**MCP-tool-only path.** This function calls into ``halacha_extractor``,
which calls ``claude_session`` — the local CLI is required. Invoking
this from the FastAPI container will raise ``Claude CLI not found``.
See the architectural rule in ``services/claude_session.py``.
"""
from legal_mcp.services import halacha_extractor
progress = progress or _noop_progress
if isinstance(case_law_id, str):
case_law_id = UUID(case_law_id)
record = await db.get_case_law(case_law_id)
if not record:
raise ValueError("precedent not found")
# Was restricted to source_kind='external_upload'; opened 2026-05-06 so
# internal_committee rows can also be re-extracted when ingest produced
# bad data. See note in db.request_metadata_extraction.
await progress("extracting_halachot", 50, "מחלץ הלכות מחדש")
result = await halacha_extractor.extract(case_law_id)
# Clear the queue timestamp on completion so the UI badge / worker queue
# don't keep showing this row. The queue worker (process_pending_extractions)
# already does this; mirror it here so per-record extraction drains too.
if result.get("status") in ("completed", "no_halachot"):
await db.clear_extraction_request(case_law_id, kind="halacha")
await progress(
"completed",
100,
f"הופקו {result.get('stored', 0)} הלכות (ממתינות לאישור)",
)
return result
# Wait this many seconds between precedents in a multi-precedent run.
# Anthropic rate-limits across the org, so back-to-back extractions of large
# rulings (e.g. 129 chunks for one, then 79 for another) can spill the second
# precedent into a 429 storm. Observed 2026-05-03: 1110/20 succeeded with 9
# halachot, 317/10 immediately after returned silent no_halachot.
INTER_PRECEDENT_COOLDOWN_SEC = 30
# How many times to retry a precedent that came back as 'extraction_failed'
# (i.e. >50% chunks crashed). Each retry uses a longer cooldown.
PRECEDENT_RETRY_ATTEMPTS = 1
PRECEDENT_RETRY_COOLDOWN_SEC = 60
async def process_pending_extractions(kind: str = "metadata", limit: int = 20) -> dict:
"""Drain the extraction queue (UI-button-stamped requests).
The button in the web UI cannot run claude_session itself (it lives in
the container, no CLI). It just stamps ``metadata_extraction_requested_at``
on the row. This function — called from local Claude Code via the MCP
tool — picks each stamped row up, runs the extractor, and clears the
timestamp.
Sequencing: precedents are processed serially (never in parallel) and
each is followed by a short cooldown so the Anthropic rate-limit
counter has time to drain before the next big precedent starts. If
halacha extraction comes back as ``extraction_failed`` we retry the
same precedent once with a longer cooldown — matching the empirical
pattern where the second precedent in a back-to-back run gets
rate-limited but recovers after a brief pause.
Args:
kind: 'metadata' or 'halacha'.
limit: max rows to process this run.
"""
from legal_mcp.services import halacha_extractor, precedent_metadata_extractor
if kind not in {"metadata", "halacha"}:
raise ValueError("kind must be 'metadata' or 'halacha'")
pending = await db.list_pending_extraction_requests(kind=kind, limit=limit)
if not pending:
return {"status": "no_pending", "kind": kind, "processed": 0, "results": []}
async def _run_once(cid: UUID) -> dict:
if kind == "metadata":
return await precedent_metadata_extractor.extract_and_apply(cid)
return await halacha_extractor.extract(cid)
results: list[dict] = []
processed = 0
for idx, row in enumerate(pending):
if idx > 0:
await asyncio.sleep(INTER_PRECEDENT_COOLDOWN_SEC)
cid = UUID(str(row["id"]))
attempts = 0
result: dict = {}
try:
result = await _run_once(cid)
# Retry only on systematic extraction failure (rate-limit storm).
# Don't retry on 'no_halachot' — that means Claude looked and
# genuinely found nothing.
while (
result.get("status") == "extraction_failed"
and attempts < PRECEDENT_RETRY_ATTEMPTS
):
attempts += 1
logger.warning(
"process_pending_extractions: %s returned extraction_failed "
"(%d/%d chunks crashed), retry %d/%d after %ds cooldown",
cid,
result.get("failed_chunks", 0),
result.get("total_chunks", 0),
attempts, PRECEDENT_RETRY_ATTEMPTS,
PRECEDENT_RETRY_COOLDOWN_SEC,
)
await asyncio.sleep(PRECEDENT_RETRY_COOLDOWN_SEC)
result = await _run_once(cid)
# Finalise: success or terminal failure both clear the request
# so the queue moves on. (Use 'failed' DB state for terminal
# extraction_failed so the UI shows the warning chip.)
if kind == "halacha" and result.get("status") == "extraction_failed":
await db.set_case_law_halacha_status(cid, "failed")
await db.clear_extraction_request(cid, kind=kind)
processed += 1
results.append({
"case_law_id": str(cid),
"case_number": row.get("case_number", ""),
"status": result.get("status", "unknown"),
"fields": result.get("fields", []),
"stored": result.get("stored", 0),
"retry_attempts": attempts,
})
except Exception as e:
logger.exception("process_pending_extractions failed for %s: %s", cid, e)
results.append({
"case_law_id": str(cid),
"case_number": row.get("case_number", ""),
"status": "failed",
"error": str(e),
"retry_attempts": attempts,
})
# Don't clear the request — it stays for the next run.
return {
"status": "completed",
"kind": kind,
"processed": processed,
"total_pending": len(pending),
"results": results,
}
async def reextract_metadata(
case_law_id: UUID | str,
progress: ProgressCb | None = None,
) -> dict:
"""Re-run metadata extraction on an existing precedent.
Only fills empty fields (subject_tags, summary, headnote, key_quote,
appeal_subtype, and case_name when it equals the citation). User
values are preserved.
**MCP-tool-only path** — same constraint as :func:`reextract_halachot`.
"""
from legal_mcp.services import precedent_metadata_extractor
progress = progress or _noop_progress
if isinstance(case_law_id, str):
case_law_id = UUID(case_law_id)
record = await db.get_case_law(case_law_id)
if not record:
raise ValueError("precedent not found")
# See note in db.request_metadata_extraction — opened to all source kinds.
await progress("extracting_metadata", 40, "מחלץ מטא-דאטה (תקציר, תגיות)")
result = await precedent_metadata_extractor.extract_and_apply(case_law_id)
# Clear the queue timestamp so the UI / worker stop showing this row.
# See note in reextract_halachot.
if result.get("status") in ("completed", "no_changes"):
await db.clear_extraction_request(case_law_id, kind="metadata")
fields = result.get("fields") or []
msg = (
f"מולאו {len(fields)} שדות: {', '.join(fields)}"
if fields
else "לא נמצא מה למלא (כל השדות מאוכלסים או לא ניתן לחלץ)"
)
await progress("completed", 100, msg)
return result
async def delete_precedent(case_law_id: UUID | str) -> bool:
"""Delete a precedent and cascade chunks + halachot."""
if isinstance(case_law_id, str):
case_law_id = UUID(case_law_id)
return await db.delete_case_law(case_law_id)
async def get_precedent(case_law_id: UUID | str) -> dict | None:
"""Get a precedent with its halachot attached."""
if isinstance(case_law_id, str):
case_law_id = UUID(case_law_id)
record = await db.get_case_law(case_law_id)
if not record:
return None
record["halachot"] = await db.list_halachot(case_law_id=case_law_id, limit=500)
return record
async def list_precedents(
practice_area: str = "",
court: str = "",
precedent_level: str = "",
source_type: str = "",
search: str = "",
limit: int = 100,
offset: int = 0,
) -> list[dict]:
return await db.list_external_case_law(
practice_area=practice_area,
court=court,
precedent_level=precedent_level,
source_type=source_type,
search=search,
limit=limit,
offset=offset,
)
async def search_library(
query: str,
practice_area: str = "",
court: str = "",
precedent_level: str = "",
appeal_subtype: str = "",
is_binding: bool | None = None,
subject_tag: str = "",
limit: int = 10,
include_halachot: bool = True,
) -> list[dict]:
"""Semantic search merging halachot (rule-level) and chunks (passage-level).
Only ``approved`` / ``published`` halachot are returned, per chair-review
policy. Chunks are returned regardless of halacha review status.
When ``VOYAGE_RERANK_ENABLED`` is set, results are passed through
voyage rerank-2 (cross-encoder). The +0.05 halacha boost from
``search_precedent_library_semantic`` is preserved before rerank
but the rerank scores ultimately decide the order.
"""
if not query.strip():
return []
query_vec = await embeddings.embed_query(query)
return await hybrid_search.search_precedent_library_hybrid(
query=query,
query_text_embedding=query_vec,
limit=limit,
practice_area=practice_area,
court=court,
precedent_level=precedent_level,
appeal_subtype=appeal_subtype,
is_binding=is_binding,
subject_tag=subject_tag,
include_halachot=include_halachot,
)
async def _embed_precedent_pages(
case_law_id: UUID,
pdf_path: Path,
page_count: int,
) -> dict:
"""Render precedent PDF pages → embed via voyage-multimodal → store.
Thumbnails go to
``data/precedent-library/thumbnails/{case_law_id}/p{N:03d}.jpg``.
"""
thumb_dir = PRECEDENT_LIBRARY_DIR / "thumbnails" / str(case_law_id)
rendered = await asyncio.to_thread(
extractor.render_pages_for_multimodal,
pdf_path,
config.MULTIMODAL_DPI,
config.MULTIMODAL_THUMB_DPI,
thumb_dir,
)
images = [pil for pil, _ in rendered]
thumbs = [t for _, t in rendered]
img_embs = await embeddings.embed_images(images)
page_records = []
for i, (emb, thumb) in enumerate(zip(img_embs, thumbs)):
rel_thumb = None
if thumb is not None:
try:
rel_thumb = str(thumb.relative_to(config.DATA_DIR))
except ValueError:
rel_thumb = str(thumb)
page_records.append({
"page_number": i + 1,
"embedding": emb,
"image_thumbnail_path": rel_thumb,
})
stored = await db.store_precedent_image_embeddings(
case_law_id, page_records, model_name=config.MULTIMODAL_MODEL,
)
logger.info(
"Multimodal: stored %d page-image embeddings for case_law %s",
stored, case_law_id,
)
return {"pages_embedded": stored}

View File

@@ -0,0 +1,295 @@
"""Auto-extract precedent metadata from a freshly-uploaded ruling.
Runs after chunking. Reads the precedent's full_text and asks Claude to
fill in the metadata fields that an upload form usually leaves empty:
short case_name, summary, headnote, key_quote, subject_tags,
appeal_subtype, decision_date, precedent_level, court.
Caller policy: only empty user-supplied fields are filled. Anything the
chair already typed in the upload form is preserved. This is enforced
in ``apply_to_record``.
"""
from __future__ import annotations
import logging
from datetime import date as date_type
from uuid import UUID
from legal_mcp.config import parse_llm_json
from legal_mcp.services import claude_session, db
logger = logging.getLogger(__name__)
# The prompt is short — we only need the first 12K chars of the ruling
# (header + opening of discussion is enough for naming + summary). For
# subject tags we sample the discussion section too.
_HEAD_CHARS = 12_000
_TAIL_CHARS = 6_000
# Note: this template is concatenated with f-strings at call-time rather
# than using .format(), because the JSON example below contains '{' / '}'
# which str.format would interpret as placeholders and crash with
# KeyError on the field names.
METADATA_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. קרא את פסק הדין/ההחלטה הבא וחלץ ממנו מטא-דאטה לקטלוג הקורפוס.
המטרה: למלא שדות בטופס העלאה שהמשתמש הזין באופן חלקי. **אל תמציא** — אם המידע לא מופיע בטקסט, השאר ריק (מחרוזת ריקה / מערך ריק).
## פלט נדרש
החזר JSON אחד (object — לא array) בפורמט הבא, ללא markdown וללא הסברים:
{
"case_name_short": "שם קצר ל-3-6 מילים (למשל 'אהרון ברק' או 'ב. קרן-נכסים'). אל תכלול מספר תיק. שם המבקש/העורר העיקרי. אם זו החלטה מאוחדת — שם הצד המוביל.",
"appeal_subtype": "תת-סוג ספציפי בתוך תחום המשפט (למשל 'תכנית רחביה', 'מימוש במכר', 'תמ\\"א 38', 'שימוש חורג', 'סופיות ההחלטה'). מילה אחת או צירוף קצר.",
"summary": "תקציר עניני 2-3 משפטים: מה הייתה השאלה, מה הוכרע. בלי שיפוט.",
"headnote": "headnote בסגנון נבו: 1-2 משפטים שמסכמים את העיקרון שנקבע/יושם בפסק. למשל 'תכנית רחביה — היטל השבחה במימוש במכר — אין לחייב כשהזכויות צפות'.",
"key_quote": "ציטוט מילולי בודד, 30-100 מילים, שמייצג את לב הפסק. חייב להופיע מילה במילה בטקסט. אם אין ציטוט מתאים — מחרוזת ריקה.",
"subject_tags": ["תגיות", "נושא", "בעברית"],
"decision_date_iso": "YYYY-MM-DD — תאריך מתן ההחלטה כפי שמופיע בטקסט (בכותרת או בחתימה הסופית). אם לא ניתן לזהות במדויק — מחרוזת ריקה.",
"precedent_level": "אחד מ-4: 'עליון' / 'מנהלי' / 'ועדת_ערר_ארצית' / 'ועדת_ערר_מחוזית'. בחר לפי הערכאה שמסומנת בכותרת הפסק. אם לא ברור — מחרוזת ריקה.",
"source_type": "אחד מ-2: 'court_ruling' (פסק דין של בית משפט — עליון/מנהלי) / 'appeals_committee' (החלטה של ועדת ערר). אם לא ברור — מחרוזת ריקה.",
"court": "שם הערכאה כפי שהוא מופיע בכותרת (למשל 'בית המשפט העליון', 'בית המשפט המחוזי בירושלים בשבתו כבית משפט לעניינים מנהליים', 'ועדת הערר לתכנון ובניה פיצויים והיטלי השבחה — מחוז ירושלים'). מחרוזת ריקה אם לא ניתן לזהות.",
"case_number_clean": "מספר הערר/תיק כפי שמופיע בכותרת — רק הספרות והאלכסון, למשל '1062/24' או '8031/21'. ללא המילה 'ערר', ללא שם הצדדים, ללא סוגריים. אם יש כמה עררים מאוחדים — הרשום הראשון. מחרוזת ריקה אם לא ניתן לזהות."
}
## כללי איכות
1. **case_name_short** — שם בולט וקצר. בלי 'נ\\'' / 'נגד' / מספרי תיק.
2. **appeal_subtype** — אופציונלי. אם הסוגיה רחבה ולא מסווגת — השאר ריק.
3. **summary** — תיאור ניטרלי, גוף שלישי.
4. **headnote** — לא מצטטים, מסכמים. סגנון נבו: ביטוי קצר אחד.
5. **key_quote** — חייב להיות הדבקה מילולית מהקלט. אם אין ציטוט בולט — השאר ריק.
6. **subject_tags** — 3-7 תגיות בעברית, snake_case (חניה, קווי_בניין, שיקול_דעת, פגם_פרוצדורלי, סמכות, מועדים, פגיעה_במקרקעין, ירידת_ערך, תכנית_רחביה, מימוש_במכר, וכד'). שייך לתחום של ועדת ערר תכנון ובניה.
7. **decision_date_iso** — תאריך מדויק בלבד. אם בטקסט יש "ניתנה היום, ט' באלול תשפ"א, 5 בספטמבר 2022" — הפלט: "2022-09-05".
8. **precedent_level** — קבע לפי הערכאה: בית המשפט העליון = "עליון"; בית משפט מחוזי בשבתו כבית משפט לעניינים מנהליים = "מנהלי"; ועדת ערר ארצית = "ועדת_ערר_ארצית"; ועדת ערר מחוזית (כמו ועדות תכנון ובניה ירושלים/מחוז המרכז וכד') = "ועדת_ערר_מחוזית". השתמש ב-underscore כפי שמופיע — לא ברווח.
9. **source_type** — שני ערכים בלבד: "court_ruling" כשהמסמך הוא פסק דין/החלטה של בית משפט (עליון/בג"ץ/מנהלי/מחוזי); "appeals_committee" כשהמסמך הוא החלטה של ועדת ערר (ארצית או מחוזית). זה משלים את `precedent_level` — שני השדות צריכים להיות תואמים.
10. **court** — מהכותרת הראשית של הפסק. ניסוח מלא (לא קיצור). מחרוזת ריקה אם לא ניתן לזהות.
"""
def _build_text_window(full_text: str) -> str:
"""Return the head + tail of the ruling, with a marker if truncated.
Most rulings have the parties/subject in the head and the conclusion
in the tail; the middle is the discussion which is captured via the
halacha extractor independently. Sending head+tail keeps the prompt
cheap while preserving naming and conclusion context.
"""
if len(full_text) <= _HEAD_CHARS + _TAIL_CHARS:
return full_text
return (
full_text[:_HEAD_CHARS]
+ "\n\n[... חלק האמצע הושמט עקב אורך — ראה את החלק האחרון של הפסק להלן ...]\n\n"
+ full_text[-_TAIL_CHARS:]
)
async def extract_metadata(case_law_id: UUID | str) -> dict:
"""Run metadata extraction. Returns a dict with the suggested values.
Does NOT write to the DB — caller decides what to merge.
"""
if isinstance(case_law_id, str):
case_law_id = UUID(case_law_id)
record = await db.get_case_law(case_law_id)
if not record:
return {}
full_text = (record.get("full_text") or "").strip()
if not full_text:
return {}
citation = record.get("case_number") or ""
court = record.get("court") or ""
date_str = str(record.get("date") or "")
practice_area = record.get("practice_area") or ""
context = (
f"מראה מקום: {citation}\n"
f"ערכאה: {court}\n"
f"תאריך: {date_str}\n"
f"תחום: {practice_area}"
)
text_window = _build_text_window(full_text)
# Static instructions go via `system` so the SDK path can cache them
# across uploads. Per-precedent content goes in the user prompt.
user_msg = (
f"## הקלט\n{context}\n\n"
f"--- תחילת הטקסט ---\n{text_window}\n--- סוף הטקסט ---"
)
try:
result = await claude_session.query_json(
user_msg, system=METADATA_EXTRACTION_PROMPT,
)
except Exception as e:
logger.warning("precedent_metadata_extractor: query failed: %s", e)
return {}
if not isinstance(result, dict):
logger.warning(
"precedent_metadata_extractor: expected dict, got %s",
type(result).__name__,
)
return {}
# Normalize keys / types
out: dict = {}
if isinstance(result.get("case_name_short"), str):
out["case_name_short"] = result["case_name_short"].strip()
if isinstance(result.get("appeal_subtype"), str):
out["appeal_subtype"] = result["appeal_subtype"].strip()
if isinstance(result.get("summary"), str):
out["summary"] = result["summary"].strip()
if isinstance(result.get("headnote"), str):
out["headnote"] = result["headnote"].strip()
if isinstance(result.get("key_quote"), str):
out["key_quote"] = result["key_quote"].strip()
tags = result.get("subject_tags") or []
if isinstance(tags, list):
out["subject_tags"] = [str(t).strip() for t in tags if str(t).strip()]
if isinstance(result.get("decision_date_iso"), str):
out["decision_date_iso"] = result["decision_date_iso"].strip()
if isinstance(result.get("precedent_level"), str):
# Validate against the closed enum used elsewhere in the system
lvl = result["precedent_level"].strip()
if lvl in {"עליון", "מנהלי", "ועדת_ערר_ארצית", "ועדת_ערר_מחוזית"}:
out["precedent_level"] = lvl
if isinstance(result.get("source_type"), str):
st = result["source_type"].strip()
if st in {"court_ruling", "appeals_committee"}:
out["source_type"] = st
if isinstance(result.get("court"), str):
out["court"] = result["court"].strip()
if isinstance(result.get("case_number_clean"), str):
out["case_number_clean"] = result["case_number_clean"].strip()
return out
async def apply_to_record(
case_law_id: UUID | str,
suggested: dict,
overwrite_case_number: bool = False,
) -> dict:
"""Merge suggested metadata into the case_law row, filling ONLY empty fields.
Empty rules:
- string field == "" → fill from suggested
- list field == [] → fill from suggested
- if suggested key is missing or empty, skip
case_name has special handling: if the current case_name equals the
case_number (a tell-tale sign of the upload form sending the long
citation into both fields), treat it as empty and overwrite.
overwrite_case_number: when True, update case_number from case_number_clean
even if the field already has a value (used for one-time migration enrichment).
"""
if isinstance(case_law_id, str):
case_law_id = UUID(case_law_id)
record = await db.get_case_law(case_law_id)
if not record:
return {"updated": False, "fields": []}
fields_to_update: dict = {}
cur_case_name = (record.get("case_name") or "").strip()
cur_case_number = (record.get("case_number") or "").strip()
suggested_case_name = (suggested.get("case_name_short") or "").strip()
if suggested_case_name and (
not cur_case_name or cur_case_name == cur_case_number
):
fields_to_update["case_name"] = suggested_case_name
if not (record.get("appeal_subtype") or "").strip():
s = (suggested.get("appeal_subtype") or "").strip()
if s:
fields_to_update["appeal_subtype"] = s
if not (record.get("summary") or "").strip():
s = (suggested.get("summary") or "").strip()
if s:
fields_to_update["summary"] = s
if not (record.get("headnote") or "").strip():
s = (suggested.get("headnote") or "").strip()
if s:
fields_to_update["headnote"] = s
if not (record.get("key_quote") or "").strip():
s = (suggested.get("key_quote") or "").strip()
if s:
fields_to_update["key_quote"] = s
cur_tags = record.get("subject_tags") or []
# Treat character-by-character corruption as empty. Early ingest
# pipelines stored a JSON string (`'["היטל השבחה"]'`) into a TEXT[]
# column, which Postgres split into individual chars:
# `['[', '"', 'ה', 'י', 'ט', 'ל', ' ', 'ה', 'ש', ...]`. Detection:
# 3+ elements where every element is at most 2 chars (legitimate
# tags are multi-character Hebrew words like `היטל_השבחה`).
is_corrupt = (
len(cur_tags) >= 3
and all(isinstance(t, str) and len(t) <= 2 for t in cur_tags)
)
if not cur_tags or is_corrupt:
sug_tags = suggested.get("subject_tags") or []
if sug_tags:
fields_to_update["subject_tags"] = sug_tags
# decision_date — only fill if currently null. The DB column is DATE,
# so we parse the LLM's ISO string into a date object before passing
# it to update_case_law (asyncpg won't coerce a string to DATE).
if record.get("date") is None:
iso = (suggested.get("decision_date_iso") or "").strip()
if iso:
try:
fields_to_update["date"] = date_type.fromisoformat(iso[:10])
except ValueError:
logger.debug(
"metadata_extractor: ignoring invalid decision_date_iso=%r",
iso,
)
if not (record.get("precedent_level") or "").strip():
lvl = (suggested.get("precedent_level") or "").strip()
if lvl:
fields_to_update["precedent_level"] = lvl
if not (record.get("source_type") or "").strip():
st = (suggested.get("source_type") or "").strip()
if st:
fields_to_update["source_type"] = st
if not (record.get("court") or "").strip():
c = (suggested.get("court") or "").strip()
if c:
fields_to_update["court"] = c
if overwrite_case_number:
cn = (suggested.get("case_number_clean") or "").strip()
if cn:
fields_to_update["case_number"] = cn
if not fields_to_update:
return {"updated": False, "fields": []}
await db.update_case_law(case_law_id, **fields_to_update)
return {"updated": True, "fields": list(fields_to_update.keys())}
async def extract_and_apply(
case_law_id: UUID | str,
overwrite_case_number: bool = False,
) -> dict:
"""Convenience wrapper: extract → merge into row → return summary."""
suggested = await extract_metadata(case_law_id)
if not suggested:
return {"status": "no_metadata", "fields": []}
result = await apply_to_record(case_law_id, suggested, overwrite_case_number=overwrite_case_number)
return {
"status": "completed" if result["updated"] else "no_changes",
"fields": result["fields"],
"suggested": suggested,
}

View File

@@ -2,10 +2,12 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
from pathlib import Path from pathlib import Path
from uuid import UUID from uuid import UUID
from legal_mcp import config
from legal_mcp.services import chunker, db, embeddings, extractor, references_extractor from legal_mcp.services import chunker, db, embeddings, extractor, references_extractor
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -30,7 +32,7 @@ async def process_document(document_id: UUID, case_id: UUID) -> dict:
try: try:
# Step 1: Extract text # Step 1: Extract text
logger.info("Extracting text from %s", doc["file_path"]) logger.info("Extracting text from %s", doc["file_path"])
text, page_count = await extractor.extract_text(doc["file_path"]) text, page_count, page_offsets = await extractor.extract_text(doc["file_path"])
await db.update_document( await db.update_document(
document_id, document_id,
@@ -68,9 +70,9 @@ async def process_document(document_id: UUID, case_id: UUID) -> dict:
except Exception as e: except Exception as e:
logger.warning("Classification failed (non-fatal): %s", e) logger.warning("Classification failed (non-fatal): %s", e)
# Step 2: Chunk # Step 2: Chunk (page_offsets propagates page_number into chunks)
logger.info("Chunking document (%d chars)", len(text)) logger.info("Chunking document (%d chars)", len(text))
chunks = chunker.chunk_document(text) chunks = chunker.chunk_document(text, page_offsets=page_offsets)
if not chunks: if not chunks:
await db.update_document(document_id, extraction_status="completed") await db.update_document(document_id, extraction_status="completed")
@@ -95,6 +97,21 @@ async def process_document(document_id: UUID, case_id: UUID) -> dict:
stored = await db.store_chunks(document_id, case_id, chunk_dicts) stored = await db.store_chunks(document_id, case_id, chunk_dicts)
# Step 4.5: Multimodal page-image embeddings (V9). Gated by
# MULTIMODAL_ENABLED. Renders each PDF page → embeds via
# voyage-multimodal-3 → stores per-page row with thumbnail.
# Non-fatal on failure (text path already succeeded).
multimodal_result = {"pages_embedded": 0}
if config.MULTIMODAL_ENABLED and page_count > 0:
try:
pdf_path = Path(doc["file_path"])
if pdf_path.suffix.lower() == ".pdf":
multimodal_result = await _embed_document_pages(
document_id, case_id, pdf_path, page_count,
)
except Exception as e:
logger.warning("Multimodal embedding failed (non-fatal): %s", e)
# Step 5: Extract references (plans, case law, legislation) — non-fatal # Step 5: Extract references (plans, case law, legislation) — non-fatal
refs_result = {"plans": 0, "case_law": 0, "case_law_linked": 0, "legislation": 0} refs_result = {"plans": 0, "case_law": 0, "case_law_linked": 0, "legislation": 0}
try: try:
@@ -124,9 +141,63 @@ async def process_document(document_id: UUID, case_id: UUID) -> dict:
"case_law": refs_result["case_law"], "case_law": refs_result["case_law"],
"legislation": refs_result["legislation"], "legislation": refs_result["legislation"],
}, },
"multimodal": multimodal_result,
} }
except Exception as e: except Exception as e:
logger.exception("Document processing failed: %s", e) logger.exception("Document processing failed: %s", e)
await db.update_document(document_id, extraction_status="failed") await db.update_document(document_id, extraction_status="failed")
return {"status": "failed", "error": str(e)} return {"status": "failed", "error": str(e)}
async def _embed_document_pages(
document_id: UUID,
case_id: UUID,
pdf_path: Path,
page_count: int,
) -> dict:
"""Render PDF pages → embed via voyage-multimodal → store per-page rows.
Thumbnails are saved under
``data/cases/{case_number}/thumbnails/{document_id}/p{N:03d}.jpg``
so the UI can show small previews next to image-side search hits.
"""
# Layout: data/cases/{case_number}/documents/originals/{file}.pdf
# → case_dir = pdf_path.parent.parent.parent
case_dir = pdf_path.parent.parent.parent
thumb_dir = case_dir / "thumbnails" / str(document_id)
logger.info("Multimodal: rendering %d pages @ %ddpi", page_count, config.MULTIMODAL_DPI)
rendered = await asyncio.to_thread(
extractor.render_pages_for_multimodal,
pdf_path,
config.MULTIMODAL_DPI,
config.MULTIMODAL_THUMB_DPI,
thumb_dir,
)
images = [pil for pil, _ in rendered]
thumb_paths = [thumb for _, thumb in rendered]
logger.info("Multimodal: embedding %d pages via %s", len(images), config.MULTIMODAL_MODEL)
img_embs = await embeddings.embed_images(images)
page_records = []
for i, (emb, thumb) in enumerate(zip(img_embs, thumb_paths)):
rel_thumb = None
if thumb is not None:
try:
rel_thumb = str(thumb.relative_to(config.DATA_DIR))
except ValueError:
rel_thumb = str(thumb)
page_records.append({
"page_number": i + 1,
"embedding": emb,
"image_thumbnail_path": rel_thumb,
})
stored = await db.store_document_image_embeddings(
document_id, case_id, page_records,
model_name=config.MULTIMODAL_MODEL,
)
logger.info("Multimodal: stored %d page-image embeddings", stored)
return {"pages_embedded": stored, "model": config.MULTIMODAL_MODEL}

View File

@@ -144,9 +144,9 @@ async def check_claims_coverage(blocks: list[dict], claims: list[dict]) -> dict:
## בלוק הדיון: ## בלוק הדיון:
{discussion}""" {discussion}"""
parsed = claude_session.query_json(prompt, timeout=120) parsed = await claude_session.query_json(prompt)
if parsed is None: if parsed is None:
logger.warning("Failed to parse claims check: %s", raw[:300]) logger.warning("Failed to parse claims check")
# Fallback: assume all covered (don't block export on parse failure) # Fallback: assume all covered (don't block export on parse failure)
return {"name": "claims_coverage", "passed": True, return {"name": "claims_coverage", "passed": True,
"errors": ["שגיאה בפענוח תוצאות — לא ניתן לבדוק"], "severity": "warning"} "errors": ["שגיאה בפענוח תוצאות — לא ניתן לבדוק"], "severity": "warning"}

View File

@@ -0,0 +1,103 @@
"""Optional cross-encoder reranking layer for semantic search.
Wraps a base search function with two-stage retrieval:
1. fetch ``VOYAGE_RERANK_FETCH_K`` candidates via the bi-encoder (cosine)
2. pass them to voyage rerank-2, return top-``limit``
When the feature flag is off (or ``force_rerank=False``) the helper just
calls the base function with ``limit`` and returns its results unchanged
— so callers can wrap unconditionally and let env control behaviour.
The helper extracts the rerank text from each row using the first
non-empty field among ``content``, ``rule_statement``,
``reasoning_summary`` (matches the schema used by ``search_similar``
and ``search_precedent_library_semantic``).
Decision validated by POC #5 (785-doc precedent corpus, 12 queries):
- mean@3: 4.306 → 4.500 (+4.5%)
- practical-category queries: 3.78 → 4.22 (+11.6%)
- latency: +702ms per query
"""
from __future__ import annotations
import logging
from collections.abc import Awaitable, Callable
from typing import Any
from legal_mcp import config
from legal_mcp.services import embeddings
logger = logging.getLogger(__name__)
SearchFn = Callable[..., Awaitable[list[dict]]]
def _rerank_text(row: dict) -> str:
"""First non-empty text field that voyage rerank should see."""
for key in ("content", "rule_statement", "reasoning_summary",
"supporting_quote"):
v = row.get(key)
if v:
return str(v)
return ""
async def maybe_rerank(
query: str,
base_search: SearchFn,
limit: int,
*,
force_rerank: bool | None = None,
fetch_k: int | None = None,
**base_kwargs: Any,
) -> list[dict]:
"""Two-stage retrieval helper.
Args:
query: original query string (needed for the rerank API).
base_search: any async function that takes ``limit=…`` and the
other ``base_kwargs`` and returns ``list[dict]``.
limit: final number of results to return.
force_rerank: override the env flag. ``None`` → use config.
fetch_k: override the bi-encoder fetch depth.
**base_kwargs: forwarded to ``base_search``.
Returns:
List of dict rows. When rerank is active, each row's ``score``
is replaced with the rerank-2 relevance score (0..1).
"""
enabled = (config.VOYAGE_RERANK_ENABLED
if force_rerank is None else force_rerank)
if not enabled:
return await base_search(limit=limit, **base_kwargs)
depth = fetch_k or config.VOYAGE_RERANK_FETCH_K
candidates = await base_search(limit=depth, **base_kwargs)
if not candidates:
return []
texts = [_rerank_text(c) for c in candidates]
# Drop candidates with empty rerank text (shouldn't happen but be safe)
keep = [(i, t) for i, t in enumerate(texts) if t]
if not keep:
logger.warning("rerank: all candidates empty, falling back to base")
return candidates[:limit]
keep_idx = [i for i, _ in keep]
keep_texts = [t for _, t in keep]
try:
ranked = await embeddings.voyage_rerank(
query, keep_texts, top_k=limit,
)
except Exception as e:
# Fail open — if Voyage rerank is down, return bi-encoder ordering
logger.warning("rerank failed, falling back to base: %s", e)
return candidates[:limit]
out: list[dict] = []
for keep_pos, score in ranked:
orig_idx = keep_idx[keep_pos]
row = dict(candidates[orig_idx])
row["score"] = float(score)
out.append(row)
return out

View File

@@ -159,7 +159,7 @@ async def _analyze_single_pass(rows, appeal_subtype: str = "") -> dict:
decisions_text += f"\n\n--- החלטה {row['decision_number'] or 'ללא מספר'} ---\n" decisions_text += f"\n\n--- החלטה {row['decision_number'] or 'ללא מספר'} ---\n"
decisions_text += row["full_text"] decisions_text += row["full_text"]
raw = claude_session.query( raw = await claude_session.query(
ANALYSIS_PROMPT.format(decisions=decisions_text), ANALYSIS_PROMPT.format(decisions=decisions_text),
timeout=claude_session.LONG_TIMEOUT, timeout=claude_session.LONG_TIMEOUT,
) )
@@ -176,7 +176,7 @@ async def _analyze_multi_pass(rows, appeal_subtype: str = "") -> dict:
decision_text = f"--- החלטה {row['decision_number'] or 'ללא מספר'} ---\n" decision_text = f"--- החלטה {row['decision_number'] or 'ללא מספר'} ---\n"
decision_text += row["full_text"] decision_text += row["full_text"]
raw = claude_session.query( raw = await claude_session.query(
SINGLE_DECISION_PROMPT.format(decision=decision_text), SINGLE_DECISION_PROMPT.format(decision=decision_text),
timeout=claude_session.LONG_TIMEOUT, timeout=claude_session.LONG_TIMEOUT,
) )
@@ -189,7 +189,7 @@ async def _analyze_multi_pass(rows, appeal_subtype: str = "") -> dict:
return {"error": "לא הצלחתי לחלץ דפוסים מההחלטות"} return {"error": "לא הצלחתי לחלץ דפוסים מההחלטות"}
# Pass 2: Synthesize across all decisions # Pass 2: Synthesize across all decisions
raw = claude_session.query( raw = await claude_session.query(
SYNTHESIS_PROMPT.format( SYNTHESIS_PROMPT.format(
num_decisions=len(rows), num_decisions=len(rows),
patterns=json.dumps(all_patterns, ensure_ascii=False, indent=2), patterns=json.dumps(all_patterns, ensure_ascii=False, indent=2),

View File

@@ -13,7 +13,7 @@ from uuid import UUID
import httpx import httpx
from legal_mcp import config from legal_mcp import config
from legal_mcp.services import audit, db, git_sync, practice_area as pa from legal_mcp.services import audit, db, extractor, git_sync, practice_area as pa
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -28,12 +28,17 @@ def _gitea_token() -> str:
return os.environ.get("GITEA_ACCESS_TOKEN") or os.environ.get("GITEA_TOKEN", "") 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: async def _setup_gitea_remote(case_number: str, title: str, case_dir: Path) -> dict:
"""Create Gitea repo and configure git remote. Best-effort — returns False on failure.""" """Create Gitea repo and configure git remote.
Returns a dict with: ok (bool), url (str|None), error (str|None).
Never raises — failures are reported via the dict so callers can surface
them to the UI instead of silently swallowing them.
"""
token = _gitea_token() token = _gitea_token()
if not token: if not token:
logger.info("No GITEA_TOKEN — skipping Gitea repo creation for %s", case_number) logger.info("No GITEA_TOKEN — skipping Gitea repo creation for %s", case_number)
return False return {"ok": False, "url": None, "error": "no_token"}
try: try:
async with httpx.AsyncClient(verify=False, timeout=30) as client: async with httpx.AsyncClient(verify=False, timeout=30) as client:
@@ -59,8 +64,9 @@ async def _setup_gitea_remote(case_number: str, title: str, case_dir: Path) -> b
repo = resp.json() repo = resp.json()
clone_url = repo.get("clone_url", "") clone_url = repo.get("clone_url", "")
html_url = repo.get("html_url", "")
if not clone_url: if not clone_url:
return False return {"ok": False, "url": None, "error": "no_clone_url"}
auth_url = clone_url.replace("https://", f"https://chaim:{token}@") auth_url = clone_url.replace("https://", f"https://chaim:{token}@")
@@ -94,15 +100,20 @@ async def _setup_gitea_remote(case_number: str, title: str, case_dir: Path) -> b
cwd=case_dir, capture_output=True, text=True, env=git_env, cwd=case_dir, capture_output=True, text=True, env=git_env,
) )
if push.returncode != 0: if push.returncode != 0:
logger.warning("Gitea push failed for %s: %s", case_number, push.stderr) stderr = push.stderr.strip()
return False logger.warning("Gitea push failed for %s: %s", case_number, stderr)
return {"ok": False, "url": html_url or None, "error": f"push_failed: {stderr[:200]}"}
logger.info("Gitea repo created and pushed for %s", case_number) logger.info("Gitea repo created and pushed for %s", case_number)
return True return {"ok": True, "url": html_url or None, "error": None}
except httpx.HTTPStatusError as exc:
msg = f"http_{exc.response.status_code}"
logger.warning("Gitea setup failed for %s: %s", case_number, msg)
return {"ok": False, "url": None, "error": msg}
except Exception as exc: except Exception as exc:
logger.warning("Gitea setup failed for %s: %s", case_number, exc) logger.warning("Gitea setup failed for %s: %s", case_number, exc)
return False return {"ok": False, "url": None, "error": f"{type(exc).__name__}: {exc}"[:200]}
async def case_create( async def case_create(
@@ -214,11 +225,10 @@ async def case_create(
except Exception: except Exception:
pass # git not available — non-critical pass # git not available — non-critical
# Create Gitea repo and configure remote (best-effort) # Create Gitea repo and configure remote — surface result so callers can
try: # show failures (e.g. stale token) and offer a retry button instead of
await _setup_gitea_remote(case_number, title, case_dir) # silently producing a case with no remote.
except Exception: case["gitea"] = await _setup_gitea_remote(case_number, title, case_dir)
pass # Gitea not available — non-critical
return json.dumps(case, default=str, ensure_ascii=False, indent=2) return json.dumps(case, default=str, ensure_ascii=False, indent=2)
@@ -360,3 +370,66 @@ async def case_delete(case_number: str, remove_files: bool = False) -> str:
result["removed_files"] = True result["removed_files"] = True
return json.dumps(result, ensure_ascii=False, indent=2) return json.dumps(result, ensure_ascii=False, indent=2)
async def case_get_final_text(case_number: str, max_chars: int = 0) -> str:
"""קליטת טקסט ההחלטה הסופית (`סופי-{case}.docx` בתיקיית exports).
בניגוד ל-`document_get_text` שעובד על שורות בטבלת `documents`,
הקובץ הסופי הוא רק קובץ בתיקייה (נוצר על ידי `api_mark_final`).
תומך בכל הפורמטים ש-extractor.extract_text מטפל בהם — מנסה
`.docx` תחילה, ואז `.pdf`, `.doc`, `.rtf`, `.txt`, `.md`.
Args:
case_number: מספר תיק הערר
max_chars: אם >0, חתוך את הטקסט המוחזר לאורך הזה. 0 = הכל.
"""
case_dir = config.find_case_dir(case_number)
exports_dir = case_dir / "exports"
final_stem = f"סופי-{case_number}"
final_path = None
for ext in (".docx", ".pdf", ".doc", ".rtf", ".txt", ".md"):
candidate = exports_dir / f"{final_stem}{ext}"
if candidate.exists():
final_path = candidate
break
if final_path is None:
return json.dumps({
"status": "not_found",
"case_number": case_number,
"expected_path": str(exports_dir / f"{final_stem}.docx"),
"tried_extensions": [".docx", ".pdf", ".doc", ".rtf", ".txt", ".md"],
"hint": (
"ההחלטה הסופית עדיין לא סומנה כ'סופית' ב-UI. "
"דפנה צריכה ללחוץ 'סמן כסופי' על קובץ הטיוטה הנכון."
),
}, ensure_ascii=False, indent=2)
try:
text, page_count, _ = await extractor.extract_text(str(final_path))
except Exception as e:
logger.exception("case_get_final_text: extraction failed for %s", case_number)
return json.dumps({
"status": "error",
"case_number": case_number,
"file_path": str(final_path),
"error": str(e),
}, ensure_ascii=False, indent=2)
text = text or ""
truncated = False
if max_chars > 0 and len(text) > max_chars:
text = text[:max_chars]
truncated = True
return json.dumps({
"status": "ok",
"case_number": case_number,
"file_path": str(final_path),
"text_length": len(text),
"page_count": page_count,
"truncated": truncated,
"text": text,
}, ensure_ascii=False, indent=2)

View File

@@ -144,7 +144,7 @@ async def document_upload_training(
shutil.copy2(str(source), str(dest)) shutil.copy2(str(source), str(dest))
# Extract text and strip Nevo preamble # Extract text and strip Nevo preamble
text, page_count = await extractor.extract_text(str(dest)) text, page_count, _ = await extractor.extract_text(str(dest))
text = extractor.strip_nevo_preamble(text) text = extractor.strip_nevo_preamble(text)
# Parse date # Parse date

View File

@@ -7,7 +7,7 @@ from pathlib import Path
from uuid import UUID from uuid import UUID
from legal_mcp import config from legal_mcp import config
from legal_mcp.services import db, embeddings, research_md from legal_mcp.services import db, embeddings, git_sync, research_md
from legal_mcp.services.lessons import ( from legal_mcp.services.lessons import (
CITATION_GUIDANCE, CITATION_GUIDANCE,
DECISION_TEMPLATES, DECISION_TEMPLATES,
@@ -403,6 +403,9 @@ async def export_docx(case_number: str, output_path: str = "") -> str:
path = await docx_exporter.export_decision(case_id, output_path or None) path = await docx_exporter.export_decision(case_id, output_path or None)
# Register this export as the new source of truth # Register this export as the new source of truth
await db.set_active_draft_path(case_id, path) await db.set_active_draft_path(case_id, path)
case_dir = config.find_case_dir(case_number)
if case_dir.exists():
git_sync.commit_and_push(case_dir, f"ייצוא DOCX: {Path(path).name}")
return json.dumps({ return json.dumps({
"status": "completed", "status": "completed",
"path": path, "path": path,
@@ -421,7 +424,7 @@ async def export_docx(case_number: str, output_path: str = "") -> str:
# Blocks written for the interim draft, in display order. # Blocks written for the interim draft, in display order.
# This is the same content the chair sees in the final decision (same template, # This is the same content the chair sees in the final decision (same template,
# same skill, same prompts) — minus opening, ruling, summary, signatures. # same skill, same prompts) — minus opening, ruling, summary, signatures.
_INTERIM_BLOCKS = ["block-vav", "block-tet", "block-zayin", "block-chet"] _INTERIM_BLOCKS = ["block-he", "block-vav", "block-tet", "block-zayin", "block-chet"]
async def extract_appraiser_facts(case_number: str) -> str: async def extract_appraiser_facts(case_number: str) -> str:
@@ -528,6 +531,9 @@ async def export_interim_draft(case_number: str, output_path: str = "") -> str:
case_id, output_path or None, mode="interim", case_id, output_path or None, mode="interim",
) )
await db.set_active_draft_path(case_id, path) await db.set_active_draft_path(case_id, path)
case_dir = config.find_case_dir(case_number)
if case_dir.exists():
git_sync.commit_and_push(case_dir, f"טיוטת ביניים: {Path(path).name}")
return json.dumps({ return json.dumps({
"status": "completed", "status": "completed",
"mode": "interim", "mode": "interim",
@@ -571,6 +577,9 @@ async def apply_user_edit(case_number: str, edit_filename: str) -> str:
try: try:
retrofit_result = docx_retrofit.retrofit_bookmarks(edit_path) retrofit_result = docx_retrofit.retrofit_bookmarks(edit_path)
await db.set_active_draft_path(case_id, str(edit_path)) await db.set_active_draft_path(case_id, str(edit_path))
case_dir = config.find_case_dir(case_number)
if case_dir.exists():
git_sync.commit_and_push(case_dir, f"גרסת עריכה: {edit_path.name}")
return json.dumps({ return json.dumps({
"status": "completed", "status": "completed",
"active_draft_path": str(edit_path), "active_draft_path": str(edit_path),
@@ -681,6 +690,12 @@ async def revise_draft(case_number: str, revisions_json: str,
active_path, output_path, revisions, author=author, active_path, output_path, revisions, author=author,
) )
await db.set_active_draft_path(case_id, str(output_path)) await db.set_active_draft_path(case_id, str(output_path))
case_dir = config.find_case_dir(case_number)
if case_dir.exists():
git_sync.commit_and_push(
case_dir,
f"revise: טיוטה-v{next_ver} ({result.applied} שינויים, {result.failed} נכשלו)",
)
return json.dumps({ return json.dumps({
"status": "completed", "status": "completed",
"output_path": str(output_path), "output_path": str(output_path),

View File

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

View File

@@ -6,7 +6,7 @@ import json
import logging import logging
from uuid import UUID from uuid import UUID
from legal_mcp.services import db, embeddings from legal_mcp.services import db, embeddings, hybrid_search
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -43,8 +43,9 @@ async def search_decisions(
) )
query_emb = await embeddings.embed_query(query) query_emb = await embeddings.embed_query(query)
results = await db.search_similar( results = await hybrid_search.search_documents_hybrid(
query_embedding=query_emb, query=query,
query_text_embedding=query_emb,
limit=limit, limit=limit,
section_type=section_type or None, section_type=section_type or None,
practice_area=practice_area or None, practice_area=practice_area or None,
@@ -58,11 +59,13 @@ async def search_decisions(
for r in results: for r in results:
formatted.append({ formatted.append({
"score": round(float(r["score"]), 4), "score": round(float(r["score"]), 4),
"case_number": r["case_number"], "case_number": r.get("case_number"),
"document": r["document_title"], "document": r.get("document_title"),
"section": r["section_type"], "section": r.get("section_type"),
"page": r["page_number"], "page": r.get("page_number"),
"content": r["content"], "content": r.get("content", ""),
"match_type": r.get("match_type", "text"),
"image_thumbnail": r.get("image_thumbnail_path"),
}) })
return json.dumps(formatted, ensure_ascii=False, indent=2) return json.dumps(formatted, ensure_ascii=False, indent=2)
@@ -86,8 +89,9 @@ async def search_case_documents(
query_emb = await embeddings.embed_query(query) query_emb = await embeddings.embed_query(query)
# Restricted to case_id — practice_area filter would be redundant. # Restricted to case_id — practice_area filter would be redundant.
results = await db.search_similar( results = await hybrid_search.search_documents_hybrid(
query_embedding=query_emb, query=query,
query_text_embedding=query_emb,
limit=limit, limit=limit,
case_id=UUID(case["id"]), case_id=UUID(case["id"]),
) )
@@ -99,10 +103,12 @@ async def search_case_documents(
for r in results: for r in results:
formatted.append({ formatted.append({
"score": round(float(r["score"]), 4), "score": round(float(r["score"]), 4),
"document": r["document_title"], "document": r.get("document_title"),
"section": r["section_type"], "section": r.get("section_type"),
"page": r["page_number"], "page": r.get("page_number"),
"content": r["content"], "content": r.get("content", ""),
"match_type": r.get("match_type", "text"),
"image_thumbnail": r.get("image_thumbnail_path"),
}) })
return json.dumps(formatted, ensure_ascii=False, indent=2) return json.dumps(formatted, ensure_ascii=False, indent=2)
@@ -137,9 +143,12 @@ async def find_similar_cases(
) )
query_emb = await embeddings.embed_query(description) query_emb = await embeddings.embed_query(description)
results = await db.search_similar( # Even with rerank we ask for ``limit*3`` so the dedup-by-case
query_embedding=query_emb, # step downstream still has enough rows to pick the best per case.
limit=limit * 3, # Get more to deduplicate by case results = await hybrid_search.search_documents_hybrid(
query=description,
query_text_embedding=query_emb,
limit=limit * 3,
practice_area=practice_area or None, practice_area=practice_area or None,
appeal_subtype=appeal_subtype or None, appeal_subtype=appeal_subtype or None,
) )
@@ -147,14 +156,16 @@ async def find_similar_cases(
if not results: if not results:
return "לא נמצאו תיקים דומים." return "לא נמצאו תיקים דומים."
# Deduplicate by case_number, keep best score per case # Deduplicate by case_number, keep best score per case.
# image-only rows still carry case_number from the join.
seen_cases = {} seen_cases = {}
for r in results: for r in results:
cn = r["case_number"] cn = r.get("case_number")
if not cn:
continue
if cn not in seen_cases or r["score"] > seen_cases[cn]["score"]: if cn not in seen_cases or r["score"] > seen_cases[cn]["score"]:
seen_cases[cn] = r seen_cases[cn] = r
# Sort by score and limit
top_cases = sorted(seen_cases.values(), key=lambda x: x["score"], reverse=True)[:limit] top_cases = sorted(seen_cases.values(), key=lambda x: x["score"], reverse=True)[:limit]
formatted = [] formatted = []
@@ -162,8 +173,69 @@ async def find_similar_cases(
formatted.append({ formatted.append({
"score": round(float(r["score"]), 4), "score": round(float(r["score"]), 4),
"case_number": r["case_number"], "case_number": r["case_number"],
"document": r["document_title"], "document": r.get("document_title"),
"relevant_section": r["content"][:500], "relevant_section": (r.get("content") or "")[:500],
"match_type": r.get("match_type", "text"),
}) })
return json.dumps(formatted, ensure_ascii=False, indent=2) return json.dumps(formatted, ensure_ascii=False, indent=2)
async def search_internal_decisions(
query: str,
practice_area: str = "",
appeal_subtype: str = "",
district: str = "",
chair_name: str = "",
limit: int = 10,
include_halachot: bool = True,
) -> str:
"""חיפוש בהחלטות ועדות ערר לתכנון ובנייה (כל המחוזות).
Args:
query: שאילתת חיפוש בעברית
practice_area: rishuy_uvniya / betterment_levy / compensation_197
appeal_subtype: סינון לפי תת-סוג ערר
district: מחוז — ירושלים / מרכז / תל אביב / צפון / דרום / ארצי. ריק = כל המחוזות
chair_name: שם יו"ר הוועדה לסינון. ריק = כל היו"רים
limit: מספר תוצאות מקסימלי
include_halachot: האם לכלול הלכות שחולצו
"""
from legal_mcp.services import internal_decisions as int_svc
results = await int_svc.search_internal(
query,
practice_area=practice_area,
appeal_subtype=appeal_subtype,
district=district,
chair_name=chair_name,
limit=limit,
include_halachot=include_halachot,
)
if not results:
return "לא נמצאו החלטות ועדת ערר רלוונטיות."
formatted = []
for r in results:
entry = {
"score": round(float(r["score"]), 4),
"type": r.get("type", "passage"),
"case_number": r.get("case_number"),
"case_name": r.get("case_name"),
"court": r.get("court"),
"district": r.get("district"),
"chair_name": r.get("chair_name"),
"decision_date": r.get("decision_date"),
}
if r.get("type") == "halacha":
entry["rule"] = r.get("rule_statement")
entry["quote"] = r.get("supporting_quote")
entry["rule_type"] = r.get("rule_type")
else:
entry["content"] = r.get("content", "")
entry["section"] = r.get("section_type")
entry["page"] = r.get("page_number")
formatted.append(entry)
return json.dumps(formatted, ensure_ascii=False, indent=2)

View File

@@ -3,10 +3,13 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
from uuid import UUID from uuid import UUID
from legal_mcp.services import db from legal_mcp.services import db
logger = logging.getLogger(__name__)
async def workflow_status(case_number: str) -> str: async def workflow_status(case_number: str) -> str:
"""סטטוס תהליך עבודה מלא לתיק - מסמכים, עיבוד, טיוטות. """סטטוס תהליך עבודה מלא לתיק - מסמכים, עיבוד, טיוטות.
@@ -308,17 +311,36 @@ async def ingest_final_version(
# Extract text from file if provided # Extract text from file if provided
if file_path and not final_text: if file_path and not final_text:
from legal_mcp.services import extractor from legal_mcp.services import extractor
final_text, _ = await extractor.extract_text(file_path) final_text, _, _ = await extractor.extract_text(file_path)
if not final_text: if not final_text:
return "לא סופק טקסט — יש לספק file_path או final_text." return "לא סופק טקסט — יש לספק file_path או final_text."
try: try:
result = await learning_loop.process_final_version(case_id, final_text) result = await learning_loop.process_final_version(case_id, final_text)
return json.dumps(result, default=str, ensure_ascii=False, indent=2)
except ValueError as e: except ValueError as e:
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False, indent=2) return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False, indent=2)
# Auto-ingest into internal committee decisions corpus (best-effort).
try:
from legal_mcp.services import internal_decisions as int_svc
await int_svc.ingest_internal_decision(
case_number=case_number,
case_name=case.get("title", ""),
decision_date=case.get("decision_date"),
chair_name=case.get("chair_name", ""),
district="ירושלים",
practice_area=case.get("practice_area", ""),
appeal_subtype=case.get("appeal_subtype", ""),
text=final_text,
)
result["internal_corpus_ingested"] = True
except Exception as e:
logger.warning("ingest_final_version: internal corpus ingestion failed (non-fatal): %s", e)
result["internal_corpus_ingested"] = False
return json.dumps(result, default=str, ensure_ascii=False, indent=2)
# ── Chair feedback tools ────────────────────────────────────────── # ── Chair feedback tools ──────────────────────────────────────────

View File

@@ -13,12 +13,20 @@ from lxml import etree
from legal_mcp.services.docx_exporter import ( from legal_mcp.services.docx_exporter import (
_BOOKMARK_ID_START, _BOOKMARK_ID_START,
HEBREW_FONT,
_add_styled_paragraph,
_insert_bookmark_end, _insert_bookmark_end,
_insert_bookmark_start, _insert_bookmark_start,
_mark_paragraph_rtl,
_mark_run_rtl,
_strip_dashes,
_wrap_block_with_bookmarks, _wrap_block_with_bookmarks,
_write_block_to_docx,
) )
from legal_mcp.services.docx_reviser import NSMAP, _w, list_bookmarks from legal_mcp.services.docx_reviser import NSMAP, _w, list_bookmarks
from docx.oxml.ns import qn
def test_insert_bookmark_helpers_create_valid_xml(tmp_path: Path) -> None: def test_insert_bookmark_helpers_create_valid_xml(tmp_path: Path) -> None:
doc = Document() doc = Document()
@@ -101,3 +109,119 @@ def test_multiple_blocks_get_unique_bookmark_ids(tmp_path: Path) -> None:
names = list_bookmarks(out) names = list_bookmarks(out)
assert set(names) == {"block-alef", "block-bet", "block-gimel"} assert set(names) == {"block-alef", "block-bet", "block-gimel"}
# ── RTL / David-font invariants ───────────────────────────────────
# These guard against regressions where Hebrew renders LTR or in the wrong
# font slot (Times New Roman instead of David). See plan file for context.
def test_mark_paragraph_rtl_adds_bidi_directly_in_pPr() -> None:
doc = Document()
p = doc.add_paragraph("טקסט בעברית")
_mark_paragraph_rtl(p)
pPr = p._p.find(qn("w:pPr"))
assert pPr is not None
# <w:bidi/> must be a direct child of pPr (paragraph direction),
# NOT nested inside <w:rPr>.
assert pPr.find(qn("w:bidi")) is not None
# paragraph-mark rPr still gets <w:rtl/>
rPr = pPr.find(qn("w:rPr"))
assert rPr is not None and rPr.find(qn("w:rtl")) is not None
def test_mark_run_rtl_forces_david_on_all_font_slots() -> None:
doc = Document()
p = doc.add_paragraph()
run = p.add_run("טקסט")
_mark_run_rtl(run)
rPr = run._r.find(qn("w:rPr"))
assert rPr is not None
fonts = rPr.find(qn("w:rFonts"))
assert fonts is not None
for slot in ("w:ascii", "w:hAnsi", "w:cs", "w:eastAsia"):
assert fonts.get(qn(slot)) == HEBREW_FONT, f"{slot} not {HEBREW_FONT}"
assert rPr.find(qn("w:rtl")) is not None
def test_styled_paragraph_applies_bidi_and_david() -> None:
"""End-to-end: _add_styled_paragraph produces pPr/bidi + rFonts/cs=David."""
doc = Document()
_add_styled_paragraph(doc, "פסקה עברית", style="Normal")
p = doc.paragraphs[-1]
assert p._p.find(qn("w:pPr")).find(qn("w:bidi")) is not None
run = p.runs[0]
fonts = run._r.find(qn("w:rPr")).find(qn("w:rFonts"))
assert fonts.get(qn("w:cs")) == HEBREW_FONT
def test_block_dalet_does_not_use_title_style() -> None:
"""Title style uses theme fonts and 28pt — avoid for Hebrew."""
doc = Document()
_write_block_to_docx(doc, "block-dalet", title="", content="")
styles_used = {p.style.name for p in doc.paragraphs}
assert "Title" not in styles_used, (
f"block-dalet should not produce a Title-styled paragraph, got {styles_used}"
)
# The 'החלטה' text must still appear somewhere
texts = [p.text for p in doc.paragraphs]
assert any("החלטה" in t for t in texts)
# ── Heading overrides, numbered-list, dash strip ──────────────────
def test_strip_dashes_removes_em_and_en_dashes() -> None:
assert _strip_dashes("תכנית 1454198 — אושרה ביום") == "תכנית 1454198 אושרה ביום"
assert _strip_dashes("א ב") == "א ב"
assert _strip_dashes("no dash") == "no dash"
# Collapsed whitespace
assert _strip_dashes("רקע — עובדתי") == "רקע עובדתי"
def test_heading2_gets_justified_and_no_numbering() -> None:
"""Section heading → Heading 2 with jc=both and numId=0."""
doc = Document()
_write_block_to_docx(doc, "block-vav", title="", content="דיון והכרעה")
heading = next(p for p in doc.paragraphs if p.style.name == "Heading 2")
pPr = heading._p.find(qn("w:pPr"))
jc = pPr.find(qn("w:jc"))
assert jc is not None and jc.get(qn("w:val")) == "both"
numPr = pPr.find(qn("w:numPr"))
assert numPr is not None
numId = numPr.find(qn("w:numId"))
assert numId is not None and numId.get(qn("w:val")) == "0"
def test_heading3_gets_justified_not_centered() -> None:
"""Heading 3 in template has jc=center — override to jc=both."""
doc = Document()
_write_block_to_docx(doc, "block-vav", title="", content="**המצב התכנוני**")
heading = next(p for p in doc.paragraphs if p.style.name == "Heading 3")
jc = heading._p.find(qn("w:pPr")).find(qn("w:jc"))
assert jc is not None and jc.get(qn("w:val")) == "both"
def test_numbered_paragraph_uses_list_paragraph_and_strips_prefix() -> None:
"""'1. text' → List Paragraph style, literal '1. ' removed."""
doc = Document()
_write_block_to_docx(
doc, "block-vav", title="",
content="1. עניינו של ערר זה.\n2. שכונת נווה יעקב.",
)
lp = [p for p in doc.paragraphs if p.style.name == "List Paragraph"]
assert len(lp) == 2
assert lp[0].text.startswith("עניינו")
assert not lp[0].text.startswith("1.")
assert lp[1].text.startswith("שכונת")
def test_body_content_has_no_em_dashes() -> None:
"""Content with em-dashes is rendered without them."""
doc = Document()
_write_block_to_docx(
doc, "block-vav", title="",
content="3. תכנית 5924 — קובעת את שטחי הבנייה.",
)
texts = "\n".join(p.text for p in doc.paragraphs)
assert "" not in texts

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""One-shot: extract appellant claims for case 8174-24.
The analyst (CMPA-13) finished but `extract_claims` timed out three times on
the main 25K-char appeal document, so we have only 19 committee/response
claims in DB and zero appellant claims. This script reruns extraction with
a higher timeout and parallel chunks.
Targets:
• כתב ערר 18.12.24 (appeal, 25,474 chars) — appellant claims
• השלמת מסמכים תמ״א 38 (decision, 3,718 chars) — supplementary appeal filing
After phase 1.1-1.3 lands, this script becomes obsolete.
Usage: /home/chaim/legal-ai/mcp-server/.venv/bin/python scripts/extract_claims_8174.py
"""
from __future__ import annotations
import asyncio
import json
import sys
import time
from pathlib import Path
from uuid import UUID
# Ensure we can import legal_mcp from this repo's mcp-server tree
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "mcp-server" / "src"))
from legal_mcp.services import claims_extractor, claude_session, db
# ── Patch claude_session to use 30-min ceiling ───────────────────────
# The hard-coded timeout=120 in claims_extractor.extract_claims_with_ai is
# what kept failing. Force every claude_session call here to use 1800s.
_orig_query_json = claude_session.query_json
_orig_query = claude_session.query
def _patched_query_json(prompt: str, timeout: int = 120):
return _orig_query_json(prompt, timeout=max(timeout, 1800))
def _patched_query(prompt: str, timeout: int = 120, max_turns: int = 1):
return _orig_query(prompt, timeout=max(timeout, 1800), max_turns=max_turns)
claude_session.query_json = _patched_query_json
claude_session.query = _patched_query
CASE_NUMBER = "8174-24"
TARGETS = [
# (doc_id, title hint, doc_type override, party_hint)
("655f96f7-d406-44ac-bb53-6b2c1ab2909c", "כתב ערר 18.12.24", "appeal", "יואל גולדמן"),
("13b4795a-4fb7-460e-bddf-a5d282a1a67f", "השלמת מסמכים תמ״א 38", "appeal", "יואל גולדמן"),
]
async def main() -> int:
case = await db.get_case_by_number(CASE_NUMBER)
if not case:
print(f"ERROR: case {CASE_NUMBER} not found")
return 1
case_id = UUID(case["id"])
print(f"=== Case {CASE_NUMBER}{case['title']} ===")
print()
for doc_id, label, doc_type, party_hint in TARGETS:
text = await db.get_document_text(UUID(doc_id))
if not text:
print(f"SKIP {label} — no extracted_text")
continue
chars = len(text)
print(f"--- {label} ({chars:,} chars, doc_type={doc_type}) ---")
t0 = time.monotonic()
try:
result = await claims_extractor.extract_and_store_claims(
case_id=case_id,
document_id=UUID(doc_id),
text=text,
doc_type=doc_type,
party_hint=party_hint,
)
except Exception as e:
print(f" FAILED: {e}")
continue
dt = time.monotonic() - t0
print(f" done in {dt:.1f}s — {json.dumps(result, ensure_ascii=False)}")
print()
# Final tally
pool = await db.get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""SELECT party_role, claim_type, source_document, count(*) as n
FROM claims WHERE case_id = $1
GROUP BY 1, 2, 3 ORDER BY 1, 3""",
case_id,
)
print("=== Final claims breakdown ===")
total = 0
for r in rows:
n = r["n"]
total += n
print(f" {r['party_role']:12} {r['claim_type']:10} ({n:3}) ← {r['source_document']}")
print(f" TOTAL: {total} claims")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# One-off A/B test runner: runs the Knowledge Curator (Hermes) on CMP-78 using
# DeepSeek V4-Pro instead of the default Sonnet 4.5 (via marcus/sonnet gateway).
# Compare against CMP-80 which runs with the default config.
set -euo pipefail
PROFILE_HOME="/home/chaim/.hermes/profiles/curator-cmp-deepseek"
PAPERCLIP_API_URL="http://localhost:3100/api"
# CMP curator agent's Paperclip key (from Infisical: nautilus /legal-ai HERMES_CURATOR_CMP_PAPERCLIP_KEY)
PAPERCLIP_API_KEY="pcp_c87edcf306d06fce13fac701bb6d747191d61dba5b51e903"
PAPERCLIP_TASK_ID="beb745e5-7195-40c5-9ac0-e9682c2c5184" # CMP-78
PAPERCLIP_TASK_KEY="$PAPERCLIP_TASK_ID"
PAPERCLIP_TASK_TITLE="[ערר 1130-25] סקירת ידע — Knowledge Curator (DeepSeek A/B test)"
PAPERCLIP_RUN_ID="deepseek-ab-$(date +%s)"
PAPERCLIP_WAKE_REASON="manual_deepseek_ab_test"
# Rendered prompt — copy of the curator template with mustache variables resolved
# manually for CMP-78. We also add a clear "[ניסוי DeepSeek V4-Pro]" prefix so
# the resulting comment is distinguishable from the default-Sonnet run on CMP-80.
read -r -d '' PROMPT <<'EOF' || true
אתה מנהל ידע (Knowledge Curator) של ועדת הערר. נעור על תיק שדפנה סימנה כסופי.
תיק: [ערר 1130-25] סקירת ידע — Knowledge Curator
issue ID: beb745e5-7195-40c5-9ac0-e9682c2c5184
run reason: manual_deepseek_ab_test
**הקשר חשוב — ניסוי A/B:** זוהי ריצה ידנית באמצעות DeepSeek V4-Pro במקום ה-Sonnet הרגיל. כל ה-comment שתפרסם חייב להתחיל בכותרת `[ניסוי DeepSeek V4-Pro]` כדי שנוכל להבדיל מהריצה המקבילה ב-CMP-80 (שרצה עם Sonnet). אל תעיר סוכנים אחרים. אל תיצור issues חדשים. אל תפתח interaction.
הוראות:
דפנה סימנה את ההחלטה הסופית של תיק 1130-25 כסופית.
קובץ סופי: `סופי-1130-25.docx`
סקור את ההחלטה מול skills/decision/SKILL.md ו-docs/legal-decision-lessons.md.
חפש 3-5 דפוסי סגנון/דיון שלא תועדו. כתוב comment בעברית, ניטרלי, ממוספר.
# שלבי ביצוע
## 1. קונטקסט
- קרא את MEMORY.md שלך (memory tool) — מה כבר זיהית.
- קרא `/home/chaim/legal-ai/skills/decision/SKILL.md` (file tool) — מה כבר תועד.
## 2. נתונים
- `mcp__legal-ai__case_get` עם case_number `1130-25` — מטא-דאטה.
- `mcp__legal-ai__case_get_final_text` עם case_number `1130-25` — קרא את הטקסט המלא של ההחלטה הסופית.
- אם רלוונטי: `mcp__legal-ai__search_decisions` להשוואה לחלטות קודמות.
## 3. ניתוח
חפש 3-5 דפוסים/פערים. לכל ממצא: מה ראיתי + מה זה אומר + הצעה ניסוחית מדויקת.
## 4. כתוב comment הממצאים
```bash
curl -sS -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
-d "$(jq -n --arg b "$BODY" '{body:$b}')" \
"$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID/comments"
```
פורמט ה-body:
- שורה ראשונה: `[ניסוי DeepSeek V4-Pro]`
- אחר כך פסקה אחת מבוא קצרה
- אחר כך הממצאים ממוספרים
## 5. סגור את ה-issue
```bash
curl -sS -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
-d '{"status":"done"}' "$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID"
```
# כללים
- אל תעדכן קבצים (skills/, lessons.py, DB) בעצמך. רק comment.
- אל תיצור issues חדשים.
- אל תעיר סוכנים אחרים.
- אל תפתח interaction.
- בעיה? comment קצר עם הסיבה + סגור (status=done).
EOF
export HERMES_HOME="$PROFILE_HOME"
export PAPERCLIP_API_URL PAPERCLIP_API_KEY PAPERCLIP_TASK_ID PAPERCLIP_TASK_KEY \
PAPERCLIP_TASK_TITLE PAPERCLIP_RUN_ID PAPERCLIP_WAKE_REASON
echo "=== DeepSeek V4-Pro Curator A/B test on CMP-78 ==="
echo "HERMES_HOME=$HERMES_HOME"
echo "TASK_ID=$PAPERCLIP_TASK_ID"
echo "RUN_ID=$PAPERCLIP_RUN_ID"
echo "Starting Hermes..."
echo "---"
hermes -z "$PROMPT" --yolo chat 2>&1

View File

@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# A/B test runner #2: DeepSeek V4-Pro on CMP-78 — WITH interaction step
# (matching the full Sonnet baseline workflow on CMP-80, including ask_user_questions).
set -euo pipefail
PROFILE_HOME="/home/chaim/.hermes/profiles/curator-cmp-deepseek"
PAPERCLIP_API_URL="http://localhost:3100/api"
PAPERCLIP_API_KEY="pcp_c87edcf306d06fce13fac701bb6d747191d61dba5b51e903"
PAPERCLIP_TASK_ID="beb745e5-7195-40c5-9ac0-e9682c2c5184" # CMP-78
PAPERCLIP_TASK_KEY="$PAPERCLIP_TASK_ID"
PAPERCLIP_TASK_TITLE="[ערר 1130-25] סקירת ידע — DeepSeek V4-Pro test #2 (with interaction)"
PAPERCLIP_RUN_ID="deepseek-ab2-$(date +%s)"
PAPERCLIP_WAKE_REASON="manual_deepseek_ab_test_v2_with_interaction"
read -r -d '' PROMPT <<'EOF' || true
אתה מנהל ידע (Knowledge Curator) של ועדת הערר. נעור על תיק שדפנה סימנה כסופי.
תיק: [ערר 1130-25] סקירת ידע — Knowledge Curator
issue ID: beb745e5-7195-40c5-9ac0-e9682c2c5184
run reason: manual_deepseek_ab_test_v2_with_interaction
**הקשר חשוב — ניסוי A/B #2:** זוהי ריצה שנייה ידנית באמצעות DeepSeek V4-Pro, הפעם **עם interaction מלא** כדי להשוות הוגנת מול ריצת Sonnet ב-CMP-80. כל הפלטים שתפרסם חייבים להתחיל בכותרת `[ניסוי DeepSeek V4-Pro #2 — עם interaction]`. אל תעיר סוכנים אחרים. אל תיצור issues חדשים.
הוראות:
דפנה סימנה את ההחלטה הסופית של תיק 1130-25 כסופית.
קובץ סופי: `סופי-1130-25.docx`
סקור את ההחלטה מול skills/decision/SKILL.md ו-docs/legal-decision-lessons.md.
חפש 3-5 דפוסי סגנון/דיון שלא תועדו. כתוב comment בעברית, ניטרלי, ממוספר.
# שלבי ביצוע
## 1. קונטקסט
- קרא את MEMORY.md שלך (memory tool) — מה כבר זיהית.
- קרא `/home/chaim/legal-ai/skills/decision/SKILL.md` (file tool) — מה כבר תועד.
## 2. נתונים
- `mcp__legal-ai__case_get` עם case_number `1130-25` — מטא-דאטה.
- `mcp__legal-ai__case_get_final_text` עם case_number `1130-25` — קרא את הטקסט המלא של ההחלטה הסופית.
## 3. ניתוח
חפש 3-5 דפוסים/פערים. לכל ממצא: מה ראיתי + מה זה אומר + הצעה ניסוחית מדויקת.
## 4. כתוב comment הממצאים
```bash
curl -sS -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
-d "$(jq -n --arg b "$BODY" '{body:$b}')" \
"$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID/comments"
```
פורמט ה-body:
- שורה ראשונה: `[ניסוי DeepSeek V4-Pro #2 — עם interaction]`
- אחר כך פסקה אחת מבוא קצרה
- אחר כך הממצאים ממוספרים
## 5. פתח interaction מסוג ask_user_questions
זה השלב שעבד את Sonnet הרבה זמן — בוא נראה כמה זמן יקח לך.
```bash
curl -sS -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
"$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID/interactions" \
-d '{
"kind": "ask_user_questions",
"idempotencyKey": "curator-deepseek-v2:'"$PAPERCLIP_TASK_ID"':select",
"title": "[DeepSeek] איזה ממצאים שווים עדכון?",
"continuationPolicy": "wake_assignee",
"payload": {
"version": 1,
"submitLabel": "אשר בחירה",
"questions": [{
"id": "findings_to_propose",
"prompt": "סמן את הממצאים שאני אכין כהצעת עדכון ל-style guide",
"selectionMode": "multi",
"options": [
{"id":"f1","label":"<מילוי לפי ממצא 1>","description":"<תקציר>"},
{"id":"f2","label":"<מילוי לפי ממצא 2>","description":"<תקציר>"}
]
}]
}
}'
```
מלא את ה-options לפי הממצאים שלך — אופציה אחת לכל ממצא ממוספר.
## 6. עדכן issue ל-status=in_review (לא done — ממתינים לבחירת חיים)
```bash
curl -sS -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
-d '{"status":"in_review"}' "$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID"
```
# כללים
- אל תעדכן קבצים (skills/, lessons.py, DB) בעצמך. רק comment + interaction.
- אל תיצור issues חדשים.
- אל תעיר סוכנים אחרים.
- בעיה? comment קצר עם הסיבה + סגור (status=done).
EOF
export HERMES_HOME="$PROFILE_HOME"
export PAPERCLIP_API_URL PAPERCLIP_API_KEY PAPERCLIP_TASK_ID PAPERCLIP_TASK_KEY \
PAPERCLIP_TASK_TITLE PAPERCLIP_RUN_ID PAPERCLIP_WAKE_REASON
echo "=== DeepSeek V4-Pro #2 (with interaction) — CMP-78 ==="
echo "HERMES_HOME=$HERMES_HOME"
echo "TASK_ID=$PAPERCLIP_TASK_ID"
echo "RUN_ID=$PAPERCLIP_RUN_ID"
echo "Started: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
echo "---"
START_EPOCH=$(date +%s)
hermes -z "$PROMPT" --yolo chat 2>&1
END_EPOCH=$(date +%s)
DURATION=$((END_EPOCH - START_EPOCH))
echo ""
echo "=== Run finished ==="
echo "Ended: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
echo "Duration: ${DURATION}s ($((DURATION/60))m $((DURATION%60))s)"

View File

@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# A/B test #3: Sonnet 4.5 re-run on CMP-78 — same task as DeepSeek #2 but with Sonnet.
# Goal: check if Sonnet is consistent across runs (esp. the case-outcome detection),
# given that the original Sonnet baseline on CMP-80 misread the outcome as "דחייה"
# while the actual result is "קבלה חלקית".
set -euo pipefail
PROFILE_HOME="/home/chaim/.hermes/profiles/curator-cmp" # default Sonnet profile
PAPERCLIP_API_URL="http://localhost:3100/api"
PAPERCLIP_API_KEY="pcp_c87edcf306d06fce13fac701bb6d747191d61dba5b51e903"
PAPERCLIP_TASK_ID="beb745e5-7195-40c5-9ac0-e9682c2c5184" # CMP-78
PAPERCLIP_TASK_KEY="$PAPERCLIP_TASK_ID"
PAPERCLIP_TASK_TITLE="[ערר 1130-25] סקירת ידע — Sonnet rerun (consistency check)"
PAPERCLIP_RUN_ID="sonnet-rerun-$(date +%s)"
PAPERCLIP_WAKE_REASON="manual_sonnet_consistency_rerun"
read -r -d '' PROMPT <<'EOF' || true
אתה מנהל ידע (Knowledge Curator) של ועדת הערר. נעור על תיק שדפנה סימנה כסופי.
תיק: [ערר 1130-25] סקירת ידע — Knowledge Curator
issue ID: beb745e5-7195-40c5-9ac0-e9682c2c5184
run reason: manual_sonnet_consistency_rerun
**הקשר חשוב — ניסוי A/B #3:** זוהי ריצה חוזרת ידנית באמצעות Sonnet 4.5 (אותו מודל שהריץ ב-CMP-80) — בדיקת עקביות. כל הפלטים שתפרסם חייבים להתחיל בכותרת `[ניסוי Sonnet 4.5 — ריצה חוזרת על CMP-78]`. אל תעיר סוכנים אחרים. אל תיצור issues חדשים.
הוראות:
דפנה סימנה את ההחלטה הסופית של תיק 1130-25 כסופית.
קובץ סופי: `סופי-1130-25.docx`
סקור את ההחלטה מול skills/decision/SKILL.md ו-docs/legal-decision-lessons.md.
חפש 3-5 דפוסי סגנון/דיון שלא תועדו. כתוב comment בעברית, ניטרלי, ממוספר.
# שלבי ביצוע
## 1. קונטקסט
- קרא את MEMORY.md שלך (memory tool) — מה כבר זיהית.
- קרא `/home/chaim/legal-ai/skills/decision/SKILL.md` (file tool) — מה כבר תועד.
## 2. נתונים
- `mcp__legal-ai__case_get` עם case_number `1130-25` — מטא-דאטה.
- `mcp__legal-ai__case_get_final_text` עם case_number `1130-25` — קרא את הטקסט המלא של ההחלטה הסופית.
**שים לב במיוחד**: זהה במדויק את **תוצאת ההחלטה** (קבלה / קבלה חלקית / דחייה) על סמך הטקסט עצמו, לא על סמך הנחות.
## 3. ניתוח
חפש 3-5 דפוסים/פערים. לכל ממצא: מה ראיתי + מה זה אומר + הצעה ניסוחית מדויקת.
## 4. כתוב comment הממצאים
```bash
curl -sS -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
-d "$(jq -n --arg b "$BODY" '{body:$b}')" \
"$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID/comments"
```
פורמט ה-body:
- שורה ראשונה: `[ניסוי Sonnet 4.5 — ריצה חוזרת על CMP-78]`
- שורה שנייה: `**תוצאת ההחלטה הזו: <קבלה / קבלה חלקית / דחייה>** — ציין מפורשות
- אחר כך פסקה אחת מבוא קצרה
- אחר כך הממצאים ממוספרים
## 5. פתח interaction מסוג ask_user_questions
זהה לפלואו של Sonnet באמת. אם תקבל "Agent run id required" — נסה כמה דרכים, ואם לא הולך, פרסם comment עם רשימת אופציות לבחירה.
```bash
curl -sS -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
"$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID/interactions" \
-d '{
"kind": "ask_user_questions",
"idempotencyKey": "curator-sonnet-rerun:'"$PAPERCLIP_TASK_ID"':select",
"title": "[Sonnet rerun] איזה ממצאים שווים עדכון?",
"continuationPolicy": "wake_assignee",
"payload": {"version": 1, "submitLabel": "אשר בחירה",
"questions": [{"id": "findings_to_propose", "prompt": "סמן ממצאים", "selectionMode": "multi", "options": []}]}}'
```
## 6. עדכן issue ל-status=in_review
```bash
curl -sS -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
-d '{"status":"in_review"}' "$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID"
```
# כללים
- אל תעדכן קבצים בעצמך. רק comment + interaction.
- אל תיצור issues חדשים.
- אל תעיר סוכנים אחרים.
EOF
export HERMES_HOME="$PROFILE_HOME"
export PAPERCLIP_API_URL PAPERCLIP_API_KEY PAPERCLIP_TASK_ID PAPERCLIP_TASK_KEY \
PAPERCLIP_TASK_TITLE PAPERCLIP_RUN_ID PAPERCLIP_WAKE_REASON
echo "=== Sonnet 4.5 rerun (consistency check) — CMP-78 ==="
echo "HERMES_HOME=$HERMES_HOME"
echo "TASK_ID=$PAPERCLIP_TASK_ID"
echo "RUN_ID=$PAPERCLIP_RUN_ID"
echo "Started: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
echo "---"
START_EPOCH=$(date +%s)
hermes -z "$PROMPT" --yolo chat 2>&1
END_EPOCH=$(date +%s)
DURATION=$((END_EPOCH - START_EPOCH))
echo ""
echo "=== Run finished ==="
echo "Ended: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
echo "Duration: ${DURATION}s ($((DURATION/60))m $((DURATION%60))s)"

View File

@@ -8,6 +8,10 @@
| Script | Type | Purpose | Scheduled | | Script | Type | Purpose | Scheduled |
|--------|------|---------|-----------| |--------|------|---------|-----------|
| `pc.sh` | bash | **wrapper לכל קריאות Paperclip API מסוכנים** — מוסיף Authorization, X-Paperclip-Run-Id (audit trail), Content-Type, base URL. תחביר: `pc.sh <METHOD> <PATH> [BODY_JSON]`. אסור `curl` ישיר ל-`$PAPERCLIP_API_URL`. ראה `HEARTBEAT.md §0`. counterpart ב-Python: `web/paperclip_api.py`. | נקרא ע"י סוכנים |
| `sync_missing_agent_skills.py` | python | סקריפט "אל-כשל" להוספת `paperclipSkillSync` ל-`הגהת מסמכים` ו-`מנתח משפטי` שפיספסו את ה-sync ההיסטורי (Gap #28). תומך `--verify`/`--dry-run`/`--apply`. גיבוי אוטומטי ל-`agents-pre-skill-sync-*.sql`. דורש `PAPERCLIP_BOARD_API_KEY` (Infisical /paperclip ב-nautilus env). idempotent. | חד-פעמי (בוצע 2026-05-04). שמור לרפרנס |
| `sync_agents_across_companies.py` | python | **סנכרון סוכנים מ-CMP (1xxx, master) ל-CMPA (8xxx, mirror)** — Gap #25. משווה adapter_config (model/timeout/instructions/skills/etc), runtime_config (heartbeat), ושדות top-level (budget/metadata/icon/title/role). מסנן אוטומטית local skills שלא קיימים ב-mirror. לוגיקת subset (mirror יכול להחזיק יותר skills כי ה-API מוסיף required runtime skills). תומך `--verify`/`--dry-run`/`--apply [--only NAME]`. גיבוי אוטומטי. דורש `PAPERCLIP_BOARD_API_KEY`. **להריץ אחרי כל שינוי הגדרות ב-CMP.** **⚠ אם `adapter_type` שונה בין CMP ל-CMPA — הסקריפט מדלג על הסוכן עם warning. בעת מעבר adapter (למשל ל-`deepseek_local`) חובה לעדכן ידנית בשתי החברות לפני sync.** | ידני אחרי כל שינוי |
| `fix_paperclipai_skills_drift.py` | python | סקריפט חד-פעמי (בוצע 2026-05-04) שניקה drift על `paperclipai/*` skills בין CMP ל-CMPA. הסיר `paperclip-dev` מכל 14 הסוכנים, ודאג ש-`paperclip-converting-plans-to-tasks` קיים רק על CEO ו-analyst. תומך `--apply` (ברירת מחדל: dry-run). דורש `PAPERCLIP_BOARD_API_KEY`. נשמר לרפרנס למקרה שhdrift חוזר. | חד-פעמי (בוצע) |
| `auto-sync-cases.sh` | bash | סנכרון תיקי ערר ל-Gitea — רץ כל דקה | `* * * * *` (cron) | | `auto-sync-cases.sh` | bash | סנכרון תיקי ערר ל-Gitea — רץ כל דקה | `* * * * *` (cron) |
| `backup-db.sh` | bash | גיבוי PostgreSQL יומי ל-`data/backups/` (gzip) | לתזמן: `0 2 * * *` | | `backup-db.sh` | bash | גיבוי PostgreSQL יומי ל-`data/backups/` (gzip) | לתזמן: `0 2 * * *` |
| `restore-db.sh` | bash | שחזור DB מגיבוי (companion ל-backup-db.sh) | ידני | | `restore-db.sh` | bash | שחזור DB מגיבוי (companion ל-backup-db.sh) | ידני |
@@ -16,6 +20,14 @@
| `convert_decision_template.py` | python | המרת `data/training/טיוטת החלטה.dotx``skills/docx/decision_template.docx` לטעינה ב-python-docx | להריץ כשמתעדכנת התבנית | | `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 | ידני | | `deploy-track-changes.sh` | bash | סנכרון skills CMP↔CMPA + בדיקות + הנחיות deploy לארכיטקטורת Track Changes | ידני |
| `retrofit_case.py` | python | retrofit רטרואקטיבי — מזריק bookmarks לקובץ קיים של תיק ספציפי ומגדיר אותו כ-active_draft | ידני (חד-פעמי לתיק) | | `retrofit_case.py` | python | retrofit רטרואקטיבי — מזריק bookmarks לקובץ קיים של תיק ספציפי ומגדיר אותו כ-active_draft | ידני (חד-פעמי לתיק) |
| `reembed_voyage.py` | python | Re-embed כל הוקטורים ב-DB עם המודל ב-`VOYAGE_MODEL` (לאחר שינוי מודל). 5 טבלאות, 1024 דמ', batches של 100. ראה `docs/voyage-upgrades-plan.md` | ידני (אחרי החלפת `VOYAGE_MODEL`) |
| `voyage_context3_poc.py` | python | POC #1 — voyage-3 vs voyage-context-3 על פסיקה אחת קצרה (קלמנוביץ, 63 chunks). הכרעה: context-3 לא מציג שיפור עקבי | בנצ'מרק חד-פעמי, נשמר לרפרנס |
| `voyage_context3_poc_long.py` | python | POC #2 — voyage-context-3 על פסיקה ארוכה (אהרון ברק 219 chunks) עם sliding windows. הכרעה: context-3 לא משתפר על פסיקה גדולה | בנצ'מרק חד-פעמי, נשמר לרפרנס |
| `voyage_multimodal_poc.py` | python | POC #3 — voyage-multimodal-3 על דוח שמאי (89 עמודים). הכרעה: שיפור משמעותי לטבלאות + 22 עמודי image-only שhttp text-OCR מאבד | בנצ'מרק חד-פעמי, מוכן לשלב C |
| `voyage_rerank_judge_poc.py` | python | POC #4 — voyage-3 vs rerank-2 vs context-3 על אהרון ברק, 18 שאילתות, claude-haiku-4-5 כ-judge. הכרעה: rerank-2 ניצח עם +9% mean@3 | בנצ'מרק חד-פעמי |
| `voyage_rerank_corpus_poc.py` | python | POC #5 — voyage-3 vs rerank-2 על קורפוס מלא (785 docs). הכרעה: +4.5% mean@3 כללי, +11.6% על P queries (practical) | בנצ'מרק חד-פעמי, אישר את שלב B |
| `multimodal_backfill.py` | python | Backfill voyage-multimodal-3 page embeddings על מסמכי תיקים קיימים. idempotent (skips by default), forces `MULTIMODAL_ENABLED=true` ל-run, רץ מהקונטיינר. שלב C — ראה `docs/voyage-upgrades-plan.md` | ידני per-case (`python multimodal_backfill.py 8174-24 8137-24`) |
| `backfill_chunk_pages.py` | python | Backfill `page_number` ב-`document_chunks` קיימים. legacy chunker לא tracked עמודים → `page_number=NULL` חוסם boost של multimodal hybrid (text+image join על אותו עמוד). re-extracts כל PDF (re-OCR אם צריך, ~$0.0015/page), מחשב page_offsets, ומעדכן chunks. idempotent | ידני per-case (`python backfill_chunk_pages.py 8174-24 8137-24`) |
## תיקיית `.archive/` — סקריפטים שהושלמו ## תיקיית `.archive/` — סקריפטים שהושלמו
@@ -32,6 +44,7 @@
| `export-decision-docx.py` | ייצוא החלטה ל-DOCX | MCP: `export_docx()` | | `export-decision-docx.py` | ייצוא החלטה ל-DOCX | MCP: `export_docx()` |
| `extract-citations.py` | חילוץ ציטוטי פסיקה מבלוק י | MCP service: `references_extractor.py` | | `extract-citations.py` | חילוץ ציטוטי פסיקה מבלוק י | MCP service: `references_extractor.py` |
| `extract-claims.py` | חילוץ טענות מבלוק ז | MCP: `extract_claims()` + `claims_extractor.py` | | `extract-claims.py` | חילוץ טענות מבלוק ז | MCP: `extract_claims()` + `claims_extractor.py` |
| `extract_claims_8174.py` | חד-פעמי — חילוץ טענות חסרות לתיק 8174-24 אחרי timeout של האנליסט (43 טענות עורר נוספו 30/04/26) | phase 1: `claude_session` async + 30min timeout + chunking סמנטי |
| `extract_all_google_vision.py` | OCR בכמות עם Google Vision | MCP: `document_upload()` pipeline | | `extract_all_google_vision.py` | OCR בכמות עם Google Vision | MCP: `document_upload()` pipeline |
| `extract_originals.py` | חילוץ טקסט מ-PDF עם Claude Opus | MCP service: `extractor.py` | | `extract_originals.py` | חילוץ טקסט מ-PDF עם Claude Opus | MCP service: `extractor.py` |
| `extract_originals_ocr.py` | חילוץ OCR מלא מ-PDF | MCP service: `extractor.py` | | `extract_originals_ocr.py` | חילוץ OCR מלא מ-PDF | MCP service: `extractor.py` |
@@ -41,6 +54,9 @@
| `seed-appeals.py` | seeding תיקי ערר ראשוניים ל-DB | MCP: `case_create()` | | `seed-appeals.py` | seeding תיקי ערר ראשוניים ל-DB | MCP: `case_create()` |
| `seed-knowledge.py` | seeding לקחים, ביטויי מעבר, פסיקה | MCP: `record_chair_feedback()`, `precedent_attach()` | | `seed-knowledge.py` | seeding לקחים, ביטויי מעבר, פסיקה | MCP: `record_chair_feedback()`, `precedent_attach()` |
| `validate-decision.py` | ולידציה מול block-schema | MCP: `validate_decision()` + `qa_validator.py` | | `validate-decision.py` | ולידציה מול block-schema | MCP: `validate_decision()` + `qa_validator.py` |
| `run_curator_deepseek_test.sh` | A/B test #1 (2026-05-05) — Hermes Curator על CMP-78 דרך DeepSeek V4-Pro ב-`provider:custom`, ללא interaction. תוצאה: 6:33 דק׳, 5 ממצאי סגנון/לקסיקון, פי 3 מהיר מ-Sonnet baseline (CMP-80) ופי ~20 זול. **הסקריפט נקודתי לתיק 1130-25 — לא להריץ שוב** | החלפת Curator לאדפטר DeepSeek מקומי (בתהליך) |
| `run_curator_deepseek_test_v2.sh` | A/B test #2 (2026-05-05) — אותו run אבל עם interaction. תוצאה: 9:08 דק׳, 5 ממצאים, היחיד מ-4 הריצות שזיהה תוצאה עובדתית נכונה (קבלה חלקית). interaction נכשל ב-API ("Agent run id required" בריצה ידנית). | החלפת Curator לאדפטר DeepSeek מקומי |
| `run_curator_sonnet_rerun.sh` | A/B test #3 (2026-05-05) — ריצה חוזרת של Sonnet 4.5 על אותו CMP-78. תוצאה: 12:52 דק׳ (לעומת 20:13 בריצה המקורית — כי בלי לולאת interaction.json). זיהה תוצאה שגויה ("דחייה") **בעקביות עם הריצה המקורית** — Sonnet עקבי-בטעות, DeepSeek אקראי. | בדיקה חד-פעמית — לא להריץ שוב |
## סקריפטים שנמחקו (git history בלבד) ## סקריפטים שנמחקו (git history בלבד)

View File

@@ -0,0 +1,346 @@
"""Backfill page_number on existing document_chunks (no re-OCR).
Why this exists: the legacy chunker did not track which page each chunk
came from. After the page-tracking fix, new uploads carry page_number
correctly, but existing chunks have ``page_number=NULL`` in the DB.
That blocks the multimodal hybrid retriever's text+image boost (which
joins (chunk, image) on (document_id, page_number)).
What it does (per case, per document):
1. Load stored ``documents.extracted_text`` from the DB. This is
the exact text that was used to produce the existing chunks —
so chunk content lookups against it match verbatim.
2. Open the PDF with PyMuPDF and call ``page.get_text()`` on each
page (cheap, no OCR). For pages with usable direct text we get
a clean snippet; for fully-scanned pages we get little/nothing.
3. Anchor: for each page with a usable snippet, search the snippet
in ``extracted_text`` to recover that page's start offset.
4. Interpolate: for OCR-only pages with no anchor, position is
linearly interpolated between the nearest anchored neighbors
(or uniformly when no anchors exist at all).
5. For every chunk row (sorted by chunk_index), find the chunk's
content in ``extracted_text`` (verbatim match), look up the
page from the offsets, and ``UPDATE document_chunks SET
page_number = ?``.
Idempotent: a second run with no --force is a no-op.
Cost: zero. Runs in seconds even for the 89-page appraisal report.
Usage:
docker cp scripts/backfill_chunk_pages.py <c>:/tmp/
docker exec <c> python /tmp/backfill_chunk_pages.py 8174-24 8137-24
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import sys
import time
from pathlib import Path
from uuid import UUID
def _setup_paths():
here = Path(__file__).resolve().parent
mcp_src = here.parent / "mcp-server" / "src"
if mcp_src.is_dir() and str(mcp_src) not in sys.path:
sys.path.insert(0, str(mcp_src))
_setup_paths()
import fitz # PyMuPDF # noqa: E402
from legal_mcp.services import db # noqa: E402
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
logger = logging.getLogger("backfill_chunk_pages")
# Snippet length for page anchoring. Long enough to be unique, short
# enough to survive minor whitespace variation between PyMuPDF direct
# extraction and the stored OCR text.
ANCHOR_SNIPPET_LEN = 80
# Minimum direct-text length on a page to attempt anchoring at all.
MIN_DIRECT_LEN = 60
def _resolve_local_path(db_path: str) -> Path:
p = Path(db_path)
if p.is_file():
return p
if str(p).startswith("/data/"):
local = Path("/home/chaim/legal-ai") / Path(*p.parts[1:])
if local.is_file():
return local
return p
def _norm_whitespace(s: str) -> str:
"""Collapse runs of whitespace; helps cross-source matching where
PyMuPDF direct extraction may differ from the stored OCR text in
line-break placement."""
return " ".join(s.split())
def _find_anchored_snippet(
extracted_text: str, snippet: str, search_start: int = 0,
) -> int:
"""Search for ``snippet`` in ``extracted_text``, tolerant to
whitespace differences. Returns the offset in the original
extracted_text, or -1."""
# Direct match first — fastest path
idx = extracted_text.find(snippet, search_start)
if idx >= 0:
return idx
# Whitespace-normalized fallback
norm_text = _norm_whitespace(extracted_text)
norm_snip = _norm_whitespace(snippet)
if not norm_snip:
return -1
norm_idx = norm_text.find(norm_snip)
if norm_idx < 0:
return -1
# Map norm offset back to original — count chars until we've passed
# `norm_idx` non-collapsed characters in the original.
orig_pos = 0
norm_pos = 0
in_ws = False
for ch in extracted_text:
if norm_pos == norm_idx:
return orig_pos
if ch.isspace():
if not in_ws:
norm_pos += 1
in_ws = True
else:
in_ws = False
norm_pos += 1
orig_pos += 1
return -1
def _compute_page_offsets(pdf_path: Path, extracted_text: str) -> list[int]:
"""Return ``page_offsets`` (start char offset of each page in
``extracted_text``), using direct PyMuPDF reads for anchoring and
linear interpolation for OCR-only pages."""
doc = fitz.open(str(pdf_path))
n_pages = len(doc)
anchors: list[int | None] = [None] * n_pages
last_pos = 0
for i, page in enumerate(doc):
direct = page.get_text().strip()
if len(direct) < MIN_DIRECT_LEN:
continue
# Take the first ANCHOR_SNIPPET_LEN chars after stripping
snippet = direct[:ANCHOR_SNIPPET_LEN]
pos = _find_anchored_snippet(extracted_text, snippet, last_pos)
if pos < 0:
# try a global search before giving up
pos = _find_anchored_snippet(extracted_text, snippet, 0)
if pos >= 0:
anchors[i] = pos
last_pos = pos
doc.close()
# Force first page to start at 0 if not already anchored
if anchors[0] is None:
anchors[0] = 0
# Fill gaps via linear interpolation between the nearest anchors;
# extrapolate beyond the last anchor by the average page length.
page_offsets: list[int] = [0] * n_pages
for i in range(n_pages):
if anchors[i] is not None:
page_offsets[i] = anchors[i]
continue
# Find prev anchored
prev_i = i - 1
while prev_i >= 0 and anchors[prev_i] is None:
prev_i -= 1
# Find next anchored
next_i = i + 1
while next_i < n_pages and anchors[next_i] is None:
next_i += 1
prev_pos = anchors[prev_i] if prev_i >= 0 else 0
if next_i < n_pages:
next_pos = anchors[next_i]
ratio = (i - prev_i) / (next_i - prev_i)
page_offsets[i] = int(prev_pos + ratio * (next_pos - prev_pos))
else:
# Extrapolate: assume uniform distribution beyond last anchor
# using page-density inferred from prior anchors (or fall
# back to total_text/n_pages).
avg = len(extracted_text) / max(1, n_pages)
page_offsets[i] = int(prev_pos + avg * (i - prev_i))
# Monotone-clip just in case interpolation ever goes backwards
for i in range(1, n_pages):
if page_offsets[i] < page_offsets[i - 1]:
page_offsets[i] = page_offsets[i - 1]
return page_offsets
def _page_at_offset(offset: int, page_offsets: list[int]) -> int:
if not page_offsets:
return 1
page = 1
for i, start in enumerate(page_offsets):
if start <= offset:
page = i + 1
else:
break
return page
async def _backfill_document(
document_id: UUID,
title: str,
db_file_path: str,
force: bool,
) -> dict:
pool = await db.get_pool()
chunks = await pool.fetch(
"SELECT id, chunk_index, content, page_number FROM document_chunks "
"WHERE document_id = $1 ORDER BY chunk_index",
document_id,
)
if not chunks:
return {"status": "no_chunks"}
n_null = sum(1 for c in chunks if c["page_number"] is None)
if not force and n_null == 0:
logger.info(" skip (all %d chunks already tagged): %s", len(chunks), title)
return {"status": "skipped", "chunks": len(chunks)}
pdf_path = _resolve_local_path(db_file_path)
if not pdf_path.is_file():
logger.warning(" file missing: %s (%s)", pdf_path, title)
return {"status": "missing"}
if pdf_path.suffix.lower() != ".pdf":
return {"status": "not_pdf"}
doc_row = await pool.fetchrow(
"SELECT extracted_text FROM documents WHERE id = $1", document_id,
)
extracted_text = doc_row["extracted_text"] if doc_row else None
if not extracted_text:
return {"status": "no_extracted_text"}
t0 = time.time()
page_offsets = _compute_page_offsets(pdf_path, extracted_text)
n_anchored = sum(1 for i in range(len(page_offsets)) if i == 0 or page_offsets[i] > page_offsets[i - 1])
# The chunker joins paragraphs with single `\n` while extracted_text
# has `\n\n` between pages, so verbatim search misses cross-page
# chunks. Use the whitespace-tolerant helper that returns an offset
# in the *original* text.
pos = 0
updated = 0
not_found = 0
for c in chunks:
content = c["content"]
if not content:
continue
# Use a unique slice from the chunk to anchor in extracted_text
# — anchoring on the chunk's first ~120 chars is enough to
# disambiguate across the document.
snippet = content[: min(len(content), 120)]
idx = _find_anchored_snippet(extracted_text, snippet, pos)
if idx < 0:
idx = _find_anchored_snippet(extracted_text, snippet, 0)
if idx < 0:
not_found += 1
continue
page = _page_at_offset(idx, page_offsets)
await pool.execute(
"UPDATE document_chunks SET page_number = $1 WHERE id = $2",
page, c["id"],
)
updated += 1
pos = idx + max(1, len(content) // 2)
elapsed = time.time() - t0
logger.info(
" %s%d pages, %d anchors, updated %d/%d chunks (%d not found) in %.2fs",
title, len(page_offsets), n_anchored, updated, len(chunks), not_found, elapsed,
)
return {
"status": "ok",
"elapsed_sec": round(elapsed, 2),
"pages": len(page_offsets),
"anchors": n_anchored,
"chunks_total": len(chunks),
"chunks_updated": updated,
"chunks_not_found": not_found,
}
async def backfill_cases(case_numbers: list[str], force: bool) -> dict:
pool = await db.get_pool()
summary: dict = {}
for cn in case_numbers:
logger.info("=" * 60)
logger.info("Case %s", cn)
case = await db.get_case_by_number(cn)
if not case:
logger.warning("Case not found: %s", cn)
summary[cn] = {"status": "case_not_found"}
continue
case_id = UUID(str(case["id"]))
docs = await pool.fetch(
"SELECT id, title, file_path FROM documents WHERE case_id = $1 ORDER BY title",
case_id,
)
logger.info(" %d documents", len(docs))
per_doc: list[dict] = []
for d in docs:
r = await _backfill_document(
UUID(str(d["id"])), d["title"], d["file_path"], force,
)
per_doc.append({"document_id": str(d["id"]), "title": d["title"], **r})
summary[cn] = {
"documents_total": len(docs),
"ok": sum(1 for r in per_doc if r["status"] == "ok"),
"skipped": sum(1 for r in per_doc if r["status"] == "skipped"),
"missing": sum(1 for r in per_doc if r["status"] == "missing"),
"no_chunks": sum(1 for r in per_doc if r["status"] == "no_chunks"),
"no_extracted_text": sum(1 for r in per_doc if r["status"] == "no_extracted_text"),
"chunks_updated": sum(r.get("chunks_updated", 0) for r in per_doc),
"documents": per_doc,
}
return summary
def main():
parser = argparse.ArgumentParser(description="Backfill page_number on existing chunks (no OCR)")
parser.add_argument("cases", nargs="+", help="Case numbers (e.g. 8174-24 8137-24)")
parser.add_argument(
"--force", action="store_true",
help="Re-process even if all chunks already have page_number (default: skip)",
)
args = parser.parse_args()
summary = asyncio.run(backfill_cases(args.cases, force=args.force))
print()
print("=" * 60)
print("SUMMARY")
print("=" * 60)
for cn, s in summary.items():
if s.get("status") == "case_not_found":
print(f" {cn}: NOT FOUND")
continue
print(
f" {cn}: {s['documents_total']} docs — "
f"ok {s['ok']}, skipped {s['skipped']}, "
f"missing {s['missing']}, chunks_updated {s['chunks_updated']}"
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Fix paperclipai/* skill drift across CMP+CMPA agents.
Goal: zero drift on paperclipai/* skills between master(CMP) and mirror(CMPA).
Rules:
* Remove ``paperclipai/paperclip/paperclip-dev`` from all 14 agents (not relevant
for legal work — it's for maintaining Paperclip itself).
* Ensure ``paperclipai/paperclip/paperclip-converting-plans-to-tasks`` exists
on CEO + analyst agents in both companies (planning skill).
* Remove ``paperclipai/paperclip/paperclip-converting-plans-to-tasks`` from any
other agent in either company that currently has it.
Local/* and company/* skills are not touched — they're scoped to a company
by design and drift is expected.
Usage::
PAPERCLIP_BOARD_API_KEY=pbk_... python scripts/fix_paperclipai_skills_drift.py # dry-run
PAPERCLIP_BOARD_API_KEY=pbk_... python scripts/fix_paperclipai_skills_drift.py --apply # commit
"""
from __future__ import annotations
import argparse
import asyncio
import os
import sys
import httpx
PAPERCLIP_API_URL = os.environ.get("PAPERCLIP_API_URL", "http://localhost:3100")
PAPERCLIP_BOARD_API_KEY = os.environ.get("PAPERCLIP_BOARD_API_KEY")
COMPANIES = {
"licensing": ("CMP ", "42a7acd0-30c5-4cbd-ac97-7424f65df294"),
"betterment": ("CMPA", "8639e837-4c9d-47fa-a76b-95788d651896"),
}
DEV_SKILL = "paperclipai/paperclip/paperclip-dev"
CONVERTING_SKILL = "paperclipai/paperclip/paperclip-converting-plans-to-tasks"
# Hebrew names of the agents that should retain converting-plans-to-tasks.
CONVERTING_TARGETS = {"עוזר משפטי", "מנתח משפטי"}
def headers() -> dict[str, str]:
if not PAPERCLIP_BOARD_API_KEY:
sys.exit("PAPERCLIP_BOARD_API_KEY not set — fetch from Infisical first.")
return {
"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}",
"Content-Type": "application/json",
}
async def fetch_company_agents(client: httpx.AsyncClient, company_id: str) -> list[dict]:
r = await client.get(f"{PAPERCLIP_API_URL}/api/companies/{company_id}/agents", headers=headers())
r.raise_for_status()
return r.json()
def compute_changes(agent: dict) -> tuple[bool, list[str], list[str]]:
skill_sync = (agent.get("adapterConfig") or {}).get("paperclipSkillSync") or {}
old = list(skill_sync.get("desiredSkills") or [])
new = [s for s in old if s != DEV_SKILL]
if agent["name"] in CONVERTING_TARGETS:
if CONVERTING_SKILL not in new:
new.append(CONVERTING_SKILL)
else:
new = [s for s in new if s != CONVERTING_SKILL]
return (sorted(old) != sorted(new), old, new)
async def patch_agent(
client: httpx.AsyncClient, agent_id: str, current_skill_sync: dict, new_skills: list[str]
) -> None:
body = {
"adapterConfig": {
"paperclipSkillSync": {**current_skill_sync, "desiredSkills": new_skills},
}
}
r = await client.patch(
f"{PAPERCLIP_API_URL}/api/agents/{agent_id}", headers=headers(), json=body, timeout=15
)
r.raise_for_status()
async def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--apply", action="store_true", help="commit changes (default: dry-run)")
args = parser.parse_args()
mode = "APPLY" if args.apply else "DRY-RUN"
print(f"=== {mode}: fixing paperclipai/* skill drift ===\n")
async with httpx.AsyncClient(timeout=15) as client:
all_agents: list[dict] = []
for label, (_, cid) in COMPANIES.items():
agents = await fetch_company_agents(client, cid)
for a in agents:
a["_company_label"] = COMPANIES[label][0]
all_agents.extend(agents)
changes_planned = 0
for a in sorted(all_agents, key=lambda x: (x["_company_label"], x["name"])):
changed, old, new = compute_changes(a)
label = a["_company_label"]
if not changed:
print(f" {label} {a['name']:20} no change")
continue
changes_planned += 1
removed = sorted(set(old) - set(new))
added = sorted(set(new) - set(old))
print(f" {label} {a['name']:20} -{len(removed)} +{len(added)}")
for s in removed:
print(f" - {s}")
for s in added:
print(f" + {s}")
if args.apply:
skill_sync = (a.get("adapterConfig") or {}).get("paperclipSkillSync") or {}
try:
await patch_agent(client, a["id"], skill_sync, new)
print(" ✓ patched")
except httpx.HTTPStatusError as e:
print(f" ✗ failed: {e.response.status_code} {e.response.text[:200]}")
raise
print(f"\n{mode}: {changes_planned} agents would change")
if not args.apply and changes_planned > 0:
print("Run with --apply to commit.")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,186 @@
"""Multimodal backfill — embed page images for existing case documents.
Iterates over documents already in the DB and renders + embeds + stores
per-page voyage-multimodal-3 vectors. Skips documents that already have
image embeddings (idempotent).
Independent of the processor pipeline — does NOT re-extract text or
re-chunk; only the multimodal step.
Designed to run from inside the FastAPI/MCP container (where /data is
mounted and writable). Locally it requires sudo for the thumbnails dir
under /home/chaim/legal-ai/data/cases/...
Usage::
# In container (Coolify):
docker exec -it <legal-ai-container> python -m legal_mcp.cli \\
multimodal_backfill --cases 8174-24 8137-24
# Or as a script (sets MULTIMODAL_ENABLED=true automatically):
/opt/api/mcp-server/.venv/bin/python /opt/api/scripts/multimodal_backfill.py 8174-24 8137-24
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import os
import sys
import time
from pathlib import Path
from uuid import UUID
def _setup_paths():
"""Ensure mcp-server src is on path even when run as a standalone script."""
here = Path(__file__).resolve().parent
mcp_src = here.parent / "mcp-server" / "src"
if mcp_src.is_dir() and str(mcp_src) not in sys.path:
sys.path.insert(0, str(mcp_src))
_setup_paths()
# Force the flag on for this run regardless of env — backfill is the
# whole point of running this script. The deploy-time default stays off.
os.environ["MULTIMODAL_ENABLED"] = "true"
from legal_mcp import config # noqa: E402
from legal_mcp.services import db, embeddings, extractor, processor # noqa: E402
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
logger = logging.getLogger("multimodal_backfill")
def _resolve_local_path(db_path: str) -> Path:
"""Map container path /data/... to host /home/chaim/legal-ai/data/...
when running locally; pass-through when already absolute and present."""
p = Path(db_path)
if p.is_file():
return p
if str(p).startswith("/data/"):
local = Path("/home/chaim/legal-ai") / Path(*p.parts[1:])
if local.is_file():
return local
return p
async def _backfill_document(
document_id: UUID,
case_id: UUID,
title: str,
db_file_path: str,
skip_if_exists: bool,
) -> dict:
pool = await db.get_pool()
if skip_if_exists:
existing = await pool.fetchval(
"SELECT count(*) FROM document_image_embeddings WHERE document_id = $1",
document_id,
)
if existing and existing > 0:
logger.info(" skip (%d rows already): %s", existing, title)
return {"status": "skipped", "rows": int(existing)}
pdf_path = _resolve_local_path(db_file_path)
if not pdf_path.is_file():
logger.warning(" file missing: %s (%s)", pdf_path, title)
return {"status": "missing"}
if pdf_path.suffix.lower() != ".pdf":
logger.info(" not a PDF, skipping: %s", title)
return {"status": "not_pdf"}
page_count = await pool.fetchval(
"SELECT page_count FROM documents WHERE id = $1", document_id,
)
if not page_count:
# Open to count
import fitz
d = fitz.open(str(pdf_path))
page_count = len(d)
d.close()
logger.info(" embedding %s (%d pages)", title, page_count)
t0 = time.time()
result = await processor._embed_document_pages(
document_id, case_id, pdf_path, page_count,
)
elapsed = time.time() - t0
logger.info(" done in %.1fs: %s", elapsed, result)
return {"status": "ok", "elapsed_sec": round(elapsed, 1), **result}
async def backfill_cases(case_numbers: list[str], skip_if_exists: bool = True) -> dict:
"""Embed page images for every PDF document in the given cases."""
await db.init_schema() # in case schema V9 hasn't been applied
pool = await db.get_pool()
summary: dict = {}
for cn in case_numbers:
logger.info("=" * 60)
logger.info("Case %s", cn)
case = await db.get_case_by_number(cn)
if not case:
logger.warning("Case not found: %s", cn)
summary[cn] = {"status": "case_not_found"}
continue
case_id = UUID(str(case["id"]))
docs = await pool.fetch(
"SELECT id, title, file_path FROM documents WHERE case_id = $1 ORDER BY title",
case_id,
)
logger.info(" %d documents", len(docs))
per_doc: list[dict] = []
for d in docs:
doc_id = UUID(str(d["id"]))
title = d["title"]
r = await _backfill_document(
doc_id, case_id, title, d["file_path"], skip_if_exists,
)
per_doc.append({"document_id": str(doc_id), "title": title, **r})
summary[cn] = {
"documents_total": len(docs),
"embedded": sum(1 for r in per_doc if r["status"] == "ok"),
"skipped": sum(1 for r in per_doc if r["status"] == "skipped"),
"missing": sum(1 for r in per_doc if r["status"] == "missing"),
"not_pdf": sum(1 for r in per_doc if r["status"] == "not_pdf"),
"documents": per_doc,
}
return summary
def main():
parser = argparse.ArgumentParser(description="Multimodal backfill for case documents")
parser.add_argument(
"cases", nargs="+", help="Case numbers to backfill (e.g. 8174-24 8137-24)"
)
parser.add_argument(
"--re-embed", action="store_true",
help="Re-embed even if image embeddings already exist (default: skip)",
)
args = parser.parse_args()
logger.info("MULTIMODAL_MODEL=%s DPI=%d THUMB_DPI=%d",
config.MULTIMODAL_MODEL, config.MULTIMODAL_DPI, config.MULTIMODAL_THUMB_DPI)
summary = asyncio.run(
backfill_cases(args.cases, skip_if_exists=not args.re_embed)
)
print()
print("=" * 60)
print("SUMMARY")
print("=" * 60)
for cn, s in summary.items():
if s.get("status") == "case_not_found":
print(f" {cn}: NOT FOUND")
continue
print(
f" {cn}: {s['documents_total']} docs — "
f"embedded {s['embedded']}, skipped {s['skipped']}, "
f"missing {s['missing']}, non-pdf {s['not_pdf']}"
)
if __name__ == "__main__":
main()

52
scripts/pc.sh Executable file
View File

@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# pc.sh — Paperclip API wrapper for agents.
#
# Usage:
# pc.sh <method> <path> [body_json] [extra_curl_args...]
#
# Adds:
# - Authorization: Bearer $PAPERCLIP_API_KEY
# - X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID (audit trail; falls back to JWT claims if empty)
# - Content-Type: application/json (when body provided)
# - Base URL: $PAPERCLIP_API_URL
#
# Examples:
# ~/legal-ai/scripts/pc.sh GET "/api/agents/me/inbox-lite"
# ~/legal-ai/scripts/pc.sh POST "/api/issues/$ISSUE_ID/checkout"
# ~/legal-ai/scripts/pc.sh POST "/api/issues/$ISSUE_ID/comments" '{"body":"שלום"}'
# ~/legal-ai/scripts/pc.sh PATCH "/api/issues/$ISSUE_ID" '{"status":"done"}'
# ~/legal-ai/scripts/pc.sh DELETE "/api/issues/$ISSUE_ID"
#
# Sourcing as a function (optional):
# source ~/legal-ai/scripts/pc.sh && pc POST "/api/issues/$ISSUE_ID/checkout"
set -euo pipefail
pc() {
local method="${1:-}"
local path="${2:-}"
local body="${3:-}"
if [ $# -ge 3 ]; then shift 3; else shift "$#"; fi
if [ -z "$method" ] || [ -z "$path" ]; then
echo "usage: pc.sh <METHOD> <PATH> [BODY_JSON] [extra curl args...]" >&2
return 2
fi
: "${PAPERCLIP_API_URL:?PAPERCLIP_API_URL not set}"
: "${PAPERCLIP_API_KEY:?PAPERCLIP_API_KEY not set}"
local args=(-s -X "$method"
-H "Authorization: Bearer $PAPERCLIP_API_KEY"
-H "X-Paperclip-Run-Id: ${PAPERCLIP_RUN_ID:-}")
if [ -n "$body" ]; then
args+=(-H "Content-Type: application/json" -d "$body")
fi
curl "${args[@]}" "$@" "${PAPERCLIP_API_URL}${path}"
}
# When invoked directly (not sourced), forward args to pc().
if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
pc "$@"
fi

170
scripts/reembed_voyage.py Normal file
View File

@@ -0,0 +1,170 @@
"""Re-embed all Voyage-stored vectors with the model in env VOYAGE_MODEL.
Use after changing VOYAGE_MODEL in env (e.g. voyage-law-2 → voyage-3).
The script reads each table that stores embeddings, batches the source
text through the new model (Voyage allows 128 inputs / call), and
UPDATEs the rows in place.
Tables touched:
- document_chunks (content)
- paragraph_embeddings (joined with decision_paragraphs.content)
- case_law_embeddings (chunk_text)
- precedent_chunks (content)
- halachot (rule_statement + reasoning_summary)
Run from the legal-ai venv with VOYAGE_API_KEY + VOYAGE_MODEL +
POSTGRES_* set in env (or ~/.env). Idempotent — safe to re-run.
Usage:
/home/chaim/legal-ai/mcp-server/.venv/bin/python \\
/home/chaim/legal-ai/scripts/reembed_voyage.py
"""
from __future__ import annotations
import asyncio
import os
import sys
import time
# Load ~/.env if present
ENV_PATH = os.path.expanduser("~/.env")
if os.path.isfile(ENV_PATH):
with open(ENV_PATH) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k, v)
import asyncpg # noqa: E402
import voyageai # noqa: E402
VOYAGE_MODEL = os.environ.get("VOYAGE_MODEL", "voyage-3")
BATCH = 100 # Voyage allows 128, leave headroom for token limits
# (table, primary key, source-text SQL, update SQL with $1=embedding $2=id)
TABLES = [
(
"document_chunks",
"SELECT id, content FROM document_chunks WHERE content IS NOT NULL AND content <> ''",
"UPDATE document_chunks SET embedding = $1 WHERE id = $2",
),
(
"paragraph_embeddings",
# paragraph_embeddings stores embedding only — text is in decision_paragraphs
"SELECT pe.id, dp.content "
"FROM paragraph_embeddings pe "
"JOIN decision_paragraphs dp ON dp.id = pe.paragraph_id "
"WHERE dp.content IS NOT NULL AND dp.content <> ''",
"UPDATE paragraph_embeddings SET embedding = $1 WHERE id = $2",
),
(
"case_law_embeddings",
"SELECT id, chunk_text FROM case_law_embeddings "
"WHERE chunk_text IS NOT NULL AND chunk_text <> ''",
"UPDATE case_law_embeddings SET embedding = $1 WHERE id = $2",
),
(
"precedent_chunks",
"SELECT id, content FROM precedent_chunks WHERE content IS NOT NULL AND content <> ''",
"UPDATE precedent_chunks SET embedding = $1 WHERE id = $2",
),
(
"halachot",
# Embed rule_statement + reasoning_summary, matching the original
# storage in halacha_extractor.extract().
"SELECT id, "
" TRIM(BOTH '' FROM rule_statement || '' || COALESCE(reasoning_summary, '')) "
" AS embed_text "
"FROM halachot WHERE rule_statement IS NOT NULL AND rule_statement <> ''",
"UPDATE halachot SET embedding = $1 WHERE id = $2",
),
]
async def embed_batch(client, texts: list[str]) -> list[list[float]]:
"""Voyage embed_texts with explicit input_type='document' for storage."""
return client.embed(texts, model=VOYAGE_MODEL, input_type="document").embeddings
async def reembed_table(
pool: asyncpg.Pool, voyage, label: str, select_sql: str, update_sql: str,
) -> dict:
rows = await pool.fetch(select_sql)
n = len(rows)
print(f"\n[{label}] {n} rows")
if n == 0:
return {"table": label, "rows": 0, "elapsed": 0.0}
start = time.time()
done = 0
for i in range(0, n, BATCH):
batch_rows = rows[i:i + BATCH]
texts = [r[1] for r in batch_rows]
ids = [r[0] for r in batch_rows]
try:
embeddings = await embed_batch(voyage, texts)
except Exception as e:
print(f" [{label}] batch {i // BATCH} failed: {e}", file=sys.stderr)
continue
# Update each row
async with pool.acquire() as conn:
async with conn.transaction():
for emb, rid in zip(embeddings, ids):
# asyncpg accepts list[float] for vector via asyncpg-pgvector;
# but pgvector type is inferred via str cast on the wire
await conn.execute(update_sql, str(emb), rid)
done += len(batch_rows)
elapsed = time.time() - start
print(f" [{label}] {done}/{n} ({done/n*100:.1f}%) "
f"elapsed={elapsed:.0f}s rate={done/max(elapsed,0.1):.1f}/s")
elapsed = time.time() - start
return {"table": label, "rows": n, "elapsed": elapsed}
async def main():
api_key = os.environ.get("VOYAGE_API_KEY")
if not api_key:
sys.exit("VOYAGE_API_KEY not set (export it or add to ~/.env)")
pg_host = os.environ.get("POSTGRES_HOST", "127.0.0.1")
pg_port = int(os.environ.get("POSTGRES_PORT", "5433"))
pg_user = os.environ.get("POSTGRES_USER", "legal_ai")
pg_pw = os.environ.get("POSTGRES_PASSWORD", "")
pg_db = os.environ.get("POSTGRES_DB", "legal_ai")
if not pg_pw:
sys.exit("POSTGRES_PASSWORD not set")
print(f"Re-embed all tables with model: {VOYAGE_MODEL}")
print(f"DB: {pg_user}@{pg_host}:{pg_port}/{pg_db}")
voyage = voyageai.Client(api_key=api_key)
pool = await asyncpg.create_pool(
host=pg_host, port=pg_port, user=pg_user,
password=pg_pw, database=pg_db,
min_size=1, max_size=4,
)
# pgvector needs explicit codec setup so we can pass list[float]
async def _init(conn: asyncpg.Connection) -> None:
await conn.execute("SET search_path = public")
await pool.__aenter__() # noqa — enter context to ensure init
summary = []
try:
for label, select_sql, update_sql in TABLES:
r = await reembed_table(pool, voyage, label, select_sql, update_sql)
summary.append(r)
finally:
await pool.close()
total_rows = sum(r["rows"] for r in summary)
total_time = sum(r["elapsed"] for r in summary)
print(f"\n{'=' * 60}\nDONE — {total_rows} rows in {total_time:.0f}s")
for r in summary:
print(f" {r['table']:30s} {r['rows']:>6} rows {r['elapsed']:>5.0f}s")
print(f"\nModel: {VOYAGE_MODEL}")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,382 @@
#!/usr/bin/env python3
"""sync_agents_across_companies.py — Mirror agent configs from CMP (1xxx) to CMPA (8xxx).
Gap #25: Paperclip enforces ``agents.company_id NOT NULL``, so we have 14
agents (7 × 2 companies). Without sync, settings drift between the master
(CMP, 1xxx) and the mirror (CMPA, 8xxx). This script copies the relevant
fields one-way: CMP → CMPA.
Design: "אל-כשל" — backup before apply, idempotent, dry-run by default,
clear field-level diff, rollback path printed on failure.
Synced fields:
- adapter_config.{model, effort, timeoutSec, maxTurnsPerRun,
instructionsBundleMode, instructionsRootPath,
instructionsEntryFile, instructionsFilePath,
dangerouslySkipPermissions, extraArgs, cwd}
- adapter_config.paperclipSkillSync.desiredSkills (filtered for skills
that exist in the mirror company — local skills like
``local/eba6210d5a/legal-decision`` only exist in CMP)
- runtime_config (full replace — heartbeat config)
- budget_monthly_cents
- metadata, icon, title, role
Not synced (intentionally per-company):
- id, company_id, name, reports_to, default_environment_id
- adapter_type, agent_api_keys
- status, pause_reason, paused_at, last_heartbeat_at
- spent_monthly_cents (separate usage)
- permissions (per-company access policies)
Usage:
python sync_agents_across_companies.py --verify # show drift only
python sync_agents_across_companies.py --dry-run # show plan
python sync_agents_across_companies.py --apply # backup + apply
Requires:
PAPERCLIP_BOARD_API_KEY (Infisical: /paperclip @ nautilus)
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import asyncpg
import httpx
PAPERCLIP_DB_URL = os.environ.get(
"PAPERCLIP_DB_URL", "postgresql://paperclip:paperclip@127.0.0.1:54329/paperclip"
)
PAPERCLIP_API_URL = os.environ.get("PAPERCLIP_API_URL", "http://localhost:3100")
PAPERCLIP_BOARD_API_KEY = os.environ.get("PAPERCLIP_BOARD_API_KEY", "")
BACKUP_DIR = Path("/home/chaim/.paperclip/instances/default/data/backups/manual")
CMP_COMPANY_ID = "42a7acd0-30c5-4cbd-ac97-7424f65df294" # MASTER (1xxx)
CMPA_COMPANY_ID = "8639e837-4c9d-47fa-a76b-95788d651896" # MIRROR (8xxx)
# adapter_config keys to sync (top-level only; paperclipSkillSync handled separately)
ADAPTER_CONFIG_SYNC_KEYS = [
"model", "effort", "timeoutSec", "maxTurnsPerRun",
"instructionsBundleMode", "instructionsRootPath", "instructionsEntryFile", "instructionsFilePath",
"dangerouslySkipPermissions", "extraArgs", "cwd",
]
# Top-level agent fields to sync
TOP_LEVEL_SYNC_FIELDS = [
"budget_monthly_cents", "metadata", "icon", "title", "role",
]
def fail(msg: str) -> None:
print(f"{msg}", file=sys.stderr)
sys.exit(1)
async def fetch_agents(conn: asyncpg.Connection, company_id: str) -> list[dict[str, Any]]:
rows = await conn.fetch(
"""
SELECT id::text, name, role, title, icon,
adapter_type, adapter_config, runtime_config, metadata,
budget_monthly_cents
FROM agents
WHERE company_id = $1::uuid
ORDER BY name
""",
company_id,
)
out = []
for r in rows:
d = dict(r)
# asyncpg returns jsonb as str; parse
for k in ("adapter_config", "runtime_config", "metadata"):
if isinstance(d.get(k), str):
d[k] = json.loads(d[k]) if d[k] else None
out.append(d)
return out
async def fetch_company_skills(conn: asyncpg.Connection, company_id: str) -> set[str]:
rows = await conn.fetch(
"SELECT key FROM company_skills WHERE company_id = $1::uuid",
company_id,
)
return {r["key"] for r in rows}
def _get(d: dict | None, key: str, default=None):
return d.get(key, default) if isinstance(d, dict) else default
def compute_diff(master: dict, mirror: dict, mirror_skills: set[str]) -> dict[str, Any]:
"""Return a dict describing what would change in mirror to match master.
Empty dict = in sync."""
diff: dict[str, Any] = {}
# Top-level fields
for field in TOP_LEVEL_SYNC_FIELDS:
if master.get(field) != mirror.get(field):
diff[field] = {"from": mirror.get(field), "to": master.get(field)}
# adapter_config (per key)
m_ac = master.get("adapter_config") or {}
r_ac = mirror.get("adapter_config") or {}
ac_changes = {}
for key in ADAPTER_CONFIG_SYNC_KEYS:
if _get(m_ac, key) != _get(r_ac, key):
ac_changes[key] = {"from": _get(r_ac, key), "to": _get(m_ac, key)}
if ac_changes:
diff["adapter_config"] = ac_changes
# paperclipSkillSync.desiredSkills — compare as a SUBSET check.
# The Paperclip API auto-adds company-level required runtime skills
# (e.g. paperclip-dev) to the desiredSkills list, so the mirror can
# legitimately have MORE skills than master. We only need master's
# filtered skills to be a subset of mirror's actual list.
master_desired = list((_get(m_ac, "paperclipSkillSync") or {}).get("desiredSkills") or [])
mirror_desired = list((_get(r_ac, "paperclipSkillSync") or {}).get("desiredSkills") or [])
master_filtered = [s for s in master_desired if s in mirror_skills]
skipped = [s for s in master_desired if s not in mirror_skills]
missing_in_mirror = set(master_filtered) - set(mirror_desired)
if missing_in_mirror:
diff["paperclipSkillSync.desiredSkills"] = {
"from": mirror_desired,
"to": master_filtered,
"missing_in_mirror": sorted(missing_in_mirror),
"skipped_unavailable_in_mirror": skipped,
}
# runtime_config (full replace)
if (master.get("runtime_config") or {}) != (mirror.get("runtime_config") or {}):
diff["runtime_config"] = {"from": mirror.get("runtime_config"), "to": master.get("runtime_config")}
return diff
def backup_agents_table() -> Path:
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
out = BACKUP_DIR / f"agents-pre-cross-company-sync-{stamp}.sql"
env = {**os.environ, "PGPASSWORD": "paperclip"}
subprocess.run(
["pg_dump", "-h", "127.0.0.1", "-p", "54329", "-U", "paperclip",
"-d", "paperclip", "-t", "agents", "--data-only", "-f", str(out)],
check=True, env=env,
)
return out
def _short(value, max_len=80) -> str:
s = json.dumps(value, ensure_ascii=False, default=str) if not isinstance(value, str) else value
if len(s) > max_len:
return s[:max_len] + "..."
return s
def print_diff(agent_name: str, diff: dict, master_id: str, mirror_id: str) -> None:
if not diff:
print(f"{agent_name:14s} — in sync (no changes)")
return
print(f"{agent_name:14s}{len(diff)} change(s): master={master_id[:8]}… → mirror={mirror_id[:8]}")
for key, change in diff.items():
if key == "adapter_config":
for ac_key, ac_change in change.items():
print(f" adapter_config.{ac_key}: {_short(ac_change['from'])}{_short(ac_change['to'])}")
elif key == "paperclipSkillSync.desiredSkills":
print(f" paperclipSkillSync.desiredSkills: {len(change['from'])}{len(change['to'])} skills")
for s in change.get("skipped_unavailable_in_mirror", []):
print(f" (skipped, not in mirror company: {s})")
elif key == "runtime_config":
print(f" runtime_config: full replace")
print(f" from: {_short(change['from'], 100)}")
print(f" to: {_short(change['to'], 100)}")
else:
print(f" {key}: {_short(change['from'])}{_short(change['to'])}")
async def call_patch(agent_id: str, body: dict) -> tuple[int, dict]:
if not PAPERCLIP_BOARD_API_KEY:
fail("PAPERCLIP_BOARD_API_KEY not set")
headers = {
"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}",
"X-Paperclip-Run-Id": "",
"Content-Type": "application/json",
}
url = f"{PAPERCLIP_API_URL}/api/agents/{agent_id}"
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.patch(url, headers=headers, json=body)
try:
data = resp.json()
except Exception:
data = {"raw": resp.text[:500]}
return resp.status_code, data
async def call_skill_sync(agent_id: str, desired_skills: list[str]) -> tuple[int, dict]:
if not PAPERCLIP_BOARD_API_KEY:
fail("PAPERCLIP_BOARD_API_KEY not set")
headers = {
"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}",
"X-Paperclip-Run-Id": "",
"Content-Type": "application/json",
}
url = f"{PAPERCLIP_API_URL}/api/agents/{agent_id}/skills/sync"
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(url, headers=headers, json={"desiredSkills": desired_skills})
try:
data = resp.json()
except Exception:
data = {"raw": resp.text[:500]}
return resp.status_code, data
async def apply_diff(mirror_id: str, agent_name: str, diff: dict) -> list[str]:
"""Apply the computed diff to the mirror agent. Returns list of error strings."""
errors: list[str] = []
# Build PATCH body for top-level + adapter_config (skills handled separately)
patch_body: dict[str, Any] = {}
for field in TOP_LEVEL_SYNC_FIELDS:
if field in diff:
# snake_case → camelCase for the API
api_key = {
"budget_monthly_cents": "budgetMonthlyCents",
"metadata": "metadata",
"icon": "icon",
"title": "title",
"role": "role",
}[field]
patch_body[api_key] = diff[field]["to"]
if "adapter_config" in diff:
patch_body["adapterConfig"] = {k: v["to"] for k, v in diff["adapter_config"].items()}
if "runtime_config" in diff:
patch_body["runtimeConfig"] = diff["runtime_config"]["to"]
if patch_body:
status, data = await call_patch(mirror_id, patch_body)
if status >= 400:
errors.append(f"PATCH HTTP {status}: {json.dumps(data)[:300]}")
else:
print(f" ✓ PATCH applied ({len(patch_body)} top-level keys)")
# Skills via dedicated endpoint (creates 'skill-sync' revision)
if "paperclipSkillSync.desiredSkills" in diff:
desired = diff["paperclipSkillSync.desiredSkills"]["to"]
status, data = await call_skill_sync(mirror_id, desired)
if status >= 400:
errors.append(f"skills/sync HTTP {status}: {json.dumps(data)[:300]}")
else:
print(f" ✓ skills/sync applied ({len(desired)} skills)")
return errors
async def main() -> None:
p = argparse.ArgumentParser()
g = p.add_mutually_exclusive_group(required=True)
g.add_argument("--verify", action="store_true", help="Show current drift, no changes")
g.add_argument("--dry-run", action="store_true", help="Show what would change")
g.add_argument("--apply", action="store_true", help="Backup + apply changes")
p.add_argument("--only", help="Sync only the named agent (e.g., 'עוזר משפטי')")
args = p.parse_args()
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
master_agents = await fetch_agents(conn, CMP_COMPANY_ID)
mirror_agents = await fetch_agents(conn, CMPA_COMPANY_ID)
mirror_skills = await fetch_company_skills(conn, CMPA_COMPANY_ID)
finally:
await conn.close()
mirror_by_name = {a["name"]: a for a in mirror_agents}
print(f"\n=== Master (CMP, 1xxx): {len(master_agents)} agents ===")
print(f"=== Mirror (CMPA, 8xxx): {len(mirror_agents)} agents ===")
print(f"=== Mirror has {len(mirror_skills)} local skills available ===\n")
print(f"=== Drift report ===")
plan: list[tuple[dict, dict, dict]] = [] # (master, mirror, diff)
for m in master_agents:
if args.only and m["name"] != args.only:
continue
mirror = mirror_by_name.get(m["name"])
if not mirror:
print(f"{m['name']:14s} — NOT FOUND in mirror (skipping; we never auto-create)")
continue
if m["adapter_type"] != mirror["adapter_type"]:
print(f"{m['name']:14s} — adapter_type mismatch ({m['adapter_type']} vs {mirror['adapter_type']}) — SKIPPING")
continue
diff = compute_diff(m, mirror, mirror_skills)
print_diff(m["name"], diff, m["id"], mirror["id"])
if diff:
plan.append((m, mirror, diff))
if args.verify:
print(f"\n(verify mode — exiting without changes)")
print(f"\nSummary: {len(plan)} agent(s) need sync, {len(master_agents) - len(plan)} in sync")
return
if not plan:
print(f"\n✓ All agents in sync — nothing to do.")
return
if args.dry_run:
print(f"\n(dry-run mode — exiting without changes)\nRe-run with --apply to execute.")
return
# APPLY
print(f"\n=== Backup ===")
backup_path = backup_agents_table()
print(f"{backup_path}")
print(f"\n=== Applying ({len(plan)} agents) ===")
all_errors: list[str] = []
for master, mirror, diff in plan:
print(f"\n{master['name']} ({mirror['id']})")
errors = await apply_diff(mirror["id"], master["name"], diff)
if errors:
for e in errors:
print(f"{e}")
all_errors.extend([f"{master['name']}: {e}" for e in errors])
if all_errors:
print(f"\n=== ⚠️ {len(all_errors)} error(s) ===")
print(f"Rollback option: psql ... -f {backup_path}")
sys.exit(1)
print(f"\n=== ✓ Sync complete — re-running --verify to confirm ===\n")
# Re-verify
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
master_agents = await fetch_agents(conn, CMP_COMPANY_ID)
mirror_agents = await fetch_agents(conn, CMPA_COMPANY_ID)
mirror_skills = await fetch_company_skills(conn, CMPA_COMPANY_ID)
finally:
await conn.close()
mirror_by_name = {a["name"]: a for a in mirror_agents}
still_drifting = 0
for m in master_agents:
mirror = mirror_by_name.get(m["name"])
if not mirror or m["adapter_type"] != mirror["adapter_type"]:
continue
diff = compute_diff(m, mirror, mirror_skills)
if diff:
still_drifting += 1
print(f"{m['name']:14s} — STILL has {len(diff)} change(s) after apply (review!)")
if still_drifting == 0:
print(f" ✓ All {len(master_agents)} agents in sync.")
else:
print(f"\n⚠️ {still_drifting} agents still drifting — investigate.")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""sync_missing_agent_skills.py — One-shot fix for Gap #28.
Adds the missing paperclipSkillSync to הגהת מסמכים and מנתח משפטי
in both companies (1xxx CMP, 8xxx CMPA). Idempotent: safe to re-run.
Design: "אל-כשל" — backup, dry-run mode, idempotent, clear errors.
Usage:
python sync_missing_agent_skills.py --dry-run # show plan only
python sync_missing_agent_skills.py --apply # actually do it
python sync_missing_agent_skills.py --verify # check current state
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import asyncpg
import httpx
PAPERCLIP_DB_URL = os.environ.get(
"PAPERCLIP_DB_URL", "postgresql://paperclip:paperclip@127.0.0.1:54329/paperclip"
)
PAPERCLIP_API_URL = os.environ.get("PAPERCLIP_API_URL", "http://localhost:3100")
PAPERCLIP_BOARD_API_KEY = os.environ.get("PAPERCLIP_BOARD_API_KEY", "")
BACKUP_DIR = Path("/home/chaim/.paperclip/instances/default/data/backups/manual")
PAPERCLIP_BASE_SKILLS = [
"paperclipai/paperclip/paperclip",
"paperclipai/paperclip/paperclip-create-agent",
"paperclipai/paperclip/paperclip-create-plugin",
"paperclipai/paperclip/para-memory-files",
]
CMP_COMPANY_ID = "42a7acd0-30c5-4cbd-ac97-7424f65df294" # 1xxx — רישוי ובניה
CMPA_COMPANY_ID = "8639e837-4c9d-47fa-a76b-95788d651896" # 8xxx — היטלי השבחה
# Per-agent + per-company desired skills
PLAN: dict[tuple[str, str], list[str]] = {
# (agent_name, company_id) -> desired skills
("מנתח משפטי", CMP_COMPANY_ID): PAPERCLIP_BASE_SKILLS + ["local/eba6210d5a/legal-decision"],
("מנתח משפטי", CMPA_COMPANY_ID): PAPERCLIP_BASE_SKILLS, # CMPA has no local skills
("הגהת מסמכים", CMP_COMPANY_ID): PAPERCLIP_BASE_SKILLS,
("הגהת מסמכים", CMPA_COMPANY_ID): PAPERCLIP_BASE_SKILLS,
}
def fail(msg: str) -> None:
print(f"{msg}", file=sys.stderr)
sys.exit(1)
async def fetch_targets() -> list[dict[str, Any]]:
"""Return rows for the agents we plan to update."""
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
rows = await conn.fetch(
"""
SELECT a.id, a.name, a.company_id::text as company_id,
COALESCE(
jsonb_array_length(a.adapter_config->'paperclipSkillSync'->'desiredSkills'),
0
) as current_skill_count
FROM agents a
WHERE a.name IN ('מנתח משפטי', 'הגהת מסמכים')
ORDER BY a.name, a.company_id
"""
)
finally:
await conn.close()
return [dict(r) for r in rows]
def backup_agents_table() -> Path:
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
out = BACKUP_DIR / f"agents-pre-skill-sync-{stamp}.sql"
env = {**os.environ, "PGPASSWORD": "paperclip"}
subprocess.run(
["pg_dump", "-h", "127.0.0.1", "-p", "54329", "-U", "paperclip",
"-d", "paperclip", "-t", "agents", "--data-only", "-f", str(out)],
check=True, env=env,
)
return out
async def call_skill_sync(agent_id: str, desired_skills: list[str]) -> tuple[int, dict[str, Any]]:
"""Call POST /api/agents/{id}/skills/sync with the desired skills list."""
if not PAPERCLIP_BOARD_API_KEY:
fail("PAPERCLIP_BOARD_API_KEY not set — needed for /api/agents/.../skills/sync")
url = f"{PAPERCLIP_API_URL}/api/agents/{agent_id}/skills/sync"
headers = {
"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}",
"X-Paperclip-Run-Id": "",
"Content-Type": "application/json",
}
body = {"desiredSkills": desired_skills}
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(url, headers=headers, json=body)
try:
data = resp.json()
except Exception:
data = {"raw": resp.text[:500]}
return resp.status_code, data
async def main() -> None:
p = argparse.ArgumentParser()
g = p.add_mutually_exclusive_group(required=True)
g.add_argument("--dry-run", action="store_true", help="Show plan, do not apply")
g.add_argument("--apply", action="store_true", help="Actually call the skill-sync API")
g.add_argument("--verify", action="store_true", help="Show current state only")
args = p.parse_args()
targets = await fetch_targets()
if len(targets) != 4:
fail(f"Expected 4 target rows (2 agents × 2 companies), got {len(targets)}")
# Build a map for plan
by_key = {(r["name"], r["company_id"]): r for r in targets}
print(f"\n=== Targets in DB ({len(targets)} rows) ===")
for r in targets:
company_label = "1xxx CMP" if r["company_id"] == CMP_COMPANY_ID else "8xxx CMPA"
print(f" {r['name']:14s} | {company_label} | id={r['id']} | currently {r['current_skill_count']} skills")
print(f"\n=== Plan ===")
for (agent_name, company_id), desired in PLAN.items():
company_label = "1xxx CMP" if company_id == CMP_COMPANY_ID else "8xxx CMPA"
target = by_key.get((agent_name, company_id))
if not target:
print(f"{agent_name} in {company_label}: NOT FOUND in DB")
continue
print(f" {agent_name:14s} | {company_label} | will set {len(desired)} skills:")
for s in desired:
print(f" - {s}")
if args.verify:
print("\n(verify mode — exiting without changes)")
return
if args.dry_run:
print("\n(dry-run mode — exiting without changes)\nRe-run with --apply to execute.")
return
# APPLY mode
print(f"\n=== Backup ===")
backup_path = backup_agents_table()
print(f" ✓ Backed up agents table → {backup_path}")
print(f"\n=== Applying skill-sync via API ===")
failures = []
for (agent_name, company_id), desired in PLAN.items():
target = by_key.get((agent_name, company_id))
if not target:
failures.append(f"{agent_name} in {company_id}: not found")
continue
status, data = await call_skill_sync(target["id"], desired)
if status >= 400:
failures.append(f"{agent_name} ({company_id[:8]}...): HTTP {status}{json.dumps(data)[:200]}")
print(f"{agent_name} ({target['id']}): HTTP {status}")
else:
new_count = len(data.get("desiredSkills") or data.get("skills") or [])
print(f"{agent_name} ({target['id']}): HTTP {status} (now {new_count or len(desired)} skills)")
if failures:
print(f"\n=== ⚠️ {len(failures)} failures ===")
for f in failures:
print(f" - {f}")
print(f"\nRollback: psql ... -f {backup_path}")
sys.exit(1)
# Verify
print(f"\n=== Post-apply verification ===")
final = await fetch_targets()
for r in final:
company_label = "1xxx CMP" if r["company_id"] == CMP_COMPANY_ID else "8xxx CMPA"
emoji = "" if r["current_skill_count"] >= 4 else ""
print(f" {emoji} {r['name']:14s} | {company_label} | now {r['current_skill_count']} skills")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,182 @@
"""POC: Compare voyage-3 vs voyage-context-3 retrieval on case 403/17.
Pulls all chunks of "אהרון ברק - תכנית רחביה" (case_law_id=e151fc25-...),
runs them through voyage-context-3 in a single contextualized_embed call,
then runs benchmark queries and compares rankings against the existing
voyage-3 embeddings (already in the DB).
No DB writes — all comparisons in memory. Output: ranking table for each
query showing top-10 from both models side-by-side.
Usage:
/home/chaim/legal-ai/mcp-server/.venv/bin/python \\
/home/chaim/legal-ai/scripts/voyage_context3_poc.py
"""
from __future__ import annotations
import asyncio
import math
import os
import sys
import time
# Load ~/.env
ENV_PATH = os.path.expanduser("~/.env")
if os.path.isfile(ENV_PATH):
with open(ENV_PATH) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k, v)
import asyncpg # noqa: E402
import voyageai # noqa: E402
# Using קלמנוביץ/לויתן (52K chars, 63 chunks, ~18K tokens)
# — fits in single context-3 call (32K token limit per inner list).
# אהרון ברק (60K tokens) requires splitting; we'll handle that after POC.
CASE_ID = "436efd48-c8ab-49f0-b3a9-52bf15ea806d" # בר"מ 25226-04-25
CONTEXT_MODEL = "voyage-context-3"
BASELINE_MODEL = "voyage-3" # already in DB
QUERIES = [
"סמכות ועדת ערר",
"פיצויים לפי סעיף 197",
"ירידת ערך מקרקעין",
"תכנית פוגעת",
"שיקול דעת ועדה מקומית",
"חוות דעת שמאי מכריע",
"מקרקעין גובלים",
"תקופת התיישנות תביעה",
"אינטרס ציבורי בתכנון",
"דחיית תביעת פיצויים",
]
def cosine(a: list[float], b: list[float]) -> float:
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(y * y for y in b))
return dot / (na * nb) if na and nb else 0.0
def parse_pgvector(s: str) -> list[float]:
"""pgvector text format: '[0.1,0.2,...]'."""
return [float(x) for x in s.strip("[]").split(",")]
async def main():
api_key = os.environ["VOYAGE_API_KEY"]
pg_pw = os.environ["POSTGRES_PASSWORD"]
voyage = voyageai.Client(api_key=api_key)
pool = await asyncpg.create_pool(
host="127.0.0.1", port=5433, user="legal_ai",
password=pg_pw, database="legal_ai",
min_size=1, max_size=2,
)
# 1. Pull all chunks + their existing voyage-3 embeddings
rows = await pool.fetch("""
SELECT chunk_index, content, embedding::text AS emb_text
FROM precedent_chunks
WHERE case_law_id = $1
ORDER BY chunk_index
""", CASE_ID)
print(f"[load] {len(rows)} chunks from case 403/17")
chunks = [r["content"] for r in rows]
indices = [r["chunk_index"] for r in rows]
baseline_embs = [parse_pgvector(r["emb_text"]) for r in rows]
# 2. Embed all chunks with voyage-context-3 — single contextualized call
total_chars = sum(len(c) for c in chunks)
print(f"[context] embedding {len(chunks)} chunks, {total_chars:,} chars total")
start = time.time()
result = voyage.contextualized_embed(
inputs=[chunks], # one document = one inner list
model=CONTEXT_MODEL,
input_type="document",
)
elapsed = time.time() - start
# ContextualizedEmbeddingsObject: result.results = list of per-document
# embeddings. result.results[0].embeddings = list of chunk embeddings.
context_embs = result.results[0].embeddings
total_tokens = getattr(result, "total_tokens", "?")
print(f"[context] done in {elapsed:.1f}s — total_tokens={total_tokens}")
assert len(context_embs) == len(chunks), "embedding count mismatch"
# 3. For each query — embed twice and compare top-10
print("\n" + "=" * 100)
print(f"{'Q':<3} {'baseline (voyage-3)':<48} {'context-3':<48}")
print("=" * 100)
rank_overlaps = []
score_lifts = []
for q_idx, query in enumerate(QUERIES, 1):
# Baseline query embedding (regular embed)
q_baseline = voyage.embed(
[query], model=BASELINE_MODEL, input_type="query"
).embeddings[0]
# Context query embedding — must use contextualized_embed even for
# single-string queries (regular embed() rejects voyage-context-3).
q_context = voyage.contextualized_embed(
inputs=[[query]],
model=CONTEXT_MODEL,
input_type="query",
).results[0].embeddings[0]
# Score every chunk under both models
scores_b = sorted(
[(cosine(q_baseline, e), i) for i, e in enumerate(baseline_embs)],
reverse=True,
)
scores_c = sorted(
[(cosine(q_context, e), i) for i, e in enumerate(context_embs)],
reverse=True,
)
top10_b = [i for _, i in scores_b[:10]]
top10_c = [i for _, i in scores_c[:10]]
# Compute overlap and avg score in top-3
overlap = len(set(top10_b) & set(top10_c))
avg_b_top3 = sum(s for s, _ in scores_b[:3]) / 3
avg_c_top3 = sum(s for s, _ in scores_c[:3]) / 3
rank_overlaps.append(overlap)
score_lifts.append(avg_c_top3 - avg_b_top3)
print(f"\n[Q{q_idx}] {query}")
print(f" overlap top-10: {overlap}/10 | avg score top-3: "
f"baseline={avg_b_top3:.3f} context-3={avg_c_top3:.3f} "
f"Δ={avg_c_top3 - avg_b_top3:+.3f}")
for rank in range(5):
sb, ib = scores_b[rank]
sc, ic = scores_c[rank]
cb = chunks[ib].replace("\n", " ").strip()[:50]
cc = chunks[ic].replace("\n", " ").strip()[:50]
print(f" #{rank+1} [{indices[ib]:3d}] {sb:.3f} {cb:<55} "
f"| [{indices[ic]:3d}] {sc:.3f} {cc}")
# Summary
print("\n" + "=" * 100)
print("SUMMARY")
print("=" * 100)
avg_overlap = sum(rank_overlaps) / len(rank_overlaps)
avg_lift = sum(score_lifts) / len(score_lifts)
print(f"Avg overlap top-10: {avg_overlap:.1f}/10 "
f"(higher = models agree more)")
print(f"Avg score lift top-3 (context - baseline): {avg_lift:+.4f}")
print(f"\nNote: cosine scores are not directly comparable across models.")
print(f"What matters more is which CHUNKS bubble to the top —")
print(f"reading the actual content above tells the real story.")
await pool.close()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,238 @@
"""POC #2: voyage-3 vs voyage-context-3 on a LONG case (אהרון ברק 403/17).
Case is 178K chars / 219 chunks / ~60K tokens — too big for a single
contextualized_embed call (32K token limit per inner list). We split the
chunks into overlapping sliding windows (~80 chunks each, ~22K tokens)
and merge: each chunk gets the embedding from the window where it sits
*most centrally* (max symmetric context on both sides).
The hypothesis: voyage-context-3 should shine here because the case is
full of internal references ("ראה לעיל סעיף 13", "להבדיל מעניין X",
"תוצאת הבחינה ב-בר"מ 1975/24 שנידונה לעיל"). voyage-3 embeds chunks
in isolation; context-3 sees ~80 surrounding chunks per embedding.
No DB writes. Output: side-by-side ranking comparison + summary.
Usage:
/home/chaim/legal-ai/mcp-server/.venv/bin/python \\
/home/chaim/legal-ai/scripts/voyage_context3_poc_long.py
"""
from __future__ import annotations
import asyncio
import math
import os
import sys
import time
ENV_PATH = os.path.expanduser("~/.env")
if os.path.isfile(ENV_PATH):
with open(ENV_PATH) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k, v)
import asyncpg # noqa: E402
import voyageai # noqa: E402
CASE_ID = "e151fc25-cf12-4563-b638-a86323f8413b" # 403/17 אהרון ברק (178K chars)
CONTEXT_MODEL = "voyage-context-3"
BASELINE_MODEL = "voyage-3"
# Sliding-window split params. With 219 chunks and ~60K tokens total
# (~275 tokens/chunk average), 3 windows of 80 chunks each is ~22K tokens
# per call — comfortably under 32K.
WINDOW_SIZE = 80
WINDOW_STRIDE = 70 # overlap = WINDOW_SIZE - WINDOW_STRIDE = 10
# Mix of:
# (a) generic queries (also tested in POC #1)
# (b) queries that require *internal* document context
QUERIES = [
# generic
"תכנית רחביה הוראות בנייה",
"פיצויים לפי סעיף 197 ירידת ערך",
"השפעת תכנית על שווי מקרקעין",
"סמכות ועדת ערר לדון בפיצויים",
"תוספת זכויות בנייה כפיצוי",
# internal-context — should benefit context-3
"ההבחנה בין השבחה לפיצויים",
"מה נקבע לגבי תמ\"א 38 בפסק הדין",
"ההלכה שנקבעה בעניין רובע 3",
"כלל הנטרול של זכויות תכנוניות",
"הסכמת השופט אלרון לחוות הדעת",
]
def cosine(a: list[float], b: list[float]) -> float:
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(y * y for y in b))
return dot / (na * nb) if na and nb else 0.0
def parse_pgvector(s: str) -> list[float]:
return [float(x) for x in s.strip("[]").split(",")]
def build_windows(n: int, size: int, stride: int) -> list[tuple[int, int]]:
"""Return list of (start, end) ranges (end exclusive) covering 0..n.
Last window extends to n exactly. Overlap = size - stride.
"""
windows = []
start = 0
while start < n:
end = min(start + size, n)
windows.append((start, end))
if end == n:
break
start += stride
return windows
def assign_chunk_to_window(
chunk_idx: int, windows: list[tuple[int, int]],
) -> int:
"""Pick the window where chunk_idx sits most centrally (max symmetric
distance to either edge). Ties broken by larger window."""
best = -1
best_score = -1
for w_idx, (s, e) in enumerate(windows):
if not (s <= chunk_idx < e):
continue
# symmetric distance: min(distance to s, distance to e-1)
dist = min(chunk_idx - s, (e - 1) - chunk_idx)
if dist > best_score:
best_score = dist
best = w_idx
return best
async def main():
api_key = os.environ["VOYAGE_API_KEY"]
pg_pw = os.environ["POSTGRES_PASSWORD"]
voyage = voyageai.Client(api_key=api_key)
pool = await asyncpg.create_pool(
host="127.0.0.1", port=5433, user="legal_ai",
password=pg_pw, database="legal_ai",
min_size=1, max_size=2,
)
rows = await pool.fetch("""
SELECT chunk_index, content, embedding::text AS emb_text
FROM precedent_chunks
WHERE case_law_id = $1
ORDER BY chunk_index
""", CASE_ID)
n = len(rows)
print(f"[load] {n} chunks from אהרון ברק 403/17")
chunks = [r["content"] for r in rows]
indices = [r["chunk_index"] for r in rows]
baseline_embs = [parse_pgvector(r["emb_text"]) for r in rows]
# Build windows
windows = build_windows(n, WINDOW_SIZE, WINDOW_STRIDE)
print(f"[windows] {len(windows)} windows: "
f"{', '.join(f'[{s}:{e})' for s, e in windows)}")
# Embed each window with context-3
window_embs: list[list[list[float]]] = [] # [window][chunk_in_window][dim]
total_call_tokens = 0
total_start = time.time()
for w_idx, (s, e) in enumerate(windows):
sub_chunks = chunks[s:e]
sub_chars = sum(len(c) for c in sub_chunks)
start = time.time()
result = voyage.contextualized_embed(
inputs=[sub_chunks],
model=CONTEXT_MODEL,
input_type="document",
)
elapsed = time.time() - start
toks = getattr(result, "total_tokens", 0)
total_call_tokens += toks
print(f" [window {w_idx}] [{s}:{e}) — {len(sub_chunks)} chunks, "
f"{sub_chars:,} chars, {toks} tokens — {elapsed:.1f}s")
window_embs.append(result.results[0].embeddings)
total_elapsed = time.time() - total_start
print(f"[context] all windows done in {total_elapsed:.1f}s, "
f"{total_call_tokens} total tokens")
# Merge: for each chunk, pick the embedding from its most-central window
context_embs: list[list[float]] = []
chunk_window_choice = []
for i in range(n):
w_idx = assign_chunk_to_window(i, windows)
chunk_window_choice.append(w_idx)
s, _ = windows[w_idx]
context_embs.append(window_embs[w_idx][i - s])
print(f"[merge] window distribution: "
f"{[chunk_window_choice.count(j) for j in range(len(windows))]}")
# Run queries
print("\n" + "=" * 100)
print(f"{'Q':<3} {'baseline (voyage-3)':<48} {'context-3 (windowed)':<48}")
print("=" * 100)
rank_overlaps = []
for q_idx, query in enumerate(QUERIES, 1):
q_baseline = voyage.embed(
[query], model=BASELINE_MODEL, input_type="query"
).embeddings[0]
q_context = voyage.contextualized_embed(
inputs=[[query]],
model=CONTEXT_MODEL,
input_type="query",
).results[0].embeddings[0]
scores_b = sorted(
[(cosine(q_baseline, e), i) for i, e in enumerate(baseline_embs)],
reverse=True,
)
scores_c = sorted(
[(cosine(q_context, e), i) for i, e in enumerate(context_embs)],
reverse=True,
)
top10_b = [i for _, i in scores_b[:10]]
top10_c = [i for _, i in scores_c[:10]]
overlap = len(set(top10_b) & set(top10_c))
rank_overlaps.append(overlap)
print(f"\n[Q{q_idx}] {query}")
print(f" overlap top-10: {overlap}/10 | "
f"avg score top-3: baseline="
f"{sum(s for s, _ in scores_b[:3])/3:.3f} "
f"context-3={sum(s for s, _ in scores_c[:3])/3:.3f}")
for rank in range(5):
sb, ib = scores_b[rank]
sc, ic = scores_c[rank]
cb = chunks[ib].replace("\n", " ").strip()[:50]
cc = chunks[ic].replace("\n", " ").strip()[:50]
print(f" #{rank+1} [{indices[ib]:3d}] {sb:.3f} {cb:<55} "
f"| [{indices[ic]:3d}] {sc:.3f} {cc}")
print("\n" + "=" * 100)
print("SUMMARY")
print("=" * 100)
avg = sum(rank_overlaps) / len(rank_overlaps)
print(f"Avg overlap top-10: {avg:.1f}/10")
print(f"Per-query overlap: {rank_overlaps}")
print(f"Total context-3 tokens used: {total_call_tokens:,} "
f"(in {len(windows)} calls)")
print(f"\nNote: cosine across models not directly comparable. The")
print(f"meaningful test is *which chunks bubble to the top* — read")
print(f"the actual text above to judge relevance.")
await pool.close()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,213 @@
"""POC #3: voyage-3 (text) vs voyage-multimodal-3.5 (page images) on a
real appraisal PDF (89 pages, full of tables / signatures / numerical
data — the corpus class where multimodal should help most).
Document under test:
baf10153-d2fc-4481-b250-9fe87440ce69
"נספח - שומה מכרעת (אבלין דוידזון שמאמא) - 15.09.24"
case 8137-24, 89 pages, 2.1 MB
The pipeline:
1. Pull the existing voyage-3 text-chunk embeddings from `document_chunks`.
2. Render each PDF page → PNG (PyMuPDF, dpi=144).
3. Embed all pages via voyage-multimodal-3.5.
4. Run benchmark queries (mix of generic + table-specific + visual)
against both: text top-K and page top-K.
The comparison is *qualitative* — text and image embeddings are
different "spaces" returning different ID types (chunk_id vs page_num).
What we look at is whether image-based retrieval surfaces tables,
signatures, or numerical data that text-only OCR loses.
No DB writes.
Usage:
/home/chaim/legal-ai/mcp-server/.venv/bin/python \\
/home/chaim/legal-ai/scripts/voyage_multimodal_poc.py
"""
from __future__ import annotations
import asyncio
import io
import math
import os
import time
ENV_PATH = os.path.expanduser("~/.env")
if os.path.isfile(ENV_PATH):
with open(ENV_PATH) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k, v)
import asyncpg # noqa: E402
import voyageai # noqa: E402
import fitz # PyMuPDF # noqa: E402
from PIL import Image # noqa: E402
DOCUMENT_ID = "baf10153-d2fc-4481-b250-9fe87440ce69"
PDF_PATH = (
"/home/chaim/legal-ai/data/cases/8137-24/documents/originals/"
"נספח - שומה מכרעת (אבלין דוידזון שמאמא) - 15.09.24.pdf"
)
TEXT_MODEL = "voyage-3"
MULTIMODAL_MODEL = "voyage-multimodal-3" # check supported: 3.5 may not exist yet
DPI = 144
# voyage-multimodal: max 1000 inputs/call, 320M pixels/call (rough),
# so 89 pages at 1240×1750 ≈ 192M pixels = single call.
QUERIES = [
# generic-textual (both should handle)
"שיטת ההיוון בשומה",
"מתודולוגיית הערכת שווי",
# table/numerical (multimodal should help)
"טבלת השוואת ערכים לפני ואחרי התכנית",
"שווי המקרקעין במצב הקודם",
"שווי המקרקעין במצב החדש",
"ירידת ערך באחוזים",
# visual elements (text-only loses)
"חתימת השמאי",
"תרשים גוש וחלקה",
"מפת מיקום הנכס",
# context-heavy
"מסקנת השמאי המכריע",
"עקרון הצפיפות בתכנית",
]
def cosine(a: list[float], b: list[float]) -> float:
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(y * y for y in b))
return dot / (na * nb) if na and nb else 0.0
def parse_pgvector(s: str) -> list[float]:
return [float(x) for x in s.strip("[]").split(",")]
def render_pdf_pages(pdf_path: str, dpi: int) -> list[Image.Image]:
"""Render each page → PIL.Image (RGB)."""
doc = fitz.open(pdf_path)
images: list[Image.Image] = []
for page in doc:
pix = page.get_pixmap(dpi=dpi)
png_bytes = pix.tobytes("png")
img = Image.open(io.BytesIO(png_bytes)).convert("RGB")
images.append(img)
doc.close()
return images
async def main():
api_key = os.environ["VOYAGE_API_KEY"]
pg_pw = os.environ["POSTGRES_PASSWORD"]
voyage = voyageai.Client(api_key=api_key)
# 1. Render PDF pages
print(f"[render] {PDF_PATH}")
start = time.time()
images = render_pdf_pages(PDF_PATH, DPI)
elapsed = time.time() - start
print(f"[render] {len(images)} pages in {elapsed:.1f}s, "
f"{images[0].size}px @ {DPI}dpi")
# 2. Pull existing text chunks + voyage-3 embeddings
pool = await asyncpg.create_pool(
host="127.0.0.1", port=5433, user="legal_ai",
password=pg_pw, database="legal_ai",
min_size=1, max_size=2,
)
rows = await pool.fetch("""
SELECT id, chunk_index, page_number, content,
embedding::text AS emb_text
FROM document_chunks
WHERE document_id = $1
ORDER BY chunk_index
""", DOCUMENT_ID)
print(f"[text] {len(rows)} text chunks loaded (voyage-3 in DB)")
text_contents = [r["content"] for r in rows]
text_chunk_pages = [r["page_number"] for r in rows]
text_embs = [parse_pgvector(r["emb_text"]) for r in rows]
# 3. Multimodal embed — try multimodal-3 first, fall back if needed
target_model = "voyage-multimodal-3"
print(f"[multimodal] embedding {len(images)} pages with {target_model}")
start = time.time()
try:
mm_result = voyage.multimodal_embed(
inputs=[[img] for img in images], # list of single-image inputs
model=target_model,
input_type="document",
truncation=True,
)
except voyageai.error.InvalidRequestError as e:
print(f" [error] {e}")
await pool.close()
return
elapsed = time.time() - start
image_embs = mm_result.embeddings
mm_tokens = getattr(mm_result, "total_tokens", "?")
image_tokens = getattr(mm_result, "image_pixels", "?")
text_tokens_mm = getattr(mm_result, "text_tokens", "?")
print(f"[multimodal] done in {elapsed:.1f}s — "
f"total_tokens={mm_tokens} text_tokens={text_tokens_mm} "
f"image_pixels={image_tokens}")
assert len(image_embs) == len(images), "embedding count mismatch"
print(f"[multimodal] embedding dim = {len(image_embs[0])}")
# 4. Run queries
print("\n" + "=" * 100)
print("QUERY RESULTS — top-5 chunks (text/voyage-3) "
"vs top-5 pages (multimodal)")
print("=" * 100)
for q_idx, query in enumerate(QUERIES, 1):
# Text-side: voyage-3 query embedding
q_text = voyage.embed(
[query], model=TEXT_MODEL, input_type="query"
).embeddings[0]
# Multimodal-side: same model, query input_type
q_mm = voyage.multimodal_embed(
inputs=[[query]],
model=target_model,
input_type="query",
).embeddings[0]
text_scores = sorted(
[(cosine(q_text, e), i) for i, e in enumerate(text_embs)],
reverse=True,
)[:5]
mm_scores = sorted(
[(cosine(q_mm, e), i) for i, e in enumerate(image_embs)],
reverse=True,
)[:5]
print(f"\n[Q{q_idx}] {query}")
print(f" --- text (voyage-3) top-5 ---")
for s, i in text_scores:
page = text_chunk_pages[i] if text_chunk_pages[i] else "?"
preview = text_contents[i].replace("\n", " ").strip()[:70]
print(f" {s:.3f} page={page:>3} chunk={i:>3} {preview}")
print(f" --- multimodal (image-only) top-5 ---")
for s, i in mm_scores:
print(f" {s:.3f} page={i+1:>3} (image)")
# Token / cost summary
print("\n" + "=" * 100)
print("SUMMARY")
print("=" * 100)
print(f"PDF: {len(images)} pages @ {DPI}dpi → {target_model}")
print(f"Total multimodal tokens: {mm_tokens}")
print(f"Embedding dim: {len(image_embs[0])}")
print(f"Time: {elapsed:.1f}s for full doc")
await pool.close()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,318 @@
"""POC #5 — full precedent_library corpus benchmark.
Tests R1 (voyage-3) vs R2 (voyage-3 + rerank-2) on the *real* corpus that
search_precedent_library queries against:
precedent_chunks — 385 rows from 3 precedent cases
halachot — 400 rule statements with reasoning summaries
Total: 785 documents. The MCP tool merges results from both tables so the
benchmark mirrors production retrieval. R3 (context-3) is dropped — it
would require windowed re-embedding of 3 cases which we already proved
doesn't help (POC #2). The question now is: does rerank-2's +9% on a
single case generalize to a heterogeneous corpus?
Also measures end-to-end latency: pure voyage-3 vs voyage-3 + rerank.
Usage:
/home/chaim/legal-ai/mcp-server/.venv/bin/python \\
/home/chaim/legal-ai/scripts/voyage_rerank_corpus_poc.py
"""
from __future__ import annotations
import asyncio
import json
import math
import os
import re
import subprocess
import sys
import time
from collections import defaultdict
ENV_PATH = os.path.expanduser("~/.env")
if os.path.isfile(ENV_PATH):
with open(ENV_PATH) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k, v)
import asyncpg # noqa: E402
import voyageai # noqa: E402
TEXT_MODEL = "voyage-3"
RERANK_MODEL = "rerank-2"
JUDGE_MODEL = "claude-haiku-4-5-20251001"
TOP_VEC = 50 # voyage-3 retrieve depth
TOP_K = 10 # final returned to "agent"
JUDGE_K = 5 # how many top results to actually judge per retriever
# 12 queries spanning typical use cases by Daphna's agents:
# precedent search for citing in decision blocks י-יא.
QUERIES = [
# K — keyword
("K1", "פיצויים לפי סעיף 197"),
("K2", "תמ\"א 38 והשבחה"),
("K3", "כלל הנטרול בשמאות"),
# C — conceptual
("C1", "תכלית היטל ההשבחה"),
("C2", "מה מקנה לבעלים זכות לפיצוי"),
("C3", "ההבחנה בין השבחה לפיצויים"),
# N — narrative / context-aware
("N1", "מה נקבע לגבי תמ\"א 38 בפסיקה"),
("N2", "ההלכה לעניין נטרול ציפיות"),
("N3", "תכנית פוגעת ושומה"),
# P — practical (drafting needs — what an agent typically asks)
("P1", "פסיקה שדנה בתכנית מתאר ארצית"),
("P2", "מתי מותר לוועדה לדחות פיצויים"),
("P3", "שיקול דעת הוועדה המקומית"),
]
def cosine(a, b):
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(y * y for y in b))
return dot / (na * nb) if na and nb else 0.0
def parse_pgvector(s):
return [float(x) for x in s.strip("[]").split(",")]
BATCH_JUDGE_PROMPT = """אתה שופט רלוונטיות במשפט ישראלי.
לפניך שאילתה ומספר פסקאות מפסקי דין/הלכות. דרג כל פסקה 1-5 לפי רלוונטיות.
5 — תשובה ישירה למה שנשאל
4 — מאד רלוונטי, מכיל מידע ליבה
3 — רלוונטי חלקית, נוגע בעקיפין
2 — מעט קשור, רעש סביב הנושא
1 — לא רלוונטי בכלל
השאילתה:
{query}
הפסקאות:
{chunks_block}
החזר JSON בלבד: {{"scores": {{"<id>": <1-5>, ...}}}}
ללא טקסט נוסף, ללא ```."""
def batch_judge(query: str, items: list[tuple[str, str]]) -> dict[str, int]:
"""Judge (id, text) pairs via claude CLI. Returns {id: score}."""
blocks = []
for cid, content in items:
snippet = content.replace("\n", " ").strip()[:1500]
blocks.append(f"<id={cid}>\n{snippet}\n</id>")
prompt = BATCH_JUDGE_PROMPT.format(
query=query, chunks_block="\n\n".join(blocks))
proc = subprocess.run(
["claude", "-p", "--model", JUDGE_MODEL],
input=prompt, capture_output=True, text=True, timeout=180,
)
out = proc.stdout.strip()
out = re.sub(r"^```(?:json)?\s*", "", out)
out = re.sub(r"\s*```$", "", out)
try:
data = json.loads(out)
raw = data.get("scores", {})
return {str(k): int(v) for k, v in raw.items()
if str(v).isdigit() and 1 <= int(v) <= 5}
except (json.JSONDecodeError, ValueError, TypeError) as e:
print(f" [judge parse fail: {e}; out={out[:200]!r}]")
return {}
async def main():
voyage_key = os.environ["VOYAGE_API_KEY"]
pg_pw = os.environ["POSTGRES_PASSWORD"]
try:
subprocess.run(["claude", "--version"], capture_output=True,
text=True, timeout=10, check=True)
except (subprocess.CalledProcessError, FileNotFoundError, TimeoutError):
sys.exit("claude CLI not found")
voyage = voyageai.Client(api_key=voyage_key)
pool = await asyncpg.create_pool(
host="127.0.0.1", port=5433, user="legal_ai",
password=pg_pw, database="legal_ai",
min_size=1, max_size=2,
)
# Load full corpus: precedent_chunks + halachot
pc_rows = await pool.fetch("""
SELECT 'pc:' || id::text AS doc_id,
content,
embedding::text AS emb_text
FROM precedent_chunks
WHERE content IS NOT NULL AND embedding IS NOT NULL
""")
h_rows = await pool.fetch("""
SELECT 'h:' || id::text AS doc_id,
TRIM(BOTH '' FROM rule_statement || '' ||
COALESCE(reasoning_summary, '')) AS content,
embedding::text AS emb_text
FROM halachot
WHERE rule_statement IS NOT NULL AND embedding IS NOT NULL
""")
all_rows = list(pc_rows) + list(h_rows)
print(f"[load] corpus: {len(pc_rows)} precedent_chunks + "
f"{len(h_rows)} halachot = {len(all_rows)} total")
doc_ids = [r["doc_id"] for r in all_rows]
contents = [r["content"] for r in all_rows]
embs = [parse_pgvector(r["emb_text"]) for r in all_rows]
# Latency measurement: 5 queries, time the two pipelines
print("\n[latency] measuring 5 sample queries…")
sample = QUERIES[:5]
r1_lat = []
r2_lat = []
for _, query in sample:
# R1: voyage-3 embed + cosine top-10
t0 = time.time()
q_emb = voyage.embed([query], model=TEXT_MODEL,
input_type="query").embeddings[0]
scores = sorted([(cosine(q_emb, e), i) for i, e in enumerate(embs)],
reverse=True)[:TOP_K]
r1_lat.append(time.time() - t0)
# R2: voyage-3 embed + cosine top-50 + rerank-2 → top-10
t0 = time.time()
q_emb = voyage.embed([query], model=TEXT_MODEL,
input_type="query").embeddings[0]
cands = sorted([(cosine(q_emb, e), i) for i, e in enumerate(embs)],
reverse=True)[:TOP_VEC]
cand_texts = [contents[i] for _, i in cands]
rr = voyage.rerank(query=query, documents=cand_texts,
model=RERANK_MODEL, top_k=TOP_K)
r2_lat.append(time.time() - t0)
print(f" R1 (voyage-3 only) avg={sum(r1_lat)/5*1000:.0f}ms"
f" min={min(r1_lat)*1000:.0f} max={max(r1_lat)*1000:.0f}")
print(f" R2 (voyage-3 + rerank-2) avg={sum(r2_lat)/5*1000:.0f}ms"
f" min={min(r2_lat)*1000:.0f} max={max(r2_lat)*1000:.0f}")
print(f" Δ (rerank overhead) avg={(sum(r2_lat)-sum(r1_lat))/5*1000:.0f}ms")
# Retrieval functions
def r1_baseline(query: str, k: int = TOP_K) -> list[int]:
q = voyage.embed([query], model=TEXT_MODEL,
input_type="query").embeddings[0]
scores = sorted([(cosine(q, e), i) for i, e in enumerate(embs)],
reverse=True)
return [i for _, i in scores[:k]]
def r2_rerank(query: str, k: int = TOP_K) -> list[int]:
cands = r1_baseline(query, k=TOP_VEC)
cand_texts = [contents[i] for i in cands]
rr = voyage.rerank(query=query, documents=cand_texts,
model=RERANK_MODEL, top_k=k)
return [cands[r.index] for r in rr.results]
retrievers = [("R1-voyage3", r1_baseline),
("R2-rerank2", r2_rerank)]
print(f"\n[judge] running {len(QUERIES)} queries × 2 retrievers, "
f"top-{JUDGE_K} judged…")
all_results = []
for qid, query in QUERIES:
print(f"\n[{qid}] {query}")
retr_results = {}
for r_name, r_fn in retrievers:
try:
retr_results[r_name] = r_fn(query, k=JUDGE_K)
except Exception as e:
print(f" {r_name}: FAILED — {e}")
retr_results[r_name] = []
union = sorted({i for top in retr_results.values() for i in top})
items = [(doc_ids[i], contents[i]) for i in union]
print(f" judging {len(items)} unique docs…")
scores_map = batch_judge(query, items)
for r_name, top in retr_results.items():
scores = [scores_map.get(doc_ids[i], 0) for i in top]
mean3 = sum(scores[:3]) / 3 if len(scores) >= 3 else 0
mean5 = sum(scores) / len(scores) if scores else 0
mrr = 0.0
for r, s in enumerate(scores):
if s >= 4:
mrr = 1.0 / (r + 1)
break
print(f" {r_name}: doc_ids={[doc_ids[i][:14] for i in top]} "
f"scores={scores} m@3={mean3:.2f} m@5={mean5:.2f} "
f"MRR={mrr:.3f}")
all_results.append({
"qid": qid, "category": qid[0], "query": query,
"retriever": r_name,
"doc_ids": [doc_ids[i] for i in top],
"scores": scores, "mean3": mean3, "mean5": mean5, "mrr": mrr,
})
# Aggregate
print("\n" + "=" * 100)
print("AGGREGATED RESULTS — full precedent_library corpus (785 docs)")
print("=" * 100)
by_r = defaultdict(lambda: {"mean3": [], "mean5": [], "mrr": []})
by_cat_r = defaultdict(lambda: {"mean3": [], "mean5": [], "mrr": []})
for r in all_results:
by_r[r["retriever"]]["mean3"].append(r["mean3"])
by_r[r["retriever"]]["mean5"].append(r["mean5"])
by_r[r["retriever"]]["mrr"].append(r["mrr"])
ck = (r["category"], r["retriever"])
by_cat_r[ck]["mean3"].append(r["mean3"])
by_cat_r[ck]["mean5"].append(r["mean5"])
by_cat_r[ck]["mrr"].append(r["mrr"])
print(f"\nOverall ({len(QUERIES)} queries):")
print(f"{'retriever':<14} {'mean@3':>8} {'mean@5':>8} {'MRR':>8}")
avg = lambda xs: sum(xs) / len(xs) if xs else 0
for r_name, _ in retrievers:
m = by_r[r_name]
print(f"{r_name:<14} {avg(m['mean3']):>8.3f} "
f"{avg(m['mean5']):>8.3f} {avg(m['mrr']):>8.3f}")
# Improvement
r1m = avg(by_r["R1-voyage3"]["mean3"])
r2m = avg(by_r["R2-rerank2"]["mean3"])
if r1m > 0:
print(f"\nR2 vs R1 improvement: "
f"mean@3 {(r2m - r1m) / r1m * 100:+.1f}%")
print(f"\nBy category:")
print(f"{'cat':<3} {'retriever':<14} {'mean@3':>8} {'mean@5':>8} "
f"{'MRR':>8}")
for cat in ["K", "C", "N", "P"]:
for r_name, _ in retrievers:
m = by_cat_r[(cat, r_name)]
if not m["mean3"]:
continue
print(f"{cat:<3} {r_name:<14} {avg(m['mean3']):>8.3f} "
f"{avg(m['mean5']):>8.3f} {avg(m['mrr']):>8.3f}")
print(f"\nPer-query winner (highest mean@3):")
print(f"{'qid':<4} {'query':<40} {'winner':<14} {'scores'}")
by_q = defaultdict(list)
for r in all_results:
by_q[r["qid"]].append(r)
for qid, results in sorted(by_q.items()):
max_s = max(r["mean3"] for r in results)
winners = [r["retriever"] for r in results if r["mean3"] == max_s]
scores = " | ".join(f"{r['retriever'][:7]}={r['mean3']:.2f}"
for r in results)
q_str = next(q for qid_, q in QUERIES if qid_ == qid)[:38]
print(f"{qid:<4} {q_str:<40} {','.join(w[:8] for w in winners):<14} "
f"{scores}")
out_path = "/tmp/voyage_rerank_corpus_results.json"
with open(out_path, "w") as f:
json.dump(all_results, f, ensure_ascii=False, indent=2)
print(f"\nSaved to {out_path}")
await pool.close()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,361 @@
"""POC #4: Comprehensive retrieval benchmark with LLM-as-judge.
Compares 3 retrievers on אהרון ברק 403/17 (219 chunks):
R1 — voyage-3 (current production baseline)
R2 — voyage-3 + voyage-rerank-2 (retrieve 50, rerank, top-10)
R3 — voyage-context-3 (windowed, from POC #2)
Judges relevance with claude-haiku-4-5 — for each (query, chunk) pair the
judge returns 1-5. Aggregates: mean relevance@3, @5, @10, MRR (rank of
first 4+ chunk), per-query winner.
20 queries grouped into 3 categories so we can see *which* query types
benefit from which retriever:
K — keyword/lexical (term-heavy, specific entity)
C — conceptual (abstract idea, principle)
N — narrative/contextual (requires document-internal reference)
Usage (key passed via env, NOT stored in script):
ANTHROPIC_API_KEY=... \\
/home/chaim/legal-ai/mcp-server/.venv/bin/python \\
/home/chaim/legal-ai/scripts/voyage_rerank_judge_poc.py
"""
from __future__ import annotations
import asyncio
import json
import math
import os
import sys
import time
from collections import defaultdict
ENV_PATH = os.path.expanduser("~/.env")
if os.path.isfile(ENV_PATH):
with open(ENV_PATH) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k, v)
import re
import subprocess
import asyncpg # noqa: E402
import voyageai # noqa: E402
CASE_ID = "e151fc25-cf12-4563-b638-a86323f8413b" # אהרון ברק 403/17
TEXT_MODEL = "voyage-3"
CONTEXT_MODEL = "voyage-context-3"
RERANK_MODEL = "rerank-2"
JUDGE_MODEL = "claude-haiku-4-5-20251001"
WINDOW_SIZE = 80
WINDOW_STRIDE = 70
# 18 queries × 3 retrievers × top-5 = 270 judge calls. ~$0.05 with haiku.
QUERIES = [
# K — keyword/lexical
("K1", "תכנית רחביה הוראות בנייה"),
("K2", "תמ\"א 38"),
("K3", "תכנית 9988"),
("K4", "סעיף 197 לחוק התכנון והבניה"),
("K5", "השופט גרוסקופף"),
("K6", "ועדה מקומית ירושלים"),
# C — conceptual / abstract principles
("C1", "כלל הנטרול של זכויות תכנוניות"),
("C2", "אינטרס הציבור בתכנון"),
("C3", "תכלית היטל ההשבחה"),
("C4", "תכנית פוגעת לעומת תכנית משביחה"),
("C5", "ההבחנה בין השבחה לפיצויים"),
("C6", "מהותו של היטל ההשבחה"),
# N — narrative / context-dependent
("N1", "מה נקבע לגבי תמ\"א 38 בפסק הדין"),
("N2", "מסקנת בית המשפט בעניין רובע 3"),
("N3", "ההלכה שנקבעה בעניין שמעוני"),
("N4", "ההבדל בין המקרה שלפנינו לעניין רון"),
("N5", "סוף דבר ותוצאת פסק הדין"),
("N6", "הסכמת השופטים האחרים לחוות הדעת"),
]
def cosine(a, b):
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(y * y for y in b))
return dot / (na * nb) if na and nb else 0.0
def parse_pgvector(s):
return [float(x) for x in s.strip("[]").split(",")]
def build_windows(n, size, stride):
out = []
s = 0
while s < n:
e = min(s + size, n)
out.append((s, e))
if e == n:
break
s += stride
return out
def central_window(idx, windows):
best, best_d = -1, -1
for w_idx, (s, e) in enumerate(windows):
if not (s <= idx < e):
continue
d = min(idx - s, (e - 1) - idx)
if d > best_d:
best_d = d
best = w_idx
return best
BATCH_JUDGE_PROMPT = """אתה שופט רלוונטיות במשפט ישראלי.
לפניך שאילתה ומספר פסקאות מפסק דין. דרג כל פסקה בנפרד 1-5 לפי רלוונטיות.
סולם:
5 — תשובה ישירה ומדויקת לשאילתה
4 — מאד רלוונטי, מכיל מידע ליבה
3 — רלוונטי חלקית, נוגע בעקיפין בנושא
2 — מעט קשור, רעש סביב הנושא
1 — לא רלוונטי בכלל
השאילתה:
{query}
הפסקאות:
{chunks_block}
החזר JSON בלבד, בפורמט: {{"scores": {{"<id>": <1-5>, ...}}}}
ללא טקסט נוסף, ללא explanations, ללא ```."""
def batch_judge(query: str,
items: list[tuple[int, str]]) -> dict[int, int]:
"""Judge a list of (chunk_idx, content) pairs in a single CLI call.
Returns: dict[chunk_idx → score 1-5]. Returns 0 for parse failures.
"""
chunks_block_lines = []
for ci, content in items:
snippet = content.replace("\n", " ").strip()[:1500]
chunks_block_lines.append(f"<id={ci}>\n{snippet}\n</id>")
prompt = BATCH_JUDGE_PROMPT.format(
query=query,
chunks_block="\n\n".join(chunks_block_lines),
)
proc = subprocess.run(
["claude", "-p", "--model", JUDGE_MODEL],
input=prompt, capture_output=True, text=True, timeout=120,
)
out = proc.stdout.strip()
# Strip ```json fences if any
out = re.sub(r"^```(?:json)?\s*", "", out)
out = re.sub(r"\s*```$", "", out)
try:
data = json.loads(out)
raw = data.get("scores", {})
return {int(k): int(v) for k, v in raw.items()
if str(v).isdigit() and 1 <= int(v) <= 5}
except (json.JSONDecodeError, ValueError, TypeError) as e:
print(f" [judge parse fail: {e}; out={out[:200]!r}]")
return {}
async def main():
voyage_key = os.environ["VOYAGE_API_KEY"]
pg_pw = os.environ["POSTGRES_PASSWORD"]
# Verify Claude CLI is available (uses OAuth from ~/.claude/.credentials)
try:
subprocess.run(["claude", "--version"], capture_output=True,
text=True, timeout=10, check=True)
except (subprocess.CalledProcessError, FileNotFoundError, TimeoutError):
sys.exit("claude CLI not found or not authenticated")
voyage = voyageai.Client(api_key=voyage_key)
# Load chunks + voyage-3 embeddings
pool = await asyncpg.create_pool(
host="127.0.0.1", port=5433, user="legal_ai",
password=pg_pw, database="legal_ai",
min_size=1, max_size=2,
)
rows = await pool.fetch("""
SELECT chunk_index, content, embedding::text AS emb_text
FROM precedent_chunks
WHERE case_law_id = $1
ORDER BY chunk_index
""", CASE_ID)
chunks = [r["content"] for r in rows]
chunk_indices = [r["chunk_index"] for r in rows]
baseline_embs = [parse_pgvector(r["emb_text"]) for r in rows]
n = len(chunks)
print(f"[load] {n} chunks loaded")
# Compute context-3 (windowed) embeddings — same as POC #2
windows = build_windows(n, WINDOW_SIZE, WINDOW_STRIDE)
print(f"[context-3] embedding {len(windows)} windows…")
win_embs = []
for s, e in windows:
result = voyage.contextualized_embed(
inputs=[chunks[s:e]],
model=CONTEXT_MODEL,
input_type="document",
)
win_embs.append(result.results[0].embeddings)
context_embs = []
for i in range(n):
w = central_window(i, windows)
s, _ = windows[w]
context_embs.append(win_embs[w][i - s])
print(f"[context-3] done")
# Retrieval functions
def r1_baseline(query: str, k: int = 10) -> list[int]:
q = voyage.embed([query], model=TEXT_MODEL,
input_type="query").embeddings[0]
scores = sorted(
[(cosine(q, e), i) for i, e in enumerate(baseline_embs)],
reverse=True,
)
return [i for _, i in scores[:k]]
def r2_rerank(query: str, k: int = 10) -> list[int]:
# 1) voyage-3 retrieve top-50
cands = r1_baseline(query, k=50)
cand_texts = [chunks[i] for i in cands]
# 2) voyage-rerank-2 over the 50
rr = voyage.rerank(
query=query, documents=cand_texts,
model=RERANK_MODEL, top_k=k,
)
# rr.results: list of RerankingResult(index=..., relevance_score=...)
# `index` refers to position in cand_texts → map back to chunk idx
return [cands[r.index] for r in rr.results]
def r3_context(query: str, k: int = 10) -> list[int]:
q = voyage.contextualized_embed(
inputs=[[query]],
model=CONTEXT_MODEL,
input_type="query",
).results[0].embeddings[0]
scores = sorted(
[(cosine(q, e), i) for i, e in enumerate(context_embs)],
reverse=True,
)
return [i for _, i in scores[:k]]
retrievers = [("R1-voyage3", r1_baseline),
("R2-rerank2", r2_rerank),
("R3-context3", r3_context)]
# Run all queries × all retrievers, judging top-5 per pair.
# Strategy: for each query, gather the union of all retrievers' top-K
# and judge them in ONE batched CLI call → 18 calls total instead of 270.
all_results = []
JUDGE_TOP_K = 5
print(f"\n[judge] running {len(QUERIES)} queries × "
f"{len(retrievers)} retrievers × top-{JUDGE_TOP_K} — batched per query…")
for qid, query in QUERIES:
print(f"\n[{qid}] {query}")
# Collect retrievals first
retr_results = {}
for r_name, r_fn in retrievers:
try:
retr_results[r_name] = r_fn(query, k=JUDGE_TOP_K)
except Exception as e:
print(f" {r_name}: FAILED — {e}")
retr_results[r_name] = []
# Union of unique chunk indices to judge
union = sorted({i for top in retr_results.values() for i in top})
items = [(i, chunks[i]) for i in union]
print(f" judging {len(items)} unique chunks via batch CLI…")
scores_map = batch_judge(query, items)
# Build per-retriever score lists
for r_name, top in retr_results.items():
scores = [scores_map.get(i, 0) for i in top]
mean3 = sum(scores[:3]) / 3 if len(scores) >= 3 else 0
mean5 = sum(scores) / len(scores) if scores else 0
mrr = 0.0
for r, s in enumerate(scores):
if s >= 4:
mrr = 1.0 / (r + 1)
break
print(f" {r_name}: chunks={[chunk_indices[i] for i in top]} "
f"scores={scores} mean@3={mean3:.2f} mean@5={mean5:.2f} "
f"MRR={mrr:.3f}")
all_results.append({
"qid": qid, "category": qid[0], "query": query,
"retriever": r_name,
"chunks": [chunk_indices[i] for i in top],
"scores": scores,
"mean3": mean3, "mean5": mean5, "mrr": mrr,
})
# Aggregate
print("\n" + "=" * 100)
print("AGGREGATED RESULTS")
print("=" * 100)
by_retriever = defaultdict(lambda: {"mean3": [], "mean5": [], "mrr": []})
by_cat_retriever = defaultdict(
lambda: {"mean3": [], "mean5": [], "mrr": []})
for r in all_results:
by_retriever[r["retriever"]]["mean3"].append(r["mean3"])
by_retriever[r["retriever"]]["mean5"].append(r["mean5"])
by_retriever[r["retriever"]]["mrr"].append(r["mrr"])
cat_key = (r["category"], r["retriever"])
by_cat_retriever[cat_key]["mean3"].append(r["mean3"])
by_cat_retriever[cat_key]["mean5"].append(r["mean5"])
by_cat_retriever[cat_key]["mrr"].append(r["mrr"])
print("\nOverall (across all 18 queries):")
print(f"{'retriever':<14} {'mean@3':>8} {'mean@5':>8} {'MRR':>8}")
for r_name, _ in retrievers:
m = by_retriever[r_name]
avg = lambda xs: sum(xs) / len(xs) if xs else 0
print(f"{r_name:<14} {avg(m['mean3']):>8.3f} "
f"{avg(m['mean5']):>8.3f} {avg(m['mrr']):>8.3f}")
print("\nBy category (K=keyword, C=conceptual, N=narrative):")
print(f"{'cat':<3} {'retriever':<14} {'mean@3':>8} {'mean@5':>8} {'MRR':>8}")
for cat in ["K", "C", "N"]:
for r_name, _ in retrievers:
m = by_cat_retriever[(cat, r_name)]
avg = lambda xs: sum(xs) / len(xs) if xs else 0
print(f"{cat:<3} {r_name:<14} {avg(m['mean3']):>8.3f} "
f"{avg(m['mean5']):>8.3f} {avg(m['mrr']):>8.3f}")
print("\nPer-query winner (highest mean@3, ties shown):")
print(f"{'qid':<4} {'query':<45} {'winner':<24} {'scores'}")
by_query = defaultdict(list)
for r in all_results:
by_query[r["qid"]].append(r)
for qid, results in sorted(by_query.items()):
max_score = max(r["mean3"] for r in results)
winners = [r["retriever"] for r in results if r["mean3"] == max_score]
scores = " | ".join(f"{r['retriever'][:7]}={r['mean3']:.2f}"
for r in results)
q_str = next(q for qid_, q in QUERIES if qid_ == qid)[:42]
print(f"{qid:<4} {q_str:<45} {','.join(w[:8] for w in winners):<24} "
f"{scores}")
# Save raw results to JSON for further analysis
out_path = "/tmp/voyage_rerank_judge_results.json"
with open(out_path, "w") as f:
json.dump(all_results, f, ensure_ascii=False, indent=2)
print(f"\nRaw results saved to {out_path}")
await pool.close()
if __name__ == "__main__":
asyncio.run(main())

View File

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

View File

@@ -0,0 +1,316 @@
---
name: new-company-setup
description: מדריך מלא להוספת חברה (board) חדשה במערכת legal-ai + Paperclip — יוצר את כל הרכיבים הנדרשים: companies row ב-Paperclip, 7 סוכנים (CEO + 6 specialists), runtime/adapter config, paperclipSkillSync, instructionsBundleMode, budget, plugin_state mappings, ועדכון קוד legal-ai. השתמש ב-skill זה כאשר המשתמש מבקש להוסיף סוג ערר חדש (למשל 5xxx, 7xxx) או להפריד תחום קיים לחברה משלו. ה-skill מכיל את כל ההגדרות שנקבעו ב-Gaps #16, #17, #19, #21, #22, #24, #25, #28 — אסור להחסיר שלב.
---
# הקמת חברה חדשה — Blueprint מלא
> **קונטקסט**: עד 2026-05-04 יש לנו 2 חברות (CMP=1xxx רישוי, CMPA=8xxx היטל השבחה + 9xxx פיצויים). הוספת חברה שלישית (לדוגמה 5xxx, 7xxx) דורשת 11 שלבים בסדר מסוים. ה-skill הזה מכיל את כל הלקחים מ-Gap analysis ועדכוני 2026-04 → 2026-05.
## רקע — ארכיטקטורה דו-חברתית
מקור החברות: Paperclip מחייב `agents.company_id NOT NULL` — אין shared agents. לכן כל סוג ערר מקבל company משלו ב-Paperclip, עם סט מלא של 7 סוכנים. החברה הראשונה (CMP) היא **master** — שינויים בה מסונכרנים אוטומטית ל-mirrors דרך `scripts/sync_agents_across_companies.py`.
**מודל מומלץ לחברה חדשה**: להפוך אותה ל-mirror של CMP במבנה — כל הסוכנים זהים, רק `company_id`, `id`, `reports_to` שונים. ככה הסקריפט הקיים יסנכרן אוטומטית.
---
## ⚠️ לפני שמתחילים — checklist הבנה
לפני שמרצים אף פקודה, ודא שאתה יודע:
- [ ] **מספרי תיקים** של החברה החדשה (לדוגמה: 5xxx, 7xxx) — חייב להיות disjoint מ-1xxx/8xxx/9xxx
- [ ] **שם בעברית** של הוועדה (לדוגמה: "ועדת ערר לתכנון ובניה צפון")
- [ ] **prefix לidentifiers** של issues (לדוגמה: `CMPN`)
- [ ] **`appeal_type` tag** — מחרוזת קצרה לניתוב (לדוגמה: `licensing_north`)
- [ ] **המודלים והעלויות** — האם זהה ל-CMP (Opus opus-4-6 ל-CEO+writer, Sonnet sonnet-4-6 לאחרים)?
- [ ] **גישה ל-Infisical** ל-`PAPERCLIP_BOARD_API_KEY` (`/paperclip` ב-nautilus env)
- [ ] **PostgreSQL access** ל-Paperclip DB (`localhost:54329`, user `paperclip`)
---
## שלב 1 — יצירת `companies` row ב-Paperclip DB
```sql
INSERT INTO companies (
id, name, issue_prefix, status,
attachment_max_bytes,
require_board_approval_for_new_agents,
hire_approval_required
) VALUES (
gen_random_uuid(),
'ועדת ערר {שם}', -- בעברית
'CMPN', -- 4 תווים אנגלית, ייחודי
'active',
10485760, -- 10MB default
false, -- ברירת מחדל מ-2026.428.0 (Gap docs)
false
)
RETURNING id;
```
**שמור את ה-UUID** — תצטרך אותו ב-כל השלבים הבאים. נקרא לו `$NEW_COMPANY_ID`.
⚠️ אל תיצור project ראשוני ידנית — Paperclip יוצר אוטומטית כשהחברה נשמרת.
---
## שלב 2 — יצירת 7 סוכנים
צור את הסוכנים בסדר הבא (ה-CEO ראשון, כי בכל הסוכנים `reports_to = CEO_id`):
| # | name (עברית) | role | model | budget_cents |
|---|---------------|------|-------|---------------|
| 1 | עוזר משפטי | `ceo` | claude-opus-4-6 | 1500 |
| 2 | מנתח משפטי | `researcher` | claude-opus-4-6 | 1500 |
| 3 | חוקר תקדימים | `researcher` | claude-sonnet-4-6 | 1500 |
| 4 | כותב החלטה | `engineer` | claude-opus-4-6 | 1500 |
| 5 | בודק איכות | `qa` | claude-sonnet-4-6 | 1500 |
| 6 | מייצא טיוטה | `engineer` | claude-sonnet-4-6 | 1500 |
| 7 | הגהת מסמכים | `engineer` | claude-opus-4-6 | 1500 |
### דרך 1 — sync from master (מומלץ)
הדרך הקלה ביותר: צור 7 סוכנים ב-CMPN עם **שמות זהים** ל-CMP, ואז הרץ `sync_agents_across_companies.py` שיעתיק את כל ההגדרות.
```sql
-- לכל אחד מ-7 הסוכנים (שנה את name ו-role בכל פעם):
INSERT INTO agents (
id, company_id, name, role, adapter_type,
adapter_config, runtime_config, budget_monthly_cents,
permissions, status
) VALUES (
gen_random_uuid(),
'{NEW_COMPANY_ID}'::uuid,
'עוזר משפטי', -- שנה ב-7 שורות
'ceo', -- שנה לפי הטבלה למעלה
'claude_local',
'{}'::jsonb, -- ייטען בשלב 4
'{}'::jsonb, -- ייטען בשלב 4
1500,
'{}'::jsonb,
'idle'
)
RETURNING id, name;
```
שמור את 7 ה-UUIDs לטבלה לעיון מהיר.
### עדכון `reports_to` (אחרי שיש לך CEO_id)
```sql
UPDATE agents
SET reports_to = '{CEO_id}'::uuid
WHERE company_id = '{NEW_COMPANY_ID}'::uuid
AND name <> 'עוזר משפטי';
```
---
## שלב 3 — סנכרון מ-CMP (master) דרך הסקריפט
```bash
PAPERCLIP_BOARD_API_KEY=$(mcp__infisical__get-secret \
projectId=9a77b161-f70c-4dd3-9d67-b7ab850cef51 \
environmentSlug=nautilus secretPath=/ \
secretName=PAPERCLIP_BOARD_API_KEY) \
python ~/legal-ai/scripts/sync_agents_across_companies.py --verify
```
**אם הסקריפט לא תומך ב-mirror החדש**, יש לעדכן אותו:
1. פתח `scripts/sync_agents_across_companies.py`
2. השתמש בdict structure: master_company → list of mirrors. או הוסף flag `--target-company`
3. הרץ `--apply` להעתיק את כל ההגדרות מ-CMP ל-CMPN
**מה הסקריפט מסנכרן** (אוטומטית):
- `adapter_config`: model, effort, timeoutSec=3600, maxTurnsPerRun=500, instructionsBundleMode=external, instructionsRootPath/EntryFile, dangerouslySkipPermissions, extraArgs (`--agent legal-{role}`), cwd
- `runtime_config.heartbeat`: graceSec=60, cooldownSec=10, wakeOnDemand=true, maxConcurrentRuns (CEO=2, others=1)
- `budget_monthly_cents` (1500)
- `metadata`, `icon`, `title`
**מה לא מסונכרן** (חייב לעשות ידנית בהמשך):
- `paperclipSkillSync.desiredSkills` — ראה שלב 4
- `permissions` — לפי policy של החברה
- local skills (אם החברה החדשה צריכה custom skills)
---
## שלב 4 — Paperclip Skills
הסקריפט מ-שלב 3 כולל כבר את ה-`paperclipSkillSync.desiredSkills` (מסונן לפי skills זמינים ב-mirror). אבל ה-mirror החדש **לא יקבל local skills** של CMP אם הם לא קיימים גם בו.
### 4א. יצירת company_skills ל-CMPN
```sql
-- העתק את 6 ה-paperclip skills הסטנדרטיים מ-CMP ל-CMPN
INSERT INTO company_skills (company_id, key, slug, name, description, markdown, source_type, trust_level, compatibility, file_inventory)
SELECT
'{NEW_COMPANY_ID}'::uuid,
key, slug, name, description, markdown, source_type, trust_level, compatibility, file_inventory
FROM company_skills
WHERE company_id = '42a7acd0-30c5-4cbd-ac97-7424f65df294' -- CMP
AND key LIKE 'paperclipai/paperclip/%';
```
### 4ב. אם החברה צריכה local skills
החלט אילו local skills (`local/.../legal-decision`, `local/.../attach-precedents`) רלוונטיות — תלוי בסוג הערר.
לדוגמה, חברה ל-"היטלי השבחה צפון" כנראה לא תצריך `attach-precedents` של CMP אלא local skill משלה.
### 4ג. הפעלת skills/sync לכל סוכן
הרץ `scripts/sync_missing_agent_skills.py` עם adaptation לחברה החדשה (העתק את הקובץ ושנה את `CMPA_COMPANY_ID` ל-NEW_COMPANY_ID + רשימת ה-skills הרצויה).
⚠️ **חובה דרך API** (`POST /api/agents/{id}/skills/sync`) — לא דרך SQL ישיר! ה-API יוצר revision מסוג `skill-sync` שנדרש לlogging. SQL ישיר לא יוצר revision.
---
## שלב 5 — Symlinks ל-instructions (managed by Paperclip)
לכל סוכן Paperclip צופה לקבצי הוראות בנתיב:
`~/.paperclip/instances/default/companies/{COMPANY_ID}/agents/{AGENT_ID}/instructions/`
```bash
NEW_COMPANY_ID="..."
LEGAL_AI_AGENTS=/home/chaim/legal-ai/.claude/agents
for ROW in \
"ceo:legal-ceo.md" \
"analyst:legal-analyst.md" \
"researcher:legal-researcher.md" \
"writer:legal-writer.md" \
"qa:legal-qa.md" \
"exporter:legal-exporter.md" \
"proofreader:legal-proofreader.md"; do
ROLE="${ROW%%:*}"
FILE="${ROW##*:}"
AGENT_ID=$(PGPASSWORD=paperclip psql -h 127.0.0.1 -p 54329 -U paperclip -d paperclip -tAc \
"SELECT id FROM agents WHERE company_id='$NEW_COMPANY_ID'::uuid AND adapter_config->>'extraArgs' LIKE '%legal-$ROLE%' LIMIT 1")
DEST=~/.paperclip/instances/default/companies/$NEW_COMPANY_ID/agents/$AGENT_ID/instructions/
mkdir -p $DEST
ln -sf "$LEGAL_AI_AGENTS/$FILE" "$DEST/AGENTS.md"
ln -sf "$LEGAL_AI_AGENTS/HEARTBEAT.md" "$DEST/HEARTBEAT.md"
done
```
**אימות:** `ls -la ~/.paperclip/instances/default/companies/$NEW_COMPANY_ID/agents/*/instructions/` — צריך לראות 14 symlinks (7 agents × 2 קבצים).
---
## שלב 6 — עדכון `web/paperclip_client.py`
הקובץ מכיל 3 dicts שצריכים את החברה החדשה:
```python
# COMPANIES dict
COMPANIES = {
"licensing": "42a7acd0-30c5-4cbd-ac97-7424f65df294",
"betterment": "8639e837-4c9d-47fa-a76b-95788d651896",
"{appeal_type_new}": "{NEW_COMPANY_ID}", # ← חדש
}
# CEO_AGENTS dict — נדרש ל-wakeup routing
CEO_AGENTS = {
COMPANIES["licensing"]: "752cebdd-...",
COMPANIES["betterment"]: "cdbfa8bc-...",
COMPANIES["{appeal_type_new}"]: "{CEO_ID_NEW}", # ← חדש
}
# _FALLBACK_APPEAL_TYPE_TO_COMPANY — ניתוב לפי tag עברי/אנגלי
_FALLBACK_APPEAL_TYPE_TO_COMPANY = {
# קיימים...
"{שם בעברית}": COMPANIES["{appeal_type_new}"],
"{english_tag}": COMPANIES["{appeal_type_new}"],
}
```
⚠️ אחרי השינוי — **deploy** ל-Coolify (FastAPI container חי במכולה — שינוי קוד דורש rebuild). ראה `legal-ai/CLAUDE.md`.
---
## שלב 7 — `tag_company_mappings` ב-legal-ai DB
ה-FastAPI ראשית מנסה לקרוא ניתוב מ-DB, רק אז fallback ל-dict הקבוע (שלב 6). הוסף mapping:
```sql
-- ב-legal-ai DB (port 5433)
INSERT INTO tag_company_mappings (tag, company_id) VALUES
('{שם עברי}', '{NEW_COMPANY_ID}'),
('{english_tag}', '{NEW_COMPANY_ID}');
```
---
## שלב 8 — עדכון `HEARTBEAT.md` §1
הסעיף §1 מכיל טבלה של חברות + CEO IDs. הוסף שורה חדשה:
```markdown
| ועדת ערר {שם} (CMPN) | `{NEW_COMPANY_ID}` | {סוג} | **{Nxxx}** | `{CEO_ID_NEW}` |
```
ובסעיף §4ג (CEO wakeup), עדכן את ה-`if` להוסיף אופציה שלישית לחברה החדשה.
---
## שלב 9 — עדכון `legal-ai/CLAUDE.md`
הקובץ מכיל את אותה טבלה. עדכן בקטעים:
- "סוגי עררים" (אם קיים)
- "Paperclip — כללי אינטגרציה קריטיים" → "ניתוב comments דרך CEO"
---
## שלב 10 — Hebrew translation (אם נדרש)
אם שם החברה מופיע ב-UI, ייתכן שצריך תרגום ב-`~/.paperclip/hebrew/translate-he.js`. בד"כ לא נדרש — שמות בעברית כבר.
```bash
# אחרי שינויים בHebrew file:
~/.paperclip/hebrew/apply-hebrew.sh
# ⚠️ לא דורש pm2 restart — UI client-side fix.
```
---
## שלב 11 — בדיקה end-to-end
1. **CEO מתעורר על comment**: צור issue test בחברה החדשה, פרסם comment, ודא ש-CEO רץ.
2. **plugin marcusgroup.legal-ai רואה את החברה**: ב-Paperclip UI → Settings → Plugins → marcusgroup.legal-ai → ודא שהחברה החדשה ב-installed companies.
3. **MCP tools פועלים**: דרך Claude Code, הרץ `mcp__legal-ai__case_create` עם appeal_type של החברה החדשה.
4. **Sync script עובד**: `python scripts/sync_agents_across_companies.py --verify` — לא צריך drift.
5. **Budget enforcement**: צור cost_event מבחן, ודא ש-spent_monthly_cents מתעדכן.
---
## ⚠️ מלכודות מתועדות (מ-Gap analysis 2026-04 → 2026-05)
מבחן בכל שלב מאפשר תפיסת issues שתועדו בעבר:
| # | מלכודת | פתרון |
|---|---------|--------|
| 1 | סוכנים בלי `paperclipSkillSync` | ראה שלב 4ג (POST /api/agents/{id}/skills/sync, לא SQL) |
| 2 | `runtime_config = '{}'` (default → graceSec=1ms!) | ראה שלב 3 (סקריפט מסנכרן `heartbeat.graceSec=60`) |
| 3 | `budget_monthly_cents = 0` | ראה שלב 2 (insert עם 1500) |
| 4 | `instructionsBundleMode` חסר | ראה שלב 3 (סקריפט מסנכרן `external` + Root + EntryFile) |
| 5 | `bootstrapPromptTemplate` deprecated | אין אצלנו — דלג |
| 6 | drift בין חברות | ראה שלב 3 — סנכרון אוטומטי כל שינוי הגדרות |
| 7 | CEO לא מתעורר על comment | ודא ש-`reports_to` עודכן ושיש symlinks ל-AGENTS.md (שלב 5) |
| 8 | `psql` ישיר ל-`issue_attachments` | אסור — ראה `HEARTBEAT.md §2` (heartbeat-context API) |
| 9 | curl ישיר ל-Paperclip API | אסור — תמיד `pc.sh` (`HEARTBEAT.md §0`) |
| 10 | "@chaim — ענה 1/2/3 בcomment" | אסור — interactions API (`legal-ceo.md §B/§C/§D`) |
---
## רפרנסים
- [`docs/new-company-setup-guide.md`](../../docs/new-company-setup-guide.md) — היסטוריית הקמת CMPA (חברה שנייה, 2026-04)
- [`scripts/sync_agents_across_companies.py`](../../scripts/sync_agents_across_companies.py) — אוטומציה לסנכרון
- [`scripts/sync_missing_agent_skills.py`](../../scripts/sync_missing_agent_skills.py) — תבנית להפעלת skills/sync
- [`~/.paperclip/CUSTOMIZATIONS.md`](../../../.paperclip/CUSTOMIZATIONS.md) — כל ההתאמות הפעילות (סעיפים: agents runtime, instructions, budgets, interactions, skill-sync)
- [`HEARTBEAT.md`](../../.claude/agents/HEARTBEAT.md) — §1 טבלת חברות (לעדכן בשלב 8)
- [`legal-ai/CLAUDE.md`](../../CLAUDE.md) — Paperclip integration rules
---
## גרסה
- 2026-05-04 — גרסה ראשונה (אחרי Gap #16-#28)

View File

@@ -13,6 +13,10 @@ const API_ORIGIN =
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
experimental: {
proxyClientMaxBodySize: "100mb",
},
async rewrites() { async rewrites() {
return [ return [
{ {

View File

@@ -246,3 +246,24 @@
color: var(--color-navy); color: var(--color-navy);
} }
} }
/* ── Status pill shimmer ──────────────────────────────────────────
* Indeterminate "in progress" indicator used by precedent-library
* StatusPill while extraction is running. A diagonal stripe slides
* left-to-right across the badge background. */
@keyframes ezer-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.shimmer-active {
background-image: linear-gradient(
90deg,
transparent 0%,
rgba(168, 124, 58, 0.18) 50%,
transparent 100%
);
background-size: 200% 100%;
background-repeat: no-repeat;
animation: ezer-shimmer 1.6s linear infinite;
}

View File

@@ -1,17 +1,31 @@
"use client"; "use client";
import { useMemo } from "react";
import Link from "next/link"; import Link from "next/link";
import { AppShell } from "@/components/app-shell"; import { AppShell } from "@/components/app-shell";
import { KPICards } from "@/components/cases/kpi-cards"; import { KPICards } from "@/components/cases/kpi-cards";
import { StatusDonut } from "@/components/cases/status-donut"; import { StatusDonut } from "@/components/cases/status-donut";
import { AppealTypeBars, subtypeOf } from "@/components/cases/appeal-type-bars";
import { CasesTable } from "@/components/cases/cases-table"; import { CasesTable } from "@/components/cases/cases-table";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useCases } from "@/lib/api/cases"; import { useCases, type Case } from "@/lib/api/cases";
export default function HomePage() { export default function HomePage() {
const { data, isPending, error } = useCases(true); const { data, isPending, error } = useCases(true);
const { permits, levies } = useMemo(() => {
const permits: Case[] = [];
const levies: Case[] = [];
(data ?? []).forEach((c) => {
const s = subtypeOf(c);
if (s === "building_permit") permits.push(c);
else if (s === "betterment_levy" || s === "compensation_197") levies.push(c);
else permits.push(c); // fallback bucket — keep visible
});
return { permits, levies };
}, [data]);
return ( return (
<AppShell> <AppShell>
<section className="space-y-8"> <section className="space-y-8">
@@ -35,25 +49,70 @@ export default function HomePage() {
<KPICards cases={data} loading={isPending} /> <KPICards cases={data} loading={isPending} />
<div className="grid gap-6 lg:grid-cols-[1fr_auto]"> <div className="grid gap-6 lg:grid-cols-[1fr_320px]">
<div className="space-y-6 min-w-0">
<Card className="bg-surface border-rule shadow-sm"> <Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5"> <CardContent className="px-6 py-5">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between gap-3 mb-4 flex-wrap">
<h2 className="text-navy text-xl mb-0">רשימת תיקים</h2> <div className="flex items-baseline gap-3">
<h2 className="text-navy text-xl mb-0">רישוי ובנייה</h2>
<span className="text-[0.72rem] uppercase tracking-[0.08em] text-ink-muted">
עררים 1xxx
</span>
</div>
<span className="text-[0.72rem] uppercase tracking-[0.08em] text-ink-muted"> <span className="text-[0.72rem] uppercase tracking-[0.08em] text-ink-muted">
מעודכן חי מעודכן חי
</span> </span>
</div> </div>
<CasesTable cases={data} loading={isPending} error={error} /> <CasesTable
cases={permits}
loading={isPending}
error={error}
emptyText="אין תיקי רישוי פעילים"
searchPlaceholder="חיפוש בעררי רישוי…"
/>
</CardContent> </CardContent>
</Card> </Card>
<Card className="bg-surface border-rule shadow-sm lg:w-[320px]"> <Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<div className="flex items-center justify-between gap-3 mb-4 flex-wrap">
<div className="flex items-baseline gap-3">
<h2 className="text-navy text-xl mb-0">היטל השבחה ופיצויים</h2>
<span className="text-[0.72rem] uppercase tracking-[0.08em] text-ink-muted">
עררים 8xxx · 9xxx
</span>
</div>
<span className="text-[0.72rem] uppercase tracking-[0.08em] text-ink-muted">
מעודכן חי
</span>
</div>
<CasesTable
cases={levies}
loading={isPending}
error={error}
emptyText="אין תיקי היטל השבחה או פיצויים פעילים"
searchPlaceholder="חיפוש בעררי השבחה ופיצויים…"
/>
</CardContent>
</Card>
</div>
<aside className="space-y-6 lg:sticky lg:top-6 lg:self-start">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5"> <CardContent className="px-6 py-5">
<h2 className="text-navy text-lg mb-4">פיזור סטטוסים</h2> <h2 className="text-navy text-lg mb-4">פיזור סטטוסים</h2>
<StatusDonut cases={data} /> <StatusDonut cases={data} />
</CardContent> </CardContent>
</Card> </Card>
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<h2 className="text-navy text-lg mb-4">פיזור לפי תחום</h2>
<AppealTypeBars cases={data} />
</CardContent>
</Card>
</aside>
</div> </div>
</section> </section>
</AppShell> </AppShell>

View File

@@ -0,0 +1,170 @@
"use client";
import { use, useState } from "react";
import Link from "next/link";
import { Pencil } from "lucide-react";
import { AppShell } from "@/components/app-shell";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { usePrecedent } from "@/lib/api/precedent-library";
import { PrecedentEditSheet } from "@/components/precedents/precedent-edit-sheet";
import { ExtractedHalachotSection } from "@/components/precedents/extracted-halachot";
const PRACTICE_AREA_LABELS: Record<string, string> = {
rishuy_uvniya: "רישוי ובנייה",
betterment_levy: "היטל השבחה",
compensation_197: "פיצויים (197)",
};
const SOURCE_TYPE_LABELS: Record<string, string> = {
court_ruling: "פסק דין",
appeals_committee: "ועדת ערר",
};
/* Next 16 breaking change: route params are now a Promise.
* The `use()` hook unwraps them inside a client component. */
export default function PrecedentDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const [editing, setEditing] = useState(false);
const { data, isPending, error } = usePrecedent(id);
return (
<AppShell>
<section className="space-y-6" dir="rtl">
<header>
<nav className="text-[0.78rem] text-ink-muted mb-1">
<Link href="/" className="hover:text-gold-deep">בית</Link>
<span aria-hidden> · </span>
<Link href="/precedents" className="hover:text-gold-deep">ספריית פסיקה</Link>
<span aria-hidden> · </span>
<span className="text-navy">פרטי פסיקה</span>
</nav>
</header>
{error ? (
<Card className="bg-danger-bg border-danger/40">
<CardContent className="px-6 py-6 text-center space-y-3">
<p className="text-danger font-semibold">שגיאה בטעינת הפסיקה</p>
<p className="text-sm text-ink-muted">{error.message}</p>
<Button asChild variant="outline">
<Link href="/precedents">חזרה לספרייה</Link>
</Button>
</CardContent>
</Card>
) : isPending || !data ? (
<div className="space-y-3">
{[...Array(5)].map((_, i) => <Skeleton key={i} className="h-16 w-full" />)}
</div>
) : (
<>
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5 space-y-4">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="min-w-0 flex-1">
<h1 className="text-navy text-2xl font-semibold mb-1 leading-tight">
{data.case_name || "—"}
</h1>
<div className="text-ink-muted text-sm font-mono" dir="ltr">
{data.case_number}
</div>
</div>
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
<Pencil className="w-3.5 h-3.5 me-1" /> ערוך פרטים
</Button>
</div>
<div className="flex items-center gap-2 flex-wrap">
{data.practice_area ? (
<Badge variant="outline" className="text-[0.7rem]">
{PRACTICE_AREA_LABELS[data.practice_area] ?? data.practice_area}
</Badge>
) : null}
{data.source_type ? (
<Badge variant="outline" className="text-[0.7rem]">
{SOURCE_TYPE_LABELS[data.source_type] ?? data.source_type}
</Badge>
) : null}
{data.precedent_level ? (
<Badge variant="outline" className="text-[0.7rem]">
{data.precedent_level}
</Badge>
) : null}
{data.is_binding ? (
<Badge
variant="outline"
className="text-[0.7rem] bg-gold-wash text-gold-deep border-gold/40"
>
הלכה מחייבת
</Badge>
) : null}
{data.court ? (
<span className="text-[0.78rem] text-ink-muted">{data.court}</span>
) : null}
{data.date ? (
<span className="text-[0.78rem] text-ink-muted tabular-nums" dir="ltr">
{data.date.slice(0, 10)}
</span>
) : null}
</div>
{data.headnote ? (
<div>
<h3 className="text-navy text-sm font-semibold m-0 mb-1">Headnote</h3>
<p className="text-ink-soft text-sm leading-relaxed m-0">
{data.headnote}
</p>
</div>
) : null}
{data.summary ? (
<div>
<h3 className="text-navy text-sm font-semibold m-0 mb-1">תקציר</h3>
<p className="text-ink-soft text-sm leading-relaxed m-0 whitespace-pre-line">
{data.summary}
</p>
</div>
) : null}
{(data as { key_quote?: string }).key_quote ? (
<div>
<h3 className="text-navy text-sm font-semibold m-0 mb-1">ציטוט מרכזי</h3>
<blockquote className="text-ink-soft text-sm leading-relaxed border-r-2 border-gold pr-3 m-0">
{(data as { key_quote?: string }).key_quote}
</blockquote>
</div>
) : null}
{data.subject_tags?.length ? (
<div className="flex items-center gap-1 flex-wrap pt-1">
{data.subject_tags.map((t) => (
<Badge key={t} variant="outline" className="text-[0.65rem]">
{t}
</Badge>
))}
</div>
) : null}
</CardContent>
</Card>
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<ExtractedHalachotSection halachot={data.halachot ?? []} />
</CardContent>
</Card>
</>
)}
<PrecedentEditSheet
caseLawId={editing ? id : null}
onOpenChange={(open) => setEditing(open)}
/>
</section>
</AppShell>
);
}

View File

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

View File

@@ -0,0 +1,372 @@
"use client";
import { useState } from "react";
import {
AlertCircle,
Bot,
ChevronDown,
ChevronUp,
PauseCircle,
PlayCircle,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
usePaperclipAgents,
type AgentPair,
type DriftEntry,
type PaperclipAgent,
} from "@/lib/api/paperclip-agents";
const ROLE_LABEL: Record<string, string> = {
ceo: "CEO",
researcher: "מחקר",
engineer: "כתיבה",
qa: "בקרה",
general: "כללי",
};
const FIELD_LABEL: Record<string, string> = {
model: "מודל",
effort: "effort",
timeoutSec: "timeout (שניות)",
maxTurnsPerRun: "max turns",
desiredSkills: "skills",
instructionsBundleMode: "bundle mode",
instructionsEntryFile: "entry file",
graceSec: "grace (שניות)",
cooldownSec: "cooldown (שניות)",
wakeOnDemand: "wake on demand",
maxConcurrentRuns: "max concurrent",
budget_monthly_cents: "תקציב חודשי",
status: "סטטוס",
};
function formatCents(cents: number | null): string {
if (cents == null) return "—";
return `$${(cents / 100).toFixed(2)}`;
}
function StatusBadge({ agent }: { agent: PaperclipAgent }) {
const status = agent.status ?? "unknown";
if (status === "paused" || status === "terminated") {
return (
<Badge variant="outline" className="bg-warn-bg text-warn border-warn/40">
<PauseCircle className="w-3 h-3 me-1" />
{status === "paused" ? "מושהה" : "סיים"}
</Badge>
);
}
return (
<Badge variant="outline" className="bg-success-bg text-success border-success/40">
<PlayCircle className="w-3 h-3 me-1" />
פעיל
</Badge>
);
}
function FieldRow({
label,
master,
mirror,
drifted,
mono,
}: {
label: string;
master: React.ReactNode;
mirror: React.ReactNode;
drifted: boolean;
mono?: boolean;
}) {
const cellBase = `tabular-nums text-[0.82rem] ${mono ? "font-mono" : ""}`;
const cellCls = (val: React.ReactNode) =>
`${cellBase} px-2 py-1 rounded ${
drifted ? "bg-warn-bg text-warn border border-warn/40" : "text-ink"
} ${val == null || val === "—" ? "text-ink-light" : ""}`;
return (
<div className="grid grid-cols-[7rem_1fr_1fr] gap-2 items-center">
<div className="text-[0.75rem] text-ink-muted">{label}</div>
<div className={cellCls(master)} dir="ltr">{master ?? "—"}</div>
<div className={cellCls(mirror)} dir="ltr">{mirror ?? "—"}</div>
</div>
);
}
function PairCard({ pair }: { pair: AgentPair }) {
const [expanded, setExpanded] = useState(false);
const driftFields = new Set(pair.drift.map((d) => d.field));
const driftCount = pair.drift.length;
const pairMissing = driftFields.has("_pair_missing");
const a = pair.master ?? pair.mirror;
if (!a) return null;
const fieldVal = (
side: "master" | "mirror",
key: keyof PaperclipAgent,
): React.ReactNode => {
const agent = pair[side];
if (!agent) return <span className="text-ink-light"></span>;
const v = agent[key];
if (v == null) return "—";
if (typeof v === "boolean") return v ? "✓" : "✗";
if (Array.isArray(v)) return `${v.length}`;
return String(v);
};
const skillsList = (agent: PaperclipAgent | null) =>
agent?.desiredSkills?.length ? agent.desiredSkills : [];
return (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-5 py-4 space-y-4">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2 min-w-0">
<Bot className="w-5 h-5 text-gold-deep shrink-0" />
<div>
<h3 className="text-navy font-semibold text-base mb-0">{pair.name}</h3>
<div className="flex items-center gap-2 mt-0.5">
<Badge variant="outline" className="text-[0.7rem]">
{ROLE_LABEL[pair.role ?? ""] ?? pair.role ?? "—"}
</Badge>
{pair.master && <StatusBadge agent={pair.master} />}
</div>
</div>
</div>
{pairMissing ? (
<Badge variant="outline" className="bg-danger-bg text-danger border-danger/40">
<AlertCircle className="w-3 h-3 me-1" />
{pair.master ? "חסר ב-CMPA" : "חסר ב-CMP"}
</Badge>
) : driftCount > 0 ? (
<Badge variant="outline" className="bg-warn-bg text-warn border-warn/40">
<AlertCircle className="w-3 h-3 me-1" />
{driftCount} פערים
</Badge>
) : (
<Badge variant="outline" className="bg-success-bg text-success border-success/40">
מסונכרן
</Badge>
)}
</div>
<div className="grid grid-cols-[7rem_1fr_1fr] gap-2 text-[0.7rem] uppercase tracking-wide text-ink-muted border-b border-rule pb-1">
<div></div>
<div>CMP (1xxx)</div>
<div>CMPA (8xxx)</div>
</div>
<div className="space-y-1">
<FieldRow label={FIELD_LABEL.model} master={fieldVal("master", "model")} mirror={fieldVal("mirror", "model")} drifted={driftFields.has("model")} mono />
<FieldRow label={FIELD_LABEL.effort} master={fieldVal("master", "effort")} mirror={fieldVal("mirror", "effort")} drifted={driftFields.has("effort")} />
<FieldRow label={FIELD_LABEL.timeoutSec} master={fieldVal("master", "timeoutSec")} mirror={fieldVal("mirror", "timeoutSec")} drifted={driftFields.has("timeoutSec")} />
<FieldRow label={FIELD_LABEL.maxTurnsPerRun} master={fieldVal("master", "maxTurnsPerRun")} mirror={fieldVal("mirror", "maxTurnsPerRun")} drifted={driftFields.has("maxTurnsPerRun")} />
<FieldRow
label={FIELD_LABEL.desiredSkills}
master={pair.master ? `${pair.master.desiredSkills.length}` : "—"}
mirror={pair.mirror ? `${pair.mirror.desiredSkills.length}` : "—"}
drifted={driftFields.has("desiredSkills")}
/>
<FieldRow label={FIELD_LABEL.graceSec} master={fieldVal("master", "graceSec")} mirror={fieldVal("mirror", "graceSec")} drifted={driftFields.has("graceSec")} />
<FieldRow label={FIELD_LABEL.cooldownSec} master={fieldVal("master", "cooldownSec")} mirror={fieldVal("mirror", "cooldownSec")} drifted={driftFields.has("cooldownSec")} />
<FieldRow label={FIELD_LABEL.wakeOnDemand} master={fieldVal("master", "wakeOnDemand")} mirror={fieldVal("mirror", "wakeOnDemand")} drifted={driftFields.has("wakeOnDemand")} />
<FieldRow label={FIELD_LABEL.maxConcurrentRuns} master={fieldVal("master", "maxConcurrentRuns")} mirror={fieldVal("mirror", "maxConcurrentRuns")} drifted={driftFields.has("maxConcurrentRuns")} />
<FieldRow
label={FIELD_LABEL.budget_monthly_cents}
master={
pair.master
? `${formatCents(pair.master.spent_monthly_cents)} / ${formatCents(pair.master.budget_monthly_cents)}`
: "—"
}
mirror={
pair.mirror
? `${formatCents(pair.mirror.spent_monthly_cents)} / ${formatCents(pair.mirror.budget_monthly_cents)}`
: "—"
}
drifted={driftFields.has("budget_monthly_cents")}
/>
<FieldRow label={FIELD_LABEL.instructionsBundleMode} master={fieldVal("master", "instructionsBundleMode")} mirror={fieldVal("mirror", "instructionsBundleMode")} drifted={driftFields.has("instructionsBundleMode")} mono />
<FieldRow label={FIELD_LABEL.instructionsEntryFile} master={fieldVal("master", "instructionsEntryFile")} mirror={fieldVal("mirror", "instructionsEntryFile")} drifted={driftFields.has("instructionsEntryFile")} mono />
</div>
<div className="flex items-center justify-between pt-1 border-t border-rule">
<Button
variant="ghost"
size="sm"
className="text-[0.78rem] text-ink-muted"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? (
<>
<ChevronUp className="w-3 h-3 me-1" />
כיווץ
</>
) : (
<>
<ChevronDown className="w-3 h-3 me-1" />
פרטים מלאים
</>
)}
</Button>
{pair.master?.updated_at && (
<span className="text-[0.7rem] text-ink-light">
עודכן: {new Date(pair.master.updated_at).toLocaleDateString("he-IL")}
</span>
)}
</div>
{expanded && (
<div className="pt-2 border-t border-rule space-y-3">
{pair.drift.length > 0 && !pairMissing && (
<div className="rounded-md bg-warn-bg/40 border border-warn/30 p-3">
<div className="text-[0.78rem] text-warn font-medium mb-2">פערי סנכרון</div>
<ul className="space-y-1 text-[0.78rem]">
{pair.drift.map((d: DriftEntry) => (
<li key={d.field} className="flex items-center gap-2 flex-wrap">
<code dir="ltr" className="text-[0.72rem]">{FIELD_LABEL[d.field] ?? d.field}</code>
<span className="text-ink-muted">CMP:</span>
<code dir="ltr" className="text-[0.72rem] text-ink">{JSON.stringify(d.master)}</code>
<span className="text-ink-muted">CMPA:</span>
<code dir="ltr" className="text-[0.72rem] text-ink">{JSON.stringify(d.mirror)}</code>
</li>
))}
</ul>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{(["master", "mirror"] as const).map((side) => {
const agent = pair[side];
const skills = skillsList(agent);
return (
<div key={side} className="rounded-md border border-rule p-3 space-y-2">
<div className="text-[0.75rem] text-ink-muted">
{side === "master" ? "CMP" : "CMPA"}
</div>
{agent ? (
<>
<div className="text-[0.72rem] font-mono text-ink-muted" dir="ltr">
id: {agent.id}
</div>
<div>
<div className="text-[0.72rem] text-ink-muted mb-1">
skills ({skills.length})
</div>
{skills.length === 0 ? (
<span className="text-[0.78rem] text-ink-light"></span>
) : (
<ul className="space-y-0.5">
{skills.map((s) => (
<li key={s} className="text-[0.72rem] font-mono" dir="ltr">
{s}
</li>
))}
</ul>
)}
</div>
{agent.instructionsFilePath && (
<div>
<div className="text-[0.72rem] text-ink-muted">instructions path</div>
<code className="text-[0.72rem] font-mono break-all" dir="ltr">
{agent.instructionsFilePath}
</code>
</div>
)}
{agent.pause_reason && (
<div className="text-[0.78rem] text-warn">
סיבת השהיה: {agent.pause_reason}
</div>
)}
</>
) : (
<span className="text-[0.78rem] text-ink-light">חסר</span>
)}
</div>
);
})}
</div>
</div>
)}
</CardContent>
</Card>
);
}
export function AgentsTab() {
const { data, isPending, error, refetch, isFetching } = usePaperclipAgents();
if (error) {
return (
<Card className="bg-surface border-danger/40">
<CardContent className="p-6 flex items-center gap-3 text-danger">
<AlertCircle className="w-5 h-5" />
<span>שגיאה: {error.message}</span>
</CardContent>
</Card>
);
}
if (isPending) {
return (
<div className="space-y-3">
{[...Array(7)].map((_, i) => (
<Skeleton key={i} className="h-48 w-full rounded-lg" />
))}
</div>
);
}
if (!data || data.pairs.length === 0) {
return (
<Card className="bg-surface border-rule">
<CardContent className="px-6 py-12 text-center text-ink-muted">
לא נמצאו סוכנים
</CardContent>
</Card>
);
}
const totalDrift = data.pairs.reduce(
(sum, p) => sum + p.drift.filter((d) => d.field !== "_pair_missing").length,
0,
);
const missingCount = data.pairs.filter((p) => !p.master || !p.mirror).length;
return (
<div className="space-y-4">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-5 py-4 flex items-center justify-between gap-3 flex-wrap">
<div className="space-y-1">
<div className="text-[0.85rem] text-ink-muted">
{data.pairs.length} סוכנים × 2 חברות (CMP master / CMPA mirror)
{totalDrift > 0 && (
<span className="text-warn ms-2">
· {totalDrift} פערי סנכרון
</span>
)}
{missingCount > 0 && (
<span className="text-danger ms-2">· {missingCount} זוגות לא שלמים</span>
)}
</div>
<div className="text-[0.7rem] text-ink-light">
פערי skills מחושבים על paperclipai/* בלבד. local/* ו-company/* מסוננים שם שונה בין החברות הוא צפוי.
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
>
רענון
</Button>
</CardContent>
</Card>
<div className="space-y-3">
{data.pairs.map((pair) => (
<PairCard key={pair.name} pair={pair} />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,128 @@
"use client";
import { Layers, AlertCircle } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { useMcpBlocks, type McpBlock } from "@/lib/api/settings";
const GEN_TYPE_LABEL: Record<string, string> = {
"template-fill": "מילוי תבנית",
"paraphrase": "פרפרזה",
"reproduction": "שעתוק",
"guided-synthesis": "סינתזה מודרכת",
"rhetorical-construction": "בניה רטורית",
};
const GEN_TYPE_TONE: Record<string, string> = {
"template-fill": "text-ink-muted border-rule",
"paraphrase": "text-info border-info/40",
"reproduction": "text-info border-info/40",
"guided-synthesis": "text-warn border-warn/40",
"rhetorical-construction": "text-gold-deep border-gold/40",
};
function BlockRow({ block }: { block: McpBlock }) {
const isLLM = block.model !== "script";
return (
<div className="rounded-md border border-rule p-4 bg-rule-soft/20 hover:bg-rule-soft/40 transition-colors">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-10 h-10 rounded-md bg-navy/5 border border-navy/20 flex items-center justify-center">
<span className="text-navy text-sm font-semibold tabular-nums">
{block.index}
</span>
</div>
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-navy font-medium">{block.title}</h3>
<code dir="ltr" className="font-mono text-[0.72rem] text-ink-muted">
{block.id}
</code>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Badge
variant="outline"
className={`text-[0.7rem] ${GEN_TYPE_TONE[block.gen_type] ?? ""}`}
>
{GEN_TYPE_LABEL[block.gen_type] ?? block.gen_type}
</Badge>
<Badge variant="outline" className="text-[0.7rem] font-mono" dir="ltr">
{block.model}
</Badge>
{isLLM && block.temperature !== null && (
<Badge variant="outline" className="text-[0.7rem]">
temp&nbsp;<span className="tabular-nums">{block.temperature}</span>
</Badge>
)}
{block.max_tokens !== null && (
<Badge variant="outline" className="text-[0.7rem]">
max&nbsp;<span className="tabular-nums">{block.max_tokens}</span>
</Badge>
)}
</div>
{(block.creac_role || block.jwm_purpose) && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-1 text-[0.78rem] text-ink-muted pt-1">
{block.creac_role && (
<div>
<span className="text-[0.7rem] uppercase tracking-wide me-1">
CREAC:
</span>
<span dir="ltr">{block.creac_role}</span>
</div>
)}
{block.jwm_purpose && (
<div>
<span className="text-[0.7rem] uppercase tracking-wide me-1">
JWM:
</span>
<span dir="ltr">{block.jwm_purpose}</span>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
export function BlocksTab() {
const { data, isPending, error } = useMcpBlocks();
if (isPending) return <Skeleton className="h-96 w-full" />;
if (error) {
return (
<Card className="bg-surface border-danger/40">
<CardContent className="p-6 flex items-center gap-3 text-danger">
<AlertCircle className="w-5 h-5" />
<span>שגיאה בטעינת בלוקים: {error.message}</span>
</CardContent>
</Card>
);
}
if (!data) return null;
return (
<div className="space-y-4">
<Card className="bg-surface border-rule">
<CardContent className="px-6 py-5">
<div className="flex items-center gap-2 mb-4 text-ink-muted text-sm">
<Layers className="w-4 h-4" />
<span>
ארכיטקטורת 12 הבלוקים של החלטת ועדת ערר. מקור הסכימה:{" "}
<code dir="ltr" className="font-mono text-[0.78rem]">
docs/block-schema.md
</code>
.
</span>
</div>
<div className="space-y-3">
{data.blocks.map((b) => (
<BlockRow key={b.id} block={b} />
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,39 @@
"use client";
import { AlertTriangle, CheckCircle2, HelpCircle } from "lucide-react";
import { Badge } from "@/components/ui/badge";
type Props = {
drift: boolean;
// When false, Coolify was unreachable: drift state is unknown, not "synced".
coolifyAvailable?: boolean;
};
export function DriftBadge({ drift, coolifyAvailable = true }: Props) {
if (!coolifyAvailable) {
return (
<Badge
variant="outline"
className="text-ink-muted border-rule gap-1"
title="Coolify לא זמין — מצב ה-drift לא ידוע"
>
<HelpCircle className="w-3 h-3" />
Unknown
</Badge>
);
}
if (drift) {
return (
<Badge variant="outline" className="text-warn border-warn/40 gap-1">
<AlertTriangle className="w-3 h-3" />
Drift
</Badge>
);
}
return (
<Badge variant="outline" className="text-success border-success/40 gap-1">
<CheckCircle2 className="w-3 h-3" />
Synced
</Badge>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { McpEnvVar } from "@/lib/api/settings";
type Props = {
spec: McpEnvVar;
value: string;
onChange: (v: string) => void;
disabled?: boolean;
};
export function EnvVarEditor({ spec, value, onChange, disabled }: Props) {
if (spec.type === "bool") {
const checked = value === "true";
return (
<Switch
checked={checked}
onCheckedChange={(c) => onChange(c ? "true" : "false")}
disabled={disabled}
/>
);
}
if (spec.enum_values && spec.enum_values.length > 0) {
return (
<Select value={value} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="w-[220px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{spec.enum_values.map((v) => (
<SelectItem key={v} value={v}>
{v}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
if (spec.type === "int" || spec.type === "float") {
return (
<Input
type="number"
value={value}
onChange={(e) => onChange(e.target.value)}
min={spec.min ?? undefined}
max={spec.max ?? undefined}
step={spec.type === "float" ? "0.01" : "1"}
disabled={disabled}
className="w-[160px] text-start"
dir="ltr"
/>
);
}
return (
<Input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className="w-[260px] text-start"
dir="ltr"
/>
);
}

View File

@@ -0,0 +1,123 @@
"use client";
import { useState } from "react";
import { ExternalLink, Save, Lock } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import type { McpEnvVar } from "@/lib/api/settings";
import { useUpdateMcpEnv } from "@/lib/api/settings";
import { toast } from "sonner";
import { DriftBadge } from "./drift-badge";
import { EnvVarEditor } from "./env-var-editor";
type Props = {
spec: McpEnvVar;
coolifyAppUuid: string;
coolifyAvailable: boolean;
onPendingRedeploy: () => void;
};
export function EnvVarRow({
spec,
coolifyAppUuid,
coolifyAvailable,
onPendingRedeploy,
}: Props) {
const [draft, setDraft] = useState<string>(spec.coolify_value ?? "");
const update = useUpdateMcpEnv();
const dirty = draft !== (spec.coolify_value ?? "");
function handleSave() {
update.mutate(
{ key: spec.key, value: draft },
{
onSuccess: (res) => {
toast.success(res.message);
onPendingRedeploy();
},
onError: (err) => toast.error(`שגיאה: ${err.message}`),
},
);
}
const coolifyEnvUrl =
`https://coolify.nautilus.marcusgroup.org/project/applications/${coolifyAppUuid}/environment-variables`;
return (
<div className="rounded-md border border-rule p-4 bg-rule-soft/20 hover:bg-rule-soft/40 transition-colors">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<code className="font-mono text-sm font-medium text-navy" dir="ltr">
{spec.key}
</code>
<Badge variant="outline" className="text-[0.7rem]">
{spec.type}
</Badge>
{spec.is_secret && (
<Badge variant="outline" className="text-[0.7rem] text-warn border-warn/40 gap-1">
<Lock className="w-3 h-3" />
secret
</Badge>
)}
<DriftBadge drift={spec.drift} coolifyAvailable={coolifyAvailable} />
{spec.has_duplicates && (
<Badge variant="outline" className="text-[0.7rem] text-warn border-warn/40">
duplicates
</Badge>
)}
</div>
<p className="text-sm text-ink-muted mt-1">{spec.description}</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2">
<span className="text-[0.72rem] text-ink-muted w-20">Coolify:</span>
{spec.is_editable ? (
<EnvVarEditor
spec={spec}
value={draft}
onChange={setDraft}
disabled={update.isPending}
/>
) : (
<span className="font-mono text-ink" dir="ltr">
{spec.coolify_value ?? <em className="text-ink-muted"> לא מוגדר </em>}
</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-[0.72rem] text-ink-muted w-20">Container:</span>
<span className="font-mono text-ink" dir="ltr">
{spec.container_value ?? <em className="text-ink-muted"> לא מוגדר </em>}
</span>
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-3">
{!spec.is_editable && (
<a
href={coolifyEnvUrl}
target="_blank"
rel="noopener noreferrer"
className="text-[0.78rem] text-gold-deep hover:underline flex items-center gap-1"
>
ערוך ב-Coolify
<ExternalLink className="w-3 h-3" />
</a>
)}
{spec.is_editable && (
<Button
size="sm"
onClick={handleSave}
disabled={!dirty || update.isPending}
>
<Save className="w-3.5 h-3.5" data-icon="inline-start" />
{update.isPending ? "שומר..." : "שמור"}
</Button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,139 @@
"use client";
import { useState, useMemo } from "react";
import { RefreshCw, AlertCircle } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import {
useMcpEnv,
useMcpRedeploy,
type McpEnvVar,
type EnvCategory,
} from "@/lib/api/settings";
import { toast } from "sonner";
import { EnvVarRow } from "./env-var-row";
const CATEGORY_LABELS: Record<EnvCategory, string> = {
multimodal: "Multimodal",
rerank: "Rerank",
halacha: "Halacha",
general: "כללי",
credentials: "אישורים",
connection: "חיבורים",
};
const CATEGORY_ORDER: EnvCategory[] = [
"multimodal", "rerank", "halacha", "general", "credentials", "connection",
];
export function EnvironmentTab() {
const { data, isPending, error } = useMcpEnv();
const redeploy = useMcpRedeploy();
const [pendingRedeploy, setPendingRedeploy] = useState(false);
const grouped = useMemo(() => {
if (!data?.vars) return new Map<EnvCategory, McpEnvVar[]>();
const m = new Map<EnvCategory, McpEnvVar[]>();
for (const v of data.vars) {
const arr = m.get(v.category) ?? [];
arr.push(v);
m.set(v.category, arr);
}
return m;
}, [data]);
function handleRedeploy() {
redeploy.mutate(undefined, {
onSuccess: (res) => {
toast.success(res.message);
setPendingRedeploy(false);
},
onError: (err) => toast.error(`Redeploy נכשל: ${err.message}`),
});
}
if (isPending) return <Skeleton className="h-96 w-full" />;
if (error) {
return (
<Card className="bg-surface border-danger/40">
<CardContent className="p-6 flex items-center gap-3 text-danger">
<AlertCircle className="w-5 h-5" />
<span>שגיאה בטעינת env vars: {error.message}</span>
</CardContent>
</Card>
);
}
if (!data) return null;
const coolifyAvailable = data.errors.length === 0;
const driftCount = data.vars.filter((v) => v.drift).length;
const duplicatesCount = data.vars.filter((v) => v.has_duplicates).length;
return (
<div className="space-y-4">
<Card className="bg-surface border-rule">
<CardContent className="px-6 py-4 flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-3 flex-wrap text-sm">
<Badge variant="outline">
Coolify app: <code dir="ltr" className="ms-1">{data.coolify_app_uuid.slice(0, 8)}</code>
</Badge>
{driftCount > 0 && (
<Badge variant="outline" className="text-warn border-warn/40">
{driftCount} drift
</Badge>
)}
{duplicatesCount > 0 && (
<Badge variant="outline" className="text-warn border-warn/40">
{duplicatesCount} duplicates
</Badge>
)}
{data.errors.length > 0 && (
<Badge variant="outline" className="text-danger border-danger/40">
{data.errors.join(", ")}
</Badge>
)}
</div>
<Button
onClick={handleRedeploy}
disabled={redeploy.isPending}
variant={pendingRedeploy ? "default" : "outline"}
size="sm"
>
<RefreshCw className={redeploy.isPending ? "w-3.5 h-3.5 animate-spin" : "w-3.5 h-3.5"} data-icon="inline-start" />
{redeploy.isPending ? "Redeploying..." : "Redeploy now"}
</Button>
</CardContent>
</Card>
{CATEGORY_ORDER.map((cat) => {
const vars = grouped.get(cat);
if (!vars || vars.length === 0) return null;
return (
<Card key={cat} className="bg-surface border-rule">
<CardContent className="px-6 py-5">
<h2 className="text-navy text-lg mb-4 flex items-center gap-2">
{CATEGORY_LABELS[cat]}
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
{vars.length}
</Badge>
</h2>
<div className="space-y-3">
{vars.map((v) => (
<EnvVarRow
key={v.key}
spec={v}
coolifyAppUuid={data.coolify_app_uuid}
coolifyAvailable={coolifyAvailable}
onPendingRedeploy={() => setPendingRedeploy(true)}
/>
))}
</div>
</CardContent>
</Card>
);
})}
</div>
);
}

View File

@@ -0,0 +1,225 @@
"use client";
import { useState } from "react";
import { Plus, Trash2, Tags, Building2 } from "lucide-react";
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 function PaperclipTab() {
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 (
<div className="space-y-6">
<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>
<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>
<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>
{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>
</div>
);
}

View File

@@ -0,0 +1,134 @@
"use client";
import { Plug, AlertCircle } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { useMcpRegistrations } from "@/lib/api/settings";
export function RegistrationsTab() {
const { data, isPending, error } = useMcpRegistrations();
if (isPending) return <Skeleton className="h-64 w-full" />;
if (error) {
return (
<Card className="bg-surface border-danger/40">
<CardContent className="p-6 flex items-center gap-3 text-danger">
<AlertCircle className="w-5 h-5" />
<span>שגיאה: {error.message}</span>
</CardContent>
</Card>
);
}
if (!data) return null;
if (data.error === "host_path_unavailable") {
return (
<Card className="bg-surface border-warn/40">
<CardContent className="p-6">
<div className="flex items-center gap-3 text-warn mb-2">
<AlertCircle className="w-5 h-5" />
<span className="font-medium">תיקיית /host לא זמינה בקונטיינר</span>
</div>
<p className="text-sm text-ink-muted mb-2">
כדי להציג רישומי MCP, יש להוסיף volume mounts ב-Coolify.
ראה runbook ב-
<code dir="ltr" className="mx-1">
docs/runbooks/coolify-mcp-settings-volumes.md
</code>
</p>
{data.message && (
<p className="text-sm text-ink-muted">{data.message}</p>
)}
</CardContent>
</Card>
);
}
if (!data.registrations.length) {
return (
<Card className="bg-surface border-rule">
<CardContent className="p-6 text-ink-muted text-sm">
לא נמצאו רישומי MCP.
</CardContent>
</Card>
);
}
// Group by client
const groups = new Map<string, typeof data.registrations>();
for (const r of data.registrations) {
const arr = groups.get(r.client) ?? [];
arr.push(r);
groups.set(r.client, arr);
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-ink-muted">
<Plug className="w-4 h-4" />
סה&quot;כ {data.registrations.length} רישומים
</div>
{[...groups.entries()].map(([client, regs]) => (
<Card key={client} className="bg-surface border-rule">
<CardContent className="px-6 py-5">
<h2 className="text-navy text-lg mb-4 flex items-center gap-2">
{client}
<Badge variant="outline" className="text-[0.7rem]">
{regs.length}
</Badge>
</h2>
<div className="space-y-3">
{regs.map((r, i) => (
<div
key={`${r.server_name}-${i}`}
className="rounded-md border border-rule bg-rule-soft/20 p-4 space-y-2 text-sm"
>
<div className="flex items-center gap-2 mb-1">
<code dir="ltr" className="font-mono font-medium text-navy">
{r.server_name}
</code>
<Badge variant="outline" className="text-[0.7rem]" dir="ltr">
{r.transport}
</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-[100px_1fr] gap-x-3 gap-y-1.5 text-[0.82rem]">
<span className="text-ink-muted">command:</span>
<code dir="ltr" className="font-mono text-ink break-all">
{r.command || "—"}
</code>
<span className="text-ink-muted">args:</span>
<code dir="ltr" className="font-mono text-ink break-all">
{r.args.length ? JSON.stringify(r.args) : "[]"}
</code>
<span className="text-ink-muted">cwd:</span>
<code dir="ltr" className="font-mono text-ink break-all">
{r.cwd || "—"}
</code>
<span className="text-ink-muted">env keys:</span>
<div className="flex flex-wrap gap-1">
{r.env_keys.length === 0 ? (
<span className="text-ink-muted"></span>
) : (
r.env_keys.map((k) => (
<Badge
key={k}
variant="outline"
className="text-[0.7rem] font-mono"
dir="ltr"
>
{k}
</Badge>
))
)}
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
))}
</div>
);
}

View File

@@ -0,0 +1,65 @@
"use client";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from "@/components/ui/sheet";
import { Badge } from "@/components/ui/badge";
import type { McpTool } from "@/lib/api/settings";
type Props = {
tool: McpTool | null;
open: boolean;
onOpenChange: (o: boolean) => void;
};
export function ToolDetailDrawer({ tool, open, onOpenChange }: Props) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent dir="rtl" side="left" className="sm:max-w-xl overflow-y-auto">
{tool && (
<>
<SheetHeader>
<SheetTitle dir="ltr" className="font-mono text-navy">
{tool.name}
</SheetTitle>
<SheetDescription>{tool.description || "—"}</SheetDescription>
</SheetHeader>
<div className="space-y-4 mt-4 px-4 pb-6">
<div>
<div className="text-[0.72rem] text-ink-muted uppercase mb-1">
Module
</div>
<Badge variant="outline" className="font-mono" dir="ltr">
{tool.module}
</Badge>
</div>
<div>
<div className="text-[0.72rem] text-ink-muted uppercase mb-1">
Source
</div>
<code dir="ltr" className="text-xs text-ink break-all">
{tool.source_location || "—"}
</code>
</div>
<div>
<div className="text-[0.72rem] text-ink-muted uppercase mb-1">
Parameters Schema
</div>
<pre
dir="ltr"
className="text-xs bg-rule-soft/40 border border-rule rounded-md p-3 overflow-x-auto"
>
{JSON.stringify(tool.params_schema, null, 2)}
</pre>
</div>
</div>
</>
)}
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,83 @@
"use client";
import { useState, useMemo } from "react";
import { Wrench, AlertCircle } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { useMcpTools, type McpTool } from "@/lib/api/settings";
import { ToolDetailDrawer } from "./tool-detail-drawer";
export function ToolsTab() {
const { data, isPending, error } = useMcpTools();
const [selected, setSelected] = useState<McpTool | null>(null);
const [open, setOpen] = useState(false);
const grouped = useMemo(() => {
if (!data?.tools) return new Map<string, McpTool[]>();
const m = new Map<string, McpTool[]>();
for (const t of data.tools) {
const mod = t.module.split(".").pop() || "other";
const arr = m.get(mod) ?? [];
arr.push(t);
m.set(mod, arr);
}
return m;
}, [data]);
if (isPending) return <Skeleton className="h-96 w-full" />;
if (error) {
return (
<Card className="bg-surface border-danger/40">
<CardContent className="p-6 flex items-center gap-3 text-danger">
<AlertCircle className="w-5 h-5" />
<span>שגיאה בטעינת tools: {error.message}</span>
</CardContent>
</Card>
);
}
if (!data) return null;
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-ink-muted">
<Wrench className="w-4 h-4" />
סה&quot;כ {data.count} tools
</div>
{[...grouped.entries()].sort().map(([mod, tools]) => (
<Card key={mod} className="bg-surface border-rule">
<CardContent className="px-6 py-5">
<h2 className="text-navy text-lg mb-3 flex items-center gap-2">
<code dir="ltr">{mod}</code>
<Badge variant="outline" className="text-[0.7rem]">
{tools.length}
</Badge>
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{tools.map((t) => (
<button
key={t.name}
onClick={() => {
setSelected(t);
setOpen(true);
}}
className="text-start rounded-md border border-rule px-3 py-2 hover:bg-rule-soft/40 transition-colors"
>
<code dir="ltr" className="font-mono text-sm text-navy">
{t.name}
</code>
{t.description && (
<p className="text-[0.78rem] text-ink-muted mt-0.5 line-clamp-2">
{t.description}
</p>
)}
</button>
))}
</div>
</CardContent>
</Card>
))}
<ToolDetailDrawer tool={selected} open={open} onOpenChange={setOpen} />
</div>
);
}

View File

@@ -1,80 +1,17 @@
"use client"; "use client";
import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Plus, Trash2, Tags, Building2 } from "lucide-react"; import { Server, Wrench, Plug, Building2, Layers, Bot } from "lucide-react";
import { AppShell } from "@/components/app-shell"; import { AppShell } from "@/components/app-shell";
import { Card, CardContent } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge"; import { PaperclipTab } from "./_components/paperclip-tab";
import { Button } from "@/components/ui/button"; import { EnvironmentTab } from "./_components/environment-tab";
import { Input } from "@/components/ui/input"; import { ToolsTab } from "./_components/tools-tab";
import { Skeleton } from "@/components/ui/skeleton"; import { RegistrationsTab } from "./_components/registrations-tab";
import { import { BlocksTab } from "./_components/blocks-tab";
Select, import { AgentsTab } from "./_components/agents-tab";
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() { 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 ( return (
<AppShell> <AppShell>
<section className="space-y-6"> <section className="space-y-6">
@@ -88,164 +25,47 @@ export default function SettingsPage() {
</nav> </nav>
<h1 className="text-navy mb-0">הגדרות</h1> <h1 className="text-navy mb-0">הגדרות</h1>
<p className="text-ink-muted text-sm mt-1 max-w-2xl"> <p className="text-ink-muted text-sm mt-1 max-w-2xl">
ניהול מיפוי תגיות ערר לחברות ב-Paperclip. כל תיק חדש ישויך תצורת המערכת, MCP server, ו-Paperclip integration.
אוטומטית לפרויקט בחברה הנכונה לפי סוג הערר.
</p> </p>
</header> </header>
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" /> <div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
{/* Companies overview */} <Tabs dir="rtl" defaultValue="paperclip" className="space-y-4">
<Card className="bg-surface border-rule shadow-sm"> <TabsList>
<CardContent className="px-6 py-5"> <TabsTrigger value="paperclip">
<h2 className="text-navy text-lg mb-3 flex items-center gap-2"> <Building2 className="w-4 h-4" data-icon="inline-start" />
<Building2 className="w-4 h-4" /> Paperclip
חברות ב-Paperclip </TabsTrigger>
</h2> <TabsTrigger value="agents">
{loadingCompanies ? ( <Bot className="w-4 h-4" data-icon="inline-start" />
<Skeleton className="h-12 w-full" /> סוכנים
) : !companies?.length ? ( </TabsTrigger>
<p className="text-ink-muted text-sm">לא נמצאו חברות</p> <TabsTrigger value="environment">
) : ( <Server className="w-4 h-4" data-icon="inline-start" />
<div className="flex flex-wrap gap-3"> סביבה
{companies.map((c) => ( </TabsTrigger>
<div <TabsTrigger value="tools">
key={c.id} <Wrench className="w-4 h-4" data-icon="inline-start" />
className="flex items-center gap-2 rounded-md bg-rule-soft/60 border border-rule px-4 py-2.5" כלים
> </TabsTrigger>
<span className="text-sm font-medium text-ink">{c.name}</span> <TabsTrigger value="blocks">
<Badge variant="outline" className="text-[0.7rem] tabular-nums"> <Layers className="w-4 h-4" data-icon="inline-start" />
{c.prefix} בלוקים
</Badge> </TabsTrigger>
</div> <TabsTrigger value="registrations">
))} <Plug className="w-4 h-4" data-icon="inline-start" />
</div> רישומים
)} </TabsTrigger>
</CardContent> </TabsList>
</Card>
{/* Tag mappings */} <TabsContent value="paperclip"><PaperclipTab /></TabsContent>
<Card className="bg-surface border-rule shadow-sm"> <TabsContent value="agents"><AgentsTab /></TabsContent>
<CardContent className="px-6 py-5"> <TabsContent value="environment"><EnvironmentTab /></TabsContent>
<h2 className="text-navy text-lg mb-4 flex items-center gap-2"> <TabsContent value="tools"><ToolsTab /></TabsContent>
<Tags className="w-4 h-4" /> <TabsContent value="blocks"><BlocksTab /></TabsContent>
מיפוי תגיות <TabsContent value="registrations"><RegistrationsTab /></TabsContent>
<Badge variant="outline" className="text-[0.7rem] tabular-nums"> </Tabs>
{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> </section>
</AppShell> </AppShell>
); );

View File

@@ -3,35 +3,70 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { ChevronDown, Settings } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { GlobalSearch } from "@/components/global-search";
import { headerSubtitle } from "@/components/header-context";
/** /**
* Ezer Mishpati navigation shell. * Ezer Mishpati navigation shell — two-row header.
* *
* Editorial/judicial aesthetic: * Row 1 (brand): logo + dynamic context subtitle · global search · agent boards
* - Navy header with a gold hairline rule (border-b-3) * Row 2 (nav): work group · knowledge group · admin dropdown
* - Parchment/cream body background (set on <body> via globals.css) *
* Editorial/judicial aesthetic preserved:
* - Navy background with a gold hairline rule (border-b-3)
* - Parchment text, gold accents on hover/active
* - Hebrew RTL throughout (set on <html> in layout.tsx) * - Hebrew RTL throughout (set on <html> in layout.tsx)
* * - Active item gets `aria-current="page"` and a gold underline anchored
* Nav items pick up an `aria-current="page"` and a gold underline when * to the bottom border, so screen readers announce the section and
* the current route matches, so screen readers announce the active * sighted users see where they are.
* section and sighted users can see where they are.
*/ */
type NavItem = { type NavItem = { href: string; label: string };
href: string; type NavGroup = { id: string; items: NavItem[] };
label: string;
};
const NAV_ITEMS: NavItem[] = [ const NAV_GROUPS: NavGroup[] = [
{
id: "work",
items: [
{ href: "/", label: "בית" }, { href: "/", label: "בית" },
{ href: "/archive", label: "ארכיון" }, { href: "/archive", label: "ארכיון" },
],
},
{
id: "knowledge",
items: [
{ href: "/precedents", label: "ספריית פסיקה" },
{ href: "/training", label: "אימון סגנון" }, { href: "/training", label: "אימון סגנון" },
{ href: "/methodology", label: "מתודולוגיה" }, { href: "/methodology", label: "מתודולוגיה" },
],
},
];
const ADMIN_ITEMS: NavItem[] = [
{ href: "/skills", label: "מיומנויות" }, { href: "/skills", label: "מיומנויות" },
{ href: "/diagnostics", label: "אבחון" }, { href: "/diagnostics", label: "אבחון" },
{ href: "/settings", label: "הגדרות" }, { href: "/settings", label: "הגדרות" },
]; ];
type AgentBoard = { prefix: string; label: string; hint: string };
const AGENT_BOARDS: AgentBoard[] = [
{ prefix: "CMP", label: "רישוי ובניה", hint: "תיקי 1xxx" },
{ prefix: "CMPA", label: "היטלי השבחה", hint: "תיקי 8xxx / 9xxx" },
];
const PAPERCLIP_BASE = "https://pc.nautilus.marcusgroup.org";
function isActive(pathname: string, href: string): boolean { function isActive(pathname: string, href: string): boolean {
if (href === "/") return pathname === "/"; if (href === "/") return pathname === "/";
return pathname === href || pathname.startsWith(`${href}/`); return pathname === href || pathname.startsWith(`${href}/`);
@@ -39,56 +74,148 @@ function isActive(pathname: string, href: string): boolean {
export function AppShell({ children }: { children: ReactNode }) { export function AppShell({ children }: { children: ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
const subtitle = headerSubtitle(pathname);
const adminActive = ADMIN_ITEMS.some((i) => isActive(pathname, i.href));
return ( return (
<> <>
<header <header
className=" className="
relative z-10 flex items-center gap-4 relative z-10 flex flex-col
px-10 py-[18px]
bg-navy text-parchment bg-navy text-parchment
border-b-[3px] border-gold border-b-[3px] border-gold
shadow-md shadow-md
" "
> >
<Link href="/" className="flex items-baseline gap-3 hover:text-parchment"> {/* ─── Row 1 — brand bar (3-column grid) ─── */}
<span className="font-display text-[1.45rem] font-bold tracking-[0.02em] text-parchment"> {/* Side columns flex 1fr each so the search column stays centered on
the viewport regardless of how wide the brand or agent labels grow. */}
<div className="grid grid-cols-[minmax(0,1fr)_minmax(280px,460px)_minmax(0,1fr)] items-center gap-4 px-10 pt-[14px] pb-2">
<Link
href="/"
className="flex items-baseline gap-3 hover:text-parchment min-w-0 justify-self-start"
>
<span className="font-display text-[1.45rem] font-bold tracking-[0.02em] text-parchment whitespace-nowrap">
עוזר משפטי עוזר משפטי
</span> </span>
<span className="text-gold-soft text-sm font-medium">ניהול תיקים</span> <span
className="text-gold-soft text-sm font-medium truncate"
aria-live="polite"
>
{subtitle}
</span>
</Link> </Link>
<nav <div className="w-full justify-self-center">
className="me-auto flex items-center gap-1" <GlobalSearch />
aria-label="ניווט ראשי" </div>
<DropdownMenu>
<DropdownMenuTrigger
className="
justify-self-end flex items-baseline gap-2 px-3 py-1.5 rounded
transition-colors outline-none
text-parchment/80 hover:text-parchment hover:bg-navy-soft/60
focus-visible:ring-2 focus-visible:ring-gold/60
data-[state=open]:bg-navy-soft/80 data-[state=open]:text-parchment
"
aria-label="ניהול סוכנים — בחר ועדה"
> >
{NAV_ITEMS.map((item) => { <span className="font-display text-[1.45rem] font-bold tracking-[0.02em] text-parchment whitespace-nowrap">
const active = isActive(pathname, item.href); ניהול סוכנים
return ( </span>
<Link <ChevronDown className="size-4 self-center text-gold-soft" aria-hidden="true" />
key={item.href} </DropdownMenuTrigger>
href={item.href}
aria-current={active ? "page" : undefined} <DropdownMenuContent align="end" sideOffset={10} className="min-w-[240px]">
<DropdownMenuLabel className="text-xs text-muted-foreground text-center">
Paperclip פתח דאשבורד
</DropdownMenuLabel>
<DropdownMenuSeparator />
{AGENT_BOARDS.map((board) => (
<DropdownMenuItem key={board.prefix} asChild>
<a
href={`${PAPERCLIP_BASE}/${board.prefix}/dashboard`}
target="_blank"
rel="noreferrer noopener"
className="flex flex-col gap-0.5 cursor-pointer py-1.5"
>
<span className="font-medium whitespace-nowrap">{board.label}</span>
<span className="text-xs text-muted-foreground tracking-wide whitespace-nowrap">
<span className="font-mono">{board.prefix}</span> · {board.hint}
</span>
</a>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* ─── Row 2 — section nav ─── */}
<div className="flex items-center gap-3 px-10 pt-1 pb-[18px]">
<nav className="flex items-center gap-3" aria-label="ניווט ראשי">
{NAV_GROUPS.map((group, idx) => (
<div key={group.id} className="flex items-center">
{idx > 0 && (
<span
className="mx-2 h-4 w-px bg-parchment/20"
aria-hidden="true"
/>
)}
<div className="flex items-center gap-1">
{group.items.map((item) => (
<NavLink key={item.href} item={item} active={isActive(pathname, item.href)} />
))}
</div>
</div>
))}
</nav>
<DropdownMenu>
<DropdownMenuTrigger
className={` className={`
relative px-3 py-1.5 rounded text-sm transition-colors relative ms-auto shrink-0 flex items-center gap-1.5
${ px-3 py-1.5 rounded text-sm transition-colors outline-none
active focus-visible:ring-2 focus-visible:ring-gold/60
${adminActive
? "text-parchment font-semibold bg-navy-soft/80" ? "text-parchment font-semibold bg-navy-soft/80"
: "text-parchment/80 hover:text-parchment hover:bg-navy-soft/60" : "text-parchment/80 hover:text-parchment hover:bg-navy-soft/60"}
} data-[state=open]:bg-navy-soft/80 data-[state=open]:text-parchment
`} `}
aria-label="הגדרות מערכת"
> >
{item.label} <Settings className="size-4" aria-hidden="true" />
{active && ( <ChevronDown className="size-3" aria-hidden="true" />
{adminActive && (
<span <span
className="absolute -bottom-[19px] inset-x-2 h-[2px] bg-gold" className="absolute -bottom-[19px] inset-x-2 h-[2px] bg-gold"
aria-hidden="true" aria-hidden="true"
/> />
)} )}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={10} className="min-w-[180px]">
<DropdownMenuLabel className="text-xs text-muted-foreground">
מערכת
</DropdownMenuLabel>
<DropdownMenuSeparator />
{ADMIN_ITEMS.map((item) => {
const active = isActive(pathname, item.href);
return (
<DropdownMenuItem key={item.href} asChild>
<Link
href={item.href}
aria-current={active ? "page" : undefined}
className={`cursor-pointer ${active ? "font-semibold" : ""}`}
>
{item.label}
</Link> </Link>
</DropdownMenuItem>
); );
})} })}
</nav> </DropdownMenuContent>
</DropdownMenu>
</div>
</header> </header>
<main <main
@@ -100,3 +227,26 @@ export function AppShell({ children }: { children: ReactNode }) {
</> </>
); );
} }
function NavLink({ item, active }: { item: NavItem; active: boolean }) {
return (
<Link
href={item.href}
aria-current={active ? "page" : undefined}
className={`
relative px-3 py-1.5 rounded text-sm transition-colors
${active
? "text-parchment font-semibold bg-navy-soft/80"
: "text-parchment/80 hover:text-parchment hover:bg-navy-soft/60"}
`}
>
{item.label}
{active && (
<span
className="absolute -bottom-[19px] inset-x-2 h-[2px] bg-gold"
aria-hidden="true"
/>
)}
</Link>
);
}

View File

@@ -5,8 +5,18 @@ import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Markdown } from "@/components/ui/markdown"; import { Markdown } from "@/components/ui/markdown";
import { useAgentActivity, useSendComment } from "@/lib/api/agents"; import {
import type { PaperclipComment } from "@/lib/api/agents"; useAgentActivity,
useSendComment,
useSubmitInteraction,
} from "@/lib/api/agents";
import type {
Interaction,
InteractionPayload,
InteractionQuestion,
InteractionTask,
PaperclipComment,
} from "@/lib/api/agents";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
Bot, Bot,
@@ -15,6 +25,9 @@ import {
Loader2, Loader2,
MessageSquare, MessageSquare,
Clock, Clock,
CheckCircle2,
XCircle,
HelpCircle,
} from "lucide-react"; } from "lucide-react";
/* ── Role → color mapping ────────────────────────────────────── */ /* ── Role → color mapping ────────────────────────────────────── */
@@ -153,6 +166,463 @@ function CommentCard({
); );
} }
/* ── Interaction card ────────────────────────────────────────── */
const RESOLVED_LABELS: Record<string, { text: string; tone: string; Icon: typeof CheckCircle2 }> = {
answered: { text: "נענה", tone: "text-emerald-700 bg-emerald-50 border-emerald-200", Icon: CheckCircle2 },
accepted: { text: "התקבל", tone: "text-emerald-700 bg-emerald-50 border-emerald-200", Icon: CheckCircle2 },
rejected: { text: "נדחה", tone: "text-rose-700 bg-rose-50 border-rose-200", Icon: XCircle },
expired: { text: "פג תוקף", tone: "text-ink-faint bg-gray-50 border-gray-200", Icon: XCircle },
failed: { text: "כשל", tone: "text-rose-700 bg-rose-50 border-rose-200", Icon: XCircle },
};
function ResolvedBadge({ status }: { status: string }) {
const meta = RESOLVED_LABELS[status];
if (!meta) return null;
const { text, tone, Icon } = meta;
return (
<span className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full border ${tone}`}>
<Icon className="w-3 h-3" />
{text}
</span>
);
}
function summaryAnswer(interaction: Interaction): string | null {
const result = interaction.result;
if (!result) return null;
if (typeof result.summaryMarkdown === "string" && result.summaryMarkdown.trim()) {
return result.summaryMarkdown;
}
if (interaction.kind === "ask_user_questions" && Array.isArray(result.answers)) {
const optionLabel = (qid: string, oid: string): string => {
const q = interaction.payload.questions?.find((qq) => qq.id === qid);
return q?.options.find((o) => o.id === oid)?.label ?? oid;
};
return (result.answers as Array<{ questionId: string; optionIds: string[] }>)
.map((a) =>
`**${interaction.payload.questions?.find((q) => q.id === a.questionId)?.prompt ?? a.questionId}** — ${a.optionIds
.map((oid) => optionLabel(a.questionId, oid))
.join(", ")}`,
)
.join("\n\n");
}
if (interaction.kind === "request_confirmation" && typeof result.reason === "string" && result.reason) {
return `נימוק: ${result.reason}`;
}
if (interaction.kind === "suggest_tasks") {
const created = Array.isArray(result.createdTasks) ? result.createdTasks.length : 0;
const skipped = Array.isArray(result.skippedClientKeys) ? result.skippedClientKeys.length : 0;
if (created || skipped) {
const parts: string[] = [];
if (created) parts.push(`נוצרו ${created} משימות`);
if (skipped) parts.push(`דילוג על ${skipped}`);
return parts.join(" · ");
}
}
return null;
}
function AskUserQuestionsForm({
interaction,
onSubmit,
pending,
}: {
interaction: Interaction;
onSubmit: (answers: Array<{ questionId: string; optionIds: string[] }>) => void;
pending: boolean;
}) {
const questions: InteractionQuestion[] = interaction.payload.questions ?? [];
const [selections, setSelections] = useState<Record<string, string[]>>({});
const setSingle = (qid: string, oid: string) =>
setSelections((prev) => ({ ...prev, [qid]: [oid] }));
const toggleMulti = (qid: string, oid: string) =>
setSelections((prev) => {
const cur = prev[qid] ?? [];
return {
...prev,
[qid]: cur.includes(oid) ? cur.filter((x) => x !== oid) : [...cur, oid],
};
});
const missingRequired = questions.some(
(q) => (q.required ?? true) && !(selections[q.id]?.length),
);
const handleSend = () => {
const answers = questions
.map((q) => ({ questionId: q.id, optionIds: selections[q.id] ?? [] }))
.filter((a) => a.optionIds.length > 0);
onSubmit(answers);
};
return (
<div className="space-y-4">
{questions.map((q) => {
const isSingle = (q.selectionMode ?? "single") === "single";
const chosen = selections[q.id] ?? [];
return (
<fieldset key={q.id} className="space-y-2">
<legend className="text-sm font-semibold text-navy mb-1">
{q.prompt}
{(q.required ?? true) && <span className="text-rose-600 mr-1">*</span>}
</legend>
<div className="space-y-1.5">
{q.options.map((opt) => {
const checked = chosen.includes(opt.id);
return (
<label
key={opt.id}
className={`flex items-start gap-2 cursor-pointer rounded-md border p-2 transition-colors ${
checked
? "border-navy bg-navy/5"
: "border-rule hover:bg-sand-soft/60"
}`}
>
<input
type={isSingle ? "radio" : "checkbox"}
name={q.id}
value={opt.id}
checked={checked}
onChange={() =>
isSingle ? setSingle(q.id, opt.id) : toggleMulti(q.id, opt.id)
}
className="mt-1 accent-navy"
disabled={pending}
/>
<span className="flex-1 text-sm">
<span className="font-medium text-navy">{opt.label}</span>
{opt.description && (
<span className="block text-xs text-ink-faint mt-0.5">
{opt.description}
</span>
)}
</span>
</label>
);
})}
</div>
</fieldset>
);
})}
<div className="flex justify-end">
<Button
size="sm"
onClick={handleSend}
disabled={pending || missingRequired}
>
{pending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4 ml-1" />
)}
{interaction.payload.submitLabel || "שלח תשובה"}
</Button>
</div>
</div>
);
}
function RequestConfirmationForm({
interaction,
onAccept,
onReject,
pending,
}: {
interaction: Interaction;
onAccept: () => void;
onReject: (reason: string) => void;
pending: boolean;
}) {
const payload = interaction.payload;
const allowReason = payload.allowDeclineReason !== false;
const requireReason = payload.rejectRequiresReason === true;
const [showReason, setShowReason] = useState(requireReason);
const [reason, setReason] = useState("");
const acceptLabel = (payload.acceptLabel as string) || "אישור";
const rejectLabel = (payload.rejectLabel as string) || "דחייה";
const reasonLabel =
(payload.rejectReasonLabel as string) || "נימוק (לא חובה)";
const reasonPlaceholder =
(payload.declineReasonPlaceholder as string) || "סיבת הדחייה...";
const handleReject = () => {
if (requireReason && !reason.trim()) {
setShowReason(true);
return;
}
onReject(reason.trim());
};
return (
<div className="space-y-3">
{typeof payload.prompt === "string" && (
<div className="text-sm text-navy whitespace-pre-line">{payload.prompt}</div>
)}
{typeof payload.detailsMarkdown === "string" && payload.detailsMarkdown && (
<div className="text-sm bg-sand-soft/40 rounded-md p-2">
<Markdown content={payload.detailsMarkdown} />
</div>
)}
{showReason && allowReason && (
<div className="space-y-1">
<label className="text-xs font-medium text-ink-faint">{reasonLabel}</label>
<Textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder={reasonPlaceholder}
className="min-h-[60px] text-sm"
dir="rtl"
disabled={pending}
/>
</div>
)}
<div className="flex flex-wrap gap-2 justify-end">
{allowReason && !showReason && (
<Button
size="sm"
variant="outline"
onClick={() => setShowReason(true)}
disabled={pending}
>
הוסף נימוק
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={handleReject}
disabled={pending || (requireReason && !reason.trim())}
>
<XCircle className="w-4 h-4 ml-1" />
{rejectLabel}
</Button>
<Button size="sm" onClick={onAccept} disabled={pending}>
{pending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<CheckCircle2 className="w-4 h-4 ml-1" />
)}
{acceptLabel}
</Button>
</div>
</div>
);
}
function SuggestTasksForm({
interaction,
onAccept,
onReject,
pending,
}: {
interaction: Interaction;
onAccept: (selectedClientKeys: string[]) => void;
onReject: (reason: string) => void;
pending: boolean;
}) {
const tasks: InteractionTask[] = (interaction.payload.tasks as InteractionTask[]) ?? [];
const [selected, setSelected] = useState<Set<string>>(
() => new Set(tasks.map((t) => t.clientKey)),
);
const [showReason, setShowReason] = useState(false);
const [reason, setReason] = useState("");
const toggle = (key: string) =>
setSelected((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
return (
<div className="space-y-3">
<div className="space-y-1.5 max-h-[260px] overflow-y-auto">
{tasks.map((t) => {
const checked = selected.has(t.clientKey);
return (
<label
key={t.clientKey}
className={`flex items-start gap-2 cursor-pointer rounded-md border p-2 ${
checked
? "border-navy bg-navy/5"
: "border-rule hover:bg-sand-soft/60"
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggle(t.clientKey)}
className="mt-1 accent-navy"
disabled={pending}
/>
<span className="flex-1 text-sm">
<span className="font-medium text-navy">{t.title}</span>
{t.description && (
<span className="block text-xs text-ink-faint mt-0.5 whitespace-pre-line">
{t.description}
</span>
)}
</span>
</label>
);
})}
</div>
{showReason && (
<Textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="סיבת הדחייה (לא חובה)..."
className="min-h-[60px] text-sm"
dir="rtl"
disabled={pending}
/>
)}
<div className="flex flex-wrap gap-2 justify-end">
<Button
size="sm"
variant="outline"
onClick={() => (showReason ? onReject(reason.trim()) : setShowReason(true))}
disabled={pending}
>
<XCircle className="w-4 h-4 ml-1" />
{showReason ? "אישור דחייה" : "דחייה"}
</Button>
<Button
size="sm"
onClick={() => onAccept(Array.from(selected))}
disabled={pending || selected.size === 0}
>
{pending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<CheckCircle2 className="w-4 h-4 ml-1" />
)}
אישור משימות נבחרות ({selected.size})
</Button>
</div>
</div>
);
}
function InteractionCard({
interaction,
caseNumber,
issueMap,
}: {
interaction: Interaction;
caseNumber: string;
issueMap: Map<string, string>;
}) {
const submit = useSubmitInteraction(caseNumber);
const identifier = issueMap.get(interaction.issue_id) ?? "";
const isPending = interaction.status === "pending";
const summary = summaryAnswer(interaction);
const send = (action: "respond" | "accept" | "reject", payload: InteractionPayload | Record<string, unknown>) => {
submit.mutate(
{
issue_id: interaction.issue_id,
interaction_id: interaction.id,
action,
payload: payload as Record<string, unknown>,
},
{
onSuccess: () => toast.success("התשובה נשלחה"),
onError: () => toast.error("שגיאה בשליחת התשובה"),
},
);
};
return (
<div
className={`group relative flex gap-3 py-3 px-2 rounded-lg border transition-colors ${
isPending
? "border-amber-300 bg-amber-50/40"
: "border-rule bg-sand-soft/30"
}`}
>
<div className="flex-shrink-0 pt-0.5">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center ${
isPending
? "bg-amber-100 text-amber-800 border border-amber-300"
: "bg-emerald-100 text-emerald-800 border border-emerald-200"
}`}
>
<HelpCircle className="w-4 h-4" />
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className="text-sm font-semibold text-navy">
{interaction.title || "שאלה לסוכן"}
</span>
{isPending ? (
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full border border-amber-300 bg-amber-100 text-amber-800">
ממתין לתשובה
</span>
) : (
<ResolvedBadge status={interaction.status} />
)}
{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(interaction.resolved_at ?? interaction.created_at)}
</span>
</div>
{interaction.summary && (
<div className="text-xs text-ink-faint mb-2">{interaction.summary}</div>
)}
{isPending ? (
interaction.kind === "ask_user_questions" ? (
<AskUserQuestionsForm
interaction={interaction}
onSubmit={(answers) => send("respond", { answers })}
pending={submit.isPending}
/>
) : interaction.kind === "request_confirmation" ? (
<RequestConfirmationForm
interaction={interaction}
onAccept={() => send("accept", {})}
onReject={(reason) =>
send("reject", reason ? { reason } : {})
}
pending={submit.isPending}
/>
) : interaction.kind === "suggest_tasks" ? (
<SuggestTasksForm
interaction={interaction}
onAccept={(keys) =>
send("accept", keys.length ? { selectedClientKeys: keys } : {})
}
onReject={(reason) =>
send("reject", reason ? { reason } : {})
}
pending={submit.isPending}
/>
) : null
) : summary ? (
<div className="text-sm">
<Markdown content={summary} />
</div>
) : null}
</div>
</div>
);
}
/* ── Main Feed ───────────────────────────────────────────────── */ /* ── Main Feed ───────────────────────────────────────────────── */
export function AgentActivityFeed({ export function AgentActivityFeed({
@@ -173,11 +643,12 @@ export function AgentActivityFeed({
} }
} }
// Auto-scroll on new comments // Auto-scroll on new comments or interactions
const commentCount = data?.comments?.length ?? 0; const commentCount = data?.comments?.length ?? 0;
const interactionCount = data?.interactions?.length ?? 0;
useEffect(() => { useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "smooth" }); endRef.current?.scrollIntoView({ behavior: "smooth" });
}, [commentCount]); }, [commentCount, interactionCount]);
const handleSend = () => { const handleSend = () => {
if (!body.trim()) return; if (!body.trim()) return;
@@ -224,6 +695,25 @@ export function AgentActivityFeed({
} }
const comments = data.comments ?? []; const comments = data.comments ?? [];
const interactions = data.interactions ?? [];
// Unified, time-sorted feed: comments + interactions interleaved.
type FeedItem =
| { kind: "comment"; at: number; comment: PaperclipComment }
| { kind: "interaction"; at: number; interaction: Interaction };
const feed: FeedItem[] = [
...comments.map<FeedItem>((c) => ({
kind: "comment",
at: c.created_at ? new Date(c.created_at).getTime() : 0,
comment: c,
})),
...interactions.map<FeedItem>((i) => ({
kind: "interaction",
at: i.created_at ? new Date(i.created_at).getTime() : 0,
interaction: i,
})),
].sort((a, b) => a.at - b.at);
// An issue is "active" if it's not done/cancelled. When everything is closed // An issue is "active" if it's not done/cancelled. When everything is closed
// we should NOT show the "agents are working, waiting for report" spinner. // we should NOT show the "agents are working, waiting for report" spinner.
@@ -246,9 +736,9 @@ export function AgentActivityFeed({
))} ))}
</div> </div>
{/* Comments stream */} {/* Comments + interactions stream */}
<div className="flex-1 overflow-y-auto max-h-[500px] space-y-1 px-1"> <div className="flex-1 overflow-y-auto max-h-[500px] space-y-1 px-1">
{comments.length === 0 ? ( {feed.length === 0 ? (
hasActiveIssue ? ( hasActiveIssue ? (
<div className="text-center py-8 text-ink-faint text-sm"> <div className="text-center py-8 text-ink-faint text-sm">
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" /> <Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
@@ -261,9 +751,22 @@ export function AgentActivityFeed({
</div> </div>
) )
) : ( ) : (
comments.map((c) => ( feed.map((item) =>
<CommentCard key={c.id} comment={c} issueMap={issueMap} /> item.kind === "comment" ? (
)) <CommentCard
key={`c-${item.comment.id}`}
comment={item.comment}
issueMap={issueMap}
/>
) : (
<InteractionCard
key={`i-${item.interaction.id}`}
interaction={item.interaction}
caseNumber={caseNumber}
issueMap={issueMap}
/>
),
)
)} )}
<div ref={endRef} /> <div ref={endRef} />
</div> </div>

View File

@@ -0,0 +1,55 @@
"use client";
import { deriveSubtype } from "@/lib/practice-area";
import type { AppealSubtype } from "@/lib/practice-area";
import type { Case } from "@/lib/api/cases";
type Bucket = { key: AppealSubtype; label: string; color: string };
const BUCKETS: Bucket[] = [
{ key: "building_permit", label: "רישוי ובנייה", color: "var(--color-info)" },
{ key: "betterment_levy", label: "היטל השבחה", color: "var(--color-gold)" },
{ key: "compensation_197", label: "פיצויים (ס׳ 197)", color: "var(--color-warn)" },
];
export function subtypeOf(c: Case): AppealSubtype {
return c.appeal_subtype && c.appeal_subtype !== "unknown"
? c.appeal_subtype
: deriveSubtype(c.case_number);
}
export function AppealTypeBars({ cases }: { cases?: Case[] }) {
const counts: Record<AppealSubtype, number> = {
building_permit: 0,
betterment_levy: 0,
compensation_197: 0,
unknown: 0,
};
(cases ?? []).forEach((c) => {
counts[subtypeOf(c)] += 1;
});
const max = Math.max(1, ...BUCKETS.map((b) => counts[b.key]));
return (
<ul className="flex flex-col gap-3">
{BUCKETS.map((b) => {
const n = counts[b.key];
const widthPct = (n / max) * 100;
return (
<li key={b.key} className="space-y-1.5">
<div className="flex items-baseline justify-between gap-2 text-sm">
<span className="text-ink-soft truncate">{b.label}</span>
<span className="text-ink font-semibold tabular-nums">{n}</span>
</div>
<div className="h-2 rounded-full bg-rule-soft/60 overflow-hidden">
<div
className="h-full rounded-full transition-[width] duration-500"
style={{ width: `${widthPct}%`, background: b.color }}
/>
</div>
</li>
);
})}
</ul>
);
}

View File

@@ -4,6 +4,7 @@ import { Badge } from "@/components/ui/badge";
import { StatusBadge } from "@/components/cases/status-badge"; import { StatusBadge } from "@/components/cases/status-badge";
import { SyncIndicator } from "@/components/cases/sync-indicator"; import { SyncIndicator } from "@/components/cases/sync-indicator";
import { CaseArchiveAction } from "@/components/cases/case-archive-action"; import { CaseArchiveAction } from "@/components/cases/case-archive-action";
import { CreateRepoButton } from "@/components/cases/create-repo-button";
import { import {
PRACTICE_AREA_LABELS, PRACTICE_AREA_LABELS,
APPEAL_SUBTYPE_LABELS, APPEAL_SUBTYPE_LABELS,
@@ -67,6 +68,7 @@ export function CaseHeader({ data }: { data?: CaseDetail }) {
archivedAt={data.archived_at} archivedAt={data.archived_at}
/> />
)} )}
<CreateRepoButton data={data} />
</div> </div>
<h1 className="text-navy text-xl font-bold leading-snug max-w-2xl mb-0"> <h1 className="text-navy text-xl font-bold leading-snug max-w-2xl mb-0">
{data?.title ?? "טוען…"} {data?.title ?? "טוען…"}

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