55 Commits

Author SHA1 Message Date
1496e520fd feat(precedent-library): add district and chair_name to edit form
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m11s
Fields existed in DB and Precedent type but were missing from:
- PrecedentUpdateRequest (backend model)
- update_case_law allowed set (db layer)
- PrecedentPatch (frontend type)
- precedent-edit-sheet form state, inputs, and patch payload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 12:16:43 +00:00
1da2a9a2cb fix: exclude archived cases from stale-case-reminder
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
Archived cases have archived_at IS NOT NULL — they are not "stuck",
they are done. The stale query was missing this filter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 16:41:38 +00:00
f3ecccd4f0 docs: add procedural patterns layer (interim decision template)
Document new daphna-procedural-patterns.md cataloging the
"appraiser clarification request" interim-decision pattern observed in
8174-24 — structure only, not phrasing (case is an outlier example).

- daphna-decision-tree.md §0.5: gating question before main tree
- legal-ceo.md voice docs table: register procedural patterns doc
- legal-writer.md: mandatory consultation when pattern_tag is set,
  with explicit warning against copying 8174-24 wording

Approved via interaction request_confirmation (CMPA-15) 2026-05-17.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 16:29:58 +00:00
a2fc36d65f fix: recognize extended chair-position placeholders as empty
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m35s
The legal-analyst agent was generating a longer placeholder form
[ימולא ע"י יו"ר הוועדה — עמדה/הנחיה לגבי סוגיה זו שתשמש את סוכן הכתיבה]
which _is_placeholder() did not match (substring check fails because ] is
further along in the longer form). Result: UI showed "✓ עמדה נקבעה" (green)
for all 4 issues even though no chair direction had been entered.

Fixes:
1. research_md.py: add regex fallback — any text starting with [ימולא is a placeholder
2. legal-analyst.md: template now emits the standard short placeholder only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 12:59:13 +00:00
653f441e99 docs: update agent audit report — mark all 12 issues resolved
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 7s
- עדכון טבלת מצב: כל המודלים מסונכרנים (instructions = DB)
- החלפת טבלת בעיות בטבלת סטטוס תיקונים עם commit references
- הוסף טבלת שינויים נוספים מהסשן
- הערה: Skills CMPA=6 עיצוב מכוון, verify מאשר "0 need sync"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 12:57:54 +00:00
c3ce0e7e1f upgrade: upgrade opus-4-6 → opus-4-7 for all heavy-reasoning agents
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
DB: עדכון 8 סוכנים (CMP + CMPA) — CEO, מנתח, כותב, מגיה
instructions: עדכון 4 קבצי הנחיות להתאמה ל-DB

opus-4-7 מחליף opus-4-6 לכל הסוכנים שדורשים reasoning כבד.
sonnet-4-6 נשאר ל-QA, חוקר, מייצא. deepseek-v4-pro נשאר לcurator.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 12:42:33 +00:00
1608ea5ed0 fix: medium/low audit items — model drift, placeholders, corpus check, curator ownership
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 7s
Model drift (instructions → match DB):
- CEO: claude-sonnet-4-6 → claude-opus-4-6 (DB runs opus; CEO needs opus quality)
- מנתח/כותב/מגיה: claude-opus-4-7 → claude-opus-4-6 (DB runs 4-6; no 4-7 in adapter)

legal-proofreader.md:
- {issue-id} placeholder → $PAPERCLIP_TASK_ID בשני המקומות (done + blocked)

legal-researcher.md:
- הוסף reference ל-HEARTBEAT.md בראש הקובץ

legal-qa.md:
- הבהרת שיטת בדיקת corpus_queries_logged: grep ידני בלבד, לא validate_decision

CLAUDE.md (curator):
- הוסף תהליך אישור הצעות curator: comment → חיים מאשר → commits ל-SKILL.md/lessons.md

maxConcurrentRuns CEO: כבר 2 ב-DB — לא נדרש שינוי

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 12:35:49 +00:00
35423eafc1 fix: high-priority agent audit items — CEO hardcoded IDs + researcher search_internal_decisions
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
CEO (legal-ceo.md):
- הסרת company UUID ו-project UUID קשוחים בדוגמת יצירת issue
- שימוש ב-$PAPERCLIP_COMPANY_ID לחברה
- project_id נשלף דינמית מה-issue ההורה דרך $PAPERCLIP_TASK_ID

researcher (legal-researcher.md):
- הוסף mcp__legal-ai__search_internal_decisions לרשימת tools
- הוסף סעיף 2ב.2א המסביר את ההבדל: search_decisions = דפנה בלבד;
  search_internal_decisions = כל ועדות הערר בכל המחוזות
- הוראות מתי להשתמש + אזהרת היררכיה (ועדת ערר < מחוזי)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 12:29:47 +00:00
a584dc3602 fix: legal-exporter — versioning, dynamic skill path, case status update
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
- טיוטה-V → טיוטה-v (lowercase) בכל המקומות (שלב 4 + כללים קריטיים)
- hardcoded CMP UUID בנתיבי legal-docx SKILL → $PAPERCLIP_COMPANY_ID (תומך CMP + CMPA)
- הוסף case_update לרשימת tools
- הוסף שלב 4.5: עדכן סטטוס תיק ל-exported אחרי שמירת DOCX

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 12:14:24 +00:00
d37d03f478 docs: add comprehensive agent audit 2026-05-17
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
7-agent parallel audit of all Paperclip agents (CEO, analyst,
researcher, writer, QA, exporter, proofreader, curator).

Found 12 issues including 3 critical:
- Exporter: V vs v naming mismatch in DOCX versioning
- Exporter: case.status not updated to exported after export
- Researcher: section ז missing from case 8174-24

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 11:52:32 +00:00
011555fb78 docs: update CLAUDE.md — webhook pipeline, scheduled jobs, paperclip_api.py
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
- Document emit_case_status_webhook flow and plugin integration
- Document stale-case-reminder and weekly-feedback-analysis jobs
- Fix paperclip_api.py vs paperclip_client.py (both exist, api.py is current)
- Add warning: weekly-feedback-job CEO has no issueId

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 11:23:47 +00:00
ea0532b7ba fix: weekly-feedback-job handler writes to file only (no Paperclip issue)
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m39s
CEO wakes for weekly-feedback-job via agents.invoke without issueId,
so $PAPERCLIP_TASK_ID is empty. Removed steps 4-5 (comment + close
issue) from handler — now file-write only with stdout logging.

Also commits pending docs and agent instructions from prior session.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 11:08:14 +00:00
cddc7c8d24 fix: start-workflow wakeup failure now returns 502 instead of silent success
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m33s
If pc_wake_ceo fails, the endpoint now raises HTTP 502 and skips the
case_update to processing — preventing cases from silently getting stuck
with no CEO running. Also adds `processing` to CEO routing table and
updates case_list docstring with full status list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 11:02:30 +00:00
83b6ff51b7 feat: fix wizard step-skip bug + extend case edit with all fields + Paperclip title sync
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m38s
- Fix keyboard navigation bug: React was reusing the submit button DOM element
  when transitioning "הבא" → "צור תיק", retaining focus and causing Enter to
  auto-submit step 3. Added key props to force element replacement.

- CaseEditDialog now covers all wizard fields: appellants, respondents,
  property_address, permit_number (in addition to existing title, subject,
  hearing_date, expected_outcome, notes).

- When case title changes, Paperclip project name is updated in background
  via new update_project_name() in paperclip_client.py.

- Extended CaseUpdateRequest, case_update MCP tool, and caseUpdateSchema
  to carry the new fields end-to-end.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 10:55:45 +00:00
8dc7a40fa2 fix: exclude exported cases from stale; add weekly-feedback-job handler to CEO
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
- /api/cases/stale: exclude 'exported' status — exported cases await Dafna's
  review intentionally, they are not stuck
- legal-ceo.md: add routing for weekly-feedback-job reason + explicit handler
  (analyze feedback, update decision-lessons.md, close issue)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 10:35:39 +00:00
a3468d5b2f fix: use timezone-aware datetime in webhook timestamp
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m17s
Replace deprecated datetime.utcnow() with datetime.now(timezone.utc)
to avoid Python 3.12+ DeprecationWarning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 10:15:52 +00:00
5f43659b5a fix: add defensive JSON parsing in check_instructions 2026-05-16 17:53:42 +00:00
86734da210 feat: add --check-instructions, pre-flight validation, and mtime tracking to sync script
- P3-T1: --check-instructions flag + check_instructions() prints a table of all
  agents' instructionsFilePath with status ( OK /  MISSING / ⚠ NOT SET),
  size, mtime, and ⚠ DRIFT when file has changed since last sync
- P3-T2: --apply now runs a pre-flight check on master agents and aborts if any
  instruction file is missing, before touching the DB or calling any API
- P3-T3: get_claude_md_mtime() helper; --apply stamps claude_md_mtime and
  claude_md_last_synced into each mirror agent's metadata via the PATCH call
- P3-T4: alias check-agents added to ~/.bashrc

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 17:51:34 +00:00
82ded005a4 fix: add days>0 guard and limit param to stale/feedback endpoints
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
2026-05-16 17:38:34 +00:00
c7ed1110f8 feat: add /api/cases/stale and /api/chair-feedback/weekly-summary endpoints
GET /api/cases/stale?days=N — returns cases not updated in N days (default 3)
  that are not in 'final' or 'new' status, with days_stale count.
GET /api/chair-feedback/weekly-summary?days=N — returns chair feedback from
  the last N days (default 7) as a Hebrew bullet-list summary for CEO agent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 17:36:12 +00:00
015e553d06 fix: add debug log and null company_id comment to webhook scheduling
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 4m16s
2026-05-16 17:13:07 +00:00
6bdf9786ac feat: emit case-status webhook on status change in PUT /api/cases/:case 2026-05-16 17:10:30 +00:00
d87f9c5a5f fix: include case details in webhook failure warning log 2026-05-16 17:08:33 +00:00
a0fab1f6de feat: add emit_case_status_webhook helper 2026-05-16 17:06:37 +00:00
d5043100a7 fix: json.loads JSONB overrides on GET — asyncpg has no codec registered
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
asyncpg returns JSONB columns as raw JSON strings when no type codec is
configured (only pgvector is registered in _init_connection). The stored
value is a correct JSONB array (jsonb_typeof=array confirmed), but
asyncpg decodes it as str. Parse it explicitly in the GET handler so
the frontend receives the correct Python list/dict.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 18:54:44 +00:00
932cc7191c fix: use ::text::jsonb to store methodology overrides correctly
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
asyncpg cannot encode a Python list as JSONB directly (expects str).
Passing str with ::jsonb causes double-encoding (stored as JSONB string).
Solution: json.dumps() the value → pass as text → PostgreSQL parses
with ::text::jsonb cast, storing it as the correct JSONB array/object.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 18:38:05 +00:00
d983cfdd3b Merge pull request 'fix: prevent JSONB double-encoding on methodology save' (#6) from fix/methodology-jsonb-double-encoding into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m39s
2026-05-10 18:34:03 +00:00
50649baeed fix: prevent JSONB double-encoding on methodology save
Pass req.value directly to asyncpg instead of json.dumps(req.value).
When a Python string was passed with ::jsonb, asyncpg encoded it as a
JSONB string (not an array), causing the frontend spread operator to
split it into individual characters — one textarea per character.

Also fix typo in DISCUSSION_RULES default: "אסה" → "מאסה".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 18:30:49 +00:00
a9cd8aeb12 fix: prevent write_interim_draft context overflow (465K → ≤300K chars)
Two bugs caused all 5 interim blocks to fail with "Claude CLI failed
(exit 1): unknown error":

1. source_context was embedded BOTH inside the prompt template (via
   {source_context}) AND prepended again in write_block — doubling every
   block's context size (232K chars × 2 = 465K chars).

2. _build_source_context loaded all 9 case documents for every block
   regardless of relevance.

Fixes:
- Remove the duplicate source_context prepend in write_block; the
  template already contains it via {source_context}
- Add per-block document filtering (_BLOCK_DOC_TYPES): block-he/zayin →
  empty, block-chet → protocol only, block-tet → appraisals only
- Add 400K char guard before calling claude -p with a descriptive error
  (vs opaque "exit 1: unknown error")
- Add prompt-size warning and size info in claude_session error messages

Result: block-he 0 chars, block-zayin 0 chars, block-vav ~172K,
block-chet ~45K, block-tet ~300K (all under 400K limit)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 10:49:47 +00:00
10a63fb9e0 fix(precedents): separate court rulings from committee decisions correctly
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m37s
- DB: add 'all_committees' virtual source_kind covering internal_committee
  + external_upload appeals_committee rows in one query
- DB: stats now count all case_law rows (not just external_upload),
  fixing the precedents_total that excluded 44 internal-committee records
- UI: courts table filters to source_type=court_ruling only;
  committees table uses the new all_committees query

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 09:59:30 +00:00
f94201c577 feat(precedents): make citation link to detail page
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 34s
Both CourtRow and CommitteeRow citation cells are now Next.js Links
→ /precedents/{id}, letting users navigate directly from the list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 09:01:26 +00:00
026457dac4 fix(precedent-edit): sync form from record without useEffect flash
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 36s
Replace useEffect-based form hydration with React's approved derived-state
pattern (setState-during-render). This eliminates the one-frame flash where
the precedent_level Select showed "—" before useEffect fired, and fixes
cases where the same record reference returned from TanStack cache caused
useEffect to not re-run after save+invalidate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 08:35:04 +00:00
75493ce233 Merge pull request 'feat: link related precedents across court instances (SCHEMA_V11)' (#4) from feat/related-precedents-v11 into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m41s
Reviewed-on: #4
2026-05-10 07:54:37 +00:00
3e14cd6798 feat: link related precedents across court instances (SCHEMA_V11)
Add ability to mark case_law records as related (e.g. same appeal
through ועדת ערר → מנהלי → עליון):
- DB: case_law_relations join table (bidirectional, V11 migration)
- DB CRUD: add/remove/get_case_law_relations
- Service: get_precedent() now returns related_cases[]
- MCP: precedent_link_cases + precedent_unlink_cases tools
- REST: POST/DELETE /api/precedent-library/{id}/relations
- UI: RelatedCasesSection on detail page with search dialog and unlink

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 07:52:29 +00:00
13a8d9e58f Merge pull request 'feat(curator): switch Hermes Curator to DeepSeek V4-Pro via deepseek_local adapter' (#3) from feat/deepseek-curator-adapter into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m53s
2026-05-10 06:21:28 +00:00
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
61 changed files with 5788 additions and 778 deletions

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

@@ -288,11 +288,11 @@ FROM documents d WHERE d.case_id = '{case_id}' AND d.doc_type IN ('appeal', 'res
**אם הכל עבר בהצלחה (בדיקות שלב 6 + טענות + עובדות שמאי):** **אם הכל עבר בהצלחה (בדיקות שלב 6 + טענות + עובדות שמאי):**
```bash ```bash
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'``` ~/legal-ai/scripts/pc.sh PATCH "/api/issues/$PAPERCLIP_TASK_ID" '{"status": "done"}'```
**אם בדיקות שלב 6 נכשלו או חילוץ נכשל:** **אם בדיקות שלב 6 נכשלו או חילוץ נכשל:**
```bash ```bash
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'``` ~/legal-ai/scripts/pc.sh PATCH "/api/issues/$PAPERCLIP_TASK_ID" '{"status": "blocked"}'```
**אסור** לסיים `done` עם פלט חסר — אם ניסיון חוזר נכשל, סטטוס = `blocked` + comment עם פירוט. **אסור** לסיים `done` עם פלט חסר — אם ניסיון חוזר נכשל, סטטוס = `blocked` + comment עם פירוט.
5. **שלח מייל**: 5. **שלח מייל**:
@@ -304,16 +304,19 @@ FROM documents d WHERE d.case_id = '{case_id}' AND d.doc_type IN ('appeal', 'res
### העֵר את העוזר המשפטי (CEO) — חובה! ### העֵר את העוזר המשפטי (CEO) — חובה!
```bash ```bash
# CEO לפי חברה — אסור לקבע UUID, חברות שונות = CEO שונה # $PAPERCLIP_TASK_ID הוא UUID המלא שPaperclip מספק בסביבת הריצה — לעולם לא CMP-XX
# אסור להחליף ידנית: משתמשים ב-$PAPERCLIP_TASK_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" # CMPA — היטלי השבחה CEO_ID="cdbfa8bc-3d61-41a4-a2e7-677ec7d34562" # CMPA — היטלי השבחה
else else
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
fi 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"}}'``` ~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" \
"{\"source\":\"automation\",\"triggerDetail\":\"system\",\"reason\":\"מנתח משפטי סיים $PAPERCLIP_TASK_ID בסטטוס done/blocked\",\"payload\":{\"issueId\":\"$PAPERCLIP_TASK_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`. **⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
**⚠️ `$PAPERCLIP_TASK_ID` — זה UUID, לא CMP-XX.** המשתנה מוגדר אוטומטית ע"י Paperclip בסביבת הריצה. אם משתמשים בו ב-double-quotes (`"..."`), bash מרחיב אותו לערך האמיתי. שגיאת `invalid input syntax for type uuid` = שלחת CMP-XX במקום UUID.
## מבנה הפלט המלא — analysis-and-research.md ## מבנה הפלט המלא — analysis-and-research.md
@@ -397,7 +400,7 @@ X שאלות עומדות להכרעה:
- [אם נמצאו — חיסכון או הבחנה?] - [אם נמצאו — חיסכון או הבחנה?]
**עמדת ועדת הערר:** **עמדת ועדת הערר:**
[ימולא ע"י יו"ר הוועדה — עמדה/הנחיה לגבי סוגיה זו שתשמש את סוכן הכתיבה] [ימולא ע"י יו"ר הוועדה]
--- ---
@@ -482,7 +485,8 @@ X שאלות עומדות להכרעה:
CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה CEO_ID="752cebdd-6748-4a04-aacd-c7ab0294ef33" # CMP — רישוי ובניה
fi fi
~/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"}}'``` ~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" \
"{\"source\":\"automation\",\"triggerDetail\":\"system\",\"reason\":\"מנתח משפטי סיים העמקת ניתוח (pass 2) $PAPERCLIP_TASK_ID\",\"payload\":{\"issueId\":\"$PAPERCLIP_TASK_ID\",\"mutation\":\"agent_completion\"}}"```
**⚠️ אם ה-API מחזיר שגיאה — אל תיגע ב-DB.** `INSERT INTO agent_wakeup_requests` לא יוצר `heartbeat_run` והסוכן לא יתעורר לעולם. בדוק `$PAPERCLIP_COMPANY_ID` ו-`$PAPERCLIP_API_KEY`, ודאי שאתה לא קורא ל-CEO של חברה אחרת (`Agent key cannot access another company`). **⚠️ אם ה-API מחזיר שגיאה — אל תיגע ב-DB.** `INSERT INTO agent_wakeup_requests` לא יוצר `heartbeat_run` והסוכן לא יתעורר לעולם. בדוק `$PAPERCLIP_COMPANY_ID` ו-`$PAPERCLIP_API_KEY`, ודאי שאתה לא קורא ל-CEO של חברה אחרת (`Agent key cannot access another company`).
## כללים קריטיים ## כללים קריטיים

View File

@@ -1,7 +1,7 @@
--- ---
name: "legal-ceo" name: "legal-ceo"
description: "עוזר משפטי — מנהל תהליך כתיבת החלטות, מתזמר סוכנים, מפקח על התקדמות" description: "עוזר משפטי — מנהל תהליך כתיבת החלטות, מתזמר סוכנים, מפקח על התקדמות"
model: "claude-sonnet-4-6" model: "claude-opus-4-7"
tools: tools:
- Read - Read
- Bash - Bash
@@ -75,6 +75,7 @@ tools:
| `docs/daphna-architecture-by-outcome.md` | מבנה בלוק י לפי תוצאה | writer + qa | | `docs/daphna-architecture-by-outcome.md` | מבנה בלוק י לפי תוצאה | writer + qa |
| `docs/daphna-acceptance-architecture.md` | 5 תבניות קבלה | writer + qa (אם תוצאה = קבלה) | | `docs/daphna-acceptance-architecture.md` | 5 תבניות קבלה | writer + qa (אם תוצאה = קבלה) |
| `docs/daphna-block-zayin-claims.md` | כללי בלוק ז | analyst + writer + qa | | `docs/daphna-block-zayin-claims.md` | כללי בלוק ז | analyst + writer + qa |
| `docs/daphna-procedural-patterns.md` | תבניות פרוצדורליות (החלטת ביניים, חזרה לשמאי) | CEO + writer (8xxx בלבד) |
| `docs/voice-1130-25.md` | דוגמה עמוקה | writer (אם תיק 1xxx מורכב) | | `docs/voice-1130-25.md` | דוגמה עמוקה | writer (אם תיק 1xxx מורכב) |
## הסוכנים שלך ## הסוכנים שלך
@@ -82,11 +83,12 @@ tools:
| סוכן | Agent ID | תפקיד | | סוכן | Agent ID | תפקיד |
|-------|----------|--------| |-------|----------|--------|
| מגיה מסמכים | 410c0167-27dc-485c-a51b-7aa8b9ff2217 | הגהת OCR — תיקון ראשי תיבות ושגיאות חילוץ | | מגיה מסמכים | 410c0167-27dc-485c-a51b-7aa8b9ff2217 | הגהת OCR — תיקון ראשי תיבות ושגיאות חילוץ |
| מנתח משפטי | c26e9439-a88a-49dc-9e67-2262c95db65c | חילוץ טענות, תשובות, תגובות | | מנתח משפטי | c26e9439-a88a-49dc-9e67-2262c95db65c | ניתוח משפטי מלא — חילוץ טענות, ניתוח עמוק, מחקר בקורפוסים, כתיבת analysis-and-research.md |
| חוקר תקדימים | 35022af0-0498-4c3d-90ca-b0ab9e987198 | ניתוח פסיקה, תכניות, פרוטוקולים | | חוקר תקדימים | 35022af0-0498-4c3d-90ca-b0ab9e987198 | ניתוח פסיקה, תכניות, פרוטוקולים |
| כותב החלטה | 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 חדש = תת-משימה
@@ -112,8 +114,7 @@ PGPASSWORD=paperclip psql -h localhost -p 54329 -U paperclip -d paperclip -c \
**אם** ה-issue שלך הוא בעצמו תת-משימה (יש לו parent), השתמש ב-parent של ה-parent — כלומר ה-issue הראשי של התיק. לקבלת ה-parent: **אם** ה-issue שלך הוא בעצמו תת-משימה (יש לו parent), השתמש ב-parent של ה-parent — כלומר ה-issue הראשי של התיק. לקבלת ה-parent:
```bash ```bash
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ ~/legal-ai/scripts/pc.sh GET "/api/issues/$PAPERCLIP_TASK_ID" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('parentId') or d['id'])"
"$PAPERCLIP_API_URL/api/issues/$PAPERCLIP_TASK_ID" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('parentId') or d['id'])"
``` ```
--- ---
@@ -160,6 +161,7 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
- אם ה-reason מכיל `user_commented`**דלג ישירות לסעיף "טיפול בתגובות חדשות מחיים"**. אל תסרוק תיקים אחרים, אל תבדוק issues, אל תעשה heartbeat רגיל. **טפל רק בתגובה.** - אם ה-reason מכיל `user_commented`**דלג ישירות לסעיף "טיפול בתגובות חדשות מחיים"**. אל תסרוק תיקים אחרים, אל תבדוק issues, אל תעשה heartbeat רגיל. **טפל רק בתגובה.**
- אם ה-reason מכיל `agent_completion` → דלג לשלב E/F בהתאם לסוכן שסיים - אם ה-reason מכיל `agent_completion` → דלג לשלב E/F בהתאם לסוכן שסיים
- אם ה-reason מכיל `precedent_extraction_`**דלג לסעיף "חילוץ פסיקה אוטומטי"**. אל תיגע בתיקים — זו עבודת ספרייה. - אם ה-reason מכיל `precedent_extraction_`**דלג לסעיף "חילוץ פסיקה אוטומטי"**. אל תיגע בתיקים — זו עבודת ספרייה.
- אם ה-reason מכיל `weekly-feedback-job`**דלג לסעיף "ניתוח פידבק שבועי"**. אל תיגע בתיקים פעילים.
- אחרת → המשך לשלב A (heartbeat רגיל) - אחרת → המשך לשלב A (heartbeat רגיל)
### חילוץ פסיקה אוטומטי ### חילוץ פסיקה אוטומטי
@@ -186,6 +188,26 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
**אל**: אל תיצור issues של ביצוע בתיקי ערר, אל תיכנס לתהליך כתיבת החלטה — זו רק עבודת תחזוקה של ספריית הפסיקה. **אל**: אל תיצור issues של ביצוע בתיקי ערר, אל תיכנס לתהליך כתיבת החלטה — זו רק עבודת תחזוקה של ספריית הפסיקה.
### ניתוח פידבק שבועי (weekly-feedback-job)
**מתי:** `$PAPERCLIP_WAKE_REASON` מכיל `weekly-feedback-job`
ה-prompt שתקבל מכיל סיכום של כל הפידבק מיו"ר מהשבוע האחרון, בפורמט:
```
- תיק X (קטגוריה): טקסט הפידבק
- תיק Y (קטגוריה): ...
```
**מה לעשות:**
1. **קרא את `docs/legal-decision-lessons.md`** — הבן מה כבר מתועד שם.
2. **נתח את הפידבק** — אילו דפוסים חוזרים? מה חדש שלא מופיע בלקחים?
3. **עדכן את `docs/legal-decision-lessons.md`** — הוסף רק לקחים חדשים ומהותיים (לא כפל). כל לקח = משפט אחד ברור.
4. **רשום ל-stdout** (לא ל-issue): `echo "weekly feedback done: N lessons added"` — החלף N במספר הלקחים שנוספו.
⚠️ **אין issue ב-Paperclip עבור job זה** — `$PAPERCLIP_TASK_ID` ריק. אל תנסה לפרסם comment ואל תנסה לסגור issue. הפעולה מסתיימת לאחר כתיבת הקובץ.
**כלל:** אל תגע בתיקים פעילים, אל תעיר סוכנים אחרים, אל תבצע heartbeat רגיל — זו משימת תחזוקה בלבד.
### שלב A: בדיקת מצב — שלמות, בדיקות שליליות, תאימות מתודולוגיה ### שלב A: בדיקת מצב — שלמות, בדיקות שליליות, תאימות מתודולוגיה
בכל heartbeat **רגיל** (לא comment routing): בכל heartbeat **רגיל** (לא comment routing):
@@ -206,6 +228,12 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
- **מסמך ריק**: האם יש מסמך appeal/response עם טקסט שלא ייצר טענות ולא דווח ככשל? - **מסמך ריק**: האם יש מסמך appeal/response עם טקסט שלא ייצר טענות ולא דווח ככשל?
#### A3. אימות תאימות מתודולוגיה #### A3. אימות תאימות מתודולוגיה
**תנאי קדם — קודם וודא שהמסמך קיים:**
```bash
ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
```
אם הקובץ **לא קיים** — עצור. המנתח לא ביצע את הניתוח המלא. בדוק את issue המנתח: אם הוא `done` אבל הקובץ חסר — צור issue מנתח חדש עם הנחיה לבצע שלבים 2-7 מ-`legal-analyst.md` (לא לחלץ טענות מחדש — `get_claims` להצגה).
קרא את `analysis-and-research.md` ובדוק: קרא את `analysis-and-research.md` ובדוק:
- [ ] סוגיות מנוסחות כסילוגיזם (כלל + עובדות + שאלה)? - [ ] סוגיות מנוסחות כסילוגיזם (כלל + עובדות + שאלה)?
- [ ] ממצאים עובדתיים מופרדים ממסקנות משפטיות? - [ ] ממצאים עובדתיים מופרדים ממסקנות משפטיות?
@@ -221,7 +249,7 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
### שלב B: הכנת סיכום, סיווג, ושאלת תוצאה ### שלב B: הכנת סיכום, סיווג, ושאלת תוצאה
**מתי:** כשיש טענות מחולצות + מחקר תקדימים, אבל אין תוצאה עדיין **מתי:** כשיש `analysis-and-research.md` מלא (מנתח סיים שלבים 1-7) וסטטוס `analyst_verified`, אבל אין תוצאה עדיין
**שיטה — dual dispatch:** קודם פרסם comment עם הסיכום המלא (לתיעוד), ואז צור interaction עם כפתורים (לחיים). **שיטה — dual dispatch:** קודם פרסם comment עם הסיכום המלא (לתיעוד), ואז צור interaction עם כפתורים (לחיים).
@@ -564,11 +592,12 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
| סטטוס | מי שינה לזה | פעולה הבאה | | סטטוס | מי שינה לזה | פעולה הבאה |
|--------|-------------|------------| |--------|-------------|------------|
| `processing` | start-workflow (ממשק) | → בדוק אם כבר קיים issue פעיל לסוכן משנה. אם לא → המשך ל-§A כרגיל (בדוק documents + claims) |
| `new` | (יצירת תיק) | → בדוק extraction_status של מסמכים. אם יש `pending` → צור issue למגיה (410c0167). אם כולם `completed`/`proofread` → צור issue למנתח | | `new` | (יצירת תיק) | → בדוק extraction_status של מסמכים. אם יש `pending` → צור issue למגיה (410c0167). אם כולם `completed`/`proofread` → צור issue למנתח |
| `proofread` | מגיה | → צור issue למנתח משפטי (ראה תבנית למטה) | | `proofread` | מגיה | → צור issue למנתח משפטי (ראה תבנית למטה) |
| `documents_ready` | מנתח | → שלב A (בדיקות שלמות + שליליות + מתודולוגיה). אם עובר → עדכן ל-`analyst_verified` | | `documents_ready` | מנתח | → שלב A (בדיקות שלמות + שליליות + מתודולוגיה). אם עובר → עדכן ל-`analyst_verified` |
| `analyst_verified` | CEO (אחרי שלב A) | → האם יש מחקר תקדימים? אם לא → צור issue לחוקר (35022af0). אם כן → שלב B | | `analyst_verified` | CEO (אחרי שלב A) | → שלב B (סיכום + שאלת תוצאה לחיים). המנתח כבר ביצע את המחקר כחלק מהניתוח — אין ליצור issue לחוקר. |
| `research_complete` | חוקר | → שלב B (סיכום + סיווג + שאלת תוצאה לחיים) | | `research_complete` | (מנתח — legacy, או תרחיש מיוחד עם חוקר) | → שלב B (סיכום + שאלת תוצאה לחיים). בזרימה הרגילה המנתח לא מגדיר סטטוס זה — רק `documents_ready`. אם תראה סטטוס זה, בדוק אם `analysis-and-research.md` קיים לפני §B. |
| `outcome_set` | CEO (אחרי שחיים בחר) | → האם יש claim_handling? אם לא → שלב B המשך (טבלת bundle/skip). אם כן → שלב C | | `outcome_set` | CEO (אחרי שחיים בחר) | → האם יש claim_handling? אם לא → שלב B המשך (טבלת bundle/skip). אם כן → שלב C |
| `direction_approved` | CEO (אחרי שחיים אישר) | → צור issue למנתח (c26e9439) ל-pass 2: העמקת ניתוח ואימות פסיקה | | `direction_approved` | CEO (אחרי שחיים אישר) | → צור issue למנתח (c26e9439) ל-pass 2: העמקת ניתוח ואימות פסיקה |
| `analysis_enriched` | מנתח (pass 2) | → שלב D2: צור issue לכותב (7ed8686f) | | `analysis_enriched` | מנתח (pass 2) | → שלב D2: צור issue לכותב (7ed8686f) |
@@ -625,15 +654,51 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
--- ---
**תבנית issue למנתח — חובה בכל תיק:** **תבנית issue למנתח — חובה בכל תיק:**
1. **טבלת מיפוי מסמכים** — לכל מסמך: שם, doc_type, פעולה נדרשת:
- `appeal` → `extract_claims` (claim_type=claim, party_role=appellant) **כותרת:** `[ערר CASE_NUMBER] ניתוח משפטי ומחקר — CASE_NAME`
- `response` → `extract_claims` (claim_type=response, party_role=respondent/committee)
- `reply` → `extract_claims` (claim_type=reply, party_role=permit_applicant/appellant) **תיאור חובה — כלול את כל הסעיפים הבאים:**
- **`appraisal` → `extract_appraiser_facts`** (לא extract_claims! שומה אינה כתב טענות. חובה בכל תיק 8xxx/9xxx)
- `reference`/`plan`/`protocol`/`permit`/`decision`/`court_decision` → אל תחלץ — חומר רקע בלבד ```
2. **בדיקת השלמה** — לכל doc_type='appraisal' בתיק, וודא שה-issue אומר במפורש להריץ `extract_appraiser_facts`. בלי זה ה-writer יקבל בלוק ז ריק ממספרים. בצע ניתוח משפטי מלא לפי legal-analyst.md שלבים 1-7:
3. **הנחיה לסגור את ה-issue ב-PATCH** — סטטוס `done` בהצלחה, `blocked` בכשל. בלי זה Paperclip יפעיל retry בלולאה (נצפה בפועל ב-CMPA-16 / 30-04-26).
4. **הנחיה לשלוח wakeup ל-CEO בסיום** (כך שאתה תידע להמשיך) שלב 1: קליטה וזיהוי
- חלץ טענות/תשובות/תגובות מכל מסמכי appeal/response/reply (ראה טבלה למטה)
- לכל appraisal: הרץ extract_appraiser_facts (לא extract_claims)
טבלת מסמכים:
[לכל מסמך: שם | doc_type | פעולה נדרשת]
- appeal → extract_claims(claim_type=claim, party_role=appellant)
- response → extract_claims(claim_type=response, party_role=respondent/committee)
- reply → extract_claims(claim_type=reply, party_role=permit_applicant/appellant)
- appraisal → extract_appraiser_facts (לא extract_claims!)
- reference/plan/protocol/permit/decision → אל תחלץ — רקע בלבד
שלב 2: ניתוח מעמיק — גוף מחליט, רקע דיוני, עובדות מוסכמות, עובדות שנויות
שלב 3: טענות סף, מפת דרכים, סוגיות להכרעה (כולל CREAC + עמדת ועדת הערר ריקה)
שלב 4: שאלות מחקר (1-3 לכל סוגיה)
שלב 5: חיפוש בשלושת הקורפוסים — חובה:
- search_precedent_library(practice_area=RELEVANT_AREA)
- search_decisions
- find_similar_cases
שלב 6: בדיקת שלמות — get_claims ≥ 1 מכל צד
שלב 7: שמור analysis-and-research.md ב-data/cases/CASE_NUMBER/documents/research/
עדכן case_update(status='documents_ready')
סגור issue: PATCH status=done (או blocked אם נכשל)
שלח wakeup ל-CEO עם $PAPERCLIP_TASK_ID כ-issueId (ראה HEARTBEAT.md §4ג)
⚠️ אחרי יצירת task זה — עדכן את ה-issue הראשי ל-status=in_review והמתן ל-wakeup
עם mutation=agent_completion מהמנתח. אין לבדוק get_claims לפני ה-wakeup.
```
1. **בדיקת השלמה** — לכל doc_type='appraisal' בתיק, וודא שה-issue אומר במפורש להריץ `extract_appraiser_facts`. בלי זה ה-writer יקבל בלוק ז ריק ממספרים.
2. **הנחיה לסגור את ה-issue ב-PATCH** — סטטוס `done` בהצלחה, `blocked` בכשל. בלי זה Paperclip יפעיל retry בלולאה (נצפה בפועל ב-CMPA-16 / 30-04-26).
3. **הנחיה לשלוח wakeup ל-CEO בסיום** (כך שאתה תידע להמשיך) — חובה להשתמש ב-`$PAPERCLIP_TASK_ID` (UUID) ולא ב-CMP-XX.
## סינון תיקים לפי חברה — חובה! ## סינון תיקים לפי חברה — חובה!
@@ -745,8 +810,10 @@ case_prefix="${case_number:0:1}"
~/legal-ai/scripts/pc.sh POST "/api/issues/{issue-id}/comments" '{"body": "..."}' ~/legal-ai/scripts/pc.sh POST "/api/issues/{issue-id}/comments" '{"body": "..."}'
# צור issue חדש (עם הקצאה לסוכן → מפעיל wakeup אוטומטי!) # צור issue חדש (עם הקצאה לסוכן → מפעיל wakeup אוטומטי!)
~/legal-ai/scripts/pc.sh POST "/api/companies/42a7acd0-30c5-4cbd-ac97-7424f65df294/issues" \ # ⚠️ שלוף projectId מה-issue ההורה — אל תקבע UUID ידנית:
'{"title":"...","projectId":"25c1b4a1-2c0e-4a2d-9938-8ae56ccda6f1","assigneeAgentId":"{agent-id}","description":"...","status":"todo"}' PROJECT_ID=$(~/legal-ai/scripts/pc.sh GET "/api/issues/$PAPERCLIP_TASK_ID" | jq -r '.projectId')
~/legal-ai/scripts/pc.sh POST "/api/companies/$PAPERCLIP_COMPANY_ID/issues" \
"{\"title\":\"...\",\"projectId\":\"$PROJECT_ID\",\"assigneeAgentId\":\"{agent-id}\",\"description\":\"...\",\"status\":\"todo\"}"
# עדכן issue # עדכן issue
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}' ~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'

View File

@@ -19,6 +19,7 @@ tools:
- mcp__legal-ai__revise_draft - mcp__legal-ai__revise_draft
- mcp__legal-ai__get_style_guide - mcp__legal-ai__get_style_guide
- mcp__legal-ai__validate_decision - mcp__legal-ai__validate_decision
- mcp__legal-ai__case_update
--- ---
# מייצא טיוטה — סוכן ייצוא סופי # מייצא טיוטה — סוכן ייצוא סופי
@@ -40,14 +41,14 @@ tools:
## סקייל ייצוא ## סקייל ייצוא
**חובה לקרוא לפני כל ייצוא:** **חובה לקרוא לפני כל ייצוא:**
- `/home/chaim/.paperclip/instances/default/skills/42a7acd0-30c5-4cbd-ac97-7424f65df294/legal-docx/SKILL.md` - `/home/chaim/.paperclip/instances/default/skills/$PAPERCLIP_COMPANY_ID/legal-docx/SKILL.md`
- `/home/chaim/.paperclip/instances/default/skills/42a7acd0-30c5-4cbd-ac97-7424f65df294/legal-docx/references/document-types.md` - `/home/chaim/.paperclip/instances/default/skills/$PAPERCLIP_COMPANY_ID/legal-docx/references/document-types.md`
**סקריפט ייצוא:** **סקריפט ייצוא:**
- `/home/chaim/.paperclip/instances/default/skills/42a7acd0-30c5-4cbd-ac97-7424f65df294/legal-docx/scripts/create-legal-doc.js` - `/home/chaim/.paperclip/instances/default/skills/$PAPERCLIP_COMPANY_ID/legal-docx/scripts/create-legal-doc.js`
**תבנית:** **תבנית:**
- `/home/chaim/.paperclip/instances/default/skills/42a7acd0-30c5-4cbd-ac97-7424f65df294/legal-docx/references/docx template.docx` - `/home/chaim/.paperclip/instances/default/skills/$PAPERCLIP_COMPANY_ID/legal-docx/references/docx template.docx`
## תהליך עבודה ## תהליך עבודה
@@ -102,12 +103,13 @@ tools:
### שלב 4: שמירה מגורסת ### שלב 4: שמירה מגורסת
1. צור תיקייה `~/legal-ai/data/cases/{מספר-ערר}/exports/` (אם לא קיימת) 1. צור תיקייה `~/legal-ai/data/cases/{מספר-ערר}/exports/` (אם לא קיימת)
2. בדוק כמה טיוטות כבר קיימות בתיקייה (קבצים שמתחילים ב-`טיוטה-V`) 2. בדוק כמה טיוטות כבר קיימות בתיקייה (קבצים שמתחילים ב-`טיוטה-v`)
3. שמור כ-`טיוטה-V{N}.docx` כאשר N = המספר הבא בתור 3. שמור כ-`טיוטה-v{N}.docx` כאשר N = המספר הבא בתור
- אם אין טיוטות: `טיוטה-V1.docx` - אם אין טיוטות: `טיוטה-v1.docx`
- אם יש V1: `טיוטה-V2.docx` - אם יש v1: `טיוטה-v2.docx`
- וכן הלאה - וכן הלאה
4. ודא שהקובץ נוצר ושגודלו סביר 4. ודא שהקובץ נוצר ושגודלו סביר
5. עדכן סטטוס תיק ל-`exported` דרך `case_update(case_number, {"status": "exported"})`
### שלב 5: דיווח ### שלב 5: דיווח
דווח למשתמש: דווח למשתמש:
@@ -145,6 +147,6 @@ fi
## כללים קריטיים ## כללים קריטיים
1. **לעולם אל תייצא בלי בדיקה** — תמיד הרץ validate_decision קודם 1. **לעולם אל תייצא בלי בדיקה** — תמיד הרץ validate_decision קודם
2. **לא לדרוס טיוטות קודמות** — תמיד גרסה חדשה (V1, V2, V3...) 2. **לא לדרוס טיוטות קודמות** — תמיד גרסה חדשה (v1, v2, v3...)
3. **שמות קבצים בעברית** — `טיוטה-V1.docx`, לא `draft-V1.docx` 3. **שמות קבצים בעברית** — `טיוטה-v1.docx`, לא `draft-v1.docx`
4. **קרא את הסקייל** — לפני כל ייצוא, קרא את legal-docx SKILL.md 4. **קרא את הסקייל** — לפני כל ייצוא, קרא את legal-docx SKILL.md

View File

@@ -92,11 +92,11 @@ tools:
**אם הכל עבר בהצלחה:** **אם הכל עבר בהצלחה:**
```bash ```bash
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "done"}'``` ~/legal-ai/scripts/pc.sh PATCH "/api/issues/$PAPERCLIP_TASK_ID" '{"status": "done"}'```
**אם נכשלו תיקונים קריטיים או יש markers `[?]` רבים:** **אם נכשלו תיקונים קריטיים או יש markers `[?]` רבים:**
```bash ```bash
~/legal-ai/scripts/pc.sh PATCH "/api/issues/{issue-id}" '{"status": "blocked"}'``` ~/legal-ai/scripts/pc.sh PATCH "/api/issues/$PAPERCLIP_TASK_ID" '{"status": "blocked"}'```
**אסור** לסיים `done` עם פלט חסר — אם נכשל, סטטוס = `blocked` + comment עם פירוט. **אסור** לסיים `done` עם פלט חסר — אם נכשל, סטטוס = `blocked` + comment עם פירוט.
### העֵר את העוזר המשפטי (CEO) — חובה! ### העֵר את העוזר המשפטי (CEO) — חובה!

View File

@@ -83,6 +83,8 @@ tools:
ה-analyst וה-researcher חייבים לתעד queries לקורפוסים שלהם. בלי תיעוד — אין דרך לוודא שתקדימי עליון רלוונטיים לא הוחמצו. ה-analyst וה-researcher חייבים לתעד queries לקורפוסים שלהם. בלי תיעוד — אין דרך לוודא שתקדימי עליון רלוונטיים לא הוחמצו.
**שיטת בדיקה:** grep ידני — קרא את קבצי המחקר וחפש בהם את הסעיפים הנ"ל. `validate_decision` **לא** בודק זאת אוטומטית. הצלבה עם MCP (סעיף 4 למטה) היא אופציונלית ומשלימה.
בדוק: בדוק:
1. **קיום סעיף "שאילתות לקורפוסים"**: 1. **קיום סעיף "שאילתות לקורפוסים"**:
- ב-`{case_dir}/documents/research/analysis-and-research.md` — סעיף **7א** (לפי שלב 5ד של ה-analyst) - ב-`{case_dir}/documents/research/analysis-and-research.md` — סעיף **7א** (לפי שלב 5ד של ה-analyst)

View File

@@ -14,6 +14,7 @@ tools:
- mcp__legal-ai__document_get_text - mcp__legal-ai__document_get_text
- 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_internal_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_attach
@@ -30,6 +31,8 @@ tools:
- mcp__legal-ai__workflow_status - mcp__legal-ai__workflow_status
--- ---
> ראה גם: [HEARTBEAT.md](HEARTBEAT.md) לכללי הפעלה כלליים — routing, company filtering, wakeup API
# חוקר תקדימים — סוכן מחקר משפטי # חוקר תקדימים — סוכן מחקר משפטי
אתה חוקר משפטי מומחה בתכנון ובניה ישראלי. תפקידך לנתח את מסמכי הרקע בתיק ערר — פסיקה, תכניות, פרוטוקולים, החלטות ביניים. אתה חוקר משפטי מומחה בתכנון ובניה ישראלי. תפקידך לנתח את מסמכי הרקע בתיק ערר — פסיקה, תכניות, פרוטוקולים, החלטות ביניים.
@@ -121,6 +124,27 @@ search_precedent_library(
- אם תוצאה דומה: תקדם לחיסכון דוקטרינרי ("כפי שקבענו ב-X") - אם תוצאה דומה: תקדם לחיסכון דוקטרינרי ("כפי שקבענו ב-X")
- אם תוצאה הפוכה: ציין כי **חובה** הבחנה (distinguishing) - אם תוצאה הפוכה: ציין כי **חובה** הבחנה (distinguishing)
#### 2ב.2א — ועדות ערר אחרות (`search_internal_decisions`) — לפי שיקול דעת
**ההבדל מ-`search_decisions`:** `search_decisions` מחפש **רק בהחלטות של דפנה**. `search_internal_decisions` מחפש בהחלטות **כל ועדות הערר** בכל המחוזות (ירושלים, מרכז, תל אביב, צפון, דרום, ארצי).
**מתי להשתמש:**
- כשהסוגיה היא חדשנית ודפנה לא הכריעה בה → בדוק אם ועדת ערר אחרת כבר הכריעה
- כשרוצים לבדוק האם יש גישות שונות בין מחוזות (ועדות ערר שונות)
- **אל תשתמש** אם `search_decisions` כבר מצא את התשובה — אין צורך לחפש פעמיים
```
search_internal_decisions(
query="...",
practice_area="histael_hashbacha", # rishuy_uvniya / betterment_levy / compensation_197
district="ירושלים", # ריק = כל המחוזות
chair_name="", # ריק = כל היו"רים; "דפנה תמיר" = דפנה בלבד (שווה ל-search_decisions)
limit=5
)
```
⚠️ **שים לב להיררכיה:** החלטת ועדת ערר נמוכה מבית משפט מחוזי. אל תציג ועדת ערר אחרת כ"הלכה מחייבת".
#### 2ב.3 — בדיקה מצטלבת מול `daphna-precedent-network.md` #### 2ב.3 — בדיקה מצטלבת מול `daphna-precedent-network.md`
לכל סוגיה — בדוק במסמך: לכל סוגיה — בדוק במסמך:

View File

@@ -59,6 +59,9 @@ tools:
### חובה לפני בלוק ז (טענות הצדדים): ### חובה לפני בלוק ז (טענות הצדדים):
- **בלוק ז: `docs/daphna-block-zayin-claims.md`** — מבנה, סדר הצדדים, ביטויי קישור, ניטרליות מלאה, אנטי-דפוסים. בלוק ז הוא **דוח עובדתי** של הטענות — לא הערכה. - **בלוק ז: `docs/daphna-block-zayin-claims.md`** — מבנה, סדר הצדדים, ביטויי קישור, ניטרליות מלאה, אנטי-דפוסים. בלוק ז הוא **דוח עובדתי** של הטענות — לא הערכה.
### חובה אם זוהתה תבנית פרוצדורלית (החלטת ביניים — 8xxx בלבד):
- **תבניות פרוצדורליות: `docs/daphna-procedural-patterns.md`** — אם CEO סימן `pattern_tag: appraiser_clarification_request` או שעץ ההחלטה הראה התקיימות של כל 5 התנאים ב-§0.5, יש לחקות את **המבנה** (לא את הניסוח) של ההחלטה. כולל ביטויי מעבר קנוניים ובדיקת QA לפני שימוש. ⚠️ **אסור** לחקות את הניסוח של ערר 8174-24 — היא דוגמת outlier.
### תשתית כללית: ### תשתית כללית:
5. **מתודולוגיה אנליטית: `docs/decision-methodology.md`** — איך לחשוב על החלטה 5. **מתודולוגיה אנליטית: `docs/decision-methodology.md`** — איך לחשוב על החלטה
6. מדריך סגנון: `skills/decision/SKILL.md` — איך דפנה כותבת 6. מדריך סגנון: `skills/decision/SKILL.md` — איך דפנה כותבת

View File

@@ -1362,21 +1362,22 @@
"description": "מסך אדמין ב-legal-ai UI שמציג את כל הגדרות הסוכנים (model, timeout, runtime_config, skills, budget) ומאפשר עריכה ושמירה. מונע SQL ישיר.", "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) או רק קריאה?", "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 מתאים.", "testStrategy": "1. עמוד עולה ב-/admin/agents בlegal-ai UI. 2. עריכת timeoutSec ושמירה → SELECT ב-DB מאמת. 3. revision חדש ב-agent_config_revisions עם source מתאים.",
"status": "pending", "status": "done",
"dependencies": [ "dependencies": [
"16", "16",
"17", "17",
"25" "25"
], ],
"priority": "medium", "priority": "medium",
"subtasks": [] "subtasks": [],
"updatedAt": "2026-05-04T17:29:25.686Z"
} }
], ],
"metadata": { "metadata": {
"version": "1.0.0", "version": "1.0.0",
"lastModified": "2026-05-04T16:44:27.554Z", "lastModified": "2026-05-04T17:29:25.687Z",
"taskCount": 29, "taskCount": 29,
"completedCount": 23, "completedCount": 24,
"tags": [ "tags": [
"legal-ai" "legal-ai"
] ]

View File

@@ -56,6 +56,8 @@
| [`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) | מדריך סגנון מלא של דפנה — טון, מבנה, ביטויים, מתודולוגיה | **לפני כל כתיבת החלטה** |
| [`.claude/agents/HEARTBEAT.md`](.claude/agents/HEARTBEAT.md) | checklist הפעלת סוכן — routing, company filtering, quirks, wakeup עם UUID נכון | **לפני כל עבודה על סוכנים** |
| [`skills/dafna-decision-template/SKILL.md`](skills/dafna-decision-template/SKILL.md) | export DOCX לפי styles של תבנית Word של דפנה — line classification, dash policy, placeholder handling | לפני export DOCX |
--- ---
@@ -106,18 +108,34 @@
├── skills/ ← כלי עבודה ומדריכים ├── skills/ ← כלי עבודה ומדריכים
│ ├── decision/ מדריך סגנון + references + 12 בלוקים │ ├── decision/ מדריך סגנון + references + 12 בלוקים
│ ├── assistant/ קטלוג מסמכים │ ├── assistant/ קטלוג מסמכים
── docx/ עיצוב DOCX ── docx/ עיצוב DOCX
│ ├── dafna-decision-template/ export DOCX לפי תבנית Word של דפנה
│ └── new-company-setup/ blueprint הוספת חברה חדשה
├── .claude/
│ └── agents/ ← הוראות סוכנים + HEARTBEAT.md (symlinks ב-Paperclip)
│ ├── HEARTBEAT.md checklist הפעלה משותף לכל הסוכנים
│ ├── legal-ceo.md תזמורן + בקרת זרימה
│ ├── legal-writer.md כתיבת בלוקים בסגנון דפנה
│ ├── legal-analyst.md ניתוח משפטי + חילוץ טענות
│ ├── legal-researcher.md חיפוש תקדימים
│ ├── legal-qa.md 7 שערי איכות
│ ├── legal-proofreader.md תיקון OCR
│ ├── legal-exporter.md ייצוא DOCX סופי
│ └── hermes-curator.md סוכן Hermes לניתוח סגנון post-export
├── data/ ├── data/
│ ├── training/ ← 4 החלטות לאימון (DOCX) │ ├── training/ ← 4 החלטות לאימון (DOCX)
│ ├── exports/ ← טיוטות DOCX מיוצאות │ ├── exports/ ← טיוטות DOCX מיוצאות
│ └── cases/{case-number}/ ← תיקי עררים (מבנה שטוח, סטטוס ב-DB) │ └── cases/{case-number}/ ← תיקי עררים (מבנה שטוח, סטטוס ב-DB)
├── web/ ← FastAPI backend (Python): 75 API endpoints ├── web/ ← FastAPI backend (Python): 75+ API endpoints
│ ├── app.py ← API ראשי │ ├── app.py ← API ראשי
│ ├── paperclip_client.py ← אינטגרציית Paperclip │ ├── paperclip_api.py ← אינטגרציית Paperclip: `pc_request()` + `emit_case_status_webhook()`
│ ├── paperclip_client.py ← legacy client (ישן — השתמש ב-paperclip_api.py)
│ └── gitea_client.py ← אינטגרציית Gitea │ └── gitea_client.py ← אינטגרציית Gitea
├── 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/ ← סקריפטים שהושלמו (לא להריץ)
``` ```
@@ -180,6 +198,47 @@
- הסקריפט מסנן local skills שלא קיימים ב-CMPA (מציג אזהרה), משתמש ב-API (לא DB ישיר), יוצר revisions, idempotent. - הסקריפט מסנן local skills שלא קיימים ב-CMPA (מציג אזהרה), משתמש ב-API (לא DB ישיר), יוצר revisions, idempotent.
- שאלות ה-skill הרשמי של Paperclip — `paperclip` skill תחת `paperclipai/paperclip`. - שאלות ה-skill הרשמי של Paperclip — `paperclip` skill תחת `paperclipai/paperclip`.
### Webhook יוצא — עדכון סטטוס תיק לפלאגין
כשסטטוס תיק משתנה דרך `PUT /api/cases/{case_number}`, הבקאנד שולח webhook אסינכרוני לפלאגין:
```
PUT /api/cases/{case_number} → emit_case_status_webhook() [BackgroundTask]
→ POST /api/plugins/marcusgroup.legal-ai/webhooks/case-status
→ plugin-legal-ai/onWebhook()
→ comment בעברית על issue + CEO wakeup (כשסטטוס = qa_failed)
```
- הקוד ב-`web/paperclip_api.py` (`emit_case_status_webhook`), fire-and-forget, timeout 5s
- הפלאגין שומר idempotency key ב-state עם TTL 5 דקות למניעת spam על retry
- `GET /api/cases/stale?days=N` — תיקים שלא עודכנו N ימים; מוחרגים: `new`, `final`, `exported`
- `GET /api/chair-feedback/weekly-summary` — סיכום פידבק YU"R לשבוע האחרון
### Scheduled Jobs (plugin-legal-ai)
| Job | לוח זמנים | מה עושה |
|-----|-----------|---------|
| `stale-case-reminder` | יומי 08:00 | שולח comment אזהרה על תיקים תקועים >3 ימים |
| `weekly-feedback-analysis` | ראשון 19:00 | מעיר CEO לניתוח פידבק YU"R ועדכון `docs/legal-decision-lessons.md` |
| `sync-case-status` | כל 30 דק' | מסנכרן סטטוסי תיקים בין legal-ai ל-Paperclip |
CEO שמתעורר מ-`weekly-feedback-job` כותב לקובץ בלבד — **אין לו issueId, אל תנסה לפרסם comment או לסגור issue**.
### 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) כתבנית.
### External adapters — Hermes Curator (`curator-cmp` / `curator-cmpa`)
- פרופילי Hermes נפרדים לסוכן `hermes-curator` — מנתח החלטות סופיות ומציע עדכוני SKILL.md/lessons.md
- מיקום: `~/.hermes/profiles/curator-cmp/` + `~/.hermes/profiles/curator-cmpa/`
- מופעל אחרי export סופי; אינו מעדכן קבצים ישירות
- **תהליך אישור הצעות:** הצעות ה-curator מגיעות כ-comment ב-Paperclip → חיים בוחן ומאשר ידנית → commits ל-`SKILL.md` ו-`docs/legal-decision-lessons.md`
--- ---
## עקרונות כתיבה קריטיים ## עקרונות כתיבה קריטיים

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

@@ -0,0 +1,414 @@
# דו"ח Audit סוכנים — 2026-05-17
> נוצר על-ידי 7 sub-agents מקבילים שחקרו כל סוכן בנפרד.
> כיסוי: קבצי הנחיות, תצורת DB, skills, MCP tools, freshness, drift CMP↔CMPA.
>
> **עדכון 2026-05-17:** כל 12 הבעיות טופלו באותו יום. ראה סעיף "סטטוס תיקונים" למטה.
---
## סיכום מנהלים
### טבלת מצב כללית — לאחר תיקונים (2026-05-17)
| סוכן | מודל (instructions = DB) | Skills CMP | Skills CMPA | סטטוס |
|------|--------------------------|-----------|-----------|--------|
| עוזר משפטי (CEO) | claude-opus-4-7 ✅ | 9 | 6 | ✅ תקין |
| מנתח משפטי | claude-opus-4-7 ✅ | 9 | 6 | ✅ תקין |
| חוקר תקדימים | claude-sonnet-4-6 ✅ | 9 | 6 | ✅ תקין |
| כותב החלטה | claude-opus-4-7 ✅ | 9 | 6 | ✅ תקין |
| בודק איכות (QA) | claude-sonnet-4-6 ✅ | 9 | 6 | ✅ תקין |
| מייצא טיוטה | claude-sonnet-4-6 ✅ | 9 | 6 | ✅ תקין |
| מגיה מסמכים | claude-opus-4-7 ✅ | 9 | 6 | ✅ תקין |
| מנהל ידע (Curator) | deepseek-v4-pro ✅ | 9 | 6 | ✅ תקין |
> Skills CMPA=6 הוא עיצוב מכוון (6 shared-only skills). verify script מאשר "0 agents need sync".
### סטטוס תיקונים — כל 12 הבעיות טופלו
| # | חומרה | סוכן | בעיה | סטטוס | commit |
|---|-------|------|------|-------|--------|
| 1 | 🔴 | מייצא | `טיוטה-V``טיוטה-v` — דורס גרסאות | ✅ תוקן | `a584dc3` |
| 2 | 🔴 | מייצא | case.status לא מעודכן ל-`exported` + case_update חסר מ-tools | ✅ תוקן | `a584dc3` |
| 3 | 🔴 | חוקר | §ז (query log) חסר בתיק 8174-24 | ✅ תוקן | data (gitignored) |
| 4 | 🟠 | כולם | Skills asymmetry CMPA | ✅ לא נדרש — verify: "0 need sync" (עיצוב מכוון) | — |
| 5 | 🟠 | חוקר | `search_internal_decisions` לא מתועד | ✅ תוקן — tool + סעיף 2ב.2א | `35423ea` |
| 6 | 🟠 | מייצא | נתיב legal-docx hardcoded ל-CMP UUID | ✅ תוקן → `$PAPERCLIP_COMPANY_ID` | `a584dc3` |
| 7 | 🟠 | CEO | Project ID + company UUID hardcoded | ✅ תוקן → דינמי מ-$PAPERCLIP_TASK_ID | `35423ea` |
| 8 | 🟡 | רוב | Model drift instructions↔DB | ✅ תוקן + שודרג ל-opus-4-7 | `1608ea5`, `c3ce0e7` |
| 9 | 🟡 | QA | corpus_queries_logged: ידני או אוטומטי? | ✅ תוקן — הבהרה מפורשת: grep ידני | `1608ea5` |
| 10 | 🟡 | CEO | maxConcurrentRuns=NULL | ✅ לא נדרש — DB כבר maxConcurrentRuns=2 | — |
| 11 | 🟡 | מגיה | {issue-id} placeholder בקוד | ✅ תוקן → `$PAPERCLIP_TASK_ID` | `1608ea5` |
| 12 | 🟢 | מנהל ידע | ownership הצעות curator לא מוגדר | ✅ תוקן — הוסף ל-CLAUDE.md | `1608ea5` |
### שינויים נוספים שבוצעו באותו סשן
| שינוי | קובץ | commit |
|-------|------|--------|
| weekly-feedback-job: כתיבה לקובץ בלבד, לא Paperclip comment | legal-ceo.md | `ea0532b` |
| try-catch על agents.invoke בפידבק שבועי | worker.ts | `73e37df` |
| try-catch על http.fetch ב-stale-case-reminder | worker.ts | `73e37df` |
| HEARTBEAT.md reference בראש legal-researcher.md | legal-researcher.md | `1608ea5` |
| search_internal_decisions הוסף ל-legal-researcher tools | legal-researcher.md | `35423ea` |
| opus-4-6 → opus-4-7 ב-DB: CEO, מנתח, כותב, מגיה (16 סוכנים) | DB | `c3ce0e7` |
---
## ממצאים לפי סוכן
### 1. עוזר משפטי (CEO)
**קובץ:** `.claude/agents/legal-ceo.md` — 796 שורות, עודכן 2026-05-17
**תצורה:**
| חברה | ID | Model | Budget |
|------|-----|-------|--------|
| CMP | `752cebdd-6748-4a04-aacd-c7ab0294ef33` | claude-opus-4-6 | 1500¢ |
| CMPA | `cdbfa8bc-3d61-41a4-a2e7-677ec7d34562` | claude-opus-4-6 | 1500¢ |
**routing conditions:** `user_commented`, `agent_completion`, `precedent_extraction_*`, `weekly-feedback-job`, fallback→heartbeat רגיל
**MCP tools מוזכרים (41):** case_get/list/update, document_list, get_claims, get_chair_directions, record/list_chair_feedback, approve_direction, brainstorm_directions, search_case_documents, search_precedent_library, workflow_status, processing_status, get_metrics, validate_decision, set_outcome, export_docx, apply_user_edit, list_bookmarks, revise_draft, precedent_process_pending, extract_halachot/metadata, library_get/list, halacha_review, halachot_pending, extract_appraiser_facts, write_interim_draft, export_interim_draft
**✅ תקין:**
- Routing logic מלא ועדכני (כולל weekly-feedback-job שתוקן לאחרונה)
- Company filtering ברור (טבלה עם UUIDs וטווחי תיקים)
- Wakeup דרך API בלבד (לא DB ישיר) — מוגדר במפורש
- HEARTBEAT.md references נכונים (§0, §1, §1.7)
- weekly-feedback-job: כתיבה לקובץ בלבד, ללא issueId — נכון
**⚠️ בעיות:**
- 🟠 **Model drift:** instructions = claude-sonnet-4-6, DB = claude-opus-4-6
- 🟠 **Hardcoded Project ID:** `25c1b4a1-2c0e-4a2d-9938-8ae56ccda6f1` (תיק 1130-25) — צריך להיות דינמי
- 🟡 **maxConcurrentRuns = NULL** ב-DB (שאר הסוכנים = 1)
- 🟡 **MCP startup race:** הוראות מדברות על sleep+retry אבל לא כ-code אוטומטי
---
### 2. מנתח משפטי
**קובץ:** `.claude/agents/legal-analyst.md` — 498 שורות, עודכן 2026-05-04
**תצורה:**
| חברה | ID | Model | Budget |
|------|-----|-------|--------|
| CMP | `c26e9439-a88a-49dc-9e67-2262c95db65c` | claude-opus-4-6 | 1500¢ |
| CMPA | `f70fd353-...` | claude-opus-4-6 | 1500¢ |
**MCP tools (18):** case_get/list/update, document_list/get_text, extract_claims, extract_appraiser_facts, get_claims, search_case_documents, search_decisions, search_precedent_library, precedent_library_get/list, halacha_review, halachot_pending, find_similar_cases, workflow_status, processing_status
**Output artifacts:** `{case_dir}/documents/research/analysis-and-research.md`
**Query logging (§5ד/§7א):** לרשום כל `search_precedent_library`, `search_decisions`, `find_similar_cases` כולל ניסיונות עם 0 תוצאות
**✅ תקין:**
- כל 18 כלי MCP מוזכרים ומיושמים
- סיווג claim_type ברור (claim/response/reply)
- Wakeup CEO בפורמט נכון
- reference files קיימים
**⚠️ בעיות:**
- 🟠 **Model drift:** instructions = claude-opus-4-7, DB = claude-opus-4-6
- 🟡 **CMPA sync gap:** עדכון אחרון CMPA = 2026-05-04 (13 ימים לפני CMP)
---
### 3. חוקר תקדימים
**קובץ:** `.claude/agents/legal-researcher.md` — 240 שורות, עודכן 2026-05-04
**תצורה:**
| חברה | ID | Model | Budget |
|------|-----|-------|--------|
| CMP | `35022af0-0498-4c3d-90ca-b0ab9e987198` | claude-sonnet-4-6 | 1500¢ |
| CMPA | `5dd06843-...` | claude-sonnet-4-6 | 1500¢ |
**MCP tools (29):** case_get/update, document_list/get_text, search_case_documents, search_decisions, find_similar_cases, extract_references, precedent_attach, precedent_list, precedent_search_library, search_precedent_library, library_get/list, extract_halachot/metadata, precedent_process_pending, halacha_review, halachot_pending, workflow_status
**Output artifact:** `{case_dir}/documents/research/precedent-research.md`
**Query logging (§ז):** חובה — כל query עם פילטרים, תוצאות, בחירה/דחייה, negative evidence
**✅ תקין:**
- שלושת הקורפוסים מוגדרים בבירור (פסיקה חיצונית / קאנון דפנה / ציטוטים ידניים)
- precedent_attach עם הוראות מלאות
- Wakeup CEO דינמי לפי חברה
**⚠️ בעיות:**
- 🔴 **§ז חסר בתיק 8174-24** — 1 מתוך 3 תיקים בדיסק חסר את תיעוד השאילתות. QA אמור לחסום ייצוא.
- 🟠 **`search_internal_decisions` לא מתועד** — הכלי ב-header אבל לא מוסבר בגוף ההנחיות. מתי להשתמש בו?
- 🟠 **Skills asymmetry CMPA** — CMPA חסרה: legal-assistant, legal-decision, legal-docx, diagnose-why-work-stopped, appendix-expert-intern, terminal-bench-loop
- 🟡 **`daphna-precedent-network.md` עדכון אחרון 27 אפריל** — עשוי להיות לפני תקדימים חדשים
- 🟡 **HEARTBEAT.md לא מוזכר בפירוש** — אין link ישיר בתחילת ההנחיות
---
### 4. כותב החלטה
**קובץ:** `.claude/agents/legal-writer.md` — 410 שורות, עודכן 2026-05-04
**תצורה:**
| חברה | ID | Model | Budget |
|------|-----|-------|--------|
| CMP | `7ed8686f-24bc-49a3-bc02-67ca15b895a9` | claude-opus-4-6 | 1500¢ |
| CMPA | `99289cb1-...` | claude-opus-4-6 | 1500¢ |
**Block range:** ה-יא (5-11), כותב בסדר; א-ד (אוטומטי), יב (אוטומטי)
**5 style docs לפני בלוק י (כולם קיימים):**
- `docs/daphna-voice-fingerprint.md` ✅ (עודכן 10 מאי)
- `docs/daphna-precedent-network.md` ✅ (עודכן 27 אפריל)
- `docs/daphna-architecture-by-outcome.md` ✅ (עודכן 28 אפריל)
- `docs/daphna-acceptance-architecture.md` ✅ (עודכן 28 אפריל)
- `docs/voice-1130-25.md` ✅ (עודכן 26 אפריל)
**MCP tools (18):** case_get/update, document_list/get_text, get_claims, get_chair_directions, get_decision_template, get_block_context, save_block_content, write_block, search_decisions, search_precedent_library, library_get/list, search_case_documents, get_style_guide, halacha_review, workflow_status, apply_user_edit
**✅ תקין:**
- 4 statuses של get_chair_directions מוגדרים (missing/empty/partial/complete)
- Revision mode ברור (לא לשמור ב-DB בעריכה)
- 10 anti-patterns ברורים
- Company filtering נכון (CEO IDs שונים לפי חברה)
**⚠️ בעיות:**
- 🟠 **Model drift:** instructions = claude-opus-4-7, DB = claude-opus-4-6
- 🟡 **חסר שלב 0 מפורש:** בדיקת `issue.description` (ההוראה הראשית מה-CEO)
---
### 5. בודק איכות (QA)
**קובץ:** `.claude/agents/legal-qa.md` — 219 שורות, עודכן 2026-05-04
**תצורה:**
| חברה | ID | Model | Budget |
|------|-----|-------|--------|
| CMP | `1a5b229e-9220-4b13-940c-f8eb7285fc29` | claude-sonnet-4-6 | 1500¢ |
| CMPA | `7191ff77-...` | claude-sonnet-4-6 | 1500¢ |
**9 בדיקות (לא 8 — §7א הוא נפרד):**
1. שלמות מבנית — critical
2. רקע ניטרלי — critical
3. כיסוי טענות — critical
4. משקלות — warning
5. ללא כפילות — warning
6. מספור רציף — warning
7א. שאילתות קורפוס (corpus_queries_logged) — **critical blocker**
7. תאימות מתודולוגיה — critical
8. קול דפנה — critical
**Reference files (כולם קיימים):**
- `docs/daphna-decision-tree.md` ✅ (521 שורות)
- `docs/daphna-voice-fingerprint.md` ✅ (471 שורות)
- `docs/daphna-architecture-by-outcome.md` ✅ (381 שורות)
- `docs/daphna-acceptance-architecture.md` ✅ (640 שורות)
- `docs/daphna-block-zayin-claims.md` ✅ (385 שורות)
- `docs/daphna-precedent-network.md` ✅ (379 שורות)
**✅ תקין:**
- כל reference files קיימים ונגישים
- Company filtering מתועד (CEO IDs נכונים)
- Decision logic done/blocked מוגדרת
**⚠️ בעיות:**
- 🟡 **בדיקה 7א לא ברורה** — אוטומטית (validate_decision) או ידנית (grep בקובצי markdown)?
- 🟡 **בדיקה 8 (קול דפנה) סובייקטיבית** — חסרות דוגמאות anti-patterns מדידות
- 🟡 **get_metrics() — אין ספי קבלה** — מה מספר/אחוז שמוגדר כ-pass?
- 🟡 **decision tree:** אם רק בדיקות 4-6 (warning) נכשלו — done או blocked?
---
### 6. מייצא טיוטה (Exporter)
**קובץ:** `.claude/agents/legal-exporter.md` — 151 שורות, עודכן 2026-05-04
**תצורה:**
| חברה | ID | Model | Budget |
|------|-----|-------|--------|
| CMP | `d0dc703b-ca83-4883-bca7-c9449e8713cd` | claude-sonnet-4-6 | 1500¢ |
| CMPA | `ada99a7d-...` | claude-sonnet-4-6 | 1500¢ |
**MCP tools (8):** export_docx, apply_user_edit, list_bookmarks, revise_draft, validate_decision, get_claims, get_block_context, workflow_status
**✅ תקין:**
- Git integration לכל ייצוא/עדכון
- validate_decision לפני export מוגדר
- active_draft detection (עריכה-*.docx) מוגדר
**⚠️ בעיות:**
- 🔴 **Naming mismatch קריטי:** הנחיות → `טיוטה-V{N}.docx` (V גדולה); קוד `revise_draft``טיוטה-v{N}.docx` (v קטנה); בדיסק בפועל → `טיוטה-v1.docx` (v קטנה). **הסוכן יחפש V גדולה ולא ימצא — יתחיל מ-v1 בכל הפעלה ויחליף קבצים קיימים!**
- 🔴 **case.status לא מעודכן ל-`exported`** — אחרי export מצליח, הסטטוס נשאר `drafted`/`reviewed`; הסטטוס `exported` קיים ב-DB schema ומוחרג מ-stale query
- 🟠 **legal-docx SKILL.md path hardcoded לCMP UUID** — CMPA ייכשל בקריאת ה-SKILL.md
- נכון: `/home/chaim/.paperclip/instances/default/skills/42a7acd0-.../legal-docx/SKILL.md`
- חסר: דינמי לפי `$PAPERCLIP_COMPANY_ID`
- 🟡 **Heartbeat grace=60s** — אם export DOCX > 60s, שני instances יתעוררו במקביל
- 🟡 **File size validation** — מוזכר בהנחיות אך לא מיושם בקוד
---
### 7. מגיה מסמכים (Proofreader)
**קובץ:** `.claude/agents/legal-proofreader.md` — 115 שורות, עודכן 2026-05-04
**תצורה:**
| חברה | ID | Model | Budget |
|------|-----|-------|--------|
| CMP | `410c0167-27dc-485c-a51b-7aa8b9ff2217` | claude-opus-4-6 | 1500¢ |
| CMPA | `17839fc6-...` | claude-opus-4-6 | 1500¢ |
**OCR workflow — 5 שלבים:** זיהוי → תיקון אוטומטי (abbreviations.json) → הגהה חכמה → שמירה → דיווח+סגירה
**abbreviations.json:** קיים ב-`/home/chaim/legal-ai/data/abbreviations.json` (2545 bytes, עודכן אפריל)
**✅ תקין:**
- abbreviations.json קיים
- Wakeup CEO דינמי לפי חברה
- חיוב סגירת issue
**⚠️ בעיות:**
- 🟠 **Model drift:** instructions = claude-opus-4-7, DB = claude-opus-4-6
- 🟡 **MCP write support לתיקיות:** לא אומת שה-tools תומכים בכתיבה ל-`documents/proofread/`
- 🟡 **Placeholder `{issue-id}` בקוד:** pc.sh calls משתמשות ב-literal `{issue-id}` — האם הסוכן מחליף עם `$PAPERCLIP_TASK_ID`?
- 🟡 **`extraction_status = proofread`:** האם השדה קיים ב-MCP document schema?
---
### 8. מנהל ידע (Hermes Curator)
**קובץ:** `.claude/agents/hermes-curator.md` — 147 שורות, עודכן 2026-05-10
**תצורה:**
| חברה | ID | Adapter | Model | Budget |
|------|-----|---------|-------|--------|
| CMP | `60dce831-5c5b-4bae-bda9-5282d506f0dc` | deepseek_local | deepseek-v4-pro | 1500¢ |
| CMPA | `d6f7c55d-570a-46b8-8d72-1286d07da0d8` | deepseek_local | deepseek-v4-pro | 1500¢ |
**Profiles:** `~/.hermes/profiles/curator-cmp/` ✅ + `curator-cmpa/` ✅ (שניהם קיימים)
**Trigger:** UI "סמן כסופי" → `web/paperclip_client.py:pc_wake_curator_for_final()` → sub-issue + wakeup
**MCP tools (6):** case_get, case_get_final_text, document_list, get_style_guide, precedent_library_list, search_internal_decisions, halacha_review
**✅ תקין:**
- deepseek_local מוגדר נכון בשתי החברות
- Profiles קיימים ועובדים (MEMORY.md מ-06/05 עם 5 ממצאים)
- Read-only design — לא מעדכן קבצים ישירות
- env vars נדרשים מתועדים
**⚠️ בעיות:**
- 🟢 **לא מוגדר:** מי מממש הצעות ל-SKILL.md/lessons.md שה-curator מציע ב-comments?
- 🟢 **Hermes bias:** DeepSeek V4-Pro עלול לפרש תוצאות בצורה סובייקטיבית — אין oversight layer
---
## בעיות חוצות-סוכנים
### 1. Skills Asymmetry CMP vs CMPA (🟠 גבוה)
**Skills ב-CMP (9):**
- משותפים (6): paperclip, paperclip-converting-plans-to-tasks, paperclip-create-agent, paperclip-create-plugin, paperclip-dev, para-memory-files
- ייחודיים CMP (3+): legal-assistant, legal-decision, legal-docx, appendix-expert-intern, diagnose-why-work-stopped, terminal-bench-loop
**Skills ב-CMPA (6):** משותפים בלבד — **חסרים כל ה-legal-* skills**
**השפעה:** סוכני CMPA לא יכולים להשתמש ב-legal-decision skill (כתיבה), legal-assistant (ניתוח), legal-docx (DOCX). לא ברור אם זו החלטה מכוונת (CMPA עובד אחרת?) או gap בסנכרון.
**פעולה:** הרץ `sync_agents_across_companies.py --verify` עם PAPERCLIP_BOARD_API_KEY לבדיקה.
### 2. Model Version Drift (🟡 בינוני)
ב-DB כל הסוכנים רצים על claude-opus-4-6 או claude-sonnet-4-6, אבל קבצי הנחיות מציינים גרסאות שונות:
| סוכן | instructions מציין | DB רץ על |
|------|-------------------|---------|
| CEO | claude-sonnet-4-6 | claude-opus-4-6 |
| מנתח | claude-opus-4-7 | claude-opus-4-6 |
| כותב | claude-opus-4-7 | claude-opus-4-6 |
| מגיה | claude-opus-4-7 | claude-opus-4-6 |
| חוקר, QA, מייצא | claude-sonnet-4-6 | claude-sonnet-4-6 ✅ |
| מנהל ידע | deepseek-v4-pro | deepseek-v4-pro ✅ |
**לא ברור:** האם CEO/מנתח/כותב **אמורים** לרוץ על Opus (בחירה מכוונת לאיכות) ורק קבצי instructions לא עודכנו? או שה-DB צריך להתעדכן?
### 3. HEARTBEAT.md Reference (🟢 נמוך)
קובץ `legal-researcher.md` לא מפנה ל-`HEARTBEAT.md` בפירוש בתחילת הקובץ. שאר הסוכנים כן עושים זאת.
---
## רשימת תיקונים לפי עדיפות
### 🔴 קריטי — לתקן לפני תיק הבא
1. **`legal-exporter.md` + `web/app.py`/`drafting.py`:** אחד הדברים:
- תיקן הנחיות: שנה `טיוטה-V``טיוטה-v` (v קטנה) בכל המקומות
- **ועוד:** הוסף לקובץ הנחיות שלב: "אחרי export מוצלח — עדכן `case.status = 'exported'` דרך MCP או API"
2. **תיק 8174-24 — §ז חסר:** בדוק אם שלב המחקר הושלם. אם לא — הפעל חוקר מחדש לתיק זה.
### 🟠 גבוה — לתקן בשבוע הקרוב
3. **Skills CMPA:** הרץ:
```bash
PAPERCLIP_BOARD_API_KEY=$(mcp__infisical__get-secret \
--projectId 9a77b161-f70c-4dd3-9d67-b7ab850cef51 \
--environmentSlug nautilus --secretPath /paperclip --secretName BOARD_API_KEY) \
python ~/legal-ai/scripts/sync_agents_across_companies.py --verify
```
החלט אם להוסיף legal-* skills ל-CMPA ואם כן — הרץ `--apply`.
4. **`legal-researcher.md`:** הוסף תת-סעיף עם הוראות ל-`search_internal_decisions`:
- מתי להשתמש (החלטות פנימיות דפנה שלא בקורפוס הציבורי)
- מה ההבדל מ-`search_decisions`
5. **`legal-exporter.md` — נתיב legal-docx:** שנה מ-hardcoded UUID ל-דינמי:
```
אם $PAPERCLIP_COMPANY_ID = 42a7acd0... → CMP path
אם $PAPERCLIP_COMPANY_ID = 8639e837... → CMPA path
```
6. **`legal-ceo.md` — Project ID:** הסר את ה-hardcoded ID של 1130-25. החלף בהוראה: "השתמש ב-`projects_list` לקבלת project_id הנכון לפי חברה ולתיק".
### 🟡 בינוני — לתקן בחודש הקרוב
7. **Model documentation:** החלט על גרסאות מודל לכל סוכן ועדכן גם הנחיות גם DB. עדיף: שמור הנחיות כ-source of truth ועדכן DB דרך `sync_agents_across_companies.py --apply`.
8. **`legal-qa.md` — הבהרת corpus_queries_logged:** הוסף: "הבדיקה היא קריאת `validate_decision` עם `check_corpus_log=true` / או grep ידני בקובץ `analysis-and-research.md` לסעיף ז".
9. **`legal-ceo.md` — maxConcurrentRuns:** עדכן DB ל-maxConcurrentRuns=1 (או 2 אם CEO רוצה מקביליות מכוונת).
10. **`legal-proofreader.md` — {issue-id} placeholder:** שנה ל-`$PAPERCLIP_TASK_ID` באופן מפורש.
11. **`legal-researcher.md` — HEARTBEAT.md link:** הוסף בשורה 1: `> ראה גם: HEARTBEAT.md לחוקים הכלליים`.
### 🟢 נמוך — future improvement
12. **מנהל ידע — ownership:** הוסף ל-CLAUDE.md הנחיה: "Curator proposals ב-comments → חיים מאשר ידנית → commits ל-SKILL.md ו-lessons.md".
---
## אימות (לאחר תיקונים)
```bash
# 1. שלוף API key
PAPERCLIP_BOARD_API_KEY=$(mcp__infisical__get-secret \
--projectId 9a77b161-f70c-4dd3-9d67-b7ab850cef51 \
--environmentSlug nautilus --secretPath /paperclip --secretName BOARD_API_KEY)
# 2. בדוק drift
python ~/legal-ai/scripts/sync_agents_across_companies.py --verify
# 3. בדוק freshness של הנחיות
python ~/legal-ai/scripts/sync_agents_across_companies.py --check-instructions
# 4. בדוק שסוכני CMPA עובדים עם skills נכונים
PGPASSWORD="paperclip" psql -h 127.0.0.1 -p 54329 -U paperclip -d paperclip -c "
SELECT a.name, array_agg(s.name ORDER BY s.name) as skills
FROM agents a
JOIN companies c ON a.company_id = c.id
LEFT JOIN agent_skills ask ON ask.agent_id = a.id
LEFT JOIN skills s ON ask.skill_id = s.id
WHERE c.name LIKE '%השבחה%' AND (a.is_deleted = false OR a.is_deleted IS NULL)
GROUP BY a.id ORDER BY a.name;
"
```

View File

@@ -29,6 +29,38 @@
--- ---
## 0.5. שאלת סף — האם בכלל להכריע עכשיו?
לפני המעבר לעץ ההחלטה הראשי (§1), שאל:
> **האם יש פתח להחלטת ביניים שתחסוך הכרעה מלאה?**
הרוב המכריע של התיקים — לא. אבל בעררי שומה מכרעת (8xxx), קיים כלי שלישי שאינו "דחייה / קבלה / קבלה חלקית" — **החלטת ביניים שמחזירה שאלה ספציפית לשמאי המכריע**.
| תנאי | מתקיים? |
|-------|----------|
| השומה המכרעת מנומקת וסדורה ברמה הכללית (הצהרת אמון בגלר אפשרית) | □ |
| יש פרט עובדתי קונקרטי (לא טענה משפטית) שדורש מענה | □ |
| הפרט לא הוצג בצורה ישירה לשמאי בעת ההכרעה הראשונה (התחדד בדיון / בהשלמת מסמכים) | □ |
| דחייה ללא טיפול בפרט תיראה כעודף שמרנות; קבלה תיראה כעודף התערבות | □ |
| השמאי המכריע זמין ומסוגל להשיב | □ |
```
כל התנאים מתקיימים?
├─ כן → ⏸️ החלטת ביניים — חזרה לשמאי
│ → daphna-procedural-patterns.md §1
│ → דלג על §1-§7 של מסמך זה; חזור אליהם רק אחרי שיגיע מענה השמאי
└─ לא → המשך ל-§1 (עץ ההחלטה הראשי)
```
⚠️ **אזהרה:** התבנית הזו רלוונטית כמעט אך ורק ל-8xxx (היטל השבחה). ב-1xxx (רישוי) אין מקבילה — הוועדה היא הסמכות העליונה לעניין, אין שמאי מכריע להחזיר אליו.
⚠️ **אזהרת איכות:** דוגמת המקור (ערר 8174-24) הוא **דוגמת מבנה בלבד, לא דוגמת ניסוח**. ראה `daphna-procedural-patterns.md` לפרטי הסימנים שיש לתקן בעת חיקוי.
---
## 1. עץ החלטה ראשי — בחירת סוג ארכיטקטורה ## 1. עץ החלטה ראשי — בחירת סוג ארכיטקטורה
``` ```
@@ -517,5 +549,6 @@
| `daphna-architecture-by-outcome.md` | §1 (עץ ראשי), §2 (משני), §4 (מודי פתיחה) | | `daphna-architecture-by-outcome.md` | §1 (עץ ראשי), §2 (משני), §4 (מודי פתיחה) |
| `daphna-acceptance-architecture.md` | §1 (עץ ראשי — קבלה), §3.7 (פורמטי סיום) | | `daphna-acceptance-architecture.md` | §1 (עץ ראשי — קבלה), §3.7 (פורמטי סיום) |
| `daphna-block-zayin-claims.md` | §3.3 (בלוק ז) | | `daphna-block-zayin-claims.md` | §3.3 (בלוק ז) |
| `daphna-procedural-patterns.md` | §0.5 (שאלת סף — החלטת ביניים) |
ראה את הקבצים המקוריים לדוגמאות ולפירוט מלא. **המסמך הזה אינו תחליף** — הוא **מצביע** איזה סעיף ואיזה מסמך לקרוא לפי השאלה. ראה את הקבצים המקוריים לדוגמאות ולפירוט מלא. **המסמך הזה אינו תחליף** — הוא **מצביע** איזה סעיף ואיזה מסמך לקרוא לפי השאלה.

View File

@@ -0,0 +1,148 @@
# קטלוג תבניות פרוצדורליות של דפנה
מסמך זה מקטלג **כלים פרוצדורליים** שדפנה משתמשת בהם **במקום** הכרעה מלאה — לא תבניות סגנון, אלא מהלכים שמתבצעים כשהתיק לא מבשיל להחלטה סופית.
⚠️ **הבחנה קריטית:**
- `daphna-architecture-by-outcome.md` + `daphna-acceptance-architecture.md` = **תבניות תוצאה** (דחייה / קבלה — דפנה הכריעה).
- מסמך זה = **תבניות אי-הכרעה / הכרעה דחויה** (דפנה בחרה לא להכריע עכשיו).
⚠️ **אזהרת קורפוס:**
החלטות תחת תבניות אלה הן בדרך כלל **outliers סגנוניים** — קצרות, חסרות, לפעמים רשלניות בניסוח. הן אינן מתאימות ל-voice corpus או ל-structure corpus. הן מתאימות **רק** למטרת זיהוי-תבנית בעתיד.
---
## תבנית 1: החלטת ביניים — חזרה לשמאי המכריע
### מתי להשתמש
כשמתקיימים **כל** התנאים הבאים:
1. **השומה המכרעת מנומקת וסדורה ברמה הכללית** — הצהרת אמון בגלר חייבת להישאר תקפה. אם השומה רעועה מיסודה, לא משתמשים בתבנית זו — הולכים לקבלה (תבנית E ב-acceptance).
2. **יש פרט עובדתי קונקרטי, לא טענה משפטית, שדורש מענה** — למשל: "12 מתוך 15 עסקאות ההשוואה הן בקיר משותף", "הנכס בבעלות יחיד ולא במושע", "השמאי לא חישב מקדם דחייה".
3. **הפרט הזה לא הוצג בצורה ישירה לשמאי בעת ההכרעה הראשונה** — או שהעורר חידד אותו בדיון / בהשלמת מסמכים.
4. **דחיית הערר בלעדיו תיראה כעודף שמרנות; קבלת הערר תיראה כעודף התערבות** — היא נקודת איזון שהחלטת ביניים פותרת.
5. **השמאי המכריע זמין ומסוגל להשיב להבהרה** (לא פרש, לא נפטר, לא נמצא בניגוד עניינים מתעורר).
### מה התבנית עושה
הוועדה **אינה מכריעה** את הערר. במקום זאת, היא:
- מציגה את הרקע (בלוק ה+ו)
- מציגה את ההליכים שכבר נערכו (בלוק ח)
- מצמצמת את בלוק ז לטענה המרכזית הרלוונטית (לא 47 טענות מקור)
- בבלוק י: מצטטת את גלר/אשקלוני, מצהירה על אמון בשומה, ואז מזהה פרט שדורש הבהרה
- בבלוק יא: פונה לשמאי המכריע עם **שאלה ספציפית וצרה אחת**
התוצאה היא **לא** "הערר נדחה" ו**לא** "הערר מתקבל" — אלא: **"לאחר קבלת הבהרת השמאי המכריע תתקבל החלטה סופית בערר"**.
### מבנה קנוני
| בלוק | תוכן | חריגה מהסטנדרט |
|------|-------|-----------------|
| ה | פתיחה — זיהוי הצדדים, השומה, הנכס, התכנית | כותרת: "החלטת ביניים" (לא "החלטה") |
| ו | רקע עובדתי — הנכס, היסטוריה קניינית, השומה, הסוגיות שהמכריע הכריע | סטנדרטי |
| ז | טענות הצדדים — **רק** הטענה הרלוונטית להבהרה, לא כל הטענות מהמקור | מקוצר באופן דרמטי |
| ח | הליכים — הדיון + השלמת מסמכים + תגובות נוספות | חשוב לתעד את ההליך שגרם להבהרת הטענה |
| י | דיון — ציטוט גלר/אשקלוני, הצהרת אמון, זיהוי הפרט, "למשנה זהירות" | קצר יחסית — אין הכרעה מלאה |
| יא | פנייה לשמאי המכריע + צמצום השאלה ("נדייק כי...") + הוראת מזכירות | תחליף לפסקת "סוף דבר" |
| יב | "לאחר קבלת הבהרת השמאי המכריע תתקבל החלטה סופית בערר" | חתימה רגילה (פה אחד + תאריך) |
### ביטויי מעבר קנוניים
| ביטוי | תפקיד |
|--------|--------|
| **"בנקודה זו יכולנו לסיים ולדחות את הערר אלא..."** | מסמן שהעמדה הראשונית היא דחייה; מכין דחייה סופית |
| **"לאחר בחינת טענות העורר במלואן בכל זאת לא נוכל להתעלם מכך כי..."** | מצביע על פרט עובדתי קונקרטי שדורש מענה |
| **"למשנה זהירות נכון יהיה לקבל הבהרה"** | מילת מפתח — מגן משפטי מפני טענת קלות דעת |
| **"אנו פונים לשמאי המכריע להבהרה במסגרתה יתבקש להבהיר..."** | הפעולה האופרטיבית |
| **"נדייק כי השמאי המכריע יבדוק את [X] בהתייחס ל[Y]"** | צמצום השאלה — שולל הבנה רחבה מדי |
| **"לשם מתן ההבהרה מזכירות הוועדה תעביר לשמאי המכריע את כתבי הטענות..."** | הוראה מינהלית |
| **"לאחר קבלת הבהרת השמאי המכריע תתקבל החלטה סופית בערר"** | סיום — לא הכרעה |
### תקדים-מקור
**ערר 8174-24 (גולדמן / בית מדרש)** — החלטה מ-11.05.2026.
⚠️ **אזהרה:** התקדים הזה הוא **דוגמת תבנית בלבד**, לא דוגמת איכות. בהחלטה זו זוהו 7 סימני "זריקה":
1. משפט run-on ב-§46 (3 חיבורים בלי פיסוק)
2. כפילות לקסיקלית ב-§40 ("כאמור סדורה")
3. בלוק ז מקוצץ — רק טענה אחת מתוך 47 מהמקור
4. סוגיות נוספות (טבצ'ניק/דייר מוגן; טענת סף) נזנחו לחלוטין
5. רטוריקת "במלואן" שלא מתיישבת עם הטקסט
6. תאריך מאוחר ביחס לתיק (שנה וחצי)
7. אזכור פסיקה מינימלי (רק גלר + אשקלוני)
לכן: **חיקוי המבנה** של תבנית זו לגיטימי; **חיקוי הניסוח** של 8174-24 — לא. בעת חיקוי, יש לתקן את הסימנים לעיל (במיוחד 1, 2, 5).
### מתי **לא** להשתמש
- כשהפגם בשומה הוא **משפטי-עקרוני** (שאלת פרשנות חוק/תכנית) — שם לוועדה יתרון (אשקלוני), ועליה להכריע בעצמה.
- כשהפגם הוא **מתודולוגי-יסודי** (השמאי בחר שיטה שגויה) — שם מקומה של תבנית E ב-acceptance ("השומה תושב לתיקון" + רשימת הוראות).
- כשעברו זמן רב מההכרעה הראשונה והשמאי כבר אינו זמין — אז ועדת הערר חייבת להכריע בעצמה.
- כשהעורר ויתר על ההליך או נמשך / נדחה.
### בדיקת איכות לפני שימוש (QA)
- [ ] שאלה ספציפית אחת, לא רשימה.
- [ ] הצהרת אמון בשמאי לפני זיהוי הפרט (סדר חשוב).
- [ ] "למשנה זהירות" מופיע — מגן משפטי.
- [ ] הבלוק ז כולל **רק** את הטענה הרלוונטית (לא ניסיון לסקור 47 טענות בקיצור).
- [ ] אין run-on של 3+ חיבורים בלי פיסוק.
- [ ] אין "במלואן" כשבפועל בחנת רק קטע.
- [ ] בלוק יב מסמן בבירור שזו לא הכרעה סופית.
---
## תבנית 2: (שמורה) — דחיית סף עם דיון "למען הסדר הטוב"
> טופלה ב-`daphna-architecture-by-outcome.md §3` (מוד F). מקושר כאן לשם שלמות הקטלוג.
זוהי תבנית קרובה אבל **אינה** החלטת ביניים — היא הכרעה מלאה (דחייה), עם דיון מהותי שאינו דרוש משפטית. ההבדל:
- **דחיית סף + מהות** = "אני דוחה, ולמרות זאת אדון לרווחת הצדדים"
- **החלטת ביניים** = "אני לא דוחה ולא מקבלת — שלחתי שאלה אחורה"
---
## תבנית 3: (עתידית) — החלטה מותנית
> מקום שמור לתבנית של "הערר מתקבל בכפוף ל-X תוך Y ימים, אחרת ייחשב כנדחה" — אם תזוהה כתבנית חוזרת בקורפוס.
---
## תיעוד תבניות חדשות
כאשר מזוהה החלטה שאינה מתיישבת עם תבניות תוצאה (`acceptance-architecture` / `architecture-by-outcome`):
1. בדוק אם היא נכנסת לקטלוג זה.
2. אם כן — עדכן כאן.
3. אם לא — שמור אותה כ-outlier (`case-tags.json` בתיק עצמו, `pattern_corpus: false`) עד שמתגלה תבנית שניה דומה.
4. **אסור** להוסיף החלטות outlier ל-voice corpus או ל-structure corpus — הן יזהמו את הקול של דפנה.
---
## מטא-data — תיוג מסמכי outlier
כל החלטה שנכנסת לתבנית פרוצדורלית (בניגוד לתבנית תוצאה) מסומנת בקובץ `case-tags.json` בתיק עצמו:
```json
{
"case_number": "8174-24",
"document_role": "interim_decision",
"voice_corpus": false,
"structure_corpus": false,
"pattern_corpus": true,
"pattern_tag": "appraiser_clarification_request",
"quality_signal": "pragmatic_disposition",
"comments": "תבנית פרוצדורלית — חזרה לשמאי. לא ייצוג של החלטה מלאה."
}
```
> **TODO עתידי:** כשנמיגרר את שדות אלו ל-DB schema (`documents.tags` או `cases.metadata`), ה-API יוכל לסנן אוטומטית בעת בניית קורפוס לאימון Hermes. כיום זה ידני.
---
## עדכון המסמך
עדכן את הקובץ הזה רק כאשר:
1. מזוהה החלטה שנייה (לפחות) עם אותה תבנית פרוצדורלית — מאשר שזו תבנית ולא אקראיות.
2. נוסף ביטוי-מעבר חדש בתבנית קיימת.
3. נוסף קריטריון "מתי להשתמש" / "מתי לא" — לרוב על בסיס feedback מהיו"ר.

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

@@ -385,3 +385,64 @@ The draft's biggest structural error was adding the "נבאר" doctrinal paragra
- [ ] Update voice-fingerprint: add new transition phrases - [ ] Update voice-fingerprint: add new transition phrases
- [ ] Update architecture-by-outcome: add "clean acceptance" archetype - [ ] Update architecture-by-outcome: add "clean acceptance" archetype
- [ ] Fix agent opening punctuation: "ונפרט;" not "נפרט." - [ ] 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

View File

@@ -116,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(
@@ -209,6 +216,22 @@ async def precedent_library_delete(case_law_id: str) -> str:
return await plib.precedent_library_delete(case_law_id) return await plib.precedent_library_delete(case_law_id)
@mcp.tool()
async def precedent_link_cases(
case_law_id_a: str,
case_law_id_b: str,
relation_type: str = "same_case_chain",
) -> str:
"""קישור שתי פסיקות כקשורות (דו-כיווני, idempotent). relation_type: same_case_chain | overruled_by | distinguished."""
return await plib.precedent_link_cases(case_law_id_a, case_law_id_b, relation_type)
@mcp.tool()
async def precedent_unlink_cases(case_law_id_a: str, case_law_id_b: str) -> str:
"""הסרת קישור בין שתי פסיקות (דו-כיווני)."""
return await plib.precedent_unlink_cases(case_law_id_a, case_law_id_b)
@mcp.tool() @mcp.tool()
async def precedent_extract_halachot(case_law_id: str) -> str: async def precedent_extract_halachot(case_law_id: str) -> str:
"""הרצה מחדש של חילוץ הלכות לפסיקה קיימת. ההלכות הקיימות נמחקות, החדשות חוזרות לסטטוס pending_review.""" """הרצה מחדש של חילוץ הלכות לפסיקה קיימת. ההלכות הקיימות נמחקות, החדשות חוזרות לסטטוס pending_review."""
@@ -390,6 +413,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:
@@ -573,6 +625,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

@@ -360,13 +360,9 @@ async def write_block(
post_hearing_context=post_hearing_context, post_hearing_context=post_hearing_context,
) )
# Restructure: sources first, then instructions # source_context is already embedded inside formatted_prompt via {source_context} in the
prompt = ( # template. Do NOT prepend it again — doing so doubles the prompt size (was 465K chars).
f"## חומרי מקור (מסמכים מלאים — צטט מהם מילה במילה כשאפשר):\n\n" prompt = formatted_prompt
f"{source_context}\n\n"
f"---\n\n"
f"{formatted_prompt}"
)
if instructions: if instructions:
prompt += f"\n\n## הנחיות נוספות:\n{instructions}" prompt += f"\n\n## הנחיות נוספות:\n{instructions}"
@@ -377,6 +373,19 @@ async def write_block(
if not dir_doc.get("approved"): if not dir_doc.get("approved"):
raise ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר. הפעל brainstorm → approve_direction קודם.") raise ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר. הפעל brainstorm → approve_direction קודם.")
# Guard against context overflow before calling claude -p.
# Sonnet: 200K context → ~800K chars max; Opus: 200K context → same.
# In practice the CLI has crashed on prompts above ~400K chars, so use
# that as a conservative ceiling (well below the token limit).
_MAX_PROMPT_CHARS = 400_000
if len(prompt) > _MAX_PROMPT_CHARS:
raise RuntimeError(
f"Prompt too large for {block_id}: {len(prompt):,} chars "
f"(limit {_MAX_PROMPT_CHARS:,}). "
f"source_context: {len(source_context):,} chars. "
f"Reduce documents or call extract_appraiser_facts first."
)
# 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
@@ -414,16 +423,35 @@ def _build_case_context(case: dict, decision: dict | None) -> str:
- תוצאה: {outcome_heb}""" - תוצאה: {outcome_heb}"""
# Which doc_types are relevant per block.
# None → skip source docs entirely (block uses other context, e.g. claims_context)
# [] → include all doc types (default for unspecified blocks)
# [..] → include only the listed doc_type values
_BLOCK_DOC_TYPES: dict[str, list[str] | None] = {
"block-he": None, # only case_context needed; no full docs
"block-vav": ["appeal", "protocol"], # כתב ערר + פרוטוקול ועדה
"block-zayin": None, # claims_context is sufficient
"block-chet": ["protocol"], # פרוטוקול + השלמות טיעון
"block-tet": ["appraisal"], # שומות בלבד
# block-yod, block-yod-alef, block-he etc. default → all docs
}
async def _build_source_context(case_id: UUID, block_id: str) -> str: async def _build_source_context(case_id: UUID, block_id: str) -> str:
"""Get full document texts for the block. """Get document texts for the block, filtered by relevance.
Per Anthropic best practices: send full source documents, not truncated excerpts. Per Anthropic best practices: send full source documents, not truncated excerpts.
Place documents at the TOP of the prompt (before instructions) for 30% better recall. Per-block filtering prevents context overflow on large cases (9+ docs).
For grounding: instruct Claude to cite word-for-word from these documents.
""" """
allowed = _BLOCK_DOC_TYPES.get(block_id, []) # [] sentinel = not in map → all docs
if allowed is None:
return "" # this block doesn't need raw source docs
docs = await db.list_documents(case_id) docs = await db.list_documents(case_id)
context_parts = [] context_parts = []
for doc in docs: for doc in docs:
if allowed and doc["doc_type"] not in allowed:
continue
text = await db.get_document_text(UUID(doc["id"])) text = await db.get_document_text(UUID(doc["id"]))
if text: if text:
context_parts.append(f"--- מסמך: {doc['title']} ({doc['doc_type']}) ---\n{text}") context_parts.append(f"--- מסמך: {doc['title']} ({doc['doc_type']}) ---\n{text}")

View File

@@ -72,6 +72,9 @@ async def query(
""" """
full_prompt = f"{system}\n\n{prompt}" if system else prompt full_prompt = f"{system}\n\n{prompt}" if system else prompt
if len(full_prompt) > 150_000:
logger.warning("Large prompt: %d chars — may hit context limits", len(full_prompt))
cmd = [ cmd = [
"claude", "-p", "claude", "-p",
"--output-format", "json", "--output-format", "json",
@@ -110,7 +113,8 @@ async def query(
if proc.returncode != 0: if proc.returncode != 0:
stderr = stderr_b.decode("utf-8", errors="replace").strip()[:500] or "unknown error" stderr = stderr_b.decode("utf-8", errors="replace").strip()[:500] or "unknown error"
raise RuntimeError(f"Claude CLI failed (exit {proc.returncode}): {stderr}") size_info = f"; prompt_len={len(full_prompt):,} chars" if len(full_prompt) > 100_000 else ""
raise RuntimeError(f"Claude CLI failed (exit {proc.returncode}): {stderr}{size_info}")
stdout = stdout_b.decode("utf-8", errors="replace").strip() stdout = stdout_b.decode("utf-8", errors="replace").strip()
if not stdout: if not stdout:

View File

@@ -691,6 +691,30 @@ CREATE INDEX IF NOT EXISTS idx_prec_img_emb_case_law
""" """
SCHEMA_V10_SQL = """
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS chair_name TEXT DEFAULT '';
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS district TEXT DEFAULT '';
ALTER TABLE cases ADD COLUMN IF NOT EXISTS chair_name TEXT DEFAULT '';
CREATE INDEX IF NOT EXISTS idx_case_law_source_kind ON case_law(source_kind);
CREATE INDEX IF NOT EXISTS idx_case_law_chair ON case_law(chair_name) WHERE chair_name <> '';
CREATE INDEX IF NOT EXISTS idx_case_law_district ON case_law(district) WHERE district <> '';
"""
SCHEMA_V11_SQL = """
CREATE TABLE IF NOT EXISTS case_law_relations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
case_law_id UUID NOT NULL REFERENCES case_law(id) ON DELETE CASCADE,
related_id UUID NOT NULL REFERENCES case_law(id) ON DELETE CASCADE,
relation_type TEXT NOT NULL DEFAULT 'same_case_chain',
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(case_law_id, related_id),
CHECK (case_law_id <> related_id)
);
CREATE INDEX IF NOT EXISTS idx_clr_a ON case_law_relations(case_law_id);
CREATE INDEX IF NOT EXISTS idx_clr_b ON case_law_relations(related_id);
"""
async def _run_schema_migrations(pool: asyncpg.Pool) -> None: async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
async with pool.acquire() as conn: async with pool.acquire() as conn:
await conn.execute(SCHEMA_SQL) await conn.execute(SCHEMA_SQL)
@@ -703,7 +727,9 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
await conn.execute(SCHEMA_V7_SQL) await conn.execute(SCHEMA_V7_SQL)
await conn.execute(SCHEMA_V8_SQL) await conn.execute(SCHEMA_V8_SQL)
await conn.execute(SCHEMA_V9_SQL) await conn.execute(SCHEMA_V9_SQL)
logger.info("Database schema initialized (v1-v9)") await conn.execute(SCHEMA_V10_SQL)
await conn.execute(SCHEMA_V11_SQL)
logger.info("Database schema initialized (v1-v11)")
async def init_schema() -> None: async def init_schema() -> None:
@@ -1724,6 +1750,59 @@ async def get_case_law(case_law_id: UUID) -> dict | None:
return _row_to_case_law(row) if row else None return _row_to_case_law(row) if row else None
async def add_case_law_relation(
a_id: UUID, b_id: UUID, relation_type: str = "same_case_chain"
) -> None:
"""Link two case_law records bidirectionally. Idempotent (ON CONFLICT DO NOTHING)."""
pool = await get_pool()
async with pool.acquire() as conn:
await conn.executemany(
"""
INSERT INTO case_law_relations(case_law_id, related_id, relation_type)
VALUES($1, $2, $3)
ON CONFLICT (case_law_id, related_id) DO NOTHING
""",
[(a_id, b_id, relation_type), (b_id, a_id, relation_type)],
)
async def remove_case_law_relation(a_id: UUID, b_id: UUID) -> None:
"""Remove a bidirectional link between two case_law records."""
pool = await get_pool()
await pool.execute(
"""
DELETE FROM case_law_relations
WHERE (case_law_id = $1 AND related_id = $2)
OR (case_law_id = $2 AND related_id = $1)
""",
a_id,
b_id,
)
async def get_case_law_relations(case_law_id: UUID) -> list[dict]:
"""Return all case_law records linked to case_law_id, ordered by date asc."""
pool = await get_pool()
rows = await pool.fetch(
"""
SELECT cl.*, r.relation_type
FROM case_law_relations r
JOIN case_law cl ON cl.id = r.related_id
WHERE r.case_law_id = $1
ORDER BY cl.date ASC NULLS LAST
""",
case_law_id,
)
results = []
for row in rows:
d = dict(row)
relation_type = d.pop("relation_type")
normalized = _row_to_case_law(d)
normalized["relation_type"] = relation_type
results.append(normalized)
return results
async def get_case_law_by_citation(case_number: str) -> dict | None: async def get_case_law_by_citation(case_number: str) -> dict | None:
pool = await get_pool() pool = await get_pool()
row = await pool.fetchrow( row = await pool.fetchrow(
@@ -1817,6 +1896,85 @@ async def create_external_case_law(
return _row_to_case_law(row) return _row_to_case_law(row)
async def create_internal_committee_decision(
case_number: str,
case_name: str,
full_text: str,
court: str = "",
decision_date: date | None = None,
chair_name: str = "",
district: str = "",
practice_area: str = "",
appeal_subtype: str = "",
subject_tags: list[str] | None = None,
summary: str = "",
is_binding: bool = True,
document_id: UUID | None = None,
) -> dict:
"""Upsert an appeals-committee decision as source_kind='internal_committee'.
If a row with this case_number already exists as cited_only, promotes it.
Idempotent: calling again updates metadata in-place.
"""
pool = await get_pool()
tags_json = json.dumps(subject_tags or [], ensure_ascii=False)
async with pool.acquire() as conn:
existing = await conn.fetchrow(
"SELECT id FROM case_law WHERE case_number = $1",
case_number,
)
if existing:
row = await conn.fetchrow(
"""
UPDATE case_law SET
case_name = $2,
court = COALESCE(NULLIF($3, ''), court),
date = COALESCE($4, date),
chair_name = COALESCE(NULLIF($5, ''), chair_name),
district = COALESCE(NULLIF($6, ''), district),
practice_area = $7,
appeal_subtype = $8,
subject_tags = $9,
summary = COALESCE(NULLIF($10, ''), summary),
full_text = $11,
source_type = 'appeals_committee',
source_kind = 'internal_committee',
is_binding = $12,
document_id = COALESCE($13, document_id),
extraction_status = 'processing',
halacha_extraction_status = 'pending'
WHERE id = $1
RETURNING *
""",
existing["id"], case_name, court, decision_date,
chair_name, district, practice_area, appeal_subtype,
tags_json, summary, full_text, is_binding, document_id,
)
else:
row = await conn.fetchrow(
"""
INSERT INTO case_law (
case_number, case_name, court, date, chair_name, district,
subject_tags, summary, full_text,
source_kind, source_type, document_id,
extraction_status, halacha_extraction_status,
practice_area, appeal_subtype, is_binding
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9,
'internal_committee', 'appeals_committee', $10,
'processing', 'pending',
$11, $12, $13
)
RETURNING *
""",
case_number, case_name, court, decision_date, chair_name, district,
tags_json, summary, full_text,
document_id, practice_area, appeal_subtype, is_binding,
)
return _row_to_case_law(row)
async def update_case_law(case_law_id: UUID, **fields) -> dict | None: async def update_case_law(case_law_id: UUID, **fields) -> dict | None:
"""Patch metadata fields on a case_law row. """Patch metadata fields on a case_law row.
@@ -1825,9 +1983,9 @@ async def update_case_law(case_law_id: UUID, **fields) -> dict | None:
precedent_level, is_binding. precedent_level, is_binding.
""" """
allowed = { allowed = {
"case_name", "court", "date", "practice_area", "appeal_subtype", "case_number", "case_name", "court", "date", "practice_area", "appeal_subtype",
"subject_tags", "summary", "headnote", "key_quote", "source_url", "subject_tags", "summary", "headnote", "key_quote", "source_url",
"source_type", "precedent_level", "is_binding", "source_type", "precedent_level", "is_binding", "district", "chair_name",
} }
updates = {k: v for k, v in fields.items() if k in allowed} updates = {k: v for k, v in fields.items() if k in allowed}
if not updates: if not updates:
@@ -1892,10 +2050,21 @@ async def list_external_case_law(
search: str = "", search: str = "",
limit: int = 100, limit: int = 100,
offset: int = 0, offset: int = 0,
source_kind: str = "external_upload",
) -> list[dict]: ) -> list[dict]:
"""List chair-uploaded precedents, with simple filters.""" """List chair-uploaded precedents, with simple filters.
source_kind="all_committees" expands to: source_kind='internal_committee'
OR (source_kind='external_upload' AND source_type='appeals_committee').
"""
pool = await get_pool() pool = await get_pool()
conditions = ["source_kind = 'external_upload'"] if source_kind == "all_committees":
conditions = [
"(source_kind = 'internal_committee' OR "
"(source_kind = 'external_upload' AND source_type = 'appeals_committee'))"
]
else:
conditions = [f"source_kind = '{source_kind}'"]
params: list = [] params: list = []
idx = 1 idx = 1
if practice_area: if practice_area:
@@ -1927,6 +2096,7 @@ async def list_external_case_law(
SELECT id, case_number, case_name, court, date, practice_area, SELECT id, case_number, case_name, court, date, practice_area,
appeal_subtype, source_type, precedent_level, is_binding, appeal_subtype, source_type, precedent_level, is_binding,
summary, headnote, subject_tags, source_kind, summary, headnote, subject_tags, source_kind,
chair_name, district,
extraction_status, halacha_extraction_status, extraction_status, halacha_extraction_status,
metadata_extraction_requested_at, metadata_extraction_requested_at,
halacha_extraction_requested_at, halacha_extraction_requested_at,
@@ -2194,8 +2364,14 @@ async def search_precedent_library_semantic(
subject_tag: str = "", subject_tag: str = "",
limit: int = 10, limit: int = 10,
include_halachot: bool = True, include_halachot: bool = True,
source_kind: str = "external_upload",
district: str = "",
chair_name: str = "",
) -> list[dict]: ) -> list[dict]:
"""Semantic search over chair-uploaded precedents. """Semantic search over precedents filtered by source_kind.
source_kind='external_upload' → court rulings (default)
source_kind='internal_committee' → appeals-committee decisions
Returns merged halachot + chunks. Halachot are pre-distilled rules, so Returns merged halachot + chunks. Halachot are pre-distilled rules, so
they get a small score boost. Only ``approved`` / ``published`` halachot they get a small score boost. Only ``approved`` / ``published`` halachot
@@ -2204,7 +2380,7 @@ async def search_precedent_library_semantic(
""" """
pool = await get_pool() pool = await get_pool()
halacha_filters = ["h.review_status IN ('approved', 'published')"] halacha_filters = ["h.review_status IN ('approved', 'published')"]
chunk_filters = ["cl.source_kind = 'external_upload'"] chunk_filters = [f"cl.source_kind = '{source_kind}'"]
h_params: list = [query_embedding, limit] h_params: list = [query_embedding, limit]
c_params: list = [query_embedding, limit] c_params: list = [query_embedding, limit]
h_idx = 3 h_idx = 3
@@ -2249,13 +2425,27 @@ async def search_precedent_library_semantic(
halacha_filters.append(f"${h_idx} = ANY(h.subject_tags)") halacha_filters.append(f"${h_idx} = ANY(h.subject_tags)")
h_params.append(subject_tag) h_params.append(subject_tag)
h_idx += 1 h_idx += 1
if district:
halacha_filters.append(f"cl.district = ${h_idx}")
h_params.append(district)
h_idx += 1
chunk_filters.append(f"cl.district = ${c_idx}")
c_params.append(district)
c_idx += 1
if chair_name:
halacha_filters.append(f"cl.chair_name = ${h_idx}")
h_params.append(chair_name)
h_idx += 1
chunk_filters.append(f"cl.chair_name = ${c_idx}")
c_params.append(chair_name)
c_idx += 1
halacha_sql = f""" halacha_sql = f"""
SELECT h.id AS halacha_id, h.case_law_id, h.rule_statement, SELECT h.id AS halacha_id, h.case_law_id, h.rule_statement,
h.reasoning_summary, h.supporting_quote, h.page_reference, h.reasoning_summary, h.supporting_quote, h.page_reference,
h.practice_areas, h.subject_tags, h.confidence, h.rule_type, h.practice_areas, h.subject_tags, h.confidence, h.rule_type,
cl.case_number, cl.case_name, cl.court, cl.date AS decision_date, cl.case_number, cl.case_name, cl.court, cl.date AS decision_date,
cl.precedent_level, cl.precedent_level, cl.chair_name, cl.district,
1 - (h.embedding <=> $1) AS score 1 - (h.embedding <=> $1) AS score
FROM halachot h FROM halachot h
JOIN case_law cl ON cl.id = h.case_law_id JOIN case_law cl ON cl.id = h.case_law_id
@@ -2269,7 +2459,7 @@ async def search_precedent_library_semantic(
SELECT pc.id AS chunk_id, pc.case_law_id, pc.content, SELECT pc.id AS chunk_id, pc.case_law_id, pc.content,
pc.section_type, pc.page_number, pc.section_type, pc.page_number,
cl.case_number, cl.case_name, cl.court, cl.date AS decision_date, cl.case_number, cl.case_name, cl.court, cl.date AS decision_date,
cl.precedent_level, cl.practice_area, cl.precedent_level, cl.practice_area, cl.chair_name, cl.district,
1 - (pc.embedding <=> $1) AS score 1 - (pc.embedding <=> $1) AS score
FROM precedent_chunks pc FROM precedent_chunks pc
JOIN case_law cl ON cl.id = pc.case_law_id JOIN case_law cl ON cl.id = pc.case_law_id
@@ -2308,19 +2498,17 @@ async def precedent_library_stats() -> dict:
pool = await get_pool() pool = await get_pool()
async with pool.acquire() as conn: async with pool.acquire() as conn:
total = await conn.fetchval( total = await conn.fetchval(
"SELECT COUNT(*) FROM case_law WHERE source_kind = 'external_upload'" "SELECT COUNT(*) FROM case_law"
) )
by_practice = await conn.fetch( by_practice = await conn.fetch(
"""SELECT practice_area, COUNT(*) AS n """SELECT practice_area, COUNT(*) AS n
FROM case_law FROM case_law
WHERE source_kind = 'external_upload'
GROUP BY practice_area GROUP BY practice_area
ORDER BY n DESC""" ORDER BY n DESC"""
) )
by_level = await conn.fetch( by_level = await conn.fetch(
"""SELECT precedent_level, COUNT(*) AS n """SELECT precedent_level, COUNT(*) AS n
FROM case_law FROM case_law
WHERE source_kind = 'external_upload'
GROUP BY precedent_level GROUP BY precedent_level
ORDER BY n DESC""" ORDER BY n DESC"""
) )
@@ -2354,22 +2542,31 @@ async def precedent_library_stats() -> dict:
async def request_metadata_extraction(case_law_id: UUID) -> bool: async def request_metadata_extraction(case_law_id: UUID) -> bool:
"""Stamp ``metadata_extraction_requested_at`` for the local MCP worker """Stamp ``metadata_extraction_requested_at`` for the local MCP worker
to pick up. Returns False if the row is missing.""" to pick up. Returns False if the row is missing.
Originally restricted to ``source_kind='external_upload'`` (see git
blame). Opened to all source kinds 2026-05-06 — internal_committee
rows can also need re-extraction (e.g. corrupted subject_tags from
an early ingest pipeline). The extractor itself preserves user
values (``precedent_metadata_extractor.extract_and_apply`` only
fills empty fields), so this is safe.
"""
pool = await get_pool() pool = await get_pool()
result = await pool.execute( result = await pool.execute(
"UPDATE case_law SET metadata_extraction_requested_at = now() " "UPDATE case_law SET metadata_extraction_requested_at = now() "
"WHERE id = $1 AND source_kind = 'external_upload'", "WHERE id = $1",
case_law_id, case_law_id,
) )
return result == "UPDATE 1" return result == "UPDATE 1"
async def request_halacha_extraction(case_law_id: UUID) -> bool: async def request_halacha_extraction(case_law_id: UUID) -> bool:
"""Same but for halacha extraction.""" """Same but for halacha extraction. See note on
:func:`request_metadata_extraction` re: opening to all source kinds."""
pool = await get_pool() pool = await get_pool()
result = await pool.execute( result = await pool.execute(
"UPDATE case_law SET halacha_extraction_requested_at = now() " "UPDATE case_law SET halacha_extraction_requested_at = now() "
"WHERE id = $1 AND source_kind = 'external_upload'", "WHERE id = $1",
case_law_id, case_law_id,
) )
return result == "UPDATE 1" return result == "UPDATE 1"
@@ -2389,12 +2586,15 @@ async def list_pending_extraction_requests(
else "halacha_extraction_requested_at" else "halacha_extraction_requested_at"
) )
pool = await get_pool() pool = await get_pool()
# Drop the legacy ``source_kind = 'external_upload'`` filter — without it
# internal_committee rows could be stamped (we opened that gate in
# request_metadata_extraction / request_halacha_extraction) but stayed
# invisible to the worker forever.
rows = await pool.fetch( rows = await pool.fetch(
f"""SELECT id, case_number, case_name, court, date, f"""SELECT id, case_number, case_name, court, date,
practice_area, is_binding, {col} AS requested_at practice_area, is_binding, {col} AS requested_at
FROM case_law FROM case_law
WHERE {col} IS NOT NULL WHERE {col} IS NOT NULL
AND source_kind = 'external_upload'
ORDER BY {col} ASC ORDER BY {col} ASC
LIMIT $1""", LIMIT $1""",
limit, limit,

View File

@@ -88,8 +88,15 @@ async def search_precedent_library_hybrid(
is_binding: bool | None = None, is_binding: bool | None = None,
subject_tag: str = "", subject_tag: str = "",
include_halachot: bool = True, include_halachot: bool = True,
source_kind: str = "external_upload",
district: str = "",
chair_name: str = "",
) -> list[dict]: ) -> list[dict]:
"""Hybrid wrapper for precedent-library search.""" """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 fetch_k = max(limit, config.VOYAGE_RERANK_FETCH_K) if config.MULTIMODAL_ENABLED else limit
async def _base(limit: int) -> list[dict]: async def _base(limit: int) -> list[dict]:
@@ -103,6 +110,9 @@ async def search_precedent_library_hybrid(
subject_tag=subject_tag, subject_tag=subject_tag,
limit=limit, limit=limit,
include_halachot=include_halachot, include_halachot=include_halachot,
source_kind=source_kind,
district=district,
chair_name=chair_name,
) )
text_results = await rerank.maybe_rerank( text_results = await rerank.maybe_rerank(

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

@@ -123,7 +123,7 @@ SUMMARY_STRATEGIES = {
DISCUSSION_RULES: dict[str, list[str]] = { DISCUSSION_RULES: dict[str, list[str]] = {
"universal": [ "universal": [
"פרק הדיון = אסה רציפה. אין כותרות משנה (H2/H3). מעברים רק עם ביטויי מעבר טקסטואליים.", "פרק הדיון = מאסה רציפה. אין כותרות משנה (H2/H3). מעברים רק עם ביטויי מעבר טקסטואליים.",
"חריג יחיד לכותרות משנה: נושאים נפרדים לחלוטין (למשל: הקלה בגובה + התייחסות לטענות נוספות).", "חריג יחיד לכותרות משנה: נושאים נפרדים לחלוטין (למשל: הקלה בגובה + התייחסות לטענות נוספות).",
"טווח אורך סעיפים: 20 עד 600+ מילים. סעיף עם ציטוט מקיף = בלוק אחד שלם, לא שבירה לסעיפים קצרים.", "טווח אורך סעיפים: 20 עד 600+ מילים. סעיף עם ציטוט מקיף = בלוק אחד שלם, לא שבירה לסעיפים קצרים.",
], ],
@@ -485,6 +485,7 @@ CONTENT_CHECKLISTS: dict[str, str] = {
- שווי מקרקעין — מצב קודם ומצב חדש (שיטת השוואה / יחידות תועלת) - שווי מקרקעין — מצב קודם ומצב חדש (שיטת השוואה / יחידות תועלת)
- עלויות עודפות (חניה, מטלות ציבוריות, תשתיות) - עלויות עודפות (חניה, מטלות ציבוריות, תשתיות)
- מקדמי זמינות, שיעורי הפקעה - מקדמי זמינות, שיעורי הפקעה
- הכרעה מפוצלת (bifurcation) — כשהוועדה מאשרת חבות אך ממנה שמאי מייעץ: ביטויי גישור ("ניתן יהיה לעלות בפני השמאי המייעץ"), נוסחת מינוי, הפניה לתקנות סדרי דין התשס"ט-2008, הוראות המשך (30 יום להשגות). ללא סיכום — ישירות לחתימה. ראה: 8070/25
### ד. שאלות משפטיות (לפי רלוונטיות) ### ד. שאלות משפטיות (לפי רלוונטיות)
- פטורים — דירת מגורים (ס' 19(ג)(1)), שטח עד 140 מ"ר, תא משפחתי - פטורים — דירת מגורים (ס' 19(ג)(1)), שטח עד 140 מ"ר, תא משפחתי
@@ -493,6 +494,7 @@ CONTENT_CHECKLISTS: dict[str, str] = {
- מקרקעי ישראל — הסדרים מיוחדים (ס' 21 לתוספת השלישית) - מקרקעי ישראל — הסדרים מיוחדים (ס' 21 לתוספת השלישית)
- שומות מוסכמות — תוקף, משמעות, "בלתי נצפה מראש" - שומות מוסכמות — תוקף, משמעות, "בלתי נצפה מראש"
- פרשנות תכניות — ייעוד, שימושים מותרים, מדיניות ועדה מקומית - פרשנות תכניות — ייעוד, שימושים מותרים, מדיניות ועדה מקומית
- טענת "תכנית צל = זכות מוקנית" — ניתוח תלת-שכבתי: (1) נורמטיבית — תכנית צל = המחשה, לא מקור נורמטיבי; (2) פרוצדורלית — הקלה ניתנת פר-מבקש, לא זכות כללית; (3) שמאית — משקל הסתברותי בהערכת ההשבחה, לא במישור המשפטי. ראה: 8070/25
### ה. ניתוח שמאי (כשיש שומה מכרעת) ### ה. ניתוח שמאי (כשיש שומה מכרעת)
- האם השומה מבוססת על מסד עובדתי הולם? - האם השומה מבוססת על מסד עובדתי הולם?

View File

@@ -257,11 +257,19 @@ async def reextract_halachot(
case_law_id = UUID(case_law_id) case_law_id = UUID(case_law_id)
record = await db.get_case_law(case_law_id) record = await db.get_case_law(case_law_id)
if not record or record.get("source_kind") != "external_upload": if not record:
raise ValueError("precedent not found or not chair-uploaded") 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, "מחלץ הלכות מחדש") await progress("extracting_halachot", 50, "מחלץ הלכות מחדש")
result = await halacha_extractor.extract(case_law_id) 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( await progress(
"completed", "completed",
100, 100,
@@ -402,11 +410,16 @@ async def reextract_metadata(
case_law_id = UUID(case_law_id) case_law_id = UUID(case_law_id)
record = await db.get_case_law(case_law_id) record = await db.get_case_law(case_law_id)
if not record or record.get("source_kind") != "external_upload": if not record:
raise ValueError("precedent not found or not chair-uploaded") raise ValueError("precedent not found")
# See note in db.request_metadata_extraction — opened to all source kinds.
await progress("extracting_metadata", 40, "מחלץ מטא-דאטה (תקציר, תגיות)") await progress("extracting_metadata", 40, "מחלץ מטא-דאטה (תקציר, תגיות)")
result = await precedent_metadata_extractor.extract_and_apply(case_law_id) 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 [] fields = result.get("fields") or []
msg = ( msg = (
f"מולאו {len(fields)} שדות: {', '.join(fields)}" f"מולאו {len(fields)} שדות: {', '.join(fields)}"
@@ -425,13 +438,14 @@ async def delete_precedent(case_law_id: UUID | str) -> bool:
async def get_precedent(case_law_id: UUID | str) -> dict | None: async def get_precedent(case_law_id: UUID | str) -> dict | None:
"""Get a precedent with its halachot attached.""" """Get a precedent with its halachot and related cases attached."""
if isinstance(case_law_id, str): if isinstance(case_law_id, str):
case_law_id = UUID(case_law_id) case_law_id = UUID(case_law_id)
record = await db.get_case_law(case_law_id) record = await db.get_case_law(case_law_id)
if not record: if not record:
return None return None
record["halachot"] = await db.list_halachot(case_law_id=case_law_id, limit=500) record["halachot"] = await db.list_halachot(case_law_id=case_law_id, limit=500)
record["related_cases"] = await db.get_case_law_relations(case_law_id)
return record return record

View File

@@ -50,7 +50,8 @@ METADATA_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. קרא א
"decision_date_iso": "YYYY-MM-DD — תאריך מתן ההחלטה כפי שמופיע בטקסט (בכותרת או בחתימה הסופית). אם לא ניתן לזהות במדויק — מחרוזת ריקה.", "decision_date_iso": "YYYY-MM-DD — תאריך מתן ההחלטה כפי שמופיע בטקסט (בכותרת או בחתימה הסופית). אם לא ניתן לזהות במדויק — מחרוזת ריקה.",
"precedent_level": "אחד מ-4: 'עליון' / 'מנהלי' / 'ועדת_ערר_ארצית' / 'ועדת_ערר_מחוזית'. בחר לפי הערכאה שמסומנת בכותרת הפסק. אם לא ברור — מחרוזת ריקה.", "precedent_level": "אחד מ-4: 'עליון' / 'מנהלי' / 'ועדת_ערר_ארצית' / 'ועדת_ערר_מחוזית'. בחר לפי הערכאה שמסומנת בכותרת הפסק. אם לא ברור — מחרוזת ריקה.",
"source_type": "אחד מ-2: 'court_ruling' (פסק דין של בית משפט — עליון/מנהלי) / 'appeals_committee' (החלטה של ועדת ערר). אם לא ברור — מחרוזת ריקה.", "source_type": "אחד מ-2: 'court_ruling' (פסק דין של בית משפט — עליון/מנהלי) / 'appeals_committee' (החלטה של ועדת ערר). אם לא ברור — מחרוזת ריקה.",
"court": "שם הערכאה כפי שהוא מופיע בכותרת (למשל 'בית המשפט העליון', 'בית המשפט המחוזי בירושלים בשבתו כבית משפט לעניינים מנהליים', 'ועדת הערר לתכנון ובניה פיצויים והיטלי השבחה — מחוז ירושלים'). מחרוזת ריקה אם לא ניתן לזהות." "court": "שם הערכאה כפי שהוא מופיע בכותרת (למשל 'בית המשפט העליון', 'בית המשפט המחוזי בירושלים בשבתו כבית משפט לעניינים מנהליים', 'ועדת הערר לתכנון ובניה פיצויים והיטלי השבחה — מחוז ירושלים'). מחרוזת ריקה אם לא ניתן לזהות.",
"case_number_clean": "מספר הערר/תיק כפי שמופיע בכותרת — רק הספרות והאלכסון, למשל '1062/24' או '8031/21'. ללא המילה 'ערר', ללא שם הצדדים, ללא סוגריים. אם יש כמה עררים מאוחדים — הרשום הראשון. מחרוזת ריקה אם לא ניתן לזהות."
} }
## כללי איכות ## כללי איכות
@@ -161,12 +162,15 @@ async def extract_metadata(case_law_id: UUID | str) -> dict:
out["source_type"] = st out["source_type"] = st
if isinstance(result.get("court"), str): if isinstance(result.get("court"), str):
out["court"] = result["court"].strip() out["court"] = result["court"].strip()
if isinstance(result.get("case_number_clean"), str):
out["case_number_clean"] = result["case_number_clean"].strip()
return out return out
async def apply_to_record( async def apply_to_record(
case_law_id: UUID | str, case_law_id: UUID | str,
suggested: dict, suggested: dict,
overwrite_case_number: bool = False,
) -> dict: ) -> dict:
"""Merge suggested metadata into the case_law row, filling ONLY empty fields. """Merge suggested metadata into the case_law row, filling ONLY empty fields.
@@ -178,6 +182,9 @@ async def apply_to_record(
case_name has special handling: if the current case_name equals the 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 case_number (a tell-tale sign of the upload form sending the long
citation into both fields), treat it as empty and overwrite. 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): if isinstance(case_law_id, str):
case_law_id = UUID(case_law_id) case_law_id = UUID(case_law_id)
@@ -216,7 +223,17 @@ async def apply_to_record(
fields_to_update["key_quote"] = s fields_to_update["key_quote"] = s
cur_tags = record.get("subject_tags") or [] cur_tags = record.get("subject_tags") or []
if not cur_tags: # 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 [] sug_tags = suggested.get("subject_tags") or []
if sug_tags: if sug_tags:
fields_to_update["subject_tags"] = sug_tags fields_to_update["subject_tags"] = sug_tags
@@ -250,6 +267,11 @@ async def apply_to_record(
if c: if c:
fields_to_update["court"] = 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: if not fields_to_update:
return {"updated": False, "fields": []} return {"updated": False, "fields": []}
@@ -257,12 +279,15 @@ async def apply_to_record(
return {"updated": True, "fields": list(fields_to_update.keys())} return {"updated": True, "fields": list(fields_to_update.keys())}
async def extract_and_apply(case_law_id: UUID | str) -> dict: async def extract_and_apply(
case_law_id: UUID | str,
overwrite_case_number: bool = False,
) -> dict:
"""Convenience wrapper: extract → merge into row → return summary.""" """Convenience wrapper: extract → merge into row → return summary."""
suggested = await extract_metadata(case_law_id) suggested = await extract_metadata(case_law_id)
if not suggested: if not suggested:
return {"status": "no_metadata", "fields": []} return {"status": "no_metadata", "fields": []}
result = await apply_to_record(case_law_id, suggested) result = await apply_to_record(case_law_id, suggested, overwrite_case_number=overwrite_case_number)
return { return {
"status": "completed" if result["updated"] else "no_changes", "status": "completed" if result["updated"] else "no_changes",
"fields": result["fields"], "fields": result["fields"],

View File

@@ -55,6 +55,9 @@ def _is_placeholder(text: str) -> bool:
for ph in CHAIR_POSITION_PLACEHOLDERS: for ph in CHAIR_POSITION_PLACEHOLDERS:
if ph in stripped: if ph in stripped:
return True return True
# Extended placeholders: [ימולא ע"י יו"ר הוועדה — extra descriptive text]
if re.match(r'^\[ימולא\b', stripped):
return True
return False return False

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__)
@@ -237,7 +237,10 @@ async def case_list(status: str = "", limit: int = 50) -> str:
"""רשימת תיקי ערר עם אפשרות סינון לפי סטטוס. """רשימת תיקי ערר עם אפשרות סינון לפי סטטוס.
Args: Args:
status: סינון לפי סטטוס (new, in_progress, drafted, reviewed, final). ריק = הכל status: סינון לפי סטטוס (new, processing, proofread, documents_ready, analyst_verified,
research_complete, outcome_set, direction_pending, direction_approved,
analysis_enriched, ready_for_writing, drafted, qa_passed, qa_failed,
exported, done). ריק = הכל
limit: מספר תוצאות מקסימלי limit: מספר תוצאות מקסימלי
""" """
cases = await db.list_cases(status=status or None, limit=limit) cases = await db.list_cases(status=status or None, limit=limit)
@@ -271,6 +274,10 @@ async def case_update(
decision_date: str = "", decision_date: str = "",
tags: list[str] | None = None, tags: list[str] | None = None,
expected_outcome: str = "", expected_outcome: str = "",
appellants: list[str] | None = None,
respondents: list[str] | None = None,
property_address: str = "",
permit_number: str = "",
) -> str: ) -> str:
"""עדכון פרטי תיק. """עדכון פרטי תיק.
@@ -284,6 +291,10 @@ async def case_update(
decision_date: תאריך החלטה (YYYY-MM-DD) decision_date: תאריך החלטה (YYYY-MM-DD)
tags: תגיות tags: תגיות
expected_outcome: תוצאה צפויה (rejection/partial_acceptance/full_acceptance/betterment_levy) expected_outcome: תוצאה צפויה (rejection/partial_acceptance/full_acceptance/betterment_levy)
appellants: רשימת עוררים חדשה
respondents: רשימת משיבים חדשה
property_address: כתובת נכס חדשה
permit_number: מספר תכנית/בקשה חדש
""" """
from datetime import date as date_type from datetime import date as date_type
@@ -322,6 +333,14 @@ async def case_update(
fields["tags"] = tags fields["tags"] = tags
if expected_outcome: if expected_outcome:
fields["expected_outcome"] = expected_outcome fields["expected_outcome"] = expected_outcome
if appellants is not None:
fields["appellants"] = appellants
if respondents is not None:
fields["respondents"] = respondents
if property_address:
fields["property_address"] = property_address
if permit_number:
fields["permit_number"] = permit_number
updated = await db.update_case(UUID(case["id"]), **fields) updated = await db.update_case(UUID(case["id"]), **fields)
@@ -370,3 +389,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

@@ -116,6 +116,54 @@ async def precedent_library_get(case_law_id: str) -> str:
return _ok(record) return _ok(record)
async def precedent_link_cases(
case_law_id_a: str,
case_law_id_b: str,
relation_type: str = "same_case_chain",
) -> str:
"""קישור שתי פסיקות כקשורות זו לזו (דו-כיווני). idempotent.
Args:
case_law_id_a: UUID של פסיקה ראשונה.
case_law_id_b: UUID של פסיקה שנייה.
relation_type: same_case_chain | overruled_by | distinguished
"""
try:
a = UUID(case_law_id_a)
b = UUID(case_law_id_b)
except ValueError:
return _err("case_law_id לא תקין")
rec_a = await db.get_case_law(a)
rec_b = await db.get_case_law(b)
if not rec_a:
return _err(f"פסיקה {case_law_id_a} לא נמצאה")
if not rec_b:
return _err(f"פסיקה {case_law_id_b} לא נמצאה")
await db.add_case_law_relation(a, b, relation_type)
return _ok({
"linked": True,
"relation_type": relation_type,
"a": {"id": case_law_id_a, "case_number": rec_a.get("case_number"), "court": rec_a.get("court")},
"b": {"id": case_law_id_b, "case_number": rec_b.get("case_number"), "court": rec_b.get("court")},
})
async def precedent_unlink_cases(case_law_id_a: str, case_law_id_b: str) -> str:
"""הסרת קישור בין שתי פסיקות (דו-כיווני).
Args:
case_law_id_a: UUID של פסיקה ראשונה.
case_law_id_b: UUID של פסיקה שנייה.
"""
try:
a = UUID(case_law_id_a)
b = UUID(case_law_id_b)
except ValueError:
return _err("case_law_id לא תקין")
await db.remove_case_law_relation(a, b)
return _ok({"unlinked": True, "a": case_law_id_a, "b": case_law_id_b})
async def precedent_library_delete(case_law_id: str) -> str: async def precedent_library_delete(case_law_id: str) -> str:
"""מחיקת פסיקה מהקורפוס. cascade: chunks + halachot.""" """מחיקת פסיקה מהקורפוס. cascade: chunks + halachot."""
try: try:

View File

@@ -179,3 +179,63 @@ async def find_similar_cases(
}) })
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:
"""סטטוס תהליך עבודה מלא לתיק - מסמכים, עיבוד, טיוטות. """סטטוס תהליך עבודה מלא לתיק - מסמכים, עיבוד, טיוטות.
@@ -315,10 +318,29 @@ async def ingest_final_version(
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

@@ -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

@@ -10,7 +10,8 @@
|--------|------|---------|-----------| |--------|------|---------|-----------|
| `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`. | נקרא ע"י סוכנים | | `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_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.** | ידני אחרי כל שינוי | | `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) | ידני |
@@ -53,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,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

@@ -259,6 +259,14 @@ async def apply_diff(mirror_id: str, agent_name: str, diff: dict) -> list[str]:
if "runtime_config" in diff: if "runtime_config" in diff:
patch_body["runtimeConfig"] = diff["runtime_config"]["to"] patch_body["runtimeConfig"] = diff["runtime_config"]["to"]
# Stamp claude_md_mtime + last_synced into metadata
mtime = diff.get("_claude_md_mtime")
if mtime:
current_meta = dict(patch_body.get("metadata") or {})
current_meta["claude_md_mtime"] = mtime
current_meta["claude_md_last_synced"] = datetime.now(timezone.utc).isoformat()
patch_body["metadata"] = current_meta
if patch_body: if patch_body:
status, data = await call_patch(mirror_id, patch_body) status, data = await call_patch(mirror_id, patch_body)
if status >= 400: if status >= 400:
@@ -278,12 +286,73 @@ async def apply_diff(mirror_id: str, agent_name: str, diff: dict) -> list[str]:
return errors return errors
def get_claude_md_mtime(adapter_config: dict) -> str | None:
"""Return Unix mtime of the agent's instructionsFilePath, or None if file missing."""
path = adapter_config.get("instructionsFilePath", "")
if not path or not os.path.exists(path):
return None
return str(int(os.path.getmtime(path)))
async def check_instructions(agents: list[dict]) -> bool:
"""Print a report of all agents' instruction files. Returns True if all OK."""
from datetime import datetime
all_ok = True
print(f"\n{'Agent':<30} {'File':<55} {'Status':<12} {'Size':>7} {'Modified'}")
print("-" * 115)
for agent in agents:
name = (agent.get("name") or agent.get("id") or "?")[:29]
try:
adapter_cfg = agent.get("adapter_config") or {}
if isinstance(adapter_cfg, str):
adapter_cfg = json.loads(adapter_cfg)
except (json.JSONDecodeError, TypeError):
print(f"{name:<30} {'(malformed adapter_config in DB)':<55} {'⚠ ERROR':<12}")
continue
file_path = adapter_cfg.get("instructionsFilePath", "")
if not file_path:
print(f"{name:<30} {'(none)':<55} {'⚠ NOT SET':<12}")
continue
if not os.path.exists(file_path):
print(f"{name:<30} {file_path[-54:]:<55} {'❌ MISSING':<12}")
all_ok = False
continue
stat = os.stat(file_path)
size_kb = stat.st_size // 1024
mtime = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
# Check for drift vs DB metadata
try:
metadata = agent.get("metadata") or {}
if isinstance(metadata, str):
metadata = json.loads(metadata)
except (json.JSONDecodeError, TypeError):
metadata = {}
db_mtime = metadata.get("claude_md_mtime", "")
actual_mtime = str(int(stat.st_mtime))
drift = " ⚠ DRIFT" if db_mtime and db_mtime != actual_mtime else ""
print(f"{name:<30} {file_path[-54:]:<55} {'✅ OK':<12} {size_kb:>5}KB {mtime}{drift}")
print()
return all_ok
async def main() -> None: async def main() -> None:
p = argparse.ArgumentParser() p = argparse.ArgumentParser()
g = p.add_mutually_exclusive_group(required=True) g = p.add_mutually_exclusive_group(required=True)
g.add_argument("--verify", action="store_true", help="Show current drift, no changes") 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("--dry-run", action="store_true", help="Show what would change")
g.add_argument("--apply", action="store_true", help="Backup + apply changes") g.add_argument("--apply", action="store_true", help="Backup + apply changes")
g.add_argument("--check-instructions", action="store_true",
help="Scan all agents' instructionsFilePath and report missing/outdated files")
p.add_argument("--only", help="Sync only the named agent (e.g., 'עוזר משפטי')") p.add_argument("--only", help="Sync only the named agent (e.g., 'עוזר משפטי')")
args = p.parse_args() args = p.parse_args()
@@ -295,6 +364,11 @@ async def main() -> None:
finally: finally:
await conn.close() await conn.close()
if args.check_instructions:
all_agents = master_agents + mirror_agents
all_ok = await check_instructions(all_agents)
sys.exit(0 if all_ok else 1)
mirror_by_name = {a["name"]: a for a in mirror_agents} mirror_by_name = {a["name"]: a for a in mirror_agents}
print(f"\n=== Master (CMP, 1xxx): {len(master_agents)} agents ===") print(f"\n=== Master (CMP, 1xxx): {len(master_agents)} agents ===")
@@ -332,6 +406,14 @@ async def main() -> None:
return return
# APPLY # APPLY
# Pre-flight: abort if any master agent is missing its instructions file
print("🔍 Pre-flight: checking instruction files...")
all_ok = await check_instructions(master_agents)
if not all_ok:
print("❌ Abort: one or more instruction files are missing. Fix before --apply.")
sys.exit(1)
print("✅ Pre-flight passed.\n")
print(f"\n=== Backup ===") print(f"\n=== Backup ===")
backup_path = backup_agents_table() backup_path = backup_agents_table()
print(f"{backup_path}") print(f"{backup_path}")
@@ -340,6 +422,11 @@ async def main() -> None:
all_errors: list[str] = [] all_errors: list[str] = []
for master, mirror, diff in plan: for master, mirror, diff in plan:
print(f"\n{master['name']} ({mirror['id']})") print(f"\n{master['name']} ({mirror['id']})")
# Inject mtime into diff so apply_diff can stamp metadata
master_ac = master.get("adapter_config") or {}
mtime = get_claude_md_mtime(master_ac)
if mtime:
diff["_claude_md_mtime"] = mtime
errors = await apply_diff(mirror["id"], master["name"], diff) errors = await apply_diff(mirror["id"], master["name"], diff)
if errors: if errors:
for e in errors: for e in errors:

View File

@@ -283,6 +283,16 @@ description: This skill should be used when writing legal decisions (החלטו
**ערר היטל השבחה:** **ערר היטל השבחה:**
פתיחה ישירה עם מסקנה. ניתוח ישיר - ציטוטי פסיקה מרובים. סיום יבש. פתיחה ישירה עם מסקנה. ניתוח ישיר - ציטוטי פסיקה מרובים. סיום יבש.
**ערר היטל השבחה — הכרעה מפוצלת (שמאי מייעץ):**
תת-מסלול חוזר: הוועדה מאשרת את עצם החבות אך אינה קובעת את גובה ההיטל — ממנה שמאי מייעץ. פתיחה: זהה למסלול הכללי — "עניינו של ערר זה בדרישת תשלום היטל השבחה...". ללא סיפור תכנוני רחב. דיון — שלב משפטי: הכרעה בשאלת עצם החבות. ניתוח הטענה המרכזית (בד"כ "זכות מוקנית") מול ההקלה שהתבקשה. ציטוטי פסיקה inline — הפניה להחלטות ועדת ערר קודמות ללא בלוק ציטוט מלא. ביטויי מפתח: "אנו סבורים כי...", "בניגוד לעמדת העורר...", "לא ניתן לטעון כי...". מעבר לשלב השמאי — 3 ביטויי גישור: (א) "בכל הנוגע לטענות לגבי מקדמים... וכל טענה בעלת אופי שמאי **ניתן יהיה לעלות בפני השמאי המייעץ**." (ב) "על כן, לאור האמור **אנו ממנים שמאי מייעץ** אשר יערוך שומה להערכת ההשבחה במקרקעין כתוצאה מאישור ההקלה..." (ג) "השמאי המייעץ ינהל את הדיון **בהתאם לתקנות התכנון והבניה (סדרי דין בבקשה להכרעה לפני שמאי מכריע או שמאי מייעץ), התשס"ט-2008**." — נוסחה קבועה. הוראות המשך: "לאחר קבלת השומה המייעצת יהיו רשאים הצדדים להגיש את השגותיהם בתוך 30 יום לוועדת הערר ולאחר מכן תתקבל החלטה באשר לאופן קידום ההליך." סיום: **ללא** כותרת "סיכום" / "סוף דבר" — זורם ישירות מהוראות המינוי לחתימה "ניתנה פה אחד היום...". הוצאות: לא מוזכרות (ההליך טרם הסתיים). ראה: נווה יעקב 8070/25.
**ערר היטל השבחה — מסגרת תלת-שכבתית לניתוח "תכנית צל":**
כשעורר טוען ש"תכנית צל" מאושרת הופכת זכויות להקלה לזכויות מוקנות, הניתוח מתבצע בשלוש שכבות נפרדות:
*שכבה 1 — נורמטיבית* (שלילת המעמד המשפטי): "תכנית צל אינה מקור נורמטיבי לאישור זכויות... אינה 'מבטיחה' אישור זכויות עבור השכן." ביטויי מפתח: "תכנית צל הינה תכנית המבקשת... להראות היתכנות בניה על ידי יתר בעלי הזכויות ו/או השלכת הבניה עליהם, על הבניין ועל הסביבה." כלל: תכנית צל = המחשה, לא מקור נורמטיבי.
*שכבה 2 — פרוצדורלית* (ההקלה ניתנת פר-מבקש): "גם בהיתר חבקין התבקשה הקלה שאושרה, הקלה אך ורק להיתר שהתבקש ולזכויות שהתבקשו מכוחו. ההקלה לא התבקשה עבור כל דיירי הבניין... בקשה להקלה ופרסומה יש בצידם שיקול דעת... באופן ייחודי לכל בקשה לגופה." כלל: אישור הקלה לדייר א' ≠ זכות מוקנית לדייר ב'.
*שכבה 3 — שמאית* (הכרה בערך ראייתי, ניתוב למישור הנכון): "העובדה שאושרה תכנית צל יש בה מידת וודאות גבוהה יותר באשר לסיכוי כי תאושר ההקלה... ולכך **משקל שמאי** בהערכת ההשבחה." כלל: תכנית צל משפיעה על **ההסתברות** (מישור שמאי), לא על **הזכות** (מישור משפטי).
סדר הניתוח: תמיד שכבה 1 → 2 → 3. לא לדלג — גם אם שכבה 1 מכריעה, יש ערך בכל שלוש לביסוס ולמניעת ערעור. ראה: נווה יעקב 8070/25.
**ערר רישוי שמתקבל חלקית — מסלול מיפוי מתחים + ניתוח נושאי:** **ערר רישוי שמתקבל חלקית — מסלול מיפוי מתחים + ניתוח נושאי:**
פתיחה במיפוי מתחים (3-6 סעיפים): הקשר כללי קצר (1-2 פסקאות), רשימת נקודות מתח ספציפיות בתיק (4-6 בולטים), מעבר לניתוח. אין שימוש בשכבות/עיגולים קונצנטריים — ניתוח לפי נושאים: כל נושא מקבל טיפול מלא (הצגה → ציטוט הוראות תכנית → פסיקה → מסקנה). נושא חניה/תשתיות מקבל טיפול מעמיק במיוחד עם ציטוטים ישירים מהוראות תכנית ונספחים. טענות ספציפיות (מטרדים, עצים, בור מים) — 1-2 סעיפים תמציתיים לכל אחת. סיכום מינימלי — רק הוראות אופרטיביות (2-3 סעיפים). ראה: בית הכרם 1126/25. פתיחה במיפוי מתחים (3-6 סעיפים): הקשר כללי קצר (1-2 פסקאות), רשימת נקודות מתח ספציפיות בתיק (4-6 בולטים), מעבר לניתוח. אין שימוש בשכבות/עיגולים קונצנטריים — ניתוח לפי נושאים: כל נושא מקבל טיפול מלא (הצגה → ציטוט הוראות תכנית → פסיקה → מסקנה). נושא חניה/תשתיות מקבל טיפול מעמיק במיוחד עם ציטוטים ישירים מהוראות תכנית ונספחים. טענות ספציפיות (מטרדים, עצים, בור מים) — 1-2 סעיפים תמציתיים לכל אחת. סיכום מינימלי — רק הוראות אופרטיביות (2-3 סעיפים). ראה: בית הכרם 1126/25.
@@ -293,18 +303,25 @@ description: This skill should be used when writing legal decisions (החלטו
### 7.5 שלושה מקורות פסיקה — אל תבלבל ### 7.5 שלושה מקורות פסיקה — אל תבלבל
המערכת מפרידה בין שלושה קורפוסי פסיקה. כל אחד מהם משמש למטרה אחרת ויש כלי MCP נפרד לחיפוש בו: המערכת מפרידה בין **ארבעה** קורפוסי פסיקה. כל אחד מהם משמש למטרה אחרת ויש כלי MCP נפרד לחיפוש בו:
| קורפוס | טבלה | כלי חיפוש | תפקיד | | קורפוס | טבלה | כלי חיפוש | תפקיד |
|---|---|---|---| |---|---|---|---|
| תקדימי דפנה (סגנון) | `style_corpus` + `paragraph_embeddings` | `search_decisions` | החלטות שדפנה עצמה כתבה. מקור לסגנון, ניסוחים, ג'וריספרודנציה אישית. | | לימוד סגנון | `style_corpus` | (לא לחיפוש תוכן) | ממשק /training — ניתוח "הקול" של היו"ר: טון, ביטויי מעבר, מבנה פסקאות. **אין לחפש כאן תוכן משפטי.** |
| ספריית הפסיקה הסמכותית | `case_law` (`source_kind='external_upload'`) + `halachot` | `search_precedent_library` | פסיקה חיצונית מחייבת — עליון, מנהלי, ועדות ערר אחרות — עם הלכות שאושרו ע"י דפנה. **המקור היחיד לציטוטים בבלוק י לפי CREAC.** | | החלטות ועדות ערר | `case_law` (`source_kind='internal_committee'`) + `halachot` | `search_internal_decisions` | **כל** ועדות הערר לתכנון ובנייה (כל המחוזות). מסונן לפי `district` ו-`chair_name`. מקור לעקביות פנימית ופרקטיקה ארצית. |
| ציטוטים שצורפו ידנית | `case_precedents` | `precedent_search_library` | quotes שדפנה צירפה לתיק ספציפי בעבר. דומה לקורפוס סמכותי אך פר-תיק, ידני, לא עוברת חילוץ הלכות. | | פסיקת בתי משפט | `case_law` (`source_kind='external_upload'`) + `halachot` | `search_precedent_library` | בתי משפט: עליון, מנהלי, בג"ץ. **המקור היחיד לציטוטים מחייבים בבלוק י לפי CREAC.** |
| ציטוטים ידניים | `case_precedents` | `precedent_search_library` | quotes שצורפו לתיק ספציפי בעבר. פר-תיק, ידני. |
**הזרימה הסטנדרטית בבלוק י:** **הזרימה הסטנדרטית בבלוק י — חפש במקביל:**
1. `search_decisions` קודם — בדוק אם דפנה כבר הכריעה בסוגיה דומה (חיסכון דוקטרינרי / הבחנה). 1. `search_internal_decisions(district="ירושלים")` — האם ועדת ערר ירושלים הכריעה בסוגיה? (עקביות פנימית)
2. `search_precedent_library` — חפש את הכלל המחייב והציטוט התומך לפסקת CREAC. - אם יש תוצאה רלוונטית: הצג תחת **"החלטות ועדת ערר ירושלים"** והתייחס לה בניתוח.
3. אם הצדדים הפנו לפסיקה שלא בקורפוס — דפנה מעלה אותה דרך `/precedents` ב-UI; חילוץ ההלכות אוטומטי וההלכות מחכות לאישורה. 2. `search_internal_decisions()` (ריק = כל המחוזות) — פרקטיקה ארצית של ועדות אחרות.
- הצג תחת **"החלטות ועדות ערר אחרות"** — כמשל/השוואה, לא כמחייב.
3. `search_precedent_library` — כלל מחייב מבית משפט לפסקת CREAC.
- הצג תחת **"פסיקת בתי משפט"** — זה המקור לציטוט מחייב.
4. אם הצדדים הפנו לפסיקה שלא בקורפוס — דפנה מעלה אותה דרך `/precedents` ב-UI.
**חשוב:** החלטות ועדת ערר הן פרקטיקה, לא מחייב. ציטוט מחייב בבלוק י מגיע רק מ-`search_precedent_library`.
**איסור על המצאת ציטוטים** — ציטוט פסיקה חייב להגיע מאחד מהקורפוסים. אם אין הלכה מאושרת תומכת בנקודה — אל תמציא; ציין שהנושא דורש הוספת פסיקה לקורפוס. **איסור על המצאת ציטוטים** — ציטוט פסיקה חייב להגיע מאחד מהקורפוסים. אם אין הלכה מאושרת תומכת בנקודה — אל תמציא; ציין שהנושא דורש הוספת פסיקה לקורפוס.

View File

@@ -252,82 +252,10 @@ new Table({
## Tracked Changes — עקוב אחר שינויים ## Tracked Changes — עקוב אחר שינויים
### שם מחבר בעברית ראה [`references/tracked-changes.md`](references/tracked-changes.md) — XML patterns לשינוי ערך, מחיקת סעיף, RTL PROPS, קבלה/דחייה.
```xml
<w:del w:id="10" w:author="עו&quot;ד כהן" w:date="2026-02-06T09:00:00Z">
```
### שינוי ערך (סכום, תאריך, תקופה) ```bash
פצל את הטקסט ועטוף רק את הערך שמשתנה: python /mnt/skills/public/docx/scripts/comment.py unpacked/ 0 "הערה" --author "עו״ד כהן"
```xml
<w:r><w:rPr>...RTL PROPS...</w:rPr>
<w:t xml:space="preserve">שכר הטרחה יעמוד על סך של </w:t></w:r>
<w:del w:id="10" w:author="עו&quot;ד כהן" w:date="...">
<w:r><w:rPr>...RTL PROPS...</w:rPr><w:delText>750</w:delText></w:r>
</w:del>
<w:ins w:id="11" w:author="עו&quot;ד כהן" w:date="...">
<w:r><w:rPr>...RTL PROPS...</w:rPr><w:t>850</w:t></w:r>
</w:ins>
<w:r><w:rPr>...RTL PROPS...</w:rPr>
<w:t xml:space="preserve"> ש״ח לשעת עבודה</w:t></w:r>
```
### מחיקת סעיף שלם
סמן גם את ה-paragraph mark כ-deleted:
```xml
<w:p>
<w:pPr>
<w:bidi/>
<w:jc w:val="both"/>
<w:rPr>
<w:del w:id="20" w:author="עו&quot;ד כהן" w:date="..."/>
</w:rPr>
</w:pPr>
<w:del w:id="21" w:author="עו&quot;ד כהן" w:date="...">
<w:r><w:rPr>...RTL PROPS...</w:rPr>
<w:delText>הסעיף שנמחק</w:delText></w:r>
</w:del>
</w:p>
```
### RTL PROPS — בלוק rPr מלא לכל run
```xml
<w:rPr>
<w:rFonts w:ascii="David" w:cs="David" w:eastAsia="David" w:hAnsi="David"/>
<w:sz w:val="24"/>
<w:szCs w:val="24"/>
<w:rtl/>
</w:rPr>
```
### קבלה/דחייה של שינויים
**קבלת Insertion:**
```
לפני: <w:ins w:id="5" w:author="..."><w:r>...<w:t>טקסט חדש</w:t></w:r></w:ins>
אחרי: <w:r>...<w:t>טקסט חדש</w:t></w:r>
→ הסר את תגית <w:ins> ושמור את התוכן הפנימי.
```
**דחיית Insertion:**
```
לפני: <w:ins w:id="5" w:author="..."><w:r>...<w:t>טקסט חדש</w:t></w:r></w:ins>
אחרי: (הסר לחלוטין)
→ מחק את כל בלוק ה-<w:ins> כולל תוכנו.
```
**קבלת מחיקה:**
```
לפני: <w:del w:id="10" w:author="..."><w:r>...<w:delText>טקסט שנמחק</w:delText></w:r></w:del>
אחרי: (הסר לחלוטין)
→ מחק את כל בלוק ה-<w:del> כולל תוכנו — המחיקה מתקבלת.
```
**שחזור טקסט מקורי (דחיית מחיקה):**
```
לפני: <w:del w:id="10" w:author="..."><w:r>...<w:delText>טקסט מקורי</w:delText></w:r></w:del>
אחרי: <w:r>...<w:t>טקסט מקורי</w:t></w:r>
→ הסר <w:del>, החלף <w:delText> ב-<w:t>, הסר <w:del> מ-rPr אם קיים.
``` ```
--- ---
@@ -397,72 +325,6 @@ python /mnt/skills/public/docx/scripts/pack.py unpacked/ output.docx --original
--- ---
## הערות שוליים (Footnotes)
**השימוש המרכזי:** הפניות לחקיקה ופסיקה.
```javascript
const { FootnoteReferenceRun } = require('docx');
// 1. הגדרה ב-Document:
const doc = new Document({
footnotes: {
1: { children: [new Paragraph({
bidirectional: true, alignment: AlignmentType.START, // ✅ START
children: [new TextRun({
text: "חוק החוזים (חלק כללי), התשל״ג-1973, סעיף 12.",
font: "David", size: 20, rightToLeft: true // 10pt להערות שוליים
})]
})] },
2: { children: [new Paragraph({
bidirectional: true, alignment: AlignmentType.START,
children: [new TextRun({
text: "ע״א 1234/20 כהן נ׳ לוי, פסקה 15 (פורסם בנבו, 1.1.2024).",
font: "David", size: 20, rightToLeft: true
})]
})] },
},
// ...sections
});
// 2. הפניה בגוף הטקסט:
new Paragraph({
bidirectional: true, alignment: AlignmentType.BOTH,
children: [
new TextRun({ text: "חובת תום הלב", font: "David", size: 24, rightToLeft: true }),
new FootnoteReferenceRun(1),
new TextRun({ text: " חלה על כל שלבי המשא ומתן", font: "David", size: 24, rightToLeft: true }),
new FootnoteReferenceRun(2),
new TextRun({ text: ".", font: "David", size: 24, rightToLeft: true }),
]
})
```
### תיקון RTL בהערות שוליים (post-unpack)
docx-js לא מגדיר RTL מלא בהערות שוליים. אחרי unpack, צריך לתקן ב-`word/footnotes.xml`:
```xml
<!-- 1. הוסף pStyle + bidi לכל הערת שוליים: -->
<w:footnote w:id="1">
<w:p>
<w:pPr>
<w:pStyle w:val="FootnoteText"/>
<w:bidi/>
<w:jc w:val="start"/>
</w:pPr>
...
<!-- 2. הוסף rtl ל-footnoteRef run: -->
<w:r>
<w:rPr>
<w:rStyle w:val="FootnoteReference"/>
<w:rtl/>
</w:rPr>
<w:footnoteRef/>
</w:r>
```
---
## מרווח שורות (Line Spacing) ## מרווח שורות (Line Spacing)
**דרישת בתי המשפט:** בדרך כלל 1.5 שורות. **דרישת בתי המשפט:** בדרך כלל 1.5 שורות.
@@ -482,48 +344,6 @@ spacing: { line: 360, lineRule: LineRuleType.AUTO, before: 120, after: 120 }
--- ---
## תוכן עניינים (TOC)
**⚠️ חובה: TOC ידני (לא TableOfContents).**
`TableOfContents` של docx-js מייצר שדה שוורד מעדכן ב-F9 ומאבד הגדרות RTL.
```javascript
const { Tab, TabStopType, LeaderType, PageBreak } = require('docx');
// שורת TOC ידנית
const tocEntry = (text, pageNum, opts = {}) => new Paragraph({
bidirectional: true,
spacing: { after: 60, line: 276, lineRule: LineRuleType.AUTO },
...(opts.indent ? { indent: { right: opts.indent } } : {}),
tabStops: [{ type: TabStopType.RIGHT, position: 9026, leader: LeaderType.DOT }],
children: [
new TextRun({
text, font: "David", size: 24, rightToLeft: true,
bold: opts.bold || false,
}),
new TextRun({ children: [new Tab()], font: "David", rightToLeft: true }),
new TextRun({
text: String(pageNum), font: "David", size: 24, rightToLeft: true,
}),
]
});
// שימוש:
new Paragraph({
bidirectional: true, alignment: AlignmentType.CENTER,
spacing: { after: 200 },
children: [new TextRun({
text: "תוכן עניינים", font: "David", size: 32, bold: true, rightToLeft: true
})]
}),
tocEntry("פרק א׳ — הגדרות כלליות", 2, { bold: true }),
tocEntry("1. הגדרות יסוד", 2, { indent: 400 }),
tocEntry("פרק ב׳ — השירותים", 3, { bold: true }),
new Paragraph({ children: [new PageBreak()] }),
```
---
## קו תחתי (Underline) ## קו תחתי (Underline)
```javascript ```javascript
@@ -544,337 +364,23 @@ underline: { type: UnderlineType.DOUBLE }
--- ---
## מספר סקשנים (Multiple Sections) ## פיצ'רים מתקדמים
**שימוש:** כותרות שונות לנספחים, עמוד לרוחב לטבלאות, שוליים שונים. ראה [`references/advanced-features.md`](references/advanced-features.md):
- **הערות שוליים** — Footnotes עם RTL + תיקון post-unpack ב-footnotes.xml
```javascript - **תוכן עניינים** — TOC ידני (אסור `TableOfContents`)
const doc = new Document({ - **מספר סקשנים** — כותרות שונות לנספחים
sections: [ - **Letterhead** — לוגו/תמונה בכותרת
// סקשן 1 — גוף ההסכם - **היפרלינקים** — `ExternalHyperlink` עם color+underline ידני (לא `style: "Hyperlink"`)
{
properties: {
page: { size: { width: 11906, height: 16838 },
margin: { top: 1417, right: 1417, bottom: 1417, left: 1417 } },
bidi: true,
},
headers: {
default: new Header({ children: [new Paragraph({
bidirectional: true, alignment: AlignmentType.CENTER,
children: [new TextRun({ text: "הסכם שירותים", font: "David", size: 20, bold: true, rightToLeft: true })]
})] })
},
children: [ /* ... */ ]
},
// סקשן 2 — נספח עם כותרת שונה
{
properties: {
page: { size: { width: 11906, height: 16838 },
margin: { top: 1417, right: 1417, bottom: 1417, left: 1417 } },
bidi: true,
},
headers: {
default: new Header({ children: [new Paragraph({
bidirectional: true, alignment: AlignmentType.START, // ✅ START
children: [new TextRun({ text: "נספח א׳ — לוח תעריפים", font: "David", size: 20, bold: true, rightToLeft: true })]
})] })
},
children: [ /* ... */ ]
}
]
});
```
--- ---
## לוגו/תמונה בכותרת (Letterhead) ## תבניות מסמכים
```javascript ראה [`references/document-templates.md`](references/document-templates.md):
const { ImageRun } = require('docx'); - **תבנית 1: כתב טענות** — `courtHeader()`, `mainTitle()`, `subHeading()` + מספור
- **תבנית 2: מכתב התראה** — `letterHeader()`, `subjectLine()` + פרטי משרד
const logoBuffer = fs.readFileSync('/path/to/logo.png'); - **תבנית 3: הסכם/חוזה** — `contractTitle()`, `partyClause()`, `signatureTable()` + הואילים
headers: {
default: new Header({
children: [
new Paragraph({
alignment: AlignmentType.CENTER,
children: [
new ImageRun({
data: logoBuffer,
transformation: { width: 200, height: 60 }, // pixels
type: "png",
}),
],
}),
new Paragraph({
bidirectional: true, alignment: AlignmentType.CENTER,
children: [new TextRun({
text: "משרד עורכי דין ישראלי ושות׳",
font: "David", size: 20, bold: true, rightToLeft: true
})],
}),
],
}),
}
```
**הערה:** תמונה חייבת להיות קובץ אמיתי — לבקש מהמשתמש אם אין.
---
## היפרלינקים
```javascript
const { ExternalHyperlink, UnderlineType } = require('docx');
new Paragraph({
bidirectional: true,
children: [
new TextRun({ text: "ראה: ", font: "David", size: 24, rightToLeft: true }),
new ExternalHyperlink({
link: "https://www.nevo.co.il/law_html/law01/073_002.htm",
children: [new TextRun({
text: "חוק החוזים באתר נבו",
font: "David", size: 24, rightToLeft: true,
color: "0563C1",
underline: { type: UnderlineType.SINGLE },
})],
}),
]
})
```
**⚠️ אזהרות:**
- **לא להשתמש ב-`style: "Hyperlink"`** — מפריע ל-RTL!
- **לא להוסיף `alignment: AlignmentType.RIGHT`** — `bidirectional: true` מספיק
---
## תבניות מסמכים — Document Templates
### תבנית 1: כתב טענות (בקשה, תביעה, הגנה, ערעור)
```javascript
const { Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell,
AlignmentType, LevelFormat, BorderStyle, WidthType } = require('docx');
const PAGE_WIDTH = 11906;
const MARGINS = { top: 1134, right: 1134, bottom: 1134, left: 1134 };
const CONTENT_WIDTH = PAGE_WIDTH - MARGINS.left - MARGINS.right;
const noBorder = { style: BorderStyle.NONE, size: 0, color: "FFFFFF" };
const noBorders = { top: noBorder, bottom: noBorder, left: noBorder, right: noBorder };
// Header בית משפט — טבלה עם שם בית המשפט (ימין) ומספר תיק (שמאל)
function courtHeader(courtName, caseNumber) {
return new Table({
width: { size: CONTENT_WIDTH, type: WidthType.DXA },
columnWidths: [CONTENT_WIDTH / 2, CONTENT_WIDTH / 2],
visuallyRightToLeft: true,
rows: [
new TableRow({
children: [
new TableCell({
width: { size: CONTENT_WIDTH / 2, type: WidthType.DXA },
borders: noBorders,
children: [new Paragraph({
bidirectional: true, alignment: AlignmentType.START,
children: [new TextRun({ text: courtName, bold: true, font: "David", size: 26, rightToLeft: true })]
})]
}),
new TableCell({
width: { size: CONTENT_WIDTH / 2, type: WidthType.DXA },
borders: noBorders,
children: [new Paragraph({
bidirectional: true, alignment: AlignmentType.END,
children: [new TextRun({ text: caseNumber, bold: true, font: "David", size: 26, rightToLeft: true })]
})]
})
]
})
]
});
}
// כותרת ראשית ממורכזת עם קו תחתון
function mainTitle(text) {
return new Paragraph({
bidirectional: true, alignment: AlignmentType.CENTER,
spacing: { before: 300, after: 300 },
children: [new TextRun({ text, bold: true, font: "David", size: 28, rightToLeft: true, underline: {} })]
});
}
// כותרת משנה מיושרת לימין עם קו תחתון
function subHeading(text) {
return new Paragraph({
bidirectional: true, alignment: AlignmentType.START,
spacing: { before: 240, after: 120 },
children: [new TextRun({ text, bold: true, font: "David", size: 24, rightToLeft: true, underline: {} })]
});
}
// שימוש:
const doc = new Document({
numbering: {
config: [{
reference: "legal-clauses",
levels: [{
level: 0, format: LevelFormat.DECIMAL, text: "%1.",
alignment: AlignmentType.START, suffix: "tab",
style: { paragraph: { indent: { left: 360, hanging: 360 } } }
}]
}]
},
sections: [{
properties: {
page: { size: { width: PAGE_WIDTH, height: 16838 }, margin: MARGINS },
bidi: true
},
children: [
courtHeader("בית המשפט המחוזי בתל אביב", "ת\"א 12345-01-26"),
mainTitle("כתב תביעה"),
// ... פרטי צדדים, סעיפים, חתימה
]
}]
});
```
### תבנית 2: מכתב התראה
```javascript
// מכתב התראה — ללא header בית משפט, עם פרטי משרד
function letterHeader(firmName, address, phone, email) {
return [
new Paragraph({
bidirectional: true, alignment: AlignmentType.START,
children: [new TextRun({ text: firmName, bold: true, font: "David", size: 28, rightToLeft: true })]
}),
new Paragraph({
bidirectional: true, alignment: AlignmentType.START,
children: [new TextRun({ text: address, font: "David", size: 22, rightToLeft: true })]
}),
new Paragraph({
bidirectional: true, alignment: AlignmentType.START,
spacing: { after: 300 },
children: [new TextRun({ text: `טל': ${phone} | ${email}`, font: "David", size: 22, rightToLeft: true })]
}),
];
}
function subjectLine(text) {
return new Paragraph({
bidirectional: true, alignment: AlignmentType.CENTER,
spacing: { before: 200, after: 200 },
children: [
new TextRun({ text: "הנדון: ", bold: true, font: "David", size: 24, rightToLeft: true }),
new TextRun({ text, bold: true, font: "David", size: 24, rightToLeft: true, underline: {} })
]
});
}
// שימוש:
sections: [{
properties: { page: { ... }, bidi: true },
children: [
...letterHeader("משרד עו\"ד כהן ושות'", "רח' הרצל 1, תל אביב", "03-1234567", "office@cohen-law.co.il"),
new Paragraph({
bidirectional: true, alignment: AlignmentType.START,
children: [new TextRun({ text: "תאריך: 10.2.2026", font: "David", size: 24, rightToLeft: true })]
}),
new Paragraph({
bidirectional: true, alignment: AlignmentType.START,
spacing: { before: 200 },
children: [new TextRun({ text: "לכבוד: [שם הנמען]", font: "David", size: 24, rightToLeft: true })]
}),
subjectLine("התראה בטרם נקיטת הליכים משפטיים"),
// ... גוף המכתב
]
}]
```
### תבנית 3: הסכם/חוזה
```javascript
// הסכם — הואילים, צדדים, חתימות בשני טורים
function contractTitle(text) {
return new Paragraph({
bidirectional: true, alignment: AlignmentType.CENTER,
spacing: { after: 300 },
children: [new TextRun({ text, bold: true, font: "David", size: 32, rightToLeft: true })]
});
}
function partyClause(label, name, id, address, alias) {
return new Paragraph({
bidirectional: true, alignment: AlignmentType.BOTH,
spacing: { after: 120 },
children: [
new TextRun({ text: `${label}: `, bold: true, font: "David", size: 24, rightToLeft: true }),
new TextRun({ text: `${name}, ח.פ./ת.ז. ${id}, מ${address} (להלן: "`, font: "David", size: 24, rightToLeft: true }),
new TextRun({ text: alias, bold: true, font: "David", size: 24, rightToLeft: true }),
new TextRun({ text: '")', font: "David", size: 24, rightToLeft: true }),
]
});
}
function signatureTable() {
return new Table({
width: { size: CONTENT_WIDTH, type: WidthType.DXA },
columnWidths: [CONTENT_WIDTH / 2, CONTENT_WIDTH / 2],
visuallyRightToLeft: true,
rows: [
new TableRow({
children: [
new TableCell({
borders: noBorders,
children: [
new Paragraph({ bidirectional: true, alignment: AlignmentType.CENTER,
children: [new TextRun({ text: "_________________", font: "David", size: 24, rightToLeft: true })] }),
new Paragraph({ bidirectional: true, alignment: AlignmentType.CENTER,
children: [new TextRun({ text: "צד א'", font: "David", size: 24, rightToLeft: true })] })
]
}),
new TableCell({
borders: noBorders,
children: [
new Paragraph({ bidirectional: true, alignment: AlignmentType.CENTER,
children: [new TextRun({ text: "_________________", font: "David", size: 24, rightToLeft: true })] }),
new Paragraph({ bidirectional: true, alignment: AlignmentType.CENTER,
children: [new TextRun({ text: "צד ב'", font: "David", size: 24, rightToLeft: true })] })
]
})
]
})
]
});
}
// שימוש:
sections: [{
properties: { page: { ... }, bidi: true },
children: [
contractTitle("הסכם שירותים"),
new Paragraph({
bidirectional: true, alignment: AlignmentType.CENTER,
children: [new TextRun({ text: "נערך ונחתם בתל אביב ביום __________", font: "David", size: 24, rightToLeft: true })]
}),
partyClause("מצד אחד", "[שם]", "[מספר]", "[כתובת]", "המזמין"),
partyClause("מצד שני", "[שם]", "[מספר]", "[כתובת]", "הספק"),
// הואילים...
// סעיפים...
new Paragraph({
bidirectional: true, alignment: AlignmentType.CENTER,
spacing: { before: 400, after: 300 },
children: [new TextRun({ text: "ולראיה באו הצדדים על החתום:", bold: true, font: "David", size: 24, rightToLeft: true })]
}),
signatureTable()
]
}]
```
--- ---
@@ -962,8 +468,11 @@ sections: [{
## קבצי עזר ## קבצי עזר
- **`references/document-types.md`** — מבנים מפורטים ל-9 סוגי מסמכים משפטיים - **[`references/document-types.md`](references/document-types.md)** — מבנים מפורטים ל-9 סוגי מסמכים
- **`scripts/create-legal-doc.js`** — סקריפט בסיסי עם כל הגדרות ה-RTL המתוקנות - **[`references/document-templates.md`](references/document-templates.md)** — 3 תבניות מלאות (כתב טענות, מכתב, הסכם)
- **[`references/tracked-changes.md`](references/tracked-changes.md)** — XML patterns לעקוב אחר שינויים
- **[`references/advanced-features.md`](references/advanced-features.md)** — הערות שוליים, TOC, סקשנים, letterhead, hyperlinks
- **`scripts/create-legal-doc.js`** — סקריפט בסיסי עם כל הגדרות RTL
--- ---

View File

@@ -3,3 +3,239 @@
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules --> <!-- END:nextjs-agent-rules -->
---
## Stack
| Layer | Technology | Version |
|-------|-----------|---------|
| Framework | Next.js | 16.2.3 |
| UI | React | 19.2.4 |
| Styles | Tailwind CSS | v4 |
| Components | shadcn/ui | latest via `shadcn` CLI |
| Data fetching | TanStack Query | v5 |
| Forms | react-hook-form + zod | v7 / v4 |
| Language | TypeScript | 5 |
| Direction | Hebrew RTL | `dir="rtl"` throughout |
---
## Commands
```bash
# Regenerate API types from the live FastAPI schema — RUN AFTER EVERY BACKEND CHANGE
npm run api:types
# Validate before every push
npm run lint
npm run build
# Local dev (rare — prod runs inside Docker; no local Python env exists)
npm run dev # requires NEXT_PUBLIC_API_ORIGIN=http://127.0.0.1:8000 or similar
```
**`npm run api:types` is mandatory** any time a FastAPI endpoint is added, removed, or its request/response shape changes. It fetches `https://legal-ai.nautilus.marcusgroup.org/openapi.json` and writes `src/lib/api/types.ts`.
---
## Backend Proxy — `/api/*`
`next.config.ts` transparently rewrites all `/api/*` requests to the FastAPI backend:
- In Docker (production): `http://127.0.0.1:8000`
- Override via env var: `NEXT_PUBLIC_API_ORIGIN`
**Never hardcode the backend origin in component code.** Always use relative paths like `/api/cases`.
The typed fetch wrapper lives in `src/lib/api/client.ts` — use `apiRequest<T>(path, options)`. It throws `ApiError` on non-2xx responses with the parsed body and status code.
---
## API Types — Never Edit by Hand
`src/lib/api/types.ts` is **auto-generated** by `openapi-typescript` from the live FastAPI OpenAPI schema.
- **Do NOT edit `src/lib/api/types.ts` manually** — changes will be overwritten on the next `npm run api:types` run.
- The typed helper modules in `src/lib/api/` (e.g. `cases.ts`, `documents.ts`, `precedents.ts`) ARE hand-written and import from `types.ts`. These are safe to edit.
- When adding a new API domain, create a new typed module in `src/lib/api/<domain>.ts` following the existing pattern.
---
## Tailwind CSS v4 — Breaking Changes from v3
Tailwind v4 has a completely different configuration model.
**What does NOT exist in v4:**
- `tailwind.config.ts` / `tailwind.config.js` — there is no config file
- `@tailwind base;` / `@tailwind components;` / `@tailwind utilities;` directives
- `tailwind.config.theme.extend` object
**What v4 uses instead:**
```css
/* globals.css — already set up, do not change */
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@theme {
/* Design tokens defined here as CSS custom properties */
--color-navy: #0f172a;
/* ... */
}
```
- Custom tokens go inside `@theme {}` in `globals.css`.
- Custom variants use `@custom-variant`.
- Class names are the same (e.g. `bg-navy`, `text-gold`), but the config source is CSS, not JS.
- PostCSS is configured via `@tailwindcss/postcss` (devDependency).
---
## shadcn/ui Components
Adding a new component:
```bash
npx shadcn add <component-name>
# e.g. npx shadcn add table
```
Installed components live in `src/components/ui/`. They are editable (shadcn copies the source, not a package import). The `radix-ui` package (v1.4) is the underlying primitive.
- Do NOT `npm install @radix-ui/react-*` directly — use `npx shadcn add` which installs the correct Radix version and generates the shadcn wrapper.
- Design tokens in `globals.css` (`--color-navy`, `--color-gold`, etc.) are already mapped to the shadcn semantic tokens (`background`, `foreground`, `primary`, etc.), so shadcn components inherit the editorial/judicial aesthetic automatically.
---
## TanStack Query v5
**v5 has breaking API changes from v4.** Key patterns used in this codebase:
```typescript
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
// Reading data
const { data, isLoading, isError } = useQuery({
queryKey: ["cases"],
queryFn: () => apiRequest<CaseListResponse>("/api/cases"),
});
// Writing data
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (body: CreateCaseRequest) =>
apiRequest<Case>("/api/cases", { method: "POST", body }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["cases"] });
},
});
```
**v5 changes from v4:**
- `useQuery` no longer accepts positional arguments — always use the options object.
- `isLoading` is replaced by `isPending` for mutations (but `isLoading` still works for queries).
- `onSuccess`/`onError`/`onSettled` callbacks on `useQuery` are removed — use mutation callbacks or `useEffect` instead.
- `getQueryData` / `setQueryData` are unchanged.
The shared `QueryClient` is created in `src/lib/api/client.ts` via `makeQueryClient()` and provided by `src/lib/providers.tsx`.
---
## RTL — Hebrew UI Rules
**All UI is Hebrew, right-to-left.** The `<html>` element has `dir="rtl"` and `lang="he"`.
Use **logical CSS properties** instead of directional ones:
| Avoid (directional) | Use (logical) |
|---------------------|--------------|
| `ml-*` / `mr-*` | `ms-*` (start) / `me-*` (end) |
| `pl-*` / `pr-*` | `ps-*` (start) / `pe-*` (end) |
| `text-left` | `text-start` |
| `text-right` | `text-end` |
| `float-left` | `float-start` |
| `border-l-*` | `border-s-*` |
In RTL, "start" = right side, "end" = left side. Using logical properties means the layout works automatically without RTL overrides.
Flexbox direction: `flex-row` in RTL naturally flows right-to-left. Use `flex-row-reverse` only when you need LTR inside an RTL context.
---
## Project Structure
```
src/
├── app/ # Next.js App Router pages
│ ├── layout.tsx # Root layout — sets dir="rtl", applies fonts
│ ├── globals.css # Tailwind v4 imports + design tokens + :root vars
│ ├── cases/ # Case management pages
│ ├── precedents/ # Precedent library pages
│ ├── methodology/ # Methodology browser
│ ├── training/ # Training document management
│ ├── settings/ # Application settings
│ └── skills/ # Skills management
├── components/
│ ├── ui/ # shadcn primitives (editable copies)
│ ├── app-shell.tsx # Top-level shell with nav
│ ├── cases/ # Case-domain components
│ ├── documents/ # Document viewer components
│ ├── precedents/ # Precedent components
│ └── compose/ # Decision drafting / block editor
├── lib/
│ ├── api/
│ │ ├── types.ts # AUTO-GENERATED — never edit
│ │ ├── client.ts # apiRequest<T> + QueryClient factory
│ │ ├── cases.ts # Typed case API helpers
│ │ ├── documents.ts # Typed document API helpers
│ │ └── ... # One file per API domain
│ ├── providers.tsx # TanStack Query + theme providers
│ ├── utils.ts # cn() and other shared utilities
│ ├── doc-types.ts # Document type constants
│ └── sse.ts # Server-Sent Events helper for streaming
```
---
## Forms
Forms use **react-hook-form** (v7) with **zod** (v4) validation via `@hookform/resolvers`:
```typescript
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const schema = z.object({ title: z.string().min(1) });
type FormValues = z.infer<typeof schema>;
const form = useForm<FormValues>({ resolver: zodResolver(schema) });
```
---
## Notifications / Toasts
Use **sonner** (`import { toast } from "sonner"`). The `<Toaster>` is mounted in `src/lib/providers.tsx`.
```typescript
toast.success("התיק נשמר בהצלחה");
toast.error("שגיאה בשמירה");
```
---
## Streaming (SSE)
Server-sent events are used for long-running AI operations (drafting, analysis). The helper is in `src/lib/sse.ts`. Use it instead of raw `EventSource`.
---
## Deploy
This frontend runs **inside Docker via Coolify** — not as a standalone Node process.
- **No `npm run dev` on the server** — there is no local Python environment for the backend.
- To see changes in production: `git commit` + `git push origin main` → Gitea Actions builds image → Coolify redeploys (~2-4 min).
- Prod URL: `https://legal-ai.nautilus.marcusgroup.org`
- The Next.js output is `standalone` (see `next.config.ts: output: "standalone"`).

View File

@@ -11,6 +11,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { usePrecedent } from "@/lib/api/precedent-library"; import { usePrecedent } from "@/lib/api/precedent-library";
import { PrecedentEditSheet } from "@/components/precedents/precedent-edit-sheet"; import { PrecedentEditSheet } from "@/components/precedents/precedent-edit-sheet";
import { ExtractedHalachotSection } from "@/components/precedents/extracted-halachot"; import { ExtractedHalachotSection } from "@/components/precedents/extracted-halachot";
import { RelatedCasesSection } from "@/components/precedents/link-related-dialog";
const PRACTICE_AREA_LABELS: Record<string, string> = { const PRACTICE_AREA_LABELS: Record<string, string> = {
rishuy_uvniya: "רישוי ובנייה", rishuy_uvniya: "רישוי ובנייה",
@@ -152,6 +153,15 @@ export default function PrecedentDetailPage({
</CardContent> </CardContent>
</Card> </Card>
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<RelatedCasesSection
caseId={id}
related={data.related_cases ?? []}
/>
</CardContent>
</Card>
<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">
<ExtractedHalachotSection halachot={data.halachot ?? []} /> <ExtractedHalachotSection halachot={data.halachot ?? []} />

View File

@@ -336,6 +336,7 @@ export function AgentsTab() {
<div className="space-y-4"> <div className="space-y-4">
<Card className="bg-surface border-rule shadow-sm"> <Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-5 py-4 flex items-center justify-between gap-3 flex-wrap"> <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"> <div className="text-[0.85rem] text-ink-muted">
{data.pairs.length} סוכנים × 2 חברות (CMP master / CMPA mirror) {data.pairs.length} סוכנים × 2 חברות (CMP master / CMPA mirror)
{totalDrift > 0 && ( {totalDrift > 0 && (
@@ -347,6 +348,10 @@ export function AgentsTab() {
<span className="text-danger ms-2">· {missingCount} זוגות לא שלמים</span> <span className="text-danger ms-2">· {missingCount} זוגות לא שלמים</span>
)} )}
</div> </div>
<div className="text-[0.7rem] text-ink-light">
פערי skills מחושבים על paperclipai/* בלבד. local/* ו-company/* מסוננים שם שונה בין החברות הוא צפוי.
</div>
</div>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@@ -15,14 +15,15 @@ import { Label } from "@/components/ui/label";
import { import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { PartiesField } from "@/components/wizard/parties-field";
import { useUpdateCase } from "@/lib/api/cases"; import { useUpdateCase } from "@/lib/api/cases";
import { caseUpdateSchema, expectedOutcomes, type CaseUpdateInput } from "@/lib/schemas/case"; import { caseUpdateSchema, expectedOutcomes, type CaseUpdateInput } from "@/lib/schemas/case";
import type { CaseDetail } from "@/lib/api/cases"; import type { CaseDetail } from "@/lib/api/cases";
/* /*
* Inline edit dialog for core case fields. Uses react-hook-form + zod * Inline edit dialog for all case fields set at creation time.
* directly (shadcn's <Form> registry entry wasn't available at init * Uses react-hook-form + zod directly (shadcn's <Form> registry entry
* time, so the styling is reproduced by hand in a lean form layout). * wasn't available at init time, so the styling is reproduced by hand).
*/ */
function FieldError({ message }: { message?: string }) { function FieldError({ message }: { message?: string }) {
@@ -42,6 +43,10 @@ export function CaseEditDialog({ data }: { data: CaseDetail }) {
hearing_date: data.hearing_date ?? "", hearing_date: data.hearing_date ?? "",
notes: "", notes: "",
expected_outcome: data.expected_outcome ?? "", expected_outcome: data.expected_outcome ?? "",
appellants: data.appellants ?? [],
respondents: data.respondents ?? [],
property_address: data.property_address ?? "",
permit_number: data.permit_number ?? "",
}, },
}); });
@@ -54,6 +59,10 @@ export function CaseEditDialog({ data }: { data: CaseDetail }) {
hearing_date: data.hearing_date ?? "", hearing_date: data.hearing_date ?? "",
notes: "", notes: "",
expected_outcome: data.expected_outcome ?? "", expected_outcome: data.expected_outcome ?? "",
appellants: data.appellants ?? [],
respondents: data.respondents ?? [],
property_address: data.property_address ?? "",
permit_number: data.permit_number ?? "",
}); });
}, [open, data, form]); }, [open, data, form]);
@@ -74,11 +83,11 @@ export function CaseEditDialog({ data }: { data: CaseDetail }) {
עריכת פרטי תיק עריכת פרטי תיק
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-lg" dir="rtl"> <DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto" dir="rtl">
<DialogHeader> <DialogHeader>
<DialogTitle>עריכת פרטי תיק {data.case_number}</DialogTitle> <DialogTitle>עריכת פרטי תיק {data.case_number}</DialogTitle>
<DialogDescription className="text-ink-muted"> <DialogDescription className="text-ink-muted">
השינויים נשמרים ישירות ל-FastAPI. השדות הריקים נשארים ללא שינוי. השינויים נשמרים ישירות ל-DB. שינוי כותרת יסנכרן גם ל-Paperclip.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -95,6 +104,55 @@ export function CaseEditDialog({ data }: { data: CaseDetail }) {
<FieldError message={form.formState.errors.subject?.message} /> <FieldError message={form.formState.errors.subject?.message} />
</div> </div>
<div className="h-px bg-rule" />
<Controller
control={form.control}
name="appellants"
render={({ field, fieldState }) => (
<PartiesField
label="עוררים"
value={field.value ?? []}
onChange={field.onChange}
error={fieldState.error?.message}
/>
)}
/>
<Controller
control={form.control}
name="respondents"
render={({ field, fieldState }) => (
<PartiesField
label="משיבים"
value={field.value ?? []}
onChange={field.onChange}
error={fieldState.error?.message}
/>
)}
/>
<div className="h-px bg-rule" />
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="property_address" className="text-navy">כתובת הנכס</Label>
<Input
id="property_address"
{...form.register("property_address")}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="permit_number" className="text-navy">מס׳ תכנית/בקשה</Label>
<Input
id="permit_number"
{...form.register("permit_number")}
className="mt-1"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<Label htmlFor="hearing_date" className="text-navy">תאריך דיון</Label> <Label htmlFor="hearing_date" className="text-navy">תאריך דיון</Label>

View File

@@ -1,7 +1,8 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Trash2, Plus, Pencil } from "lucide-react"; import Link from "next/link";
import { Trash2, Plus, Pencil, Wand2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
@@ -16,11 +17,12 @@ import {
import { import {
usePrecedents, usePrecedents,
useDeletePrecedent, useDeletePrecedent,
useRequestHalachotExtraction,
isPrecedentActive, isPrecedentActive,
type Precedent, type Precedent,
type PracticeArea, type PracticeArea,
} from "@/lib/api/precedent-library"; } from "@/lib/api/precedent-library";
import { PRACTICE_AREAS, PRECEDENT_LEVELS, practiceAreaShort } from "./practice-area"; import { PRACTICE_AREAS, practiceAreaShort } from "./practice-area";
import { PrecedentUploadSheet } from "./precedent-upload-sheet"; import { PrecedentUploadSheet } from "./precedent-upload-sheet";
import { PrecedentEditSheet } from "./precedent-edit-sheet"; import { PrecedentEditSheet } from "./precedent-edit-sheet";
@@ -33,17 +35,25 @@ function formatDate(iso: string | null) {
} }
} }
/* The upload form (and Nevo PDFs) embed Unicode bidi marks (RTL/LTR/embedding/
* isolate) inside the citation. They render as zero-width but visually push
* the text away from the cell edge. Strip them for display only — DB still
* has the original. */
function cleanCitation(s: string | null | undefined): string { function cleanCitation(s: string | null | undefined): string {
if (!s) return "—"; if (!s) return "—";
return s.replace(/[--]/g, "").trim(); return s.replace(/[--]/g, "").trim();
} }
/* Shimmering pill — used while extraction is actively running. // Show the "extract halachot" button only when the precedent hasn't had a
* Visually distinct from the static "queued" / "completed" pills. */ // successful (or even attempted) extraction yet. Hide while processing or
// after completion to avoid duplicate requests.
function needsHalachaExtraction(p: Precedent): boolean {
if (p.extraction_status !== "completed") return false; // text not ready
if (p.halacha_extraction_status === "processing") return false;
if (p.halacha_extraction_status === "completed") return false;
if (p.halacha_extraction_status === "pending" && p.halacha_extraction_requested_at) {
return false; // already queued
}
// Remaining cases: pending+no-requested_at (never tried) or failed (retry).
return true;
}
function ActivePill({ label }: { label: string }) { function ActivePill({ label }: { label: string }) {
return ( return (
<Badge <Badge
@@ -55,12 +65,6 @@ function ActivePill({ label }: { label: string }) {
); );
} }
/**
* Five distinct states. The "queued" state is what the user actually
* sees most of the time (after upload, both extractions are auto-queued
* but the local MCP worker hasn't drained them yet); "מחלץ" / "מעבד"
* shimmers and only appears while the extractor is actively running.
*/
function StatusPill({ p }: { p: Precedent }) { function StatusPill({ p }: { p: Precedent }) {
if (p.extraction_status === "failed") { if (p.extraction_status === "failed") {
return ( return (
@@ -103,37 +107,28 @@ function StatusPill({ p }: { p: Precedent }) {
</Badge> </Badge>
); );
} }
// halacha_extraction_status === "completed"
if (p.halachot_count === 0) { if (p.halachot_count === 0) {
return <Badge variant="outline">ללא הלכות</Badge>; return <Badge variant="outline">ללא הלכות</Badge>;
} }
return ( return (
<Badge <Badge variant="outline" className="bg-gold-wash text-gold-deep border-gold/40">
variant="outline"
className="bg-gold-wash text-gold-deep border-gold/40"
>
{p.approved_count}/{p.halachot_count} מאושרות {p.approved_count}/{p.halachot_count} מאושרות
</Badge> </Badge>
); );
} }
function PrecedentRow({ function CourtRow({ p, onEdit }: { p: Precedent; onEdit: (id: string) => void }) {
p, onEdit,
}: {
p: Precedent;
onEdit: (id: string) => void;
}) {
const del = useDeletePrecedent(); const del = useDeletePrecedent();
const reqHalachot = useRequestHalachotExtraction();
const active = isPrecedentActive(p); const active = isPrecedentActive(p);
const showExtractHalachot = needsHalachaExtraction(p);
const onDelete = async () => { const onDelete = async () => {
if (active) { if (active) {
toast.error( toast.error("מתבצע עיבוד — לא ניתן למחוק עכשיו.");
"מתבצע עיבוד — לא ניתן למחוק עכשיו. המתיני לסיום או רעני את הדף.",
);
return; return;
} }
if (!window.confirm(`למחוק את ${p.case_number}? cascade ימחק את ה-chunks וההלכות.`)) return; if (!window.confirm(`למחוק את ${p.case_number}?`)) return;
try { try {
await del.mutateAsync(p.id); await del.mutateAsync(p.id);
toast.success("נמחק"); toast.success("נמחק");
@@ -142,65 +137,62 @@ function PrecedentRow({
} }
}; };
const onExtractHalachot = async () => {
try {
await reqHalachot.mutateAsync(p.id);
toast.success("סומן לחילוץ הלכות. הריצי מ-Claude Code: precedent_process_pending_halachot");
} catch (e) {
toast.error(e instanceof Error ? e.message : "שגיאה");
}
};
return ( return (
<TableRow className="border-rule hover:bg-gold-wash/30 align-top"> <TableRow className="border-rule hover:bg-gold-wash/30 align-top">
<TableCell <TableCell
/* shadcn TableCell defaults to whitespace-nowrap which forces the
* row wider than the container; for this column we want the long
* citation to wrap onto a second line instead of triggering the
* horizontal scroll on the table wrapper. min-w/max-w keeps the
* column wide enough to avoid awkward 2-word lines while leaving
* room for the other columns. */
className="font-semibold text-navy text-right whitespace-normal break-words min-w-[280px] max-w-[420px] py-3" className="font-semibold text-navy text-right whitespace-normal break-words min-w-[280px] max-w-[420px] py-3"
dir="rtl" dir="rtl"
> >
<span dir="auto">{cleanCitation(p.case_number)}</span> <Link href={`/precedents/${p.id}`} className="hover:underline hover:text-gold-deep" dir="auto">
{cleanCitation(p.case_number)}
</Link>
</TableCell> </TableCell>
<TableCell className="text-ink whitespace-normal break-words max-w-[260px] py-3"> <TableCell className="text-ink whitespace-normal break-words max-w-[260px] py-3">
<div className="font-medium">{cleanCitation(p.case_name)}</div> <div className="font-medium">{cleanCitation(p.case_name)}</div>
{p.court ? ( {p.court ? <div className="text-[0.72rem] text-ink-muted">{p.court}</div> : null}
<div className="text-[0.72rem] text-ink-muted">{p.court}</div>
) : null}
</TableCell>
<TableCell className="text-ink-muted">
{p.date ? formatDate(p.date) : <span className="text-ink-light"></span>}
</TableCell> </TableCell>
<TableCell className="text-ink-muted">{p.date ? formatDate(p.date) : <span className="text-ink-light"></span>}</TableCell>
<TableCell> <TableCell>
{p.practice_area ? ( {p.practice_area ? (
<Badge variant="outline" className="bg-navy-soft/40 text-navy border-navy/30"> <Badge variant="outline" className="bg-navy-soft/40 text-navy border-navy/30">
{practiceAreaShort(p.practice_area)} {practiceAreaShort(p.practice_area)}
</Badge> </Badge>
) : ( ) : <span className="text-ink-light"></span>}
<span className="text-ink-light"></span>
)}
</TableCell> </TableCell>
<TableCell className="text-ink-muted text-[0.78rem]"> <TableCell className="text-ink-muted text-[0.78rem]">
{p.precedent_level ? ( {p.precedent_level || <span className="text-ink-light"></span>}
p.precedent_level
) : (
<span className="text-ink-light"></span>
)}
</TableCell>
<TableCell>
<StatusPill p={p} />
</TableCell> </TableCell>
<TableCell><StatusPill p={p} /></TableCell>
<TableCell className="text-end"> <TableCell className="text-end">
<div className="flex items-center gap-1 justify-end"> <div className="flex items-center gap-1 justify-end">
<Button <Button variant="ghost" size="sm" onClick={() => onEdit(p.id)}
variant="ghost" size="sm" onClick={() => onEdit(p.id)} aria-label={`ערוך את ${p.case_number}`} title="ערוך"
aria-label={`ערוך את ${p.case_number}`} className="text-ink-muted hover:text-navy">
title="ערוך פרטים"
className="text-ink-muted hover:text-navy"
>
<Pencil className="w-4 h-4" /> <Pencil className="w-4 h-4" />
</Button> </Button>
<Button {showExtractHalachot && (
variant="ghost" size="sm" onClick={onDelete} <Button variant="ghost" size="sm" onClick={onExtractHalachot}
disabled={reqHalachot.isPending}
aria-label={`חלץ הלכות מ-${p.case_number}`}
title="חלץ הלכות"
className="text-gold-deep hover:text-gold-deep hover:bg-gold-wash disabled:opacity-30">
<Wand2 className="w-4 h-4" />
</Button>
)}
<Button variant="ghost" size="sm" onClick={onDelete}
disabled={del.isPending || active} disabled={del.isPending || active}
aria-label={`מחק את ${p.case_number}`} aria-label={`מחק את ${p.case_number}`}
title={active ? "מתבצע עיבוד — לא ניתן למחוק" : "מחק"} title={active ? "מתבצע עיבוד — לא ניתן למחוק" : "מחק"}
className="text-danger hover:text-danger hover:bg-danger-bg disabled:opacity-30" className="text-danger hover:text-danger hover:bg-danger-bg disabled:opacity-30">
>
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</Button> </Button>
</div> </div>
@@ -209,33 +201,134 @@ function PrecedentRow({
); );
} }
function CommitteeRow({ p, onEdit }: { p: Precedent; onEdit: (id: string) => void }) {
const del = useDeletePrecedent();
const reqHalachot = useRequestHalachotExtraction();
const active = isPrecedentActive(p);
const showExtractHalachot = needsHalachaExtraction(p);
const onDelete = async () => {
if (active) {
toast.error("מתבצע עיבוד — לא ניתן למחוק עכשיו.");
return;
}
if (!window.confirm(`למחוק את ${p.case_number}?`)) return;
try {
await del.mutateAsync(p.id);
toast.success("נמחק");
} catch (e) {
toast.error(e instanceof Error ? e.message : "שגיאה");
}
};
const onExtractHalachot = async () => {
try {
await reqHalachot.mutateAsync(p.id);
toast.success("סומן לחילוץ הלכות. הריצי מ-Claude Code: precedent_process_pending_halachot");
} catch (e) {
toast.error(e instanceof Error ? e.message : "שגיאה");
}
};
return (
<TableRow className="border-rule hover:bg-gold-wash/30 align-top">
<TableCell
className="font-semibold text-navy text-right whitespace-normal break-words min-w-[200px] max-w-[320px] py-3"
dir="rtl"
>
<Link href={`/precedents/${p.id}`} className="hover:underline hover:text-gold-deep" dir="auto">
{cleanCitation(p.case_number)}
</Link>
</TableCell>
<TableCell className="text-ink whitespace-normal break-words max-w-[220px] py-3">
<div className="font-medium">{cleanCitation(p.case_name)}</div>
</TableCell>
<TableCell className="text-ink-muted text-[0.78rem]">
{p.district || <span className="text-ink-light"></span>}
</TableCell>
<TableCell className="text-ink-muted text-[0.78rem]">
{p.chair_name || <span className="text-ink-light"></span>}
</TableCell>
<TableCell className="text-ink-muted">{p.date ? formatDate(p.date) : <span className="text-ink-light"></span>}</TableCell>
<TableCell>
{p.practice_area ? (
<Badge variant="outline" className="bg-navy-soft/40 text-navy border-navy/30">
{practiceAreaShort(p.practice_area)}
</Badge>
) : <span className="text-ink-light"></span>}
</TableCell>
<TableCell><StatusPill p={p} /></TableCell>
<TableCell className="text-end">
<div className="flex items-center gap-1 justify-end">
<Button variant="ghost" size="sm" onClick={() => onEdit(p.id)}
aria-label={`ערוך את ${p.case_number}`} title="ערוך"
className="text-ink-muted hover:text-navy">
<Pencil className="w-4 h-4" />
</Button>
{showExtractHalachot && (
<Button variant="ghost" size="sm" onClick={onExtractHalachot}
disabled={reqHalachot.isPending}
aria-label={`חלץ הלכות מ-${p.case_number}`}
title="חלץ הלכות"
className="text-gold-deep hover:text-gold-deep hover:bg-gold-wash disabled:opacity-30">
<Wand2 className="w-4 h-4" />
</Button>
)}
<Button variant="ghost" size="sm" onClick={onDelete}
disabled={del.isPending || active}
aria-label={`מחק את ${p.case_number}`}
title={active ? "מתבצע עיבוד — לא ניתן למחוק" : "מחק"}
className="text-danger hover:text-danger hover:bg-danger-bg disabled:opacity-30">
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
);
}
function TableSkeleton({ cols }: { cols: number }) {
return (
<>
{[...Array(4)].map((_, i) => (
<TableRow key={i} className="border-rule">
{[...Array(cols)].map((_, j) => (
<TableCell key={j}><Skeleton className="h-5 w-full" /></TableCell>
))}
</TableRow>
))}
</>
);
}
export function LibraryListPanel() { export function LibraryListPanel() {
const [practiceArea, setPracticeArea] = useState<PracticeArea>(""); const [practiceArea, setPracticeArea] = useState<PracticeArea>("");
const [precedentLevel, setPrecedentLevel] = useState("");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [uploadOpen, setUploadOpen] = useState(false); const [uploadOpen, setUploadOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const { data, isPending, error } = usePrecedents({ const sharedFilters = {
practiceArea: practiceArea || undefined, practiceArea: practiceArea || undefined,
precedentLevel: precedentLevel || undefined,
search: search.trim() || undefined, search: search.trim() || undefined,
limit: 200, limit: 200,
}); };
const courts = usePrecedents({ ...sharedFilters, sourceKind: "external_upload", sourceType: "court_ruling" });
const committee = usePrecedents({ ...sharedFilters, sourceKind: "all_committees" });
return ( return (
<div className="space-y-4"> <div className="space-y-8">
{/* Shared filters */}
<div className="flex items-end gap-3 flex-wrap"> <div className="flex items-end gap-3 flex-wrap">
<div className="flex-1 min-w-[200px]"> <div className="flex-1 min-w-[200px]">
<label className="text-[0.78rem] text-ink-muted">חיפוש (מספר תיק / שם / תקציר)</label> <label className="text-[0.78rem] text-ink-muted">חיפוש (מספר תיק / שם)</label>
<Input <Input
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="עע&quot;מ 3975/22" placeholder="עע״מ 3975/22 / 1200-25"
dir="rtl" dir="rtl"
/> />
</div> </div>
<div className="min-w-[180px]"> <div className="min-w-[180px]">
<label className="text-[0.78rem] text-ink-muted">תחום</label> <label className="text-[0.78rem] text-ink-muted">תחום</label>
<Select value={practiceArea || "_all"} onValueChange={(v) => setPracticeArea(v === "_all" ? "" : v as PracticeArea)}> <Select value={practiceArea || "_all"} onValueChange={(v) => setPracticeArea(v === "_all" ? "" : v as PracticeArea)}>
@@ -248,29 +341,25 @@ export function LibraryListPanel() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="min-w-[170px]">
<label className="text-[0.78rem] text-ink-muted">רמת תקדים</label>
<Select value={precedentLevel || "_all"} onValueChange={(v) => setPrecedentLevel(v === "_all" ? "" : v)}>
<SelectTrigger><SelectValue placeholder="הכל" /></SelectTrigger>
<SelectContent>
<SelectItem value="_all">הכל</SelectItem>
{PRECEDENT_LEVELS.map((l) => (
<SelectItem key={l.value} value={l.value}>{l.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={() => setUploadOpen(true)} className="bg-navy text-parchment hover:bg-navy-soft"> <Button onClick={() => setUploadOpen(true)} className="bg-navy text-parchment hover:bg-navy-soft">
<Plus className="w-4 h-4 me-1" /> <Plus className="w-4 h-4 me-1" />
העלאת פסיקה העלאת פסיקה
</Button> </Button>
</div> </div>
{error ? ( {/* Table 1 — Court rulings */}
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-5 text-danger text-center"> <section>
{error.message} <div className="flex items-center gap-2 mb-3">
<h3 className="text-base font-semibold text-navy">פסיקת בתי משפט</h3>
{courts.data && (
<Badge variant="outline" className="text-ink-muted text-[0.72rem]">
{courts.data.count}
</Badge>
)}
</div>
{courts.error ? (
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-4 text-danger text-center text-sm">
{courts.error.message}
</div> </div>
) : ( ) : (
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden"> <div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
@@ -287,31 +376,73 @@ export function LibraryListPanel() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{isPending ? ( {courts.isPending ? (
[...Array(5)].map((_, i) => ( <TableSkeleton cols={7} />
<TableRow key={i} className="border-rule"> ) : !courts.data?.items.length ? (
{[...Array(7)].map((_, j) => (
<TableCell key={j}>
<Skeleton className="h-5 w-full" />
</TableCell>
))}
</TableRow>
))
) : !data?.items.length ? (
<TableRow className="border-rule"> <TableRow className="border-rule">
<TableCell colSpan={7} className="text-center text-ink-muted py-10"> <TableCell colSpan={7} className="text-center text-ink-muted py-8">
אין פסיקה בקורפוס. העלה את פסק הדין הראשון. אין פסיקת בתי משפט בקורפוס.
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
data.items.map((p) => ( courts.data.items.map((p) => (
<PrecedentRow key={p.id} p={p} onEdit={setEditingId} /> <CourtRow key={p.id} p={p} onEdit={setEditingId} />
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
)} )}
</section>
{/* Table 2 — Appeals committee decisions */}
<section>
<div className="flex items-center gap-2 mb-3">
<h3 className="text-base font-semibold text-navy">החלטות ועדות ערר</h3>
{committee.data && (
<Badge variant="outline" className="text-ink-muted text-[0.72rem]">
{committee.data.count}
</Badge>
)}
</div>
{committee.error ? (
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-4 text-danger text-center text-sm">
{committee.error.message}
</div>
) : (
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-rule-soft/60">
<TableRow className="border-rule">
<TableHead className="text-navy text-right">מספר ערר</TableHead>
<TableHead className="text-navy text-right">שם</TableHead>
<TableHead className="text-navy text-right">מחוז</TableHead>
<TableHead className="text-navy text-right">יו״ר</TableHead>
<TableHead className="text-navy text-right">תאריך</TableHead>
<TableHead className="text-navy text-right">תחום</TableHead>
<TableHead className="text-navy text-right">הלכות</TableHead>
<TableHead className="text-navy" />
</TableRow>
</TableHeader>
<TableBody>
{committee.isPending ? (
<TableSkeleton cols={8} />
) : !committee.data?.items.length ? (
<TableRow className="border-rule">
<TableCell colSpan={8} className="text-center text-ink-muted py-8">
אין החלטות ועדת ערר בקורפוס.
</TableCell>
</TableRow>
) : (
committee.data.items.map((p) => (
<CommitteeRow key={p.id} p={p} onEdit={setEditingId} />
))
)}
</TableBody>
</Table>
</div>
)}
</section>
<PrecedentUploadSheet open={uploadOpen} onOpenChange={setUploadOpen} /> <PrecedentUploadSheet open={uploadOpen} onOpenChange={setUploadOpen} />
<PrecedentEditSheet <PrecedentEditSheet

View File

@@ -0,0 +1,222 @@
"use client";
import { useState } from "react";
import { Link2, Loader2, X } from "lucide-react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
usePrecedents,
useLinkRelatedCase,
useUnlinkRelatedCase,
RelatedCase,
} from "@/lib/api/precedent-library";
const LEVEL_LABELS: Record<string, string> = {
"עליון": "עליון",
"מנהלי": "מנהלי",
"ועדת_ערר_ארצית": "ארצי",
"ועדת_ערר_מחוזית": "מחוזי",
};
const LEVEL_COLORS: Record<string, string> = {
"עליון": "bg-red-50 text-red-700 border-red-200",
"מנהלי": "bg-orange-50 text-orange-700 border-orange-200",
"ועדת_ערר_ארצית": "bg-blue-50 text-blue-700 border-blue-200",
"ועדת_ערר_מחוזית": "bg-green-50 text-green-700 border-green-200",
};
// ── Search Dialog ────────────────────────────────────────────────────
type DialogProps = {
caseId: string;
currentRelated: RelatedCase[];
open: boolean;
onOpenChange: (open: boolean) => void;
};
function LinkDialog({ caseId, currentRelated, open, onOpenChange }: DialogProps) {
const [query, setQuery] = useState("");
const { mutateAsync: linkCase, isPending } = useLinkRelatedCase(caseId);
const alreadyLinkedIds = new Set([...currentRelated.map((r) => r.id), caseId]);
const { data, isPending: searching } = usePrecedents(
query.length >= 2 ? { search: query, limit: 10 } : {},
);
const candidates = (data?.items ?? []).filter((p) => !alreadyLinkedIds.has(p.id));
async function handleLink(relatedId: string) {
try {
await linkCase({ relatedId });
toast.success("הפסיקות קושרו");
setQuery("");
} catch {
toast.error("שגיאה בקישור");
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg" dir="rtl">
<DialogHeader>
<DialogTitle className="text-navy">קשר החלטה קשורה</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<Input
placeholder="חפש לפי מספר תיק, שם, ערכאה..."
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
dir="rtl"
/>
{query.length >= 2 && (
<div className="space-y-1 max-h-72 overflow-y-auto">
{searching ? (
<div className="flex items-center gap-2 text-ink-muted text-sm py-3">
<Loader2 className="w-3.5 h-3.5 animate-spin" /> מחפש...
</div>
) : candidates.length === 0 ? (
<p className="text-ink-muted text-sm py-3 text-center">לא נמצאו תוצאות</p>
) : (
candidates.map((p) => (
<button
key={p.id}
onClick={() => handleLink(p.id)}
disabled={isPending}
className="w-full text-right flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg border border-rule hover:bg-surface/60 transition-colors disabled:opacity-50"
>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-navy truncate">
{p.case_name || p.case_number}
</div>
<div className="text-[0.72rem] text-ink-muted font-mono" dir="ltr">
{p.case_number}
</div>
</div>
{p.precedent_level && (
<Badge
variant="outline"
className={`text-[0.62rem] shrink-0 ${LEVEL_COLORS[p.precedent_level] ?? ""}`}
>
{LEVEL_LABELS[p.precedent_level] ?? p.precedent_level}
</Badge>
)}
</button>
))
)}
</div>
)}
{query.length > 0 && query.length < 2 && (
<p className="text-ink-muted text-xs">הקלד לפחות 2 תווים</p>
)}
</div>
</DialogContent>
</Dialog>
);
}
// ── Related Case Card ────────────────────────────────────────────────
function RelatedCaseCard({ caseId, related }: { caseId: string; related: RelatedCase }) {
const { mutateAsync: unlinkCase, isPending } = useUnlinkRelatedCase(caseId);
async function handleUnlink() {
try {
await unlinkCase(related.id);
toast.success("הקישור הוסר");
} catch {
toast.error("שגיאה בהסרת הקישור");
}
}
return (
<div className="flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg border border-rule bg-surface">
<a
href={`/precedents/${related.id}`}
className="min-w-0 flex-1 hover:opacity-80 transition-opacity"
>
<div className="text-sm font-medium text-navy truncate">
{related.case_name || related.case_number}
</div>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{related.precedent_level && (
<Badge
variant="outline"
className={`text-[0.62rem] ${LEVEL_COLORS[related.precedent_level] ?? ""}`}
>
{LEVEL_LABELS[related.precedent_level] ?? related.precedent_level}
</Badge>
)}
{related.court && (
<span className="text-[0.7rem] text-ink-muted truncate">{related.court}</span>
)}
{related.date && (
<span className="text-[0.7rem] text-ink-muted tabular-nums" dir="ltr">
{related.date.slice(0, 10)}
</span>
)}
</div>
</a>
<button
onClick={handleUnlink}
disabled={isPending}
className="p-1 rounded hover:bg-danger-bg hover:text-danger transition-colors text-ink-muted disabled:opacity-40 shrink-0"
title="הסר קישור"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
);
}
// ── Public section component ─────────────────────────────────────────
type SectionProps = {
caseId: string;
related: RelatedCase[];
};
export function RelatedCasesSection({ caseId, related }: SectionProps) {
const [dialogOpen, setDialogOpen] = useState(false);
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-navy text-sm font-semibold">
החלטות קשורות{related.length > 0 ? ` (${related.length})` : ""}
</h3>
<Button variant="outline" size="sm" onClick={() => setDialogOpen(true)}>
<Link2 className="w-3.5 h-3.5 me-1" /> קשר החלטה
</Button>
</div>
{related.length === 0 ? (
<p className="text-ink-muted text-sm">אין החלטות קשורות עדיין</p>
) : (
<div className="space-y-1.5">
{related.map((r) => (
<RelatedCaseCard key={r.id} caseId={caseId} related={r} />
))}
</div>
)}
<LinkDialog
caseId={caseId}
currentRelated={related}
open={dialogOpen}
onOpenChange={setDialogOpen}
/>
</div>
);
}

View File

@@ -35,3 +35,17 @@ export function practiceAreaShort(value: string | null | undefined): string {
const match = PRACTICE_AREAS.find((p) => p.value === value); const match = PRACTICE_AREAS.find((p) => p.value === value);
return match ? match.short : value; return match ? match.short : value;
} }
/**
* Display label for ``appeal_subtype``. The field is free text — the chair
* usually types specific subjects like "תכנית רחביה" or "סופיות ההחלטה" —
* but the LLM extractor sometimes mistakenly stuffs the practice_area
* enum value (``betterment_levy``, ``rishuy_uvniya``, ``compensation_197``)
* in there. When that happens, translate to Hebrew so the UI doesn't
* leak English. Otherwise return the value unchanged.
*/
export function appealSubtypeLabel(value: string | null | undefined): string {
if (!value) return "";
const enumMatch = PRACTICE_AREAS.find((p) => p.value === value);
return enumMatch ? enumMatch.label : value;
}

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useState } from "react";
import { Save, Sparkles } from "lucide-react"; import { Save, Sparkles } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@@ -22,7 +22,7 @@ import {
type SourceType, type SourceType,
} from "@/lib/api/precedent-library"; } from "@/lib/api/precedent-library";
import { import {
PRACTICE_AREAS, PRECEDENT_LEVELS, SOURCE_TYPES, PRACTICE_AREAS, PRECEDENT_LEVELS, SOURCE_TYPES, appealSubtypeLabel,
} from "./practice-area"; } from "./practice-area";
import { ExtractedHalachotSection } from "./extracted-halachot"; import { ExtractedHalachotSection } from "./extracted-halachot";
@@ -38,6 +38,8 @@ type FormState = {
citation: string; citation: string;
case_name: string; case_name: string;
court: string; court: string;
district: string;
chair_name: string;
decision_date: string; decision_date: string;
practice_area: PracticeArea; practice_area: PracticeArea;
appeal_subtype: string; appeal_subtype: string;
@@ -51,8 +53,8 @@ type FormState = {
}; };
const EMPTY: FormState = { const EMPTY: FormState = {
citation: "", case_name: "", court: "", decision_date: "", citation: "", case_name: "", court: "", district: "", chair_name: "",
practice_area: "", appeal_subtype: "", source_type: "", decision_date: "", practice_area: "", appeal_subtype: "", source_type: "",
precedent_level: "", is_binding: true, subject_tags: "", precedent_level: "", is_binding: true, subject_tags: "",
summary: "", headnote: "", key_quote: "", summary: "", headnote: "", key_quote: "",
}; };
@@ -65,17 +67,21 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
const [form, setForm] = useState<FormState>(EMPTY); const [form, setForm] = useState<FormState>(EMPTY);
// Hydrate form when the record loads. // React-approved derived-state pattern: sync form whenever a different
useEffect(() => { // record arrives (including after save+refetch). Using setState during
if (!record) return; // render avoids the one-frame flash that useEffect would produce.
// eslint-disable-next-line react-hooks/set-state-in-effect const [syncedRecordId, setSyncedRecordId] = useState<string | null>(null);
if (record && record.id !== syncedRecordId) {
setSyncedRecordId(record.id as string);
setForm({ setForm({
citation: record.case_number || "", citation: record.case_number || "",
case_name: record.case_name || "", case_name: record.case_name || "",
court: record.court || "", court: record.court || "",
district: record.district || "",
chair_name: record.chair_name || "",
decision_date: record.date ? record.date.slice(0, 10) : "", decision_date: record.date ? record.date.slice(0, 10) : "",
practice_area: (record.practice_area || "") as PracticeArea, practice_area: (record.practice_area || "") as PracticeArea,
appeal_subtype: record.appeal_subtype || "", appeal_subtype: appealSubtypeLabel(record.appeal_subtype),
source_type: (record.source_type || "") as SourceType, source_type: (record.source_type || "") as SourceType,
precedent_level: record.precedent_level || "", precedent_level: record.precedent_level || "",
is_binding: record.is_binding ?? true, is_binding: record.is_binding ?? true,
@@ -84,7 +90,7 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
headnote: record.headnote || "", headnote: record.headnote || "",
key_quote: (record as { key_quote?: string }).key_quote || "", key_quote: (record as { key_quote?: string }).key_quote || "",
}); });
}, [record]); }
const onSubmit = async (e: React.FormEvent) => { const onSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -93,6 +99,8 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
const patch: Record<string, unknown> = { const patch: Record<string, unknown> = {
case_name: form.case_name.trim(), case_name: form.case_name.trim(),
court: form.court.trim(), court: form.court.trim(),
district: form.district.trim(),
chair_name: form.chair_name.trim(),
practice_area: form.practice_area || undefined, practice_area: form.practice_area || undefined,
appeal_subtype: form.appeal_subtype.trim(), appeal_subtype: form.appeal_subtype.trim(),
source_type: form.source_type || undefined, source_type: form.source_type || undefined,
@@ -178,6 +186,18 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
<Input id="court" value={form.court} <Input id="court" value={form.court}
onChange={(e) => setForm({ ...form, court: e.target.value })} /> onChange={(e) => setForm({ ...form, court: e.target.value })} />
</div> </div>
<div className="space-y-1">
<Label htmlFor="district">מחוז</Label>
<Input id="district" value={form.district}
onChange={(e) => setForm({ ...form, district: e.target.value })}
placeholder="ירושלים / תל אביב / מרכז" />
</div>
<div className="space-y-1">
<Label htmlFor="chair-name">יו"ר</Label>
<Input id="chair-name" value={form.chair_name}
onChange={(e) => setForm({ ...form, chair_name: e.target.value })}
placeholder="עו״ד דפנה תמיר" />
</div>
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="date">תאריך</Label> <Label htmlFor="date">תאריך</Label>
<Input id="date" type="date" value={form.decision_date} <Input id="date" type="date" value={form.decision_date}

View File

@@ -321,6 +321,7 @@ export function CaseWizard() {
</Button> </Button>
{isLast ? ( {isLast ? (
<Button <Button
key="submit-btn"
type="submit" type="submit"
disabled={mutate.isPending} disabled={mutate.isPending}
className="bg-navy hover:bg-navy-soft text-parchment" className="bg-navy hover:bg-navy-soft text-parchment"
@@ -329,6 +330,7 @@ export function CaseWizard() {
</Button> </Button>
) : ( ) : (
<Button <Button
key="next-btn"
type="button" type="button"
onClick={goNext} onClick={goNext}
className="bg-navy hover:bg-navy-soft text-parchment" className="bg-navy hover:bg-navy-soft text-parchment"

View File

@@ -50,6 +50,10 @@ export type Case = {
processing_count?: number; processing_count?: number;
committee_type?: string | null; committee_type?: string | null;
hearing_date?: string | null; hearing_date?: string | null;
appellants?: string[] | null;
respondents?: string[] | null;
property_address?: string | null;
permit_number?: string | null;
}; };
export type CaseDocument = { export type CaseDocument = {

View File

@@ -46,6 +46,8 @@ export type Precedent = {
headnote: string; headnote: string;
subject_tags: string[]; subject_tags: string[];
source_kind: string; source_kind: string;
chair_name: string | null;
district: string | null;
extraction_status: string; extraction_status: string;
halacha_extraction_status: string; halacha_extraction_status: string;
metadata_extraction_requested_at: string | null; metadata_extraction_requested_at: string | null;
@@ -82,9 +84,20 @@ export type Halacha = {
precedent_level?: string; precedent_level?: string;
}; };
export type RelatedCase = {
id: string;
case_number: string;
case_name: string;
court: string;
precedent_level: string;
date: string | null;
relation_type: string;
};
export type PrecedentDetail = Precedent & { export type PrecedentDetail = Precedent & {
full_text: string; full_text: string;
halachot: Halacha[]; halachot: Halacha[];
related_cases: RelatedCase[];
}; };
export type SearchHit = export type SearchHit =
@@ -137,6 +150,7 @@ export type ListFilters = {
court?: string; court?: string;
precedentLevel?: string; precedentLevel?: string;
sourceType?: SourceType; sourceType?: SourceType;
sourceKind?: string;
search?: string; search?: string;
limit?: number; limit?: number;
offset?: number; offset?: number;
@@ -164,6 +178,7 @@ export function usePrecedents(filters: ListFilters = {}) {
if (filters.court) p.set("court", filters.court); if (filters.court) p.set("court", filters.court);
if (filters.precedentLevel) p.set("precedent_level", filters.precedentLevel); if (filters.precedentLevel) p.set("precedent_level", filters.precedentLevel);
if (filters.sourceType) p.set("source_type", filters.sourceType); if (filters.sourceType) p.set("source_type", filters.sourceType);
if (filters.sourceKind) p.set("source_kind", filters.sourceKind);
if (filters.search) p.set("search", filters.search); if (filters.search) p.set("search", filters.search);
if (filters.limit) p.set("limit", String(filters.limit)); if (filters.limit) p.set("limit", String(filters.limit));
if (filters.offset) p.set("offset", String(filters.offset)); if (filters.offset) p.set("offset", String(filters.offset));
@@ -353,6 +368,40 @@ export function useDeletePrecedent() {
}); });
} }
export function useLinkRelatedCase(caseId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (vars: { relatedId: string; relationType?: string }) =>
apiRequest<{ linked: boolean }>(
`/api/precedent-library/${encodeURIComponent(caseId)}/relations`,
{
method: "POST",
body: {
related_id: vars.relatedId,
relation_type: vars.relationType ?? "same_case_chain",
},
},
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: libraryKeys.detail(caseId) });
},
});
}
export function useUnlinkRelatedCase(caseId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (relatedId: string) =>
apiRequest<{ unlinked: boolean }>(
`/api/precedent-library/${encodeURIComponent(caseId)}/relations/${encodeURIComponent(relatedId)}`,
{ method: "DELETE" },
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: libraryKeys.detail(caseId) });
},
});
}
export type PrecedentPatch = Partial<{ export type PrecedentPatch = Partial<{
case_name: string; case_name: string;
court: string; court: string;
@@ -365,6 +414,8 @@ export type PrecedentPatch = Partial<{
source_type: SourceType; source_type: SourceType;
precedent_level: string; precedent_level: string;
is_binding: boolean; is_binding: boolean;
district: string;
chair_name: string;
}>; }>;
export function useUpdatePrecedent() { export function useUpdatePrecedent() {

View File

@@ -432,6 +432,26 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/cases/stale": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Api Stale Cases
* @description Return cases that haven't been updated in N days and are not in a terminal/waiting status.
*/
get: operations["api_stale_cases_api_cases_stale_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/cases/{case_number}/archive": { "/api/cases/{case_number}/archive": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1355,7 +1375,7 @@ export interface paths {
}; };
/** /**
* Api Case Agents * Api Case Agents
* @description Get all Paperclip agent activity for a case: issues, comments, agent status. * @description Get all Paperclip agent activity for a case: issues, comments, interactions, agent status.
*/ */
get: operations["api_case_agents_api_cases__case_number__agents_get"]; get: operations["api_case_agents_api_cases__case_number__agents_get"];
put?: never; put?: never;
@@ -1388,6 +1408,149 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/cases/{case_number}/agents/interaction-response": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Api Post Interaction Response
* @description Submit a user's answer to a Paperclip issue-thread interaction.
*
* Routes to /respond | /accept | /reject based on `action`. Paperclip
* auto-wakes the issue assignee after a successful submission.
*/
post: operations["api_post_interaction_response_api_cases__case_number__agents_interaction_response_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/settings/mcp/env": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Api Mcp Env
* @description List all catalog env vars with Coolify (authoritative) + container values.
*/
get: operations["api_mcp_env_api_settings_mcp_env_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/settings/mcp/env/{key}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/**
* Api Mcp Env Update
* @description Update a non-secret env var in Coolify. Requires redeploy to take effect.
*/
patch: operations["api_mcp_env_update_api_settings_mcp_env__key__patch"];
trace?: never;
};
"/api/settings/mcp/env/redeploy": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Api Mcp Env Redeploy
* @description Trigger Coolify redeploy of the legal-ai app.
*/
post: operations["api_mcp_env_redeploy_api_settings_mcp_env_redeploy_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/settings/mcp/tools": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Api Mcp Tools
* @description List all MCP tools registered in legal_mcp.
*/
get: operations["api_mcp_tools_api_settings_mcp_tools_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/settings/mcp/registrations": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Api Mcp Registrations
* @description List MCP server registrations from host config files.
*/
get: operations["api_mcp_registrations_api_settings_mcp_registrations_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/settings/mcp/blocks": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Api Mcp Blocks
* @description List the 12-block decision schema (read-only reference).
*/
get: operations["api_mcp_blocks_api_settings_mcp_blocks_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/settings/paperclip-companies": { "/api/settings/paperclip-companies": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1516,6 +1679,29 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/admin/paperclip-agents": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Api List Paperclip Agents
* @description List all Paperclip agents grouped into master+mirror pairs with drift detection.
*
* Read-only. Source of truth: Paperclip ``GET /api/companies/{id}/agents`` API
* (not direct DB) — keeps us decoupled from Paperclip's schema changes.
*/
get: operations["api_list_paperclip_agents_api_admin_paperclip_agents_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/skills/install": { "/api/admin/skills/install": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1761,6 +1947,26 @@ export interface paths {
patch: operations["api_resolve_feedback_api_feedback__feedback_id__resolve_patch"]; patch: operations["api_resolve_feedback_api_feedback__feedback_id__resolve_patch"];
trace?: never; trace?: never;
}; };
"/api/chair-feedback/weekly-summary": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Api Chair Feedback Weekly Summary
* @description Return chair feedback from the last N days as a text summary for the CEO agent.
*/
get: operations["api_chair_feedback_weekly_summary_api_chair_feedback_weekly_summary_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/precedent-library/upload": { "/api/precedent-library/upload": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1853,6 +2059,40 @@ export interface paths {
patch: operations["precedent_library_update_api_precedent_library__case_law_id__patch"]; patch: operations["precedent_library_update_api_precedent_library__case_law_id__patch"];
trace?: never; trace?: never;
}; };
"/api/precedent-library/{case_law_id}/relations": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Precedent Add Relation */
post: operations["precedent_add_relation_api_precedent_library__case_law_id__relations_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/precedent-library/{case_law_id}/relations/{related_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Precedent Remove Relation */
delete: operations["precedent_remove_relation_api_precedent_library__case_law_id__relations__related_id__delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/precedent-library/{case_law_id}/request-metadata": { "/api/precedent-library/{case_law_id}/request-metadata": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1864,8 +2104,8 @@ export interface paths {
put?: never; put?: never;
/** /**
* Precedent Request Metadata * Precedent Request Metadata
* @description Stamp the case_law row as needing metadata extraction. The local * @description Stamp the case_law row as needing metadata extraction AND wake the
* MCP worker (`precedent_process_pending_metadata`) will pick it up. * Paperclip CEO so extraction runs automatically — same flow as upload.
*/ */
post: operations["precedent_request_metadata_api_precedent_library__case_law_id__request_metadata_post"]; post: operations["precedent_request_metadata_api_precedent_library__case_law_id__request_metadata_post"];
delete?: never; delete?: never;
@@ -1915,6 +2155,69 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/internal-decisions/upload": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Internal Decisions Upload
* @description Upload a planning appeals-committee decision to the internal corpus.
*/
post: operations["internal_decisions_upload_api_internal_decisions_upload_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/internal-decisions/migrate": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Internal Decisions Migrate
* @description Migrate existing data to the internal committee corpus.
*
* source: 'style_corpus' | 'external_corpus' | 'both'
* dry_run: if true, only report what would be done (no writes)
*/
post: operations["internal_decisions_migrate_api_internal_decisions_migrate_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/internal-decisions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Internal Decisions List
* @description List internal committee decisions with optional filters.
*/
get: operations["internal_decisions_list_api_internal_decisions_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/halachot": { "/api/halachot": {
parameters: { parameters: {
query?: never; query?: never;
@@ -2028,6 +2331,63 @@ export interface components {
*/ */
title: string; title: string;
}; };
/** Body_internal_decisions_upload_api_internal_decisions_upload_post */
Body_internal_decisions_upload_api_internal_decisions_upload_post: {
/** File */
file: string;
/** Case Number */
case_number: string;
/**
* Case Name
* @default
*/
case_name: string;
/**
* Court
* @default
*/
court: string;
/**
* Decision Date
* @default
*/
decision_date: string;
/**
* Chair Name
* @default
*/
chair_name: string;
/**
* District
* @default
*/
district: string;
/**
* Practice Area
* @default
*/
practice_area: string;
/**
* Appeal Subtype
* @default
*/
appeal_subtype: string;
/**
* Subject Tags
* @default []
*/
subject_tags: string;
/**
* Is Binding
* @default true
*/
is_binding: boolean;
/**
* Summary
* @default
*/
summary: string;
};
/** Body_precedent_library_upload_api_precedent_library_upload_post */ /** Body_precedent_library_upload_api_precedent_library_upload_post */
Body_precedent_library_upload_api_precedent_library_upload_post: { Body_precedent_library_upload_api_precedent_library_upload_post: {
/** File */ /** File */
@@ -2295,6 +2655,27 @@ export interface components {
/** Practice Areas */ /** Practice Areas */
practice_areas?: string[] | null; practice_areas?: string[] | null;
}; };
/** InteractionResponseRequest */
InteractionResponseRequest: {
/** Issue Id */
issue_id: string;
/** Interaction Id */
interaction_id: string;
/**
* Action
* @enum {string}
*/
action: "respond" | "accept" | "reject";
/** Payload */
payload: {
[key: string]: unknown;
};
};
/** McpEnvUpdateRequest */
McpEnvUpdateRequest: {
/** Value */
value: unknown;
};
/** MethodologyUpdateRequest */ /** MethodologyUpdateRequest */
MethodologyUpdateRequest: { MethodologyUpdateRequest: {
/** Value */ /** Value */
@@ -2349,6 +2730,16 @@ export interface components {
*/ */
pdf_document_id: string; pdf_document_id: string;
}; };
/** PrecedentRelationRequest */
PrecedentRelationRequest: {
/** Related Id */
related_id: string;
/**
* Relation Type
* @default same_case_chain
*/
relation_type: string;
};
/** PrecedentUpdateRequest */ /** PrecedentUpdateRequest */
PrecedentUpdateRequest: { PrecedentUpdateRequest: {
/** Case Name */ /** Case Name */
@@ -2996,6 +3387,37 @@ export interface operations {
}; };
}; };
}; };
api_stale_cases_api_cases_stale_get: {
parameters: {
query?: {
days?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
api_archive_case_api_cases__case_number__archive_post: { api_archive_case_api_cases__case_number__archive_post: {
parameters: { parameters: {
query?: never; query?: never;
@@ -4491,6 +4913,176 @@ export interface operations {
}; };
}; };
}; };
api_post_interaction_response_api_cases__case_number__agents_interaction_response_post: {
parameters: {
query?: never;
header?: never;
path: {
case_number: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["InteractionResponseRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
api_mcp_env_api_settings_mcp_env_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
};
};
api_mcp_env_update_api_settings_mcp_env__key__patch: {
parameters: {
query?: never;
header?: never;
path: {
key: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["McpEnvUpdateRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
api_mcp_env_redeploy_api_settings_mcp_env_redeploy_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
};
};
api_mcp_tools_api_settings_mcp_tools_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
};
};
api_mcp_registrations_api_settings_mcp_registrations_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
};
};
api_mcp_blocks_api_settings_mcp_blocks_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
};
};
api_paperclip_companies_api_settings_paperclip_companies_get: { api_paperclip_companies_api_settings_paperclip_companies_get: {
parameters: { parameters: {
query?: never; query?: never;
@@ -4714,6 +5306,26 @@ export interface operations {
}; };
}; };
}; };
api_list_paperclip_agents_api_admin_paperclip_agents_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
};
};
api_install_skill_api_admin_skills_install_post: { api_install_skill_api_admin_skills_install_post: {
parameters: { parameters: {
query?: never; query?: never;
@@ -5133,6 +5745,38 @@ export interface operations {
}; };
}; };
}; };
api_chair_feedback_weekly_summary_api_chair_feedback_weekly_summary_get: {
parameters: {
query?: {
days?: number;
limit?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
precedent_library_upload_api_precedent_library_upload_post: { precedent_library_upload_api_precedent_library_upload_post: {
parameters: { parameters: {
query?: never; query?: never;
@@ -5174,6 +5818,7 @@ export interface operations {
precedent_level?: string; precedent_level?: string;
source_type?: string; source_type?: string;
search?: string; search?: string;
source_kind?: string;
limit?: number; limit?: number;
offset?: number; offset?: number;
}; };
@@ -5358,6 +6003,73 @@ export interface operations {
}; };
}; };
}; };
precedent_add_relation_api_precedent_library__case_law_id__relations_post: {
parameters: {
query?: never;
header?: never;
path: {
case_law_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["PrecedentRelationRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
precedent_remove_relation_api_precedent_library__case_law_id__relations__related_id__delete: {
parameters: {
query?: never;
header?: never;
path: {
case_law_id: string;
related_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
precedent_request_metadata_api_precedent_library__case_law_id__request_metadata_post: { precedent_request_metadata_api_precedent_library__case_law_id__request_metadata_post: {
parameters: { parameters: {
query?: never; query?: never;
@@ -5452,6 +6164,105 @@ export interface operations {
}; };
}; };
}; };
internal_decisions_upload_api_internal_decisions_upload_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["Body_internal_decisions_upload_api_internal_decisions_upload_post"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
internal_decisions_migrate_api_internal_decisions_migrate_post: {
parameters: {
query?: {
source?: string;
dry_run?: boolean;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
internal_decisions_list_api_internal_decisions_get: {
parameters: {
query?: {
district?: string;
chair_name?: string;
practice_area?: string;
limit?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
halachot_list_api_halachot_get: { halachot_list_api_halachot_get: {
parameters: { parameters: {
query?: { query?: {

View File

@@ -89,6 +89,14 @@ export const caseUpdateSchema = z.object({
.enum(expectedOutcomes.map((o) => o.value) as [string, ...string[]]) .enum(expectedOutcomes.map((o) => o.value) as [string, ...string[]])
.optional(), .optional(),
status: z.string().optional(), status: z.string().optional(),
appellants: z
.array(z.string().trim().min(1).refine((v) => hebrewPartyRe.test(v), "שם לא תקין"))
.optional(),
respondents: z
.array(z.string().trim().min(1).refine((v) => hebrewPartyRe.test(v), "שם לא תקין"))
.optional(),
property_address: z.string().trim().max(200).optional(),
permit_number: z.string().trim().max(100).optional(),
}); });
export type CaseUpdateInput = z.infer<typeof caseUpdateSchema>; export type CaseUpdateInput = z.infer<typeof caseUpdateSchema>;

View File

@@ -20,7 +20,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "mcp-server" / "
import zipfile import zipfile
from fastapi import FastAPI, File, Form, HTTPException, UploadFile from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from typing import Any, Literal from typing import Any, Literal
from pydantic import BaseModel from pydantic import BaseModel
@@ -44,7 +44,7 @@ from web.mcp_env_catalog import (
normalize_for_compare, normalize_for_compare,
) )
from web.progress_store import ProgressStore from web.progress_store import ProgressStore
from web.paperclip_api import pc_request from web.paperclip_api import emit_case_status_webhook, pc_request
from web.paperclip_client import ( from web.paperclip_client import (
COMPANIES as PAPERCLIP_COMPANIES, COMPANIES as PAPERCLIP_COMPANIES,
accept_interaction as pc_accept_interaction, accept_interaction as pc_accept_interaction,
@@ -62,6 +62,7 @@ from web.paperclip_client import (
respond_to_interaction as pc_respond_to_interaction, respond_to_interaction as pc_respond_to_interaction,
restore_project as pc_restore_project, restore_project as pc_restore_project,
wake_ceo_agent as pc_wake_ceo, wake_ceo_agent as pc_wake_ceo,
wake_curator_for_final as pc_wake_curator_for_final,
wake_for_precedent_extraction as pc_wake_for_precedent_extraction, wake_for_precedent_extraction as pc_wake_for_precedent_extraction,
) )
@@ -1134,6 +1135,37 @@ async def list_cases(
return result return result
@app.get("/api/cases/stale")
async def api_stale_cases(days: int = 3):
"""Return cases that haven't been updated in N days and are not in a terminal/waiting status."""
if days <= 0:
return {"cases": [], "total": 0}
pool = await db.get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT case_number, title, status,
EXTRACT(DAY FROM (now() - updated_at))::int AS days_stale
FROM cases
WHERE status NOT IN ('final', 'new', 'exported')
AND archived_at IS NULL
AND updated_at < now() - make_interval(days => $1)
ORDER BY updated_at ASC -- oldest stale first (longest overdue = highest priority)
""",
days,
)
cases = [
{
"case_number": r["case_number"],
"title": r["title"],
"status": r["status"],
"days_stale": r["days_stale"],
}
for r in rows
]
return {"cases": cases, "total": len(cases)}
@app.post("/api/cases/{case_number}/archive") @app.post("/api/cases/{case_number}/archive")
async def api_archive_case(case_number: str): async def api_archive_case(case_number: str):
"""Move a case to the archive. Also archives the matching Paperclip project.""" """Move a case to the archive. Also archives the matching Paperclip project."""
@@ -1209,6 +1241,10 @@ class CaseUpdateRequest(BaseModel):
decision_date: str = "" decision_date: str = ""
tags: list[str] | None = None tags: list[str] | None = None
expected_outcome: str = "" expected_outcome: str = ""
appellants: list[str] | None = None
respondents: list[str] | None = None
property_address: str = ""
permit_number: str = ""
@app.post("/api/cases/create") @app.post("/api/cases/create")
@@ -1336,8 +1372,12 @@ async def api_case_get(case_number: str):
@app.put("/api/cases/{case_number}") @app.put("/api/cases/{case_number}")
async def api_case_update(case_number: str, req: CaseUpdateRequest): async def api_case_update(case_number: str, req: CaseUpdateRequest, background_tasks: BackgroundTasks):
"""Update case details.""" """Update case details."""
# Capture old status before the update so we can detect changes.
existing = await db.get_case_by_number(case_number)
old_status = (existing or {}).get("status", "")
result = await cases_tools.case_update( result = await cases_tools.case_update(
case_number=case_number, case_number=case_number,
status=req.status, status=req.status,
@@ -1348,12 +1388,45 @@ async def api_case_update(case_number: str, req: CaseUpdateRequest):
decision_date=req.decision_date, decision_date=req.decision_date,
tags=req.tags, tags=req.tags,
expected_outcome=req.expected_outcome, expected_outcome=req.expected_outcome,
appellants=req.appellants,
respondents=req.respondents,
property_address=req.property_address,
permit_number=req.permit_number,
) )
try: try:
return json.loads(result) parsed = json.loads(result)
except json.JSONDecodeError: except json.JSONDecodeError:
raise HTTPException(404, result) raise HTTPException(404, result)
# Paperclip sync: update project name when title changes (fire-and-forget).
old_title = (existing or {}).get("title", "")
if req.title and req.title != old_title:
background_tasks.add_task(
paperclip_client.update_project_name,
case_number=case_number,
new_title=req.title,
)
# Emit webhook when status changes (fire-and-forget via BackgroundTasks).
new_status = req.status
if new_status and old_status != new_status:
prefix = case_number[:1]
company_id = (
PAPERCLIP_COMPANIES["licensing"] if prefix == "1"
else PAPERCLIP_COMPANIES["betterment"] if prefix in ("8", "9")
else None
)
background_tasks.add_task(
emit_case_status_webhook,
case_number=case_number,
old_status=old_status,
new_status=new_status,
company_id=company_id, # None is safe — plugin handles unknown company gracefully
)
logger.debug("webhook scheduled: case %s %s%s", case_number, old_status, new_status)
return parsed
@app.delete("/api/cases") @app.delete("/api/cases")
async def api_case_delete(case_number: str, remove_files: bool = False): async def api_case_delete(case_number: str, remove_files: bool = False):
@@ -2347,10 +2420,38 @@ async def api_mark_final(case_number: str, filename: str):
if case_dir.exists(): if case_dir.exists():
commit_and_push(case_dir, f"גרסה סופית: {final_name}") commit_and_push(case_dir, f"גרסה סופית: {final_name}")
# ingest_final_version into the case_law corpus is NOT called here:
# it uses claude_session under the hood, which only works when invoked
# from the local MCP server (Claude CLI present), not from this
# FastAPI container. Run it manually via Claude Code / MCP when needed.
# See: ~/.claude/projects/-home-chaim-legal-ai/memory/feedback_claude_session_local_only.md
ingest_status: dict = {"status": "skipped", "reason": "container_no_claude_cli"}
# Best-effort: wake the Knowledge Curator (Hermes) to analyze the
# signed final and propose updates to skills/lessons. Non-fatal on
# failure so marking final never breaks for the user.
curator_status: dict = {"status": "skipped"}
try:
# Company by case-number prefix: 1xxx=CMP (licensing), 8/9xxx=CMPA (betterment)
prefix = case_number[:1]
company_id = (
PAPERCLIP_COMPANIES["licensing"] if prefix == "1"
else PAPERCLIP_COMPANIES["betterment"] if prefix in ("8", "9")
else ""
)
curator_status = await pc_wake_curator_for_final(
case_number, final_name, company_id=company_id
)
except Exception as e:
logger.warning("curator wakeup failed for %s: %s", case_number, e)
curator_status = {"status": "error", "error": str(e)}
return { return {
"final_filename": final_name, "final_filename": final_name,
"training_copy": str(training_dest), "training_copy": str(training_dest),
"status": "final", "status": "final",
"ingest_final": ingest_status,
"curator": curator_status,
} }
@@ -2488,14 +2589,17 @@ async def api_start_workflow(case_number: str):
except Exception as e: except Exception as e:
raise HTTPException(502, f"שגיאת Paperclip: {e}") raise HTTPException(502, f"שגיאת Paperclip: {e}")
# 3. Wake the CEO agent # 3. Wake the CEO agent — must succeed before marking case as processing
try: try:
wakeup = await pc_wake_ceo(issue["issue_id"], case_number, issue.get("company_id", "")) wakeup = await pc_wake_ceo(issue["issue_id"], case_number, issue.get("company_id", ""))
except Exception as e: except Exception as e:
logger.warning("CEO wakeup failed for case %s: %s", case_number, e) logger.error("CEO wakeup failed for case %s: %s", case_number, e)
wakeup = {"error": str(e)} raise HTTPException(
502,
f"נוצר issue {issue['identifier']} אך עירור ה-CEO נכשל: {e}. ניתן לנסות שנית.",
)
# 4. Update case status to processing # 4. Update case status to processing (only after wakeup confirmed)
await cases_tools.case_update(case_number, status="processing") await cases_tools.case_update(case_number, status="processing")
return { return {
@@ -3028,8 +3132,16 @@ async def api_get_methodology(category: str):
items = {} items = {}
for key, default_val in defaults.items(): for key, default_val in defaults.items():
if key in overrides: if key in overrides:
raw = overrides[key]["rule_value"]
# asyncpg returns JSONB as a raw JSON string when no codec is registered.
# Parse it back to a Python object so the frontend receives the correct type.
if isinstance(raw, str):
try:
raw = json.loads(raw)
except (json.JSONDecodeError, TypeError):
pass
items[key] = { items[key] = {
"value": overrides[key]["rule_value"], "value": raw,
"is_override": True, "is_override": True,
"updated_at": overrides[key]["created_at"].isoformat() if overrides[key]["created_at"] else None, "updated_at": overrides[key]["created_at"].isoformat() if overrides[key]["created_at"] else None,
} }
@@ -3066,10 +3178,14 @@ async def api_update_methodology(category: str, key: str, req: MethodologyUpdate
raise HTTPException(422, "content_checklists value must be a non-empty string") raise HTTPException(422, "content_checklists value must be a non-empty string")
pool = await db.get_pool() pool = await db.get_pool()
# json.dumps → text, then PostgreSQL casts text→jsonb.
# Passing a Python list directly causes "expected str, got list" in asyncpg;
# passing a str with ::jsonb causes double-encoding (stored as JSONB string).
# ::text::jsonb bypasses asyncpg's codec and lets PostgreSQL parse the JSON.
await pool.execute( await pool.execute(
"INSERT INTO appeal_type_rules (id, appeal_type, rule_category, rule_key, rule_value) " "INSERT INTO appeal_type_rules (id, appeal_type, rule_category, rule_key, rule_value) "
"VALUES (gen_random_uuid(), '_global', $1, $2, $3::jsonb) " "VALUES (gen_random_uuid(), '_global', $1, $2, $3::text::jsonb) "
"ON CONFLICT (appeal_type, rule_category, rule_key) DO UPDATE SET rule_value = $3::jsonb", "ON CONFLICT (appeal_type, rule_category, rule_key) DO UPDATE SET rule_value = $3::text::jsonb",
category, key, json.dumps(req.value, ensure_ascii=False), category, key, json.dumps(req.value, ensure_ascii=False),
) )
@@ -3191,6 +3307,8 @@ _AGENT_NAME_ORDER = {
} }
# Fields that should match between master (CMP) and mirror (CMPA). Drift = bug. # Fields that should match between master (CMP) and mirror (CMPA). Drift = bug.
# `status` is intentionally excluded — it's runtime state (running/idle/paused),
# not config, and changes constantly.
_DRIFT_FIELDS = ( _DRIFT_FIELDS = (
"model", "model",
"effort", "effort",
@@ -3204,10 +3322,21 @@ _DRIFT_FIELDS = (
"wakeOnDemand", "wakeOnDemand",
"maxConcurrentRuns", "maxConcurrentRuns",
"budget_monthly_cents", "budget_monthly_cents",
"status",
) )
def _portable_skills(skills: list[str]) -> list[str]:
"""Return only the skills whose drift across companies is meaningful.
`local/*` skills carry per-install hashes (different IDs per company even
when the underlying skill is identical); `company/{cid}/*` skills are
scoped to a single company by construction. Both are expected to differ
between master and mirror — comparing them produces noise. Only
`paperclipai/*` (vendor-shipped) skills should match exactly.
"""
return sorted(s for s in skills if s.startswith("paperclipai/"))
def _shape_paperclip_agent(raw: dict, company_id: str, company_name: str) -> dict: def _shape_paperclip_agent(raw: dict, company_id: str, company_name: str) -> dict:
"""Flatten a Paperclip agent row into the shape the UI consumes.""" """Flatten a Paperclip agent row into the shape the UI consumes."""
ac = raw.get("adapterConfig") or {} ac = raw.get("adapterConfig") or {}
@@ -3252,6 +3381,9 @@ def _compute_drift(master: dict | None, mirror: dict | None) -> list[dict]:
for field in _DRIFT_FIELDS: for field in _DRIFT_FIELDS:
m_val = master.get(field) m_val = master.get(field)
i_val = mirror.get(field) i_val = mirror.get(field)
if field == "desiredSkills":
m_val = _portable_skills(m_val or [])
i_val = _portable_skills(i_val or [])
if m_val != i_val: if m_val != i_val:
drift.append({"field": field, "master": m_val, "mirror": i_val}) drift.append({"field": field, "master": m_val, "mirror": i_val})
return drift return drift
@@ -3934,6 +4066,34 @@ async def api_resolve_feedback(feedback_id: str, body: dict):
return {"status": "resolved"} return {"status": "resolved"}
@app.get("/api/chair-feedback/weekly-summary")
async def api_chair_feedback_weekly_summary(days: int = 7, limit: int = 100):
"""Return chair feedback from the last N days as a text summary for the CEO agent."""
if days <= 0:
return {"summary": "", "entry_count": 0}
pool = await db.get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT cf.feedback_text, c.case_number, c.title
FROM chair_feedback cf
LEFT JOIN cases c ON c.id = cf.case_id
WHERE cf.created_at >= now() - make_interval(days => $1)
ORDER BY cf.created_at DESC
LIMIT $2
""",
days,
limit,
)
if not rows:
return {"summary": "", "entry_count": 0}
lines = [
f"- תיק {r['case_number'] or ''} ({r['title'] or ''}): {r['feedback_text']}"
for r in rows
]
return {"summary": "\n".join(lines), "entry_count": len(rows)}
# ── Background Processing ───────────────────────────────────────── # ── Background Processing ─────────────────────────────────────────
@@ -4127,6 +4287,8 @@ class PrecedentUpdateRequest(BaseModel):
source_type: str | None = None source_type: str | None = None
precedent_level: str | None = None precedent_level: str | None = None
is_binding: bool | None = None is_binding: bool | None = None
district: str | None = None
chair_name: str | None = None
class HalachaUpdateRequest(BaseModel): class HalachaUpdateRequest(BaseModel):
@@ -4246,13 +4408,15 @@ async def precedent_library_list(
precedent_level: str = "", precedent_level: str = "",
source_type: str = "", source_type: str = "",
search: str = "", search: str = "",
source_kind: str = "external_upload",
limit: int = 100, limit: int = 100,
offset: int = 0, offset: int = 0,
): ):
rows = await db.list_external_case_law( rows = await db.list_external_case_law(
practice_area=practice_area, court=court, practice_area=practice_area, court=court,
precedent_level=precedent_level, source_type=source_type, precedent_level=precedent_level, source_type=source_type,
search=search, limit=limit, offset=offset, search=search, source_kind=source_kind,
limit=limit, offset=offset,
) )
return {"items": rows, "count": len(rows)} return {"items": rows, "count": len(rows)}
@@ -4333,6 +4497,37 @@ async def precedent_library_delete(case_law_id: str):
return {"deleted": True, "case_law_id": case_law_id} return {"deleted": True, "case_law_id": case_law_id}
class PrecedentRelationRequest(BaseModel):
related_id: str
relation_type: str = "same_case_chain"
@app.post("/api/precedent-library/{case_law_id}/relations")
async def precedent_add_relation(case_law_id: str, req: PrecedentRelationRequest):
try:
a = UUID(case_law_id)
b = UUID(req.related_id)
except ValueError:
raise HTTPException(400, "case_law_id לא תקין")
if not await db.get_case_law(a):
raise HTTPException(404, "פסיקה לא נמצאה")
if not await db.get_case_law(b):
raise HTTPException(404, f"פסיקה קשורה {req.related_id} לא נמצאה")
await db.add_case_law_relation(a, b, req.relation_type)
return {"linked": True, "case_law_id": case_law_id, "related_id": req.related_id}
@app.delete("/api/precedent-library/{case_law_id}/relations/{related_id}")
async def precedent_remove_relation(case_law_id: str, related_id: str):
try:
a = UUID(case_law_id)
b = UUID(related_id)
except ValueError:
raise HTTPException(400, "case_law_id לא תקין")
await db.remove_case_law_relation(a, b)
return {"unlinked": True, "case_law_id": case_law_id, "related_id": related_id}
# Halacha and metadata extraction are LLM-driven and rely on the local # Halacha and metadata extraction are LLM-driven and rely on the local
# `claude` CLI via mcp-server/services/claude_session.py — they CANNOT run # `claude` CLI via mcp-server/services/claude_session.py — they CANNOT run
# from this container (no CLI, no claude.ai session). The endpoints below # from this container (no CLI, no claude.ai session). The endpoints below
@@ -4342,18 +4537,41 @@ async def precedent_library_delete(case_law_id: str):
# drain the queue. # drain the queue.
async def _wake_ceo_for_precedent(case_law_id: UUID, kind: str) -> dict:
"""Trigger Paperclip CEO to drain the precedent extraction queue, mirroring
the upload flow (see ``precedent_library_upload`` → ``pc_wake_for_precedent_extraction``).
Best-effort — any failure is logged but doesn't surface to the user, who
can still invoke ``mcp__legal-ai__precedent_process_pending`` manually.
Returns a dict with the wakeup outcome for inclusion in the API response.
"""
record = await db.get_case_law(case_law_id)
if not record:
return {"ok": False, "skipped": "record_missing"}
try:
return await pc_wake_for_precedent_extraction(
case_law_id=str(case_law_id),
citation=str(record.get("case_number") or ""),
practice_area=str(record.get("practice_area") or ""),
)
except Exception:
logger.exception("precedent-extraction wakeup failed (non-fatal, kind=%s)", kind)
return {"ok": False, "error": "wakeup_failed"}
@app.post("/api/precedent-library/{case_law_id}/request-metadata") @app.post("/api/precedent-library/{case_law_id}/request-metadata")
async def precedent_request_metadata(case_law_id: str): async def precedent_request_metadata(case_law_id: str):
"""Stamp the case_law row as needing metadata extraction. The local """Stamp the case_law row as needing metadata extraction AND wake the
MCP worker (`precedent_process_pending_metadata`) will pick it up.""" Paperclip CEO so extraction runs automatically — same flow as upload."""
try: try:
cid = UUID(case_law_id) cid = UUID(case_law_id)
except ValueError: except ValueError:
raise HTTPException(400, "case_law_id לא תקין") raise HTTPException(400, "case_law_id לא תקין")
ok = await db.request_metadata_extraction(cid) ok = await db.request_metadata_extraction(cid)
if not ok: if not ok:
raise HTTPException(404, "פסיקה לא נמצאה (או לא מסוג external_upload)") raise HTTPException(404, "פסיקה לא נמצאה")
return {"queued": True, "case_law_id": case_law_id, "kind": "metadata"} wakeup = await _wake_ceo_for_precedent(cid, kind="metadata")
return {"queued": True, "case_law_id": case_law_id, "kind": "metadata", "wakeup": wakeup}
@app.post("/api/precedent-library/{case_law_id}/request-halachot") @app.post("/api/precedent-library/{case_law_id}/request-halachot")
@@ -4365,8 +4583,9 @@ async def precedent_request_halachot(case_law_id: str):
raise HTTPException(400, "case_law_id לא תקין") raise HTTPException(400, "case_law_id לא תקין")
ok = await db.request_halacha_extraction(cid) ok = await db.request_halacha_extraction(cid)
if not ok: if not ok:
raise HTTPException(404, "פסיקה לא נמצאה (או לא מסוג external_upload)") raise HTTPException(404, "פסיקה לא נמצאה")
return {"queued": True, "case_law_id": case_law_id, "kind": "halacha"} wakeup = await _wake_ceo_for_precedent(cid, kind="halacha")
return {"queued": True, "case_law_id": case_law_id, "kind": "halacha", "wakeup": wakeup}
@app.get("/api/precedent-library/queue/pending") @app.get("/api/precedent-library/queue/pending")
@@ -4379,6 +4598,148 @@ async def precedent_queue_pending(kind: str = "metadata", limit: int = 20):
return {"items": items, "count": len(items)} return {"items": items, "count": len(items)}
from legal_mcp.services import internal_decisions as int_decisions_service # noqa: E402
@app.post("/api/internal-decisions/upload")
async def internal_decisions_upload(
file: UploadFile = File(...),
case_number: str = Form(...),
case_name: str = Form(""),
court: str = Form(""),
decision_date: str = Form(""),
chair_name: str = Form(""),
district: str = Form(""),
practice_area: str = Form(""),
appeal_subtype: str = Form(""),
subject_tags: str = Form("[]"),
is_binding: bool = Form(True),
summary: str = Form(""),
):
"""Upload a planning appeals-committee decision to the internal corpus."""
if practice_area and practice_area not in _PRACTICE_AREAS:
raise HTTPException(400, "practice_area לא תקין")
if not case_number.strip():
raise HTTPException(400, "case_number חובה")
suffix = Path(file.filename or "").suffix.lower()
if suffix not in ALLOWED_EXTENSIONS:
raise HTTPException(400, f"סוג קובץ לא נתמך: {suffix}")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
staged = UPLOAD_DIR / f"int_{uuid4().hex[:8]}_{file.filename}"
size = 0
with staged.open("wb") as out:
while chunk := await file.read(1024 * 1024):
size += len(chunk)
if size > MAX_FILE_SIZE:
staged.unlink(missing_ok=True)
raise HTTPException(413, "קובץ גדול מדי")
out.write(chunk)
try:
tags = json.loads(subject_tags) if subject_tags else []
if not isinstance(tags, list):
tags = []
except json.JSONDecodeError:
tags = []
task_id = str(uuid4())
await _progress.set(task_id, {
"status": "queued", "filename": file.filename or "",
"stage": "queued", "percent": 0,
})
async def _run():
try:
await int_decisions_service.ingest_internal_decision(
case_number=case_number.strip(),
case_name=case_name.strip(),
court=court.strip(),
decision_date=decision_date or None,
chair_name=chair_name.strip(),
district=district.strip(),
practice_area=practice_area,
appeal_subtype=appeal_subtype.strip(),
subject_tags=tags,
is_binding=is_binding,
summary=summary.strip(),
file_path=staged,
)
await _progress.set(task_id, {"status": "completed", "percent": 100})
except Exception as e:
logger.exception("internal-decisions upload failed")
await _progress.set(task_id, {"status": "failed", "error": str(e)})
finally:
staged.unlink(missing_ok=True)
asyncio.create_task(_run())
return {"task_id": task_id}
@app.post("/api/internal-decisions/migrate")
async def internal_decisions_migrate(
source: str = "both",
dry_run: bool = True,
):
"""Migrate existing data to the internal committee corpus.
source: 'style_corpus' | 'external_corpus' | 'both'
dry_run: if true, only report what would be done (no writes)
"""
if source not in {"style_corpus", "external_corpus", "both"}:
raise HTTPException(400, "source חייב להיות style_corpus / external_corpus / both")
results: dict = {}
if source in {"style_corpus", "both"}:
results["style_corpus"] = await int_decisions_service.migrate_from_style_corpus(dry_run=dry_run)
if source in {"external_corpus", "both"}:
results["external_corpus"] = await int_decisions_service.migrate_from_external_corpus(dry_run=dry_run)
return results
@app.get("/api/internal-decisions")
async def internal_decisions_list(
district: str = "",
chair_name: str = "",
practice_area: str = "",
limit: int = 100,
):
"""List internal committee decisions with optional filters."""
pool = await db.get_pool()
async with pool.acquire() as conn:
conditions = ["source_kind = 'internal_committee'"]
params: list = []
idx = 1
if district:
conditions.append(f"district = ${idx}")
params.append(district)
idx += 1
if chair_name:
conditions.append(f"chair_name = ${idx}")
params.append(chair_name)
idx += 1
if practice_area:
conditions.append(f"practice_area = ${idx}")
params.append(practice_area)
idx += 1
params.append(limit)
where = " AND ".join(conditions)
rows = await conn.fetch(
f"SELECT id, case_number, case_name, court, district, chair_name, "
f"date, practice_area, appeal_subtype, extraction_status, halacha_extraction_status "
f"FROM case_law WHERE {where} ORDER BY date DESC NULLS LAST LIMIT ${idx}",
*params,
)
total = await conn.fetchval(
"SELECT COUNT(*) FROM case_law WHERE source_kind = 'internal_committee'"
)
return {
"total": total,
"items": [dict(r) for r in rows],
}
@app.get("/api/halachot") @app.get("/api/halachot")
async def halachot_list( async def halachot_list(
case_law_id: str = "", case_law_id: str = "",

View File

@@ -19,6 +19,7 @@ from __future__ import annotations
import logging import logging
import os import os
from datetime import datetime, timezone
from typing import Any from typing import Any
import httpx import httpx
@@ -81,3 +82,35 @@ async def pc_request(
if raise_on_error: if raise_on_error:
resp.raise_for_status() resp.raise_for_status()
return resp return resp
async def emit_case_status_webhook(
case_number: str,
old_status: str,
new_status: str,
company_id: str | None = None,
run_id: str | None = None,
) -> None:
"""Notify the Paperclip plugin that a case status changed.
Fire-and-forget: logs errors but never raises, so callers aren't blocked.
"""
try:
await pc_request(
"POST",
"/api/plugins/marcusgroup.legal-ai/webhooks/case-status",
json={
"caseNumber": case_number,
"oldStatus": old_status,
"newStatus": new_status,
"companyId": company_id,
"timestamp": datetime.now(timezone.utc).isoformat(),
},
run_id=run_id,
timeout=5.0,
)
except Exception as exc:
logger.warning(
"emit_case_status_webhook failed for case %s (%s%s): %s",
case_number, old_status, new_status, exc,
)

View File

@@ -45,6 +45,14 @@ CEO_AGENTS = {
# Default for backwards compat # Default for backwards compat
CEO_AGENT_ID = CEO_AGENTS[COMPANIES["licensing"]] CEO_AGENT_ID = CEO_AGENTS[COMPANIES["licensing"]]
# Knowledge Curator (Hermes) agent per company — woken after a case is
# marked final, to analyze the signed decision and propose updates to
# the style guide / lessons. POC stage 1.
CURATOR_AGENTS = {
COMPANIES["licensing"]: "60dce831-5c5b-4bae-bda9-5282d506f0dc", # CMP curator
COMPANIES["betterment"]: "d6f7c55d-570a-46b8-8d72-1286d07da0d8", # CMPA curator
}
# Fallback mapping — used only when DB lookup returns no results # Fallback mapping — used only when DB lookup returns no results
_FALLBACK_APPEAL_TYPE_TO_COMPANY = { _FALLBACK_APPEAL_TYPE_TO_COMPANY = {
"רישוי": COMPANIES["licensing"], "רישוי": COMPANIES["licensing"],
@@ -223,6 +231,21 @@ async def restore_project(case_number: str) -> dict:
await conn.close() await conn.close()
async def update_project_name(case_number: str, new_title: str) -> None:
"""Update the Paperclip project name when a case title changes."""
project_name = f"ערר {case_number}{new_title}"[:200]
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
await conn.execute(
"UPDATE projects SET name = $1, updated_at = now() WHERE name LIKE $2",
project_name, f"%{case_number}%",
)
except Exception:
logger.warning("Failed to update Paperclip project name for case %s", case_number)
finally:
await conn.close()
async def _ensure_default_workspace( async def _ensure_default_workspace(
conn: asyncpg.Connection, conn: asyncpg.Connection,
project_id: str, project_id: str,
@@ -898,3 +921,92 @@ async def wake_ceo_agent(issue_id: str, case_number: str, company_id: str = "")
result = resp.json() result = resp.json()
logger.info("CEO agent wakeup for case %s: %s", case_number, result) logger.info("CEO agent wakeup for case %s: %s", case_number, result)
return result return result
async def wake_curator_for_final(
case_number: str,
final_filename: str,
company_id: str = "",
) -> dict:
"""Wake the Knowledge Curator (Hermes) when a case is marked final.
Creates a child issue under the main case issue, assigns it to the
curator, and triggers wakeup. Best-effort — silently skips if no
curator is configured for the company or no main issue is found.
Returns ``{"status": "ok"|"skipped", ...}``.
"""
if not PAPERCLIP_BOARD_API_KEY:
logger.warning("PAPERCLIP_BOARD_API_KEY not set — skipping curator wakeup")
return {"status": "skipped", "reason": "no_api_key"}
curator_id = CURATOR_AGENTS.get(company_id)
if not curator_id:
logger.info("No curator configured for company %s — skipping", company_id)
return {"status": "skipped", "reason": "no_curator", "company_id": company_id}
issues = await get_case_issues(case_number)
if not issues:
logger.warning("No Paperclip issues found for case %s — skipping curator", case_number)
return {"status": "skipped", "reason": "no_issue"}
main_issue = next((i for i in issues if i.get("status") == "in_progress"), None) or issues[0]
main_issue_id = main_issue["id"]
description = (
f"דפנה סימנה את ההחלטה הסופית של תיק {case_number} כסופית.\n"
f"קובץ סופי: `{final_filename}`\n\n"
f"סקור את ההחלטה מול skills/decision/SKILL.md ו-docs/legal-decision-lessons.md.\n"
f"חפש 3-5 דפוסי סגנון/דיון שלא תועדו. כתוב comment בעברית, ניטרלי, "
f"ממוספר. עדכן את MEMORY.md שלך. סגור את ה-issue (status=done)."
)
child_resp = await pc_request(
"POST",
f"/api/issues/{main_issue_id}/children",
json={
"title": f"[ערר {case_number}] סקירת ידע — Knowledge Curator",
"description": description,
"status": "in_progress",
"priority": "low",
"assigneeAgentId": curator_id,
},
raise_on_error=True,
)
sub_issue = child_resp.json()
sub_issue_id = sub_issue["id"]
# Tag plugin_state for case-number visibility on the case page
try:
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
await _link_case_to_issue(conn, sub_issue_id, case_number)
finally:
await conn.close()
except Exception as e:
logger.warning("plugin_state link failed for sub_issue=%s: %s", sub_issue_id, e)
# Trigger wakeup (use API per Paperclip rule — never DB insert)
wake_resp = await pc_request(
"POST",
f"/api/agents/{curator_id}/wakeup",
json={
"source": "on_demand",
"triggerDetail": "manual",
"reason": f"final_marked_{case_number}",
"payload": {
"issueId": sub_issue_id,
"mutation": "assignment",
},
},
raise_on_error=True,
)
logger.info(
"Curator wakeup for case %s: sub_issue=%s curator=%s wake=%s",
case_number, sub_issue_id, curator_id, wake_resp.status_code,
)
return {
"status": "ok",
"sub_issue_id": sub_issue_id,
"curator_id": curator_id,
"main_issue_id": main_issue_id,
}