79 Commits

Author SHA1 Message Date
a61495f5ef fix(api): export endpoint returns 409 when QA gate blocks (FU-6 UX — avoid false success toast)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 18:03:21 +00:00
084b31cd9b fix(qa): enforce critical-QA gate on export + fix neutral_background critical-but-passed (GAP-15/16, INV-QA3/EX3)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 17:58:50 +00:00
1473bdf3c2 merge: FU-4/GAP-10 corpus-isolation fix
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m39s
Enforce source_kind on halacha_filters (db.py) — closes cross-corpus halacha leak (#56).
Verified by offline regression test (mcp-server/tests/test_precedent_corpus_isolation.py).
2026-05-30 17:53:46 +00:00
f51036bd98 merge: System Spec-set + gap-audit (sub-projects 1+2)
Adds docs/spec/ (14-file living system spec, 11 invariants) + gap-audit (23 findings
→ 8 fix-units) + TaskMaster tasks 59-66. Closes PR #8. Docs/tasks only — no runtime code.
2026-05-30 17:53:46 +00:00
1af689a969 fix(retrieval): enforce source_kind on halacha_filters — close cross-corpus leak (GAP-10, INV-RET1)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 17:46:59 +00:00
80d1c5ff27 tasks(legal-ai): reconcile #56 (cancel→superseded by 62.1) + #57 (link to FU-3)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 17:43:12 +00:00
d72d5429ed tasks(legal-ai): 8 fix-unit tasks (59-66) + 23 GAP subtasks from gap-audit
Granularity (epic-per-fix-unit + subtask-per-gap) and dependency-aware/WSJF
prioritization both backed by ≥3 authoritative sources (SAFe/Pichler/OWASP/CVSS;
Wake-INVEST/Cohn/Agile-Alliance/Atlassian/SAFe).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 17:38:31 +00:00
28bed4906c docs(spec): gap-audit — 23 findings mapped to invariants + proposed fix-units (sub-project 2) 2026-05-30 17:27:06 +00:00
ebfda74575 docs(spec): X1 — canonical case_number = official assigned number (no month invention); mixed-form reconciliation is a migration task 2026-05-30 17:23:14 +00:00
e3880aef4e docs(spec): sign-off fixes — 06 index row (G2,G9), refresh stale §7 note, fix X3 G9 anchor niqqud
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 17:15:00 +00:00
380998da17 docs(spec): X5 — file:line/name precision (log_search_bg, user param, active_draft_path) 2026-05-30 17:09:33 +00:00
8c4b8cf19e docs(spec): X5-audit-provenance
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 17:05:43 +00:00
b0351958db docs(spec): X4-agents map + reserved process-agents section
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 16:59:31 +00:00
c881665b7c docs(spec): constitution index — X3 enforces G2,G9 (operational)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 16:56:39 +00:00
7fd6d8cb95 docs(spec): X3 — replace out-of-repo memory links with plain mentions (self-containment) 2026-05-30 16:56:20 +00:00
951f2366e6 docs(spec): X3-integration-deploy
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 16:53:01 +00:00
a0004f0274 docs(spec): constitution — document third authority model (project-operational)
X2/X3/X4 invariants are facts about this system's own integration/ops (no external
authority); they use מקור-סמכות=project runbooks, tied to a global engineering invariant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 16:49:58 +00:00
f0fd405f4e docs(spec): X2-multi-company sync rules
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 16:47:19 +00:00
b0e4e14832 docs(spec): X1-identifiers canonical model
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 16:41:37 +00:00
b46d25f605 docs(spec): 07-learning loop
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 15:21:34 +00:00
0fd06659da docs(spec): 06-export DOCX contract
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 15:16:00 +00:00
c0ef90d722 docs(spec): 05-qa-review — clarify neutral_background dual return path (critical fallback w/ passed=True); fix line ref
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 15:12:30 +00:00
c1872aa214 docs(spec): 05-qa-review — QA gates + human gates 2026-05-30 15:09:42 +00:00
1582556b0b docs(spec): 04-analysis-writing — 12 blocks + reasoned-decision invariants
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 15:03:56 +00:00
5e80bf560d docs(spec): constitution index — add G9 to 03-retrieval row (consistency)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 15:00:30 +00:00
72737df154 docs(spec): 03-retrieval corpora + retrieval invariants
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 14:57:11 +00:00
998194462f docs(spec): 02-data-model entities + completeness contract
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 14:50:06 +00:00
9199214b7c docs(spec): 01-ingest — trim §4 redundancy (reference INV-ING3)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 14:46:23 +00:00
da80bcf0fe docs(spec): 01-ingest unified intake contract
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 14:42:26 +00:00
6afd155dc1 docs(spec): scope ≥3-source rule to engineering decisions; reframe legal-content (G11)
Per chair clarification: the ≥3-authoritative-source verification protocol governs
ENGINEERING/architecture decisions only (G1–G10). Legal-domain content (G11) is the
authority of the chair + project docs (block-schema, decision-methodology, lessons,
skills/decision) — NOT externally triple-sourced.

- §2/§4/§5 scoped to engineering invariants; added the two-authority distinction
- G11 reframed: source-of-authority = chair + project docs; removed FJC/South Bucks/
  1958-statute as "sources to verify" and the UNVERIFIED flag
- Removed the "open items — primary-source verification" section (the over-application)
- Pruned now-orphaned legal sources from the appendix (kept NCSC/CEPEJ/FJC for G9/G10)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 14:37:54 +00:00
1daaa4861b docs(spec): reframe G2 example as structural asymmetry + note forthcoming files
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 14:21:00 +00:00
fd682d130f docs(spec): 00-constitution — mission, 11 global invariants, engineering rules
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 14:15:28 +00:00
c351d6d714 docs(spec): scaffold docs/spec/ living spec-set
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 14:12:25 +00:00
1d01135e32 docs(plan): implementation plan for system spec-set (sub-project 1)
13 tasks across 3 phases (keystone constitution → lifecycle files → cross-cutting),
each verification-gated (≥3 sources or UNVERIFIED+escalate) with review checkpoints.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 14:08:31 +00:00
a5b22dadf3 docs(spec): master design for system spec + integrity layer
Establishes the foundation to fix a recurring root-cause failure class
(non-canonical identifiers, asymmetric ingest paths, silent manual gates):

- Confirmed system mission (quasi-judicial decision assistant; human decides)
- Decomposition into 5 sub-projects (spec → audit → integrity layer → re-check → process agents)
- spec-set structure under docs/spec/ (lifecycle-organized + cross-cutting files)
- 11 global invariants + engineering rules, each backed by ≥3 authoritative sources
  (NCSC/JTC, FJC, CEPEJ, South Bucks; RAG/Lewis, Manning IR, Elastic/Pinecone/Weaviate;
   DAMA-DMBOK, ISO 8000, ISO 15489, Kleppmann, Codd, Fowler)
- 3-source verification protocol; UNVERIFIED items escalated, not decided solo

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 14:05:06 +00:00
7826ff4910 fix(cases): tolerant case_number lookup so agents see case documents
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m39s
Reported: an agent claimed the case had no documents because document_list
returned empty — but the documents exist. Root cause: get_case_by_number did
an exact `WHERE case_number = $1`, so any formatting variant of the number
silently failed to resolve. Verified on 8137-24 (9 docs): "8137/24",
"ערר 8137-24", leading/trailing space, and "בל\"מ 8126/03/25" all returned
"תיק לא נמצא", which the agent read as "no documents" and went blind.

Add _normalize_case_number (strip leading proceeding-type prefix to the first
digit, trim, unify '/'→'-') and a normalized fallback in the lookup query
(exact match preferred via ORDER BY). One fix covers every case_number-scoped
tool (document_list, extract_references, search_case_documents, get_claims,
drafting, ...). Bogus numbers still correctly resolve to "not found". (#58)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 11:54:52 +00:00
58ab003206 fix(retrieval): make decisions findable by name + unhide committee uploads
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m57s
Root cause of "agent can't find the Agasi decision in the corpus" (CMPA-55):
the decision was fully ingested, but the retrieval layer failed on the
realistic agent query — searching by case name.

- RC-A (#52): lexical tsvector covered only chunk content + halacha text,
  so a bare-name query ("אגסי") matched decisions that *cite* the case, not
  the case itself. Add meta_tsv on case_law(case_name, case_number) (SCHEMA
  V20) and OR it into the lexical halacha/chunk SQL with a match boost, so a
  name/number hit surfaces the case's own rows. Agasi: rank 4 → rank 1.
- RC-B (#53): precedent_library_list hard-defaulted source_kind=external_upload
  and never exposed the param, hiding uploaded ערר/בל"מ (internal_committee)
  decisions. Thread source_kind through service → tool → MCP tool (supports
  'internal_committee' / 'all_committees').
- #54: agent instructions (researcher/analyst/writer) — search-by-name
  protocol: add content/case-number, search both corpora, use all_committees
  before declaring "not in corpus".
- #55: chunker produced tiny fragment chunks ("דיון", "החלטה") from header
  keywords matched mid-sentence. Anchor SECTION_PATTERNS to line start +
  merge sub-min sections; exclude <50-char fragments at query time (484
  existing fragments hidden; full re-chunk tracked as #57).

Tests: scripts/test_retrieval_by_name.py (name ranks case above citer +
substantive regressions); chunker unit checks (0 tiny chunks). New findings
filed as tasks #56 (halacha source_kind leak) and #57 (re-chunk migration).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 11:26:19 +00:00
165efc62b0 docs(claude): correct canonical tasks.json path + add CLI cwd footgun warning
TaskMaster's --tag selects the logical group inside a file, not which
tasks.json to write; the CLI resolves the file from cwd. Document the
canonical project-root-relative path and the cwd footgun.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 11:19:47 +00:00
d3c6baf9e2 security(chat): bind chat service to docker bridge + require Bearer auth
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m38s
Address security-review finding: the host-side legal-chat-service was
binding 0.0.0.0:8770 with no authentication. The service spawns the
claude CLI, whose tool set includes Bash + Edit — so an unauthenticated
/chat/start is effectively RCE. Oracle Cloud's security list closes the
port externally, but defense-in-depth requires two independent layers:

1. Bind defaults to 10.0.1.1 (docker0 bridge gateway). Reachable from
   containers on docker bridges (the legal-ai container has a route via
   the coolify network), invisible to anything outside the host. The
   --host flag is still configurable for local-dev (127.0.0.1) or
   special-case deployments, but 0.0.0.0 is explicitly discouraged in
   the docstring.
2. /chat/start requires Authorization: Bearer <LEGAL_CHAT_SHARED_SECRET>.
   The secret is loaded from /home/chaim/.legal-chat-service.env (chmod
   600, off-repo) by the pm2 ecosystem and mirrored as a Coolify env
   var so the FastAPI chat_proxy sends a matching header. hmac.compare_digest
   prevents timing oracles. /health stays unauthenticated (static OK,
   no subprocess) so the FastAPI proxy can probe liveness without the
   secret.

The service refuses to start if LEGAL_CHAT_SHARED_SECRET is empty or
shorter than 24 chars — no silent fallback to an open mode.

When the Infisical MCP comes back, migrate the secret into the vault
at /_GUIDELINES per the project secrets policy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 10:22:14 +00:00
5ad541e54c ui(precedents): upload sheet routes ערר/בל"מ to internal-decisions endpoint
Some checks failed
Build & Deploy / build-and-deploy (push) Has been cancelled
Citations starting with ערר/בל"מ/ARAR are committee decisions and must
carry chair_name + district. The /precedents upload form previously
errored out for these (precedent_library service rejects them) with no
in-UI path forward — internal_decision_upload was only reachable via
the /missing-precedents flow.

The form now auto-detects committee citations, reveals chair_name +
district fields, hides the irrelevant source_type/precedent_level
(derived server-side), and posts to /api/internal-decisions/upload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:22:03 +00:00
a3454bcb57 fix(training): bundle reference content + use docker bridge gateway
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
The Style Studio's curator-prompt + chat features read reference docs
from disk at runtime. Two issues from the initial production run:

1. Dockerfile + .dockerignore excluded .claude/, docs/, and most of
   skills/. Now COPY the four specific files the new endpoints need:
     - .claude/agents/hermes-curator.md
     - skills/decision/SKILL.md
     - docs/legal-decision-lessons.md
     - docs/corpus-analysis.md
   .dockerignore opens whitelists for just those files.

2. Coolify's custom_docker_run_options=--add-host=host.docker.internal:host-gateway
   is not honored on dockerimage build_pack apps (ExtraHosts stayed []).
   Switch chat_proxy.py default to http://10.0.1.1:8770 — the docker0
   bridge gateway, same pattern Paperclip uses for 3100. Bind the host
   pm2 service to 0.0.0.0:8770 so the container can reach it via the
   bridge IP. Oracle Cloud's security list keeps the port unreachable
   from the public internet.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 10:15:27 +00:00
bb0cd7c6a2 feat(training): Style Studio — upload, rich corpus, lessons, curator portrait, chat
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m7s
Six-phase upgrade of /training from a read-only dashboard into a full
Style Studio for managing Daphna's style corpus.

- Upload Sheet on /training: file → proofread preview → commit (no more
  CLI-only `upload-training` skill).
- Rich corpus metadata: GET /api/training/corpus returns summary, outcome,
  key_principles, page_count, parties (regex), legal_citation, lessons_count.
  PATCH endpoint for chair edits. CorpusDetailDrawer with 4 tabs (details
  /content/lessons/patterns) replaces the bare table row.
- LLM metadata enrichment: style_metadata_extractor + MCP tools
  (style_corpus_enrich, style_corpus_pending_enrichment) fill summary
  /outcome/key_principles via claude_session (free, host-side).
- Per-decision lessons: new decision_lessons table + 4 REST endpoints +
  LessonsTab in drawer; hermes-curator now auto-posts findings as
  decision_lessons(source=curator).
- Curator Portrait tab: prompt rendered with link to Gitea, recent
  curator findings, style_analyzer training prompts, propose-change
  form that writes proposals to data/curator-proposals/ for manual
  chair review (no auto-mutation of the agent file).
- Style chat tab: SSE-streamed conversations with the style agent.
  New host-side pm2 service (legal-chat-service, port 8770) wraps
  claude CLI with stream-json + --resume continuation; FastAPI proxies
  via host.docker.internal. Zero API cost — uses chaim's claude.ai
  subscription. chat_conversations + chat_messages persist history.

Architecture: keeps the existing rule that claude_session only runs
on the host (not the container). The new legal-chat-service is the
canonical bridge between the container and the local CLI for the chat
feature; everything else (upload, metadata, lessons) stays within the
container's existing capabilities.

Audit script (scripts/audit_training_corpus.py) included for verifying
which corpus rows still need enrichment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 10:06:22 +00:00
0629f19d5f ui(missing-precedents): drawer = notes + upload only
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m21s
The drawer was showing a full metadata form (legal topic, case name,
legal issue, cited-by-party + name, status) — most of it duplicated
fields that get auto-extracted from the file once it's uploaded, or
that are already known from when the row was detected. The visible
placeholder text ('לינדאב בע"מ', 'אנטרים', 'זכות עמידה') looked like
real data and confused readers.

Strip the form down to a single "הערות" textarea — that's the only
field the chair actually needs to edit. Reasons for who cited the
decision and in what context belong there too. Everything else (shape
of the precedent on the case_law side) is the LLM extractor's job.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:58:23 +00:00
f920cfc738 ui(precedents): edit sheet — make citation_formatted editable
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 46s
The "ערוך פרטים" sheet labeled the case_number field "מראה מקום" and
marked it read-only — confusing because the formal citation IS supposed
to be editable. Rename the read-only field to "מספר תיק (מזהה ייחודי)"
to clarify it's the system key, and add a separate Textarea for the
true formal citation (citation_formatted) with the same markdown-bold
convention used by the inline editor on the detail page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:40:08 +00:00
c4046cc0a0 ui(precedents): citation action buttons icon-only
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 35s
Drop the visible "העתק" / "ערוך" labels and keep just the icon —
matches the editorial/judicial restraint of the surrounding card.
Tooltip + aria-label preserve the affordance for hover and assistive
tech.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:33:55 +00:00
cbc7a1e336 feat(precedents): formal citation per Israeli citation rules + copy/edit UI
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m25s
Until now, "case_number" was the only stored identifier for a precedent.
But a *citation per the Israeli unified citation rules* is a different
beast — it has bold parties, an unbold prefix (court abbrev + panel/
district parenthetical + case number), and an unbold trailing reporter
(נבו / פ"ד...).  Without storing it as a first-class field we couldn't
hand the chair a one-click "copy as citation" experience for pasting
into decisions.

Changes:
- Schema V19: case_law.citation_formatted TEXT (Markdown — parties
  wrapped in **…** so the copy helper can render <strong> for Word/Docs
  paste and keep plain-text fallback meaningful).
- Metadata extractor: composes citation_formatted from the document
  text per the unified citation rules, with worked examples for ע"א /
  עת"מ / ערר / בל"מ in the prompt. Refuses to store half-formed strings.
- PATCH /api/precedent-library/{id} accepts citation_formatted so the
  chair can correct LLM mistakes.
- /precedents/[id]: dedicated "מראה מקום" block with bold rendering,
  a copy-to-clipboard button (text/html + text/plain so Word keeps
  the bolds), and an inline edit textarea.
- /precedents list rows: link displays the formatted citation when
  available, with a small inline copy button — falls back to the bare
  case_number for older rows.

Backfill of existing rows happens by re-stamping the extraction queue
once V19 has rolled out and the new field is reachable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 07:14:34 +00:00
a02a4e3a64 feat(precedents): minimum-effort upload — file+citation, rest auto-extracted
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m35s
The missing-precedents drawer + general precedent upload both required
the user to type chair_name, district, practice_area, court, date etc.
upfront — even though those fields can be (and already are, post-upload)
extracted from the document text by the LLM. The metadata-extraction
wakeup also only fired for the /precedent-library/upload path, leaving
missing-precedents committee uploads stuck with whatever stub the user
typed.

Changes:
- Extractor learns chair_name + district, overwrites the new
  PLACEHOLDER_PENDING_EXTRACTION sentinel for internal_committee rows
  (the DB CHECK forces non-empty; we stamp the placeholder at insert).
- missing_precedent_upload no longer 400s on missing chair/district;
  it infers district from the citation when possible, falls back to
  the placeholder, and always fires pc_wake_for_precedent_extraction
  so the LLM can fill in the rest.
- Both upload sheets default to file (+ citation) only; every other
  field is tucked into a closed <details> labeled "אופציונלי — דריסה
  ידנית של שדות שיחולצו אוטומטית". Required validators on chair/
  district/practice_area dropped — the LLM fills them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 14:43:25 +00:00
b01722b1b4 feat: emit missing_precedent + export_complete webhooks to plugin
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
Adds two webhook emitters in paperclip_api.py that the plugin's
onWebhook handler now routes by ``eventType``:

* ``emit_missing_precedent_webhook(...)`` — fires from
  POST /api/missing-precedents on first insert (non-duplicate).
  The plugin surfaces an askUserQuestions interaction on the
  linked issue so Daphna can choose upload / irrelevant / defer
  without needing to open the legal-ai UI.

* ``emit_export_complete_webhook(...)`` — fires from
  POST /api/cases/{n}/export-docx after a successful export. The
  plugin attaches a "final-decision" markdown document with a
  download link to the linked Paperclip issue.

Both are fire-and-forget BackgroundTasks — failures are logged
but never block the originating request. Company resolution
follows the same 1xxx→licensing / 8-9xxx→betterment rule used
by emit_case_status_webhook.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 13:29:04 +00:00
1d4f214abe chore(taskmaster): mark #26 + #27 done (Paperclip SDK upgrade + host already on 525)
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 7s
2026-05-26 12:19:16 +00:00
2aee398b4a feat: Stage C — RAG advanced (#33, #47, #48, #49, #50, #51)
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m35s
Six independent sub-tasks dispatched in parallel; aggregated here.

## #33 — Hide case_name column
library-list-panel.tsx: `<TableHead>` + `<TableCell>` for "שם"
get `className="hidden"` in both Court and Committee row variants.
DB column preserved for future use.

## #47 — Audit script periodic
New scripts/audit_corpus_integrity.py — 3 SQL checks (external+ערר
prefix, internal missing chair/district, cases.practice_area enum)
+ CEO wakeup on violations + cron `0 7 * * *`. First run: 0 issues.

## #48 — Parent-doc retrieval (gated, default off)
Schema V17: precedent_chunks.parent_chunk_id + chunk_role
('child'|'parent'). New chunker.chunk_document_hierarchical() —
section-aware parents (~1500 tokens) containing ~5 overlapping
children (~300 tokens each). New db.store_precedent_chunks_hierarchical
two-pass writer. Search SQL (semantic + lexical) LEFT-JOIN parent and
swap content + dedupe by parent_chunk_id when flag on. Toggle:
PARENT_DOC_RETRIEVAL_ENABLED + PARENT_DOC_{CHILD,PARENT}_SIZE_TOKENS.
Backfill ~3min and ~$0.20 — deferred to follow-up.

## #49 — Multimodal backfill
New scripts/backfill_multimodal_precedents.py with token-matching
case_number ↔ source files (PDF + DOCX via PyMuPDF). Ran in container:
26 precedents embedded, 503 pages, $0.21, 0 errors. precedent_image_embeddings
grew 3 → 29 rows. 44 remaining are style_corpus-migrated rows (no
source file on disk) — will catch up when re-uploaded.

## #50 — Closed-loop feedback + nDCG
Schema V18: search_logs + search_relevance_feedback. New telemetry.py
with fire-and-forget log_search_bg (p50 = 0.002ms — zero overhead) +
auto-infer_relevance_from_citations (reads case drafts → marks score=3
when cited precedent appears in past search top-K). Hooks added to 5
search paths. scripts/compute_ndcg.py for aggregation. Two admin API
endpoints (GET /api/admin/rag-metrics + POST .../infer). Dashboard UI
deferred — API is enough for now.

## #51 — Halacha quality monitoring
New scripts/monitor_halacha_quality.py — baseline avg confidence
(trusted=0.849, all=0.833, pending=0.694) with rolling window drift
detection. Default 5% threshold. Exits non-zero on alert for cron
integration. Recommended: `0 8 * * 1` weekly Mon 8am.

## Bonus: 230 unlinked citations → missing_precedents
Bulk-imported 230 distinct unlinked citations from
precedent_internal_citations to missing_precedents.status='open',
party='committee', with notes listing source citers. Top candidate:
ע"א 3213/97 (cited 5x). Total open missing_precedents now 237.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 11:26:52 +00:00
3a05e30c8d fix(appraiser-facts): route extraction through analyst wakeup (was silent 0)
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m38s
The "חלץ עובדות שמאיות" UI button hit POST /api/cases/{n}/extract-appraiser-facts
which called appraiser_facts_extractor inline — that shells out to the local
`claude` CLI, which is absent in the Coolify container, so every doc errored,
the per-doc try/except swallowed it, and the response was "completed, 0 facts".

Refactored the endpoint to wake the legal-analyst of the correct company via
Paperclip (same pattern as wake_curator_for_final), and surface
extraction_failed instead of "completed" when every doc errored.
2026-05-26 11:02:55 +00:00
7ad995aade feat: #34 citation graph + #32 wide-modal precedent edit + #13 verify
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m38s
## #34 — Daphna's internal citation graph

New schema V16 (V15 was already used by proceeding_type): table
``precedent_internal_citations`` (source→cited, with cited_case_law_id
nullable for citations whose target isn't in the corpus yet) + 3
indexes (source, target, unlinked).

New service ``citation_extractor.py`` with regex patterns for ערר /
בל"מ / עע"מ / בר"מ / עמ"נ / ע"א / בג"ץ / רע"א — accepts both ``\/``
and ``-`` separators, requires actual parenthesized district label
to avoid greedy mid-paragraph captures. Resolves citations against
``case_law.case_number`` substring; default confidence 0.90 linked,
0.75 unlinked. ON CONFLICT DO NOTHING on (source, cited_case_number).

3 new MCP tools: ``extract_internal_citations``,
``list_internal_citations``, ``list_incoming_citations``. Optional
flag ``include_cited_by=True`` on ``search_internal_decisions``
appends cited-by candidates as ``match_type='cited_by'`` stubs.

Bulk-extracted from 40 internal_committee rows authored by דפנה תמיר:
**353 distinct citations, 348 stored, 96 linked / 252 unlinked**.
Top citers: 1079/24 (30), 1024/24 (19), 1009/25 (18). Top unlinked
target: ע"א 3213/97 (cited 5x) — natural #35 candidates.

## #32 — Wide-modal precedent edit

`precedent-edit-sheet.tsx`: ``<Sheet side="left">`` → centered
``<Dialog>`` with ``sm:max-w-4xl`` ``max-h-[90vh]`` ``overflow-y-auto``.
Component API unchanged so existing callers
(`/precedents/[id]/page.tsx`, `library-list-panel.tsx`) work as-is.
RTL preserved. Mobile falls back to near-full-width via shadcn default.

## #13 — 403/17 verification

`case_law e151fc25-...` (אהרון ברק - תכנית רחביה) already in perfect
shape after Stage A work: all metadata fields populated, 351 halachot
with avg_conf=0.864 (well above 0.78 threshold). No re-extraction
needed; closing task as verified.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 10:37:53 +00:00
9f4f8c60a4 fix(labels): drop בל"מ prefix from extension_request_* subtype labels
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 35s
Now that proceeding_type drives a dedicated בל"מ badge, repeating the
prefix in the appeal_subtype label produced 'בל"מ רישוי' on the row
plus a בל"מ pill — double-marking. The extension_request_* values now
render as the same domain label as their non-extension siblings
(רישוי ובנייה / היטל השבחה / פיצויים), and the בל"מ pill is the
single source of truth for proceeding type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 10:03:39 +00:00
d32452f95c fix(api): include proceeding_type in /api/cases list response
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
The cases-table reads from the list endpoint, not /details, so without
proceeding_type in the row payload the בל"מ badge can't render for
cases that flipped the field manually (only the legacy
appeal_subtype LIKE 'extension_request_%' path was firing).

Added the field to both detail=false and detail=true branches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 10:01:24 +00:00
ac3ed455cf fix(cases): בל"מ badge reads proceeding_type, not just appeal_subtype
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 43s
After the proceeding_type field landed, users started flipping cases
to בל"מ via the edit dialog. But the case-header badge + cases-table
filter were still gated on isBlamSubtype(appeal_subtype), so the badge
didn't appear when only the proceeding_type changed. Now the badge
shows when either proceeding_type === 'בל"מ' OR appeal_subtype is an
extension_request_* variant — the legacy path stays so existing rows
that never got a proceeding_type still render correctly.

Also regen types.ts from prod (proceeding_type now in OpenAPI schema)
and register the one-shot process_pending_blam.py script.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 09:34:23 +00:00
d359ab9884 feat(proceeding-type): explicit ערר/בל"מ field for cases + corpus
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m40s
Same case_number can exist as both a regular appeal (ערר) and an
extension-of-time request (בל"מ), and we were inferring the difference
from appeal_subtype prefixes — fragile, and case-number lookups
weren't disambiguated. Now stored as a first-class field on both
case_law (corpus) and cases (live cases), with partial unique indexes
on (case_number, proceeding_type).

- SCHEMA_V15: column + CHECK constraints + backfill from
  appeal_subtype LIKE 'extension_request_%' + partial unique indexes
  replace the old global UNIQUE(case_number).
- derive_proceeding_type() centralizes the inference rule
  (extension_request_* → בל"מ; subject regex fallback; default ערר).
- Metadata extractor prompt asks Claude to populate the new field
  explicitly; apply_to_record writes it for internal_committee rows.
- internal_decision_upload, case_create, case_update accept an
  optional proceeding_type; FastAPI request models expose it.
- Wizard + edit dialog get a sided Select; case header renders the
  resolved label (ערר / בל"מ).
- Uploaded the 2 staged בל"מ decisions on betterment levy:
  8126/24 (סופר נוח, 13 chunks), 8047/23 (הרנון, 48 chunks).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 09:17:33 +00:00
1645653ba9 chore(taskmaster): mark Stage A+B + #30/31/35/36/37 as done
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 26s
37/51 tasks done after the parallel sub-agent sprint:
- #30 closed (9/9 subtasks)
- #31 closed (3/3)
- #35 closed (6/6) — missing_precedents feature
- #36 closed (5/5) — legal_arguments aggregation
- #37 closed (5/5) — בל"מ subtypes
- #38, #39, #40, #41, #43, #44, #45, #46 done

Deferred: #42 (Haiku query expansion).
Pending: Stage C #47-51 + 3 UI smaller items (#32-34).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 08:36:02 +00:00
f3cc9ca9d4 feat: Stage A finalizers + #35/#36/#37 — critical-gap closure
Some checks failed
Build & Deploy / build-and-deploy (push) Has been cancelled
Four parallel sub-agents closed the remaining critical gaps from the
26/05 Stage A/B sprint. Each block independently tested; aggregated here.

## #30/#31 finalizers (sub-agent A)
* Auto-derive practice_area in case_create from case_number prefix
  (1xxx→rishuy_uvniya, 8xxx→betterment_levy, 9xxx→compensation_197);
  default for CaseCreateRequest is now "" (the DB constraint catches
  any stray "appeals_committee").
* practice_area.py: derive_subtype now handles axis-B domain values
  (rishuy_uvniya/betterment_levy/compensation_197) without parsing the
  case number; new helper derive_domain_practice_area().
* Halacha re-extraction verified unnecessary — all 6 reclassified
  records already had is_binding=false and approved halachot.
* Regression tests: 6 cases in tests/test_corpus_constraints.py
  covering practice_area enum, internal-committee chair/district,
  external-upload arar prefix, MCP guard.
* UI: district input → Select dropdown (7 districts) in
  precedent-edit-sheet.tsx, preserving legacy free-text values.

## #37 בל"מ subtypes (sub-agent B)
* 3 new appeal_subtypes: extension_request_{building_permit,
  betterment_levy,compensation}. APPEALS_COMMITTEE_SUBTYPES extended,
  SUBTYPES_BY_AREA mappings added.
* New helpers: is_blam_subject(), is_blam_subtype(),
  derive_subtype_with_blam(case_number, subject, practice_area).
  case_create now uses it to auto-detect "בקשה להארכת מועד" subjects.
* 3 methodology templates under docs/methodology/extension-request-*.md.
* paperclip_client.py mapping updated for the 3 new subtypes
  (extension_request_building_permit→CMP, the other two→CMPA).
* Frontend: bilingual "בל"מ" badge + filter dropdown on cases list +
  detail header; appeal-type-bars collapseBlam() merges בל"מ into its
  parent domain for aggregate bars.
* Wizard auto-detects בל"מ from subject during case creation.
* 3 Berlinger cases (1017/1018/1019-03-26) migrated to
  appeal_subtype=extension_request_building_permit via psql.

## #35 missing_precedents feature (sub-agent C)
* Schema V13: missing_precedents table (citation, case_id, party,
  legal_topic, status, linked_case_law_id, claim_quote, ...) +
  FK constraints + 3 indexes. Applied via psql + idempotent migration.
* 6 db.py service functions, 3 MCP tools, 6 FastAPI endpoints
  (POST/GET/PATCH/DELETE/upload — upload routes by citation prefix
  to ingest_internal_decision or ingest_precedent).
* Next.js page /missing-precedents with 5 status tabs + filters +
  sidebar badge counter + detail drawer with metadata edit + smart
  upload form that switches fields per committee/court.
* Bootstrap: 7 rows imported from the JSON file
  (3 citations × cases, all status=closed with linked_case_law_id).
* legal-researcher.md: new §2ב.5 with missing_precedent_create
  usage + dedup semantics + tool grant.

## #36 legal_arguments aggregation (sub-agent D)
* Schema V14: legal_arguments + legal_argument_propositions M:M.
  Applied via psql.
* New service argument_aggregator.py with two functions —
  aggregate_claims_to_arguments() (Claude CLI / claude_session) and
  get_legal_arguments(). Graceful llm_unavailable handling when CLI
  is missing (containers).
* 2 MCP tools + 2 API endpoints (POST .../aggregate-arguments as
  BackgroundTask, GET .../legal-arguments).
* Frontend: shadcn Accordion + new legal-arguments-panel.tsx with
  hierarchical (party → priority badge → arguments) display, "טיעונים"
  tab on the case page, "חשב/חשב מחדש" buttons.
* scripts/backfill_legal_arguments.py + SCRIPTS.md entry — dry-run
  found 8 candidate cases including 1017/1018/1019.

## Open follow-ups (intentionally deferred)
* npm run api:types in web-ui (CLAUDE.md flow) — recommended before
  the next UI commit; not required for backend deployment.
* Run backfill_legal_arguments.py --apply once the container picks up
  the new aggregator service.
* webhook on missing-precedents upload-close to Paperclip (optional).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 08:34:40 +00:00
af651d0135 feat(rag): Stage B — RAG improvements (HNSW + BM25 hybrid + MMR + dynamic boost)
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m35s
Five enhancements to the precedent retrieval stack:

* **#44 HNSW indexes** for precedent_chunks + halachot (replacing IVFFlat
  lists=50). Build time ~3s combined. Better recall@10 with pgvector 0.8.2.
* **#45 Halacha sweep** — 96 pending halachot at conf>=0.78 promoted to
  approved (1141 → 1237). Cluster at conf=0.78 spot-checked OK. Applied
  via psql only — env HALACHA_AUTO_APPROVE_THRESHOLD unchanged (0.80).
* **#43 MMR diversity** — search_precedent_library_hybrid now caps at
  ``max_per_case_law=2`` (default). Prevents one precedent dominating
  top-10 when many of its chunks/halachot rank high. New helper
  ``_diversify_by_case_law`` in hybrid_search.py.
* **#46 Dynamic halacha boost** — replaces the static ``score+=0.05``
  with ``score+=confidence*0.06``. Calibrated so avg-confidence (~0.85)
  stays at +0.05; high-conf halachot get a slight extra lift, low-conf
  ones get less. Behaviour preserved at the mean.
* **#41 BM25/tsvector hybrid + RRF**. Schema V12 adds STORED tsvector
  columns ``precedent_chunks.content_tsv`` and ``halachot.rule_tsv``
  (using simple config — Postgres has no Hebrew stemmer) + GIN indexes.
  New ``db.search_precedent_library_lexical`` mirrors the semantic
  function with ts_rank_cd over plainto_tsquery. ``hybrid_search``
  runs sem+lex in parallel and fuses via RRF before rerank. Toggle:
  env ``BM25_HYBRID_ENABLED`` (default true), graceful fallback to
  semantic-only on lexical failure.

#40 (VOYAGE_RERANK_ENABLED) was already true in Coolify env; no change.
#42 (Claude Haiku query expansion) deferred — latency + cost concerns
warrant a separate plan; the bm25 lexical leg already recovers most of
the exact-string recall #42 was meant to address.

Closes TaskMaster #41, #43-#46.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 08:08:02 +00:00
b197d2329c fix(corpus): move citation guard to service level
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m31s
Defense in depth — the MCP wrapper guard catches researcher uploads, but
the HTTP API (/api/precedent-library/upload) bypasses the wrapper and
calls services.precedent_library.ingest_precedent directly. The guard
now also lives in the service, so HTTP uploads of ערר/בל"מ citations
to the external corpus get rejected at the source.

Companion to DB constraint case_law_external_arar_check (applied via
psql) — three independent layers now enforce the same invariant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 07:49:49 +00:00
c6e368e4f7 feat(corpus): Stage A — corpus tagging fixes + prevention layer
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m8s
מתקן את הבאג של תיוג שגוי לועדות ערר ומונע חזרתו:

**Code changes:**
* New MCP tool `internal_decision_upload` (chair_name+district required)
  — sole supported path for ingesting committee decisions; tags
  source_kind='internal_committee' automatically.
* Citation guard in `precedent_library_upload` rejects citations starting
  with "ערר" or "בל\"מ" with a directive to use internal_decision_upload.
* `practice_area.py` taxonomy unification: PRACTICE_AREAS now accepts
  both multi-tenant (appeals_committee/national_insurance/labor_law)
  and domain (rishuy_uvniya/betterment_levy/compensation_197) values.
  New helper `to_db_practice_area(multi_tenant, subtype) -> domain`.

**Agent docs:**
* legal-researcher (+5K): upload-tool decision flowchart, code samples
  per source_kind, district enum (ירושלים/מרכז/תל אביב/צפון/דרום/חיפה/ארצי)
* legal-ceo, legal-analyst, legal-writer, legal-qa, HEARTBEAT — taxonomy
  awareness + source_kind-aware citation patterns + research_complete
  as valid status.
* Fixed two pre-existing wrong practice_area values in examples
  (histael_hashbacha→betterment_levy, pitsuim_197→compensation_197).

Closes TaskMaster #30(parts), #38(parts), #39 (root cause).
DB-side backfill + CHECK constraints applied directly via psql:
* 11 cases.practice_area corrected (1xxx→rishuy, 8xxx→betterment)
* 6 case_law records reclassified external_upload→internal_committee
  with inferred district
* 6 chair_name backfilled from full_text (5 שרית אריאלי + 1 דפנה תמיר)
* 88 new halachot extracted for newly-uploaded precedents
  (אנטרים + ירושלים שקופה 1112/22 + אגא וכט)
* CHECK constraints: cases.practice_area enum, case_law internal⇒district

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 07:40:18 +00:00
8153bc9f03 fix(extractor): add regex fix for Hebrew law year gershayim corruption
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m36s
תש[א-ת]+יי[א-ת] → תש[א-ת]+"[א-ת]  (e.g. תשכייה → תשכ"ה)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 16:12:20 +00:00
4892fb6e8f fix(extractor): apply Hebrew quote fixer to direct PDF extraction path
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m40s
Born-digital Hebrew PDFs from legal software often encode gershayim (״)
as double-yod (יי), producing the same corruption patterns as OCR.
The fixer was only called after Google Cloud Vision OCR — digitally
created PDFs that passed quality checks received no correction.

Changes:
- Apply _fix_hebrew_quotes() in the direct extraction path
- Add 'בליימ' → 'בל"מ' (בקשה להארכת מועד — systematic corruption in 1017-03-26)
- Add 'תמייא' → 'תמ"א' (תכנית מתאר ארצית)
- Update docstring to reflect the broader scope

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 15:59:39 +00:00
b368bce690 fix: handle invalid date formats gracefully and add missing dialog descriptions
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 4m14s
- Wrap date.fromisoformat() in try/except in case_update tool — prevents
  unhandled ValueError from surfacing as 500; FastAPI now catches it as 422
- Add DialogDescription (sr-only) to 5 dialogs missing aria-describedby:
  documents-panel preview + delete, drafts-panel delete + feedback, link-related-dialog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 15:53:01 +00:00
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
126 changed files with 21429 additions and 968 deletions

View File

@@ -181,6 +181,36 @@ python3 /home/chaim/legal-ai/scripts/notify.py \
--- ---
## §7. סטטוסי תיק תקפים (case status flow)
הסטטוסים שאתה עשוי לראות ב-`case.status` (לפי `legal-ceo.md` "מפת סטטוסים"):
```
new → proofread → documents_ready → analyst_verified → research_complete*
→ outcome_set → direction_approved → analysis_enriched → ready_for_writing
→ drafted → qa_passed / qa_failed → exported
```
`research_complete` — **valid status** (לא legacy מחוסר תוקף). מנותב ע"י `legal-researcher.md` שלב 5 כשמחקר תקדימים רץ בנפרד מהמנתח (תרחיש מתקדם). ה-CEO יודע לטפל בו כאילו זה `analyst_verified` (ראה `legal-ceo.md` "מפת סטטוסים").
---
## §8. ניתוב upload פסיקה לקורפוס — flowchart מהיר
```
חיים העלה PDF פסיקה לתיק → ה-citation הוא:
├── "ערר NNNN/YY" או "בל"מ NNNN/YY"
│ → internal_decision_upload (חובה chair_name + district)
└── "עע"מ / בר"מ / עמ"נ / בג"ץ / ע"א / ע"פ / רע"א / רע"פ / ת"א / ת"מ"
→ precedent_library_upload (external_upload)
```
- **`internal_decision_upload`** דורש: `file_path`, `case_number`, `chair_name`, `district`. district מתוך הרשימה: ירושלים / מרכז / תל אביב / צפון / דרום / חיפה / ארצי.
- **`precedent_library_upload`** לא מקבל chair_name/district. אם תנסה להעלות "ערר ..." דרכו — citation guard ידחה.
- פירוט מלא: `legal-researcher.md` סעיף "איזה כלי upload להשתמש".
---
## נתיבי API — הפניה ל-skill הרשמי ## נתיבי API — הפניה ל-skill הרשמי
| פעולה | איפה ב-skill | | פעולה | איפה ב-skill |

View File

@@ -76,6 +76,24 @@ profiles:
Authorization: Bearer $PAPERCLIP_API_KEY Authorization: Bearer $PAPERCLIP_API_KEY
{ "body": "<my findings>" } { "body": "<my findings>" }
``` ```
5b. **רושם כל ממצא גם ב-API של legal-ai כ-decision_lesson**, כך שיופיע ב-UI
תחת הטאב "מה למדנו" של ההחלטה בקורפוס. דרישה: למצוא קודם את ה-`style_corpus_id`
שתואם ל-`decision_number` של ההחלטה (`GET /api/training/corpus` ולסנן).
לכל ממצא:
```
POST https://legal-ai.nautilus.marcusgroup.org/api/training/corpus/{corpus_id}/lessons
Content-Type: application/json
{
"lesson_text": "<התקציר של הממצא — מה ראיתי + הצעה — שורה אחת>",
"category": "<style|structure|lexicon|tabular|general>",
"source": "curator"
}
```
מיפוי תגי-ממצא ל-`category`:
- `[סגנון]` → `style`
- `[מבנה]` → `structure`
- `[לקסיקון משפטי]` → `lexicon`
- `[טבלאי]` → `tabular`
6. סוגר את ה-issue (status=done) אחרי שכתבתי את ה-comment 6. סוגר את ה-issue (status=done) אחרי שכתבתי את ה-comment
## פורמט ה-comment ## פורמט ה-comment

View File

@@ -63,6 +63,26 @@ tools:
- חוקי תמ"א 38, פינוי ובינוי, והתחדשות עירונית - חוקי תמ"א 38, פינוי ובינוי, והתחדשות עירונית
- ועדות ערר — תכנון ובניה והיטל השבחה (סמכות, הרכב, סדרי דין) - ועדות ערר — תכנון ובניה והיטל השבחה (סמכות, הרכב, סדרי דין)
## טקסונומיה — שני namespaces ל-`practice_area`
⚠️ **חובה לדעת לפני שאתה כותב practice_area לכל כלי MCP או יוצר תיק חדש.**
יש שני namespaces שונים:
| Axis | ערכים | איפה משתמשים |
|------|--------|--------------|
| **A. Multi-tenant (legacy/routing)** | `appeals_committee`, `national_insurance`, `labor_law` | בחירת tenant. הסוכנים בוועדת ערר תמיד `appeals_committee` |
| **B. Domain (DB + filters)** | `rishuy_uvniya`, `betterment_levy`, `compensation_197` | **DB columns + כל פילטר ב-`search_precedent_library` / `search_internal_decisions`** |
**כלל זהב — בכל קריאה לכלי שמחפש או כותב לקורפוס, השתמש ב-Axis B בלבד:**
- 1xxx → `rishuy_uvniya`
- 8xxx → `betterment_levy`
- 9xxx → `compensation_197`
**יצירת תיק חדש (`case_create`):** ב-DB, העמודה `cases.practice_area` מאוכפת ע"י CHECK constraint לערכי Axis B (או ריק). **אסור** לכתוב `appeals_committee` ל-`cases.practice_area` — זה ידחה. אם אתה לא בטוח באיזה axis תיק קיים נמצא, קרא קודם `case_get` ובדוק.
**זיהוי בל"מ (בקשה להארכת מועד):** אם ה-subject של מסמך/תיק מכיל "בקשה להארכת מועד" או הקידומת "בל\"מ" — זהו סיווג ייחודי (במיוחד תיקי 8xxx). חלץ זאת בעת הניתוח וציין ב-`appeal_subtype` כאחד הסיווגים המקובלים. בל"מ הוא דיוני בעיקרו ולכן הניתוח שלו שונה — לרוב יש טענת סף יחידה (האם להאריך) ולא דיון מהותי. סמן זאת בפלט כדי שהכותב ידע לבחור תבנית קצרה.
## הבחנה קריטית — 3 סוגי פריטים מחולצים ## הבחנה קריטית — 3 סוגי פריטים מחולצים
| סוג (claim_type) | מה זה | מי אמר | | סוג (claim_type) | מה זה | מי אמר |
@@ -181,8 +201,8 @@ tools:
| סיווג תיק | practice_area | | סיווג תיק | practice_area |
|------------|---------------| |------------|---------------|
| 1xxx (רישוי ובניה) | `rishuy_uvniya` | | 1xxx (רישוי ובניה) | `rishuy_uvniya` |
| 8xxx (היטל השבחה) | `histael_hashbacha` | | 8xxx (היטל השבחה) | `betterment_levy` |
| 9xxx (פיצויים ס' 197) | `pitsuim_197` | | 9xxx (פיצויים ס' 197) | `compensation_197` |
אם הסוגיה מאוזכרת ב-`appeal_subtype` ידוע (כמו "שימוש חורג", "חריגות בנייה", "סטייה ניכרת") — הוסף `appeal_subtype` לפילטר. צמצום מוקדם > הרחבה מאוחרת. אם הסוגיה מאוזכרת ב-`appeal_subtype` ידוע (כמו "שימוש חורג", "חריגות בנייה", "סטייה ניכרת") — הוסף `appeal_subtype` לפילטר. צמצום מוקדם > הרחבה מאוחרת.
@@ -288,11 +308,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 +324,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 +420,7 @@ X שאלות עומדות להכרעה:
- [אם נמצאו — חיסכון או הבחנה?] - [אם נמצאו — חיסכון או הבחנה?]
**עמדת ועדת הערר:** **עמדת ועדת הערר:**
[ימולא ע"י יו"ר הוועדה — עמדה/הנחיה לגבי סוגיה זו שתשמש את סוכן הכתיבה] [ימולא ע"י יו"ר הוועדה]
--- ---
@@ -432,12 +455,12 @@ X שאלות עומדות להכרעה:
### 8א. אימות פסיקה ### 8א. אימות פסיקה
סרוק את עמדות היו"ר וזהה כל אזכור פסיקה (בג"ץ, עע"מ, עת"מ, ע"א, ערר וכו'). סרוק את עמדות היו"ר וזהה כל אזכור פסיקה (בג"ץ, עע"מ, עת"מ, ע"א, ערר וכו').
לכל פסק דין שמוזכר: לכל פסק דין שמוזכר:
1. חפש ב**קורפוס הסמכותי** (`search_precedent_library`) — חובה ראשונה. שם נמצאות הלכות מאושרות עם supporting_quote מוכן לציטוט. 1. חפש ב**קורפוס הסמכותי** (`search_precedent_library`) — חובה ראשונה. שם נמצאות הלכות מאושרות עם supporting_quote מוכן לציטוט. הקורפוס כולל גם הלכות מהחלטות ועדות ערר שהועלו (internal_committee).
2. חפש בקאנון דפנה (`search_decisions`, `find_similar_cases`) 2. חפש בקאנון דפנה (`search_decisions`, `find_similar_cases`)
3. חפש במסמכי התיק (`search_case_documents`) — אולי מצוטט בכתבי הטענות 3. חפש במסמכי התיק (`search_case_documents`) — אולי מצוטט בכתבי הטענות
4. **אם נמצא ב-precedent_library** — צטט citation+supporting_quote מדויקים מהקורפוס. 4. **אם נמצא ב-precedent_library** — צטט citation+supporting_quote מדויקים מהקורפוס.
5. **אם נמצא רק במסמכי התיק** — סמן: "מקור: כתבי טענות, דורש אימות מול הקורפוס". 5. **אם נמצא רק במסמכי התיק** — סמן: "מקור: כתבי טענות, דורש אימות מול הקורפוס".
6. **אם לא נמצא בכלל** — סמן: "דורש אימות חיצוני" + נסח הנחיות חיפוש. 6. **אם לא נמצא בכלל** — קודם **נסה שוב עם הקשר** (לא שם לבדו): צרף מונחי תוכן או מספר תיק לשאילתה. שם תיק לבדו (`"אגסי"`) אינו מפתח אמין — הוא עלול להחזיר את מי שמצטט את התיק ולא את התיק עצמו. רק אם גם זה ריק — סמן: "דורש אימות חיצוני" + נסח הנחיות חיפוש.
הוסף לסעיף "7א. שאילתות לקורפוסים" כל query נוסף שהורצה ב-pass 2. הוסף לסעיף "7א. שאילתות לקורפוסים" כל query נוסף שהורצה ב-pass 2.
@@ -482,7 +505,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
@@ -18,6 +18,8 @@ tools:
- mcp__legal-ai__list_chair_feedback - mcp__legal-ai__list_chair_feedback
- mcp__legal-ai__search_case_documents - mcp__legal-ai__search_case_documents
- mcp__legal-ai__search_precedent_library - mcp__legal-ai__search_precedent_library
- mcp__legal-ai__search_internal_decisions
- mcp__legal-ai__internal_decision_upload
- mcp__legal-ai__workflow_status - mcp__legal-ai__workflow_status
- mcp__legal-ai__processing_status - mcp__legal-ai__processing_status
- mcp__legal-ai__get_metrics - mcp__legal-ai__get_metrics
@@ -75,14 +77,57 @@ 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 מורכב) |
## טקסונומיה — שני namespaces ל-`practice_area` (חובה לדעת)
⚠️ **קריטי לפני שאתה כותב practice_area לכל כלי MCP — יש שני namespaces שונים שמוגדרים במערכת:**
| Axis | ערכים | איפה משתמשים |
|------|--------|--------------|
| **A. Multi-tenant (legacy, routing)** | `appeals_committee`, `national_insurance`, `labor_law` | רק לבחירת ה-tenant ברמת המוצר. הסוכנים בוועדת ערר תמיד `appeals_committee` |
| **B. Domain (DB columns + filters)** | `rishuy_uvniya`, `betterment_levy`, `compensation_197` | **כל קריאה ל-`search_precedent_library` / `search_internal_decisions` / `precedent_library_upload` / `internal_decision_upload`** — זה ה-namespace הקובע |
**המרה אוטומטית:** `to_db_practice_area(multi_tenant_pa, appeal_subtype)` ממירה Axis A → Axis B (משתמש פנימי בלבד).
**כללי ברזל לכלי MCP:**
- בכל קריאה לכלי שמחפש או כותב לקורפוס פסיקה — **השתמש בערכי Axis B בלבד**:
- 1xxx (רישוי ובניה) → `rishuy_uvniya`
- 8xxx (היטל השבחה) → `betterment_levy`
- 9xxx (פיצויים ס' 197) → `compensation_197`
- **אסור** לעבור `appeals_committee` כ-`practice_area` ל-`search_precedent_library` — זה ייתן 0 תוצאות (הקורפוס מאוחסן ב-Axis B).
- DB constraint `cases_practice_area_check` אוכף: practice_area של תיק חייב להיות אחד מהשלושה ב-Axis B (או ריק).
## כלי MCP חדשים (יוני 2026) — חובה לקרוא
### `internal_decision_upload` — העלאת החלטת ועדת ערר לקורפוס
החלטות של ועדות ערר אחרות (`source_kind='internal_committee'`) עוברות **רק** דרך כלי זה — לא דרך `precedent_library_upload` (citation guard דוחה).
**חתימה (חובה כל ארבעת השדות):**
```
internal_decision_upload(
file_path=..., # נתיב מלא ל-PDF/DOCX/RTF/TXT/MD
case_number=..., # "ערר 1024-25" / "בל\"מ 8126/25" / וכו'
chair_name=..., # שם יו"ר — חובה (לחיפוש סלקטיבי)
district=..., # ירושלים / מרכז / תל אביב / צפון / דרום / חיפה / ארצי
... # case_name, court, decision_date, practice_area, וכו' — אופציונליים
)
```
**מי משתמש בפועל:** ב-`legal-researcher` (ראה `legal-researcher.md`). ה-CEO רק יודע שזה קיים — אם חוקר מדווח שלא הצליח להעלות החלטת ועדת ערר, ה-CEO בודק שה-chair_name + district סופקו.
### `search_internal_decisions` — חיפוש בהחלטות ועדות ערר
`search_decisions` = רק החלטות דפנה (style corpus). `search_internal_decisions` = כל ועדות הערר בכל המחוזות, עם פילטרים `chair_name` ו-`district`. ה-CEO משתמש בכלי זה בתרחישי routing מתקדמים — בד"כ ה-researcher ו-analyst הם המשתמשים העיקריים.
## הסוכנים שלך ## הסוכנים שלך
| סוכן | 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 לפני ייצוא |
@@ -113,8 +158,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'])"
``` ```
--- ---
@@ -161,6 +205,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 רגיל)
### חילוץ פסיקה אוטומטי ### חילוץ פסיקה אוטומטי
@@ -187,6 +232,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):
@@ -207,6 +272,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` ובדוק:
- [ ] סוגיות מנוסחות כסילוגיזם (כלל + עובדות + שאלה)? - [ ] סוגיות מנוסחות כסילוגיזם (כלל + עובדות + שאלה)?
- [ ] ממצאים עובדתיים מופרדים ממסקנות משפטיות? - [ ] ממצאים עובדתיים מופרדים ממסקנות משפטיות?
@@ -222,7 +293,7 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
### שלב B: הכנת סיכום, סיווג, ושאלת תוצאה ### שלב B: הכנת סיכום, סיווג, ושאלת תוצאה
**מתי:** כשיש טענות מחולצות + מחקר תקדימים, אבל אין תוצאה עדיין **מתי:** כשיש `analysis-and-research.md` מלא (מנתח סיים שלבים 1-7) וסטטוס `analyst_verified`, אבל אין תוצאה עדיין
**שיטה — dual dispatch:** קודם פרסם comment עם הסיכום המלא (לתיעוד), ואז צור interaction עם כפתורים (לחיים). **שיטה — dual dispatch:** קודם פרסם comment עם הסיכום המלא (לתיעוד), ואז צור interaction עם כפתורים (לחיים).
@@ -565,11 +636,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` | מנתח / חוקר תקדימים (valid status — legacy + תרחישים מתקדמים) | → שלב B (סיכום + שאלת תוצאה לחיים). **זה סטטוס תקף**, לא שגיאה. בזרימה הרגילה המנתח מגדיר `documents_ready`, אבל אם החוקר רץ בנפרד (`legal-researcher.md` שלב 5) הוא מעדכן ל-`research_complete`. אם תראה סטטוס זה, בדוק שגם `analysis-and-research.md` וגם `precedent-research.md` קיימים, ואז המשך ל-§B כרגיל. |
| `outcome_set` | CEO (אחרי שחיים בחר) | → האם יש claim_handling? אם לא → שלב B המשך (טבלת bundle/skip). אם כן → שלב C | | `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) |
@@ -626,15 +698,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.
## סינון תיקים לפי חברה — חובה! ## סינון תיקים לפי חברה — חובה!
@@ -746,8 +854,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

@@ -15,7 +15,9 @@ tools:
- mcp__legal-ai__workflow_status - mcp__legal-ai__workflow_status
- mcp__legal-ai__search_case_documents - mcp__legal-ai__search_case_documents
- mcp__legal-ai__search_precedent_library - mcp__legal-ai__search_precedent_library
- mcp__legal-ai__search_internal_decisions
- mcp__legal-ai__precedent_library_get - mcp__legal-ai__precedent_library_get
- mcp__legal-ai__precedent_list
- mcp__legal-ai__halacha_review - mcp__legal-ai__halacha_review
--- ---
@@ -83,6 +85,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)
@@ -143,6 +147,39 @@ tools:
- האם יש תקדים אישי שלה רלוונטי? אם כן — האם הופנה אליו (חיסכון / דחייה / הבחנה)? - האם יש תקדים אישי שלה רלוונטי? אם כן — האם הופנה אליו (חיסכון / דחייה / הבחנה)?
- **ציטוטי פסיקה חיצונית בבלוק י** — לכל ציטוט (`citation` + `supporting_quote`) שמופיע, חפש ב-`search_precedent_library` (subject_tag הרלוונטי) וודא שהציטוט קיים בקורפוס ושהלכה אושרה. ציטוט שלא תואם להלכה מאושרת = critical. - **ציטוטי פסיקה חיצונית בבלוק י** — לכל ציטוט (`citation` + `supporting_quote`) שמופיע, חפש ב-`search_precedent_library` (subject_tag הרלוונטי) וודא שהציטוט קיים בקורפוס ושהלכה אושרה. ציטוט שלא תואם להלכה מאושרת = critical.
### 9. צירוף פסיקה ל-DB (`precedent_attach`) — critical
לכל ציטוט פסיקה בבלוק י (חיצוני או internal_committee), **חייב להיות רישום ב-`case_precedents`** דרך `precedent_attach` של ה-researcher.
**שיטת בדיקה:**
1. הרץ `precedent_list(case_number)` — קבל רשימת כל הציטוטים שנרשמו ל-DB.
2. סרוק את בלוק י (וטענות סף) וזהה כל ציטוט פסיקה (citation + quote).
3. **לכל ציטוט**: ודא שהוא מופיע ב-`precedent_list`. אם חסר → `qa = fail` (critical, חוסם ייצוא). דווח אילו ציטוטים לא נרשמו.
**למה זה חשוב:** ה-DOCX exporter ו-Hermes curator קוראים מ-`case_precedents`. ציטוט שנמצא רק בטקסט ולא ב-DB יחמיץ at-export-time validation וניתוח Hermes.
### 10. מראה מקום מלא בציטוטים — warning
לכל ציטוט פסיקה בבלוק י, ודא שהוא כולל:
- **מספר תיק מלא** (לא רק "פלוני נ' פלמוני")
- **ערכאה** (עליון / מנהלי / מחוזי / שלום / ועדת ערר)
- **תאריך / `פורסם בנבו`** או `פורסם ב-`
- **`page_reference`** כשמדובר בציטוט ארוך מתוך פס"ד
אם חסר אחד מהשלושה הראשונים → **`qa = warning`**, דווח לחיים בcomment + הצע למלא. (לא חוסם — לא כל פסק דין יש לו פאג'ינציה.)
### 11. תקפות סטטוס תיק (status_validity) — sanity check
בדוק `case_get(case_number).status` — הוא צריך להיות בערכים תקפים. הזרימה הכוללת:
```
new → proofread → documents_ready → analyst_verified → research_complete (legacy/optional)
→ outcome_set → direction_approved → analysis_enriched → ready_for_writing
→ drafted (אתה כאן!) → qa_passed / qa_failed → exported
```
⚠️ **`research_complete` הוא valid status** (לא bug, לא legacy ערומה). ב-`legal-researcher.md` שלב 5 הוא הסטטוס שהחוקר מגדיר בסיום מחקר. אם תיק במצב זה נשלח אליך לפני `drafted` — דווח, אל תכשיל.
#### תבנית קבלה (מ-`daphna-acceptance-architecture.md` — אם תוצאה = קבלה) #### תבנית קבלה (מ-`daphna-acceptance-architecture.md` — אם תוצאה = קבלה)
- האם הסיבה לקבלה ברורה: פגם פנימי / החזרה / תיקונים / 8xxx מהותית / שומה? - האם הסיבה לקבלה ברורה: פגם פנימי / החזרה / תיקונים / 8xxx מהותית / שומה?
- האם התבנית הנבחרת (A/B/C/D/E) מתאימה לסיבה? - האם התבנית הנבחרת (A/B/C/D/E) מתאימה לסיבה?
@@ -163,6 +200,9 @@ tools:
| **שאילתות לקורפוסים** | **critical** | **חוסם ייצוא** | | **שאילתות לקורפוסים** | **critical** | **חוסם ייצוא** |
| מתודולוגיה | critical | חוסם ייצוא | | מתודולוגיה | critical | חוסם ייצוא |
| **קול דפנה** | **critical** | **חוסם ייצוא** | | **קול דפנה** | **critical** | **חוסם ייצוא** |
| **צירוף פסיקה ל-DB** | **critical** | **חוסם ייצוא** |
| מראה מקום מלא | warning | מדווח, לא חוסם |
| תקפות סטטוס | sanity | דיווח בלבד |
## תהליך עבודה ## תהליך עבודה

View File

@@ -14,12 +14,15 @@ 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
- mcp__legal-ai__precedent_list - mcp__legal-ai__precedent_list
- mcp__legal-ai__precedent_search_library - mcp__legal-ai__precedent_search_library
- mcp__legal-ai__search_precedent_library - mcp__legal-ai__search_precedent_library
- mcp__legal-ai__internal_decision_upload
- mcp__legal-ai__precedent_library_upload
- mcp__legal-ai__precedent_library_get - mcp__legal-ai__precedent_library_get
- mcp__legal-ai__precedent_library_list - mcp__legal-ai__precedent_library_list
- mcp__legal-ai__precedent_extract_halachot - mcp__legal-ai__precedent_extract_halachot
@@ -27,9 +30,14 @@ tools:
- mcp__legal-ai__precedent_process_pending - mcp__legal-ai__precedent_process_pending
- mcp__legal-ai__halacha_review - mcp__legal-ai__halacha_review
- mcp__legal-ai__halachot_pending - mcp__legal-ai__halachot_pending
- mcp__legal-ai__missing_precedent_create
- mcp__legal-ai__missing_precedent_list
- mcp__legal-ai__missing_precedent_close
- mcp__legal-ai__workflow_status - mcp__legal-ai__workflow_status
--- ---
> ראה גם: [HEARTBEAT.md](HEARTBEAT.md) לכללי הפעלה כלליים — routing, company filtering, wakeup API
# חוקר תקדימים — סוכן מחקר משפטי # חוקר תקדימים — סוכן מחקר משפטי
אתה חוקר משפטי מומחה בתכנון ובניה ישראלי. תפקידך לנתח את מסמכי הרקע בתיק ערר — פסיקה, תכניות, פרוטוקולים, החלטות ביניים. אתה חוקר משפטי מומחה בתכנון ובניה ישראלי. תפקידך לנתח את מסמכי הרקע בתיק ערר — פסיקה, תכניות, פרוטוקולים, החלטות ביניים.
@@ -67,6 +75,92 @@ tools:
כתבי ערר, תשובות, תגובות — אלה בטיפול סוכן "מנתח משפטי". כתבי ערר, תשובות, תגובות — אלה בטיפול סוכן "מנתח משפטי".
## ⚠️ חובה לקרוא — איזה כלי upload להשתמש לכל סוג פסיקה
כשאתה מעלה פסיקה לקורפוס הסמכותי, **יש שני זרמים שונים** והם **לא ניתנים להחלפה**. שגיאה כאן פוגעת בכל המערכת.
### Flowchart החלטה — איזה כלי?
```
האם ה-citation מתחיל ב-"ערר" או "בל"מ" (החלטת ועדת ערר)?
├── כן → internal_decision_upload ✅ (חובה chair_name + district)
└── לא →
האם מתחיל ב-עע"מ / בר"מ / עמ"נ / בג"ץ / ע"א / ע"פ / רע"א / רע"פ / ת"א / ת"מ
(פסיקת בית משפט מנהלי/עליון/מחוזי/שלום)?
├── כן → precedent_library_upload ✅ (external_upload)
└── לא → דווח לחיים: citation לא מוכר, אל תעלה
```
### זרם A — `precedent_library_upload` (external)
לפסיקת ערכאות שיפוטיות: עליון (בג"ץ/ע"א/רע"א/ע"פ/רע"פ/דנ"א), מנהלי (עע"מ/בר"מ/עמ"נ), מחוזי (ת"א/ת"מ), שלום.
```python
mcp__legal-ai__precedent_library_upload(
file_path="/path/to/file.pdf",
citation="עע\"מ 3911/19 פלוני נ' הוועדה המקומית רמת גן (פורסם בנבו, 12.07.2023)",
case_name="פלוני נ' הוועדה המקומית רמת גן",
court="בית המשפט העליון",
decision_date="2023-07-12",
practice_area="rishuy_uvniya", # Axis B בלבד
subject_tags=["שימוש חורג", "מגרש מסחרי"],
)
```
**הכלי שומר `source_kind='external_upload'`.** Citation guard: אם תנסה להעלות citation שמתחיל ב-"ערר" או "בל\"מ" — הכלי **ידחה** עם שגיאה ויפנה ל-`internal_decision_upload`.
### זרם B — `internal_decision_upload` (internal_committee) — **חובה לחלק מהפסיקה**
להחלטות **ועדות ערר** מכל המחוזות (ירושלים, מרכז, תל אביב, צפון, דרום, חיפה, ארצי). כולל גם ערר רגיל וגם בל"מ.
```python
mcp__legal-ai__internal_decision_upload(
file_path="/path/to/file.pdf",
case_number="ערר (ועדות ערר - תכנון ובנייה ירושלים) 1110/20",
chair_name="שרית אריאלי", # חובה!
district="ירושלים", # חובה! אחד מ-7
case_name="פלוני נ' הוועדה המקומית מודיעין",
court="ועדת הערר לתכנון ובנייה — מחוז ירושלים",
decision_date="2020-11-15",
practice_area="rishuy_uvniya", # Axis B
appeal_subtype="building_permit",
proceeding_type="ערר", # 'ערר' / 'בל"מ' — ראה מטה
subject_tags=["שימוש חורג"],
is_binding=False, # תמיד False — שכנוע אופקי, לא חוב
)
```
**שדות חובה (הכלי דוחה בלעדיהם):**
- `file_path`
- `case_number`
- `chair_name` — בלעדיו אי-אפשר לחפש סלקטיבית לפי הרכב
- `district` — ערכים תקפים: **ירושלים / מרכז / תל אביב / צפון / דרום / חיפה / ארצי** (גם "תל-אביב" עם מקף נקלט)
**שדה מומלץ — `proceeding_type`:**
- `"ערר"` — הליך ערר עיקרי (כותרת ב-PDF: "ערר (ועדות ערר ...) NNNN/YY")
- `'בל"מ'` — בקשה להארכת מועד להגשת ערר (כותרת: "בל\"מ NNNN/YY" או נושא "בקשה להארכת מועד להגשת ערר")
- שני הסוגים יכולים לחלוק אותו מספר תיק (למשל 8047/23 קיים גם כערר וגם כבל"מ).
- בכותרת הראשית של ה-PDF זה תמיד מפורש — לקרוא משם ולא לנחש.
- אם תשאיר ריק — הכלי גוזר אוטומטית מ-appeal_subtype (`extension_request_*` → 'בל"מ') או מתבנית הטקסט. עדיף מפורש.
**הכלי שומר `source_kind='internal_committee'`.** DB constraint `case_law_internal_district_check` אוכף ש-`district NOT NULL` כשמדובר ב-internal_committee.
### אם chair_name או district חסר ב-PDF
- חפש בתוך הטקסט: "בפני: עו\"ד X" / "יו\"ר הוועדה: X" / "מחוז ירושלים" / שם המחוז בכותרת
- אם לא מצליח לזהות — **אל תנחש**. דווח לחיים ב-comment: "נמצא PDF של החלטת ערר ללא chair_name/district ברורים — נדרש מילוי ידני". המשך עם שאר העבודה.
### 2 שכבות חיפוש מקבילות
לאחר ההעלאות הנכונות:
| כלי | מטרה | מתי |
|-----|------|-----|
| `search_precedent_library` | חיפוש פסיקה **חיצונית** (עליון/מנהלי/מחוזי) | כל סוגיה מרכזית — חובה |
| `search_internal_decisions` | חיפוש בהחלטות **ועדות ערר** (כל המחוזות) | כשהסוגיה דיונית או כשאין הלכת עליון |
שניהם מקבלים את אותם הפילטרים: `practice_area` (Axis B), `subject_tag`, וכו'. `search_internal_decisions` מקבל בנוסף `district` ו-`chair_name`.
## תהליך עבודה ## תהליך עבודה
### שלב 1: התמצאות ### שלב 1: התמצאות
@@ -101,8 +195,8 @@ tools:
| סיווג תיק | practice_area | | סיווג תיק | practice_area |
|------------|---------------| |------------|---------------|
| 1xxx (רישוי ובניה) | `rishuy_uvniya` | | 1xxx (רישוי ובניה) | `rishuy_uvniya` |
| 8xxx (היטל השבחה) | `histael_hashbacha` | | 8xxx (היטל השבחה) | `betterment_levy` |
| 9xxx (פיצויים ס' 197) | `pitsuim_197` | | 9xxx (פיצויים ס' 197) | `compensation_197` |
אם הסוגיה ב-`appeal_subtype` ידוע (כמו "שימוש חורג", "סטייה ניכרת") — הוסף `appeal_subtype` לפילטר. אם הסוגיה ב-`appeal_subtype` ידוע (כמו "שימוש חורג", "סטייה ניכרת") — הוסף `appeal_subtype` לפילטר.
@@ -121,6 +215,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="betterment_levy", # 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`
לכל סוגיה — בדוק במסמך: לכל סוגיה — בדוק במסמך:
@@ -154,6 +269,42 @@ search_precedent_library(
**מינימום:** queries לקורפוס הסמכותי = מספר סוגיות מרכזיות שזוהו. **מינימום:** queries לקורפוס הסמכותי = מספר סוגיות מרכזיות שזוהו.
#### 2ב.4א — איתור החלטה ספציפית לפי שם — פרוטוקול לפני "לא בקורפוס" ⚠️
שם תיק לבדו (למשל `"אגסי"`) **אינו מפתח חיפוש אמין**. ההטמעה הסמנטית והאינדקס הלקסיקלי בנויים על תוכן ההלכה/הפסקה — כך ששאילתת-שם עלולה להחזיר דווקא החלטות ש**מצטטות** את התיק, ולא את התיק עצמו. לפני שמכריזים שהחלטה אינה בקורפוס:
1. **הוסף הקשר לשאילתה** — לא `"אגסי"` אלא `"אגסי פטור 19(ג)(1) שתי דירות 140 מ"ר"`, או חפש לפי **מספר התיק** (`"ערר 81002-01-21"`).
2. **חפש בשני הקורפוסים**`search_precedent_library` **וגם** `search_internal_decisions`. החלטות ערר/בל"מ שהיו"ר מעלה נשמרות כ-`internal_committee` ומתגלות בחיפוש הפנימי.
3. **לאימות קיום / דפדוף**`precedent_library_list(search="<שם>", source_kind="all_committees")`. ברירת המחדל `external_upload` **מסתירה** החלטות ועדת ערר שהועלו — חובה `all_committees` או `internal_committee`.
4. רק אם **כל** הניסיונות לעיל ריקים — הכרז "לא בקורפוס" ועבור ל-2ב.5.
#### 2ב.5 — תיעוד פסיקה חסרה (`missing_precedent_create`) — חובה
**מתי לקרוא:** לכל ציטוט שהצדדים הביאו (בכתב ערר / תגובה / תגובת ועדה) **שלא נמצא בקורפוס** אחרי חיפוש מובנה לפי פרוטוקול 2ב.4א (`search_precedent_library` + `search_internal_decisions` + `precedent_search_library`, כולל שאילתה עם הקשר/מספר תיק).
**למה זה חשוב:**
- ה-writer יודע שלא להסתמך על פסיקה שלא ב-DB ("טוענים שמופיע" ≠ "אומת")
- היו"ר רואה בדף ייחודי `/missing-precedents` מה ממתין להעלאה ויכול לסגור פערים בקליק
- ההיסטוריה נשמרת: ראינו את הציטוט, לא מצאנו, חיכינו להעלאה, הועלה, נסגר
```python
mcp__legal-ai__missing_precedent_create(
citation = "עע\"מ 1461/20 אנטרים אינווסטמנטס נ' הועדה המקומית ירושלים (נבו 4.5.2021)",
case_number = "1017-03-26", # תיק הערר שבו הצד ציטט
cited_by_party = "permit_applicant", # appellant/respondent/committee/permit_applicant/unknown
cited_by_party_name = "לינדאב בע\"מ",
legal_topic = "זכות עמידה",
legal_issue = "זכות ערר על בקשה להיתר מוקנית רק לבעל זכות במקרקעין",
claim_quote = "...הציטוט המדויק מכתב הטענות...",
case_name = "אנטרים", # שם קצר
notes = "אופציונלי"
)
```
הכלי deduplicates: ציטוט+תיק זהים → מחזיר את הרשומה הקיימת. אם הציטוט כבר תויג (אפילו ב-status='closed' כי היו"ר העלה אותו בינתיים) — אל תיצור כפילות.
**במסמך `precedent-research.md`** הוסף סעיף `## ח. פסיקה חסרה בקורפוס` עם רשימת רשומות שנוצרו (כולל ה-id שהוחזר), כדי שה-writer וה-QA יבחינו בין "אומת מהקורפוס" ל"דיווח בלבד".
5. **דווח** איזה תקדמים מהקאנון רלוונטיים, איזה תקדמים אישיים נמצאו, ואילו הלכות מהקורפוס הסמכותי תומכות. 5. **דווח** איזה תקדמים מהקאנון רלוונטיים, איזה תקדמים אישיים נמצאו, ואילו הלכות מהקורפוס הסמכותי תומכות.
### שלב 3: מיפוי תכנית ### שלב 3: מיפוי תכנית

View File

@@ -20,6 +20,7 @@ tools:
- mcp__legal-ai__write_block - mcp__legal-ai__write_block
- mcp__legal-ai__search_decisions - mcp__legal-ai__search_decisions
- mcp__legal-ai__search_precedent_library - mcp__legal-ai__search_precedent_library
- mcp__legal-ai__search_internal_decisions
- mcp__legal-ai__precedent_library_get - mcp__legal-ai__precedent_library_get
- mcp__legal-ai__precedent_library_list - mcp__legal-ai__precedent_library_list
- mcp__legal-ai__halacha_review - mcp__legal-ai__halacha_review
@@ -59,6 +60,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` — איך דפנה כותבת
@@ -347,6 +351,28 @@ fi
חפש לפי `practice_area` (rishuy_uvniya / betterment_levy / compensation_197) ולפי `subject_tag` רלוונטי. הלכות שלא אושרו ע"י דפנה לא מוחזרות מהכלי — אם החיפוש ריק, חזור ל-`search_decisions` בלבד. חפש לפי `practice_area` (rishuy_uvniya / betterment_levy / compensation_197) ולפי `subject_tag` רלוונטי. הלכות שלא אושרו ע"י דפנה לא מוחזרות מהכלי — אם החיפוש ריק, חזור ל-`search_decisions` בלבד.
**איתור החלטה לפי שם:** אם אתה מחפש החלטה ספציפית בשמה (למשל "אגסי"), אל תחפש בשם לבדו — צרף מונחי תוכן או מספר תיק (`"אגסי 19(ג)(1) 140 מ"ר"` / `"ערר 81002-01-21"`). שאילתת-שם בלבד עלולה להחזיר את מי שמצטט את ההחלטה ולא את ההחלטה עצמה.
### ⚠️ ניסוח ציטוטי פסיקה בקול ההחלטה — לפי `source_kind`
כל רשומה בקורפוס נושאת `source_kind` (ראה בפלט של `precedent_library_get` / `search_precedent_library` / `search_internal_decisions`). הניסוח בבלוק י **משתנה לפי הסוג** — לא רק הציטוט, אלא **התפקיד הרטורי** של פסק הדין בהנמקה:
| source_kind | מקור | מעמד | תבנית ניסוח בבלוק י |
|-------------|------|------|----------------------|
| `external_upload` | בית משפט (עליון/מנהלי/מחוזי/שלום) | **סמכותי — מחייב או משכנע גבוה** | "בהתאם להלכת **X** ב-עע\"מ NNNN/YY, נקבע כי..." / "כפי שהבהיר בית המשפט העליון ב-בג\"ץ NNN/YY, '...'" |
| `internal_committee` (אחר) | ועדת ערר אחרת | **שכנוע אופקי בלבד — לא מחייב** | "כפי שנקבע על-ידי כב' היו\"ר **Y** במחוז Z בערר NNNN/YY, '...'. סוגיה זו עלתה בפנינו, ואנו מסכימים עם הניתוח הנ\"ל..." |
| `internal_committee` של דפנה עצמה | החלטה קודמת של דפנה | **עקביות עצמית (ג'וריספרודנציה אישית)** | "כפי שקבעתי בעבר בערר NNNN/YY, '...'. אין מקום לסטות מכך גם בעניין שלפנינו." (קול אישי "אנחנו"/"אני" — לפי מה שמופיע בקורפוס המקור) |
**עקרון CREAC (Rule + Explanation):**
- **Rule (כלל)**: רק מ-`external_upload` (פסיקת ערכאות) או מחוקקה. **אסור** להציג ועדת ערר אחרת כ"כלל מחייב".
- **Explanation (הרחבה/שכנוע)**: `internal_committee` יכול לתפוס כאן — אבל **בנפרד** מהכלל, כשכנוע נוסף.
- **אם אין הלכת עליון** ויש רק ועדת ערר תומכת — נסח: "לעת הזו, סוגיה זו טרם נדונה בערכאות עליונות. עם זאת, כפי שנקבע ב<ערר>... מצאנו את ההנמקה משכנעת ואנו אומצים אותה."
**בדיקה לפני שאתה כותב ציטוט:**
1. הוצא את ה-`source_kind` מהפלט של `search_precedent_library` או `search_internal_decisions`.
2. אם `internal_committee` — בדוק את `chair_name`. אם זו דפנה תמיר → סגנון "כפי שקבעתי בעבר". אחרת → סגנון אופקי עם ציון מחוז.
3. אל תערבב — שלוש קטגוריות שונות, שלוש תבניות שונות.
### אנטי-דפוסים — בדיקה אחרי כתיבה (חובה) ### אנטי-דפוסים — בדיקה אחרי כתיבה (חובה)
- [ ] **אין רשימות ממוספרות בתוך פסקה** (`(1)... (2)... (3)...`) — דפנה מעולם לא משתמשת - [ ] **אין רשימות ממוספרות בתוך פסקה** (`(1)... (2)... (3)...`) — דפנה מעולם לא משתמשת

View File

@@ -1,5 +1,7 @@
data/ data/
.claude/ .claude/
!.claude/agents/
!.claude/agents/hermes-curator.md
mcp-server/.venv/ mcp-server/.venv/
**/__pycache__/ **/__pycache__/
*.pyc *.pyc
@@ -11,7 +13,11 @@ scripts/
skills/ skills/
!skills/docx/ !skills/docx/
!skills/docx/decision_template.docx !skills/docx/decision_template.docx
!skills/decision/
!skills/decision/SKILL.md
docs/ docs/
!docs/legal-decision-lessons.md
!docs/corpus-analysis.md
legacy/ legacy/
node_modules/ node_modules/
.next/ .next/

File diff suppressed because it is too large Load Diff

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 |
--- ---
@@ -89,6 +91,16 @@
- שינויי קוד נכנסים לתוקף אחרי `pm2 restart paperclip` - שינויי קוד נכנסים לתוקף אחרי `pm2 restart paperclip`
- **אין צורך ב-Docker או Coolify** - **אין צורך ב-Docker או Coolify**
**legal-chat-service** — רץ **מקומית דרך pm2** (חדש, מאפריל 2026):
- פורט: `localhost:8770` (loopback בלבד)
- שירות aiohttp קצר שעוטף את `claude` CLI ב-streaming + session continuation, ומשרת את הטאב "שיחה" בדף `/training`. הקונטיינר משדל אליו proxy דרך `host.docker.internal:8770`.
- קוד: [mcp-server/src/legal_mcp/chat_service/](mcp-server/src/legal_mcp/chat_service/)
- התקנה: `pm2 start /home/chaim/legal-ai/scripts/legal-chat-service.config.cjs && pm2 save`
- בריאות: `curl http://127.0.0.1:8770/health``{"ok":true,...}`
- שינויי קוד: `pm2 restart legal-chat-service`
- **אפס עלות API** — claude CLI משתמש ב-claude.ai subscription של chaim. הנחת היסוד של `claude_session.py` (claude CLI מקומי בלבד) נשמרת — השירות הזה הוא הגשר הרשמי בין הקונטיינר לחוץ.
- Coolify dependency: ה-Service Definition של legal-ai חייב להכיל `extra_hosts: host.docker.internal:host-gateway` (אחרת ה-proxy יקבל ConnectError).
--- ---
## מבנה תיקיות ## מבנה תיקיות
@@ -106,14 +118,28 @@
├── 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
@@ -137,12 +163,14 @@
הפרויקט משתמש ב-**TaskMaster AI** (MCP server) לניהול משימות מובנה: הפרויקט משתמש ב-**TaskMaster AI** (MCP server) לניהול משימות מובנה:
- **תמיד** להשתמש ב-TaskMaster לפירוק, מעקב וניהול משימות — לא ב-TASKS.md ידני - **תמיד** להשתמש ב-TaskMaster לפירוק, מעקב וניהול משימות — לא ב-TASKS.md ידני
- קובץ המשימות: `tasks/tasks.json` - קובץ המשימות הקנוני: `~/legal-ai/.taskmaster/tasks/tasks.json` (יחסי ל-project root, **לא** `~/.taskmaster/tasks/tasks.json`). מכיל את כל ה-tags של legal-ai (`master`, `legal-ai`).
- פקודות עיקריות: `get_tasks`, `next_task`, `add_task`, `update_task`, `expand_task` - פקודות עיקריות: `get_tasks`, `next_task`, `add_task`, `update_task`, `expand_task`
- לפני התחלת עבודה → `next_task` כדי לדעת מה הבא לפי תלויות - לפני התחלת עבודה → `next_task` כדי לדעת מה הבא לפי תלויות
- אחרי סיום משימה → `update_task` עם status=done - אחרי סיום משימה → `update_task` עם status=done
- משימה מורכבת → `expand_task` לפירוק לתתי-משימות - משימה מורכבת → `expand_task` לפירוק לתתי-משימות
> **⚠️ מלכוד cwd ב-CLI:** הדגל `--tag` בוחר קבוצה לוגית *בתוך* הקובץ — הוא **לא** בוחר לאיזה `tasks.json` לכתוב. ה-CLI מאתר את הקובץ לפי ה-cwd (`<cwd>/.taskmaster/tasks/tasks.json`). תמיד `cd ~/legal-ai` לפני `task-master add-task` או כל פקודה משנה, ואז אמת ב-MCP `get_tasks` שהשינוי נחת. הרצה מ-`~/` כותבת לקובץ נטוש והמשימה לא תופיע בשאילתות MCP. כשלא בטוחים — לערוך את `~/legal-ai/.taskmaster/tasks/tasks.json` ישירות.
--- ---
## Paperclip — כללי אינטגרציה קריטיים ## Paperclip — כללי אינטגרציה קריטיים
@@ -182,6 +210,32 @@
- הסקריפט מסנן 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` ### External adapters — `deepseek_local`
- מיקום ה-package: [adapters/deepseek-paperclip-adapter/](adapters/deepseek-paperclip-adapter/) (לא ב-`node_modules`). - מיקום ה-package: [adapters/deepseek-paperclip-adapter/](adapters/deepseek-paperclip-adapter/) (לא ב-`node_modules`).
- רישום ב-Paperclip: רשומה ב-`~/.paperclip/adapter-plugins.json` (נטען אוטומטית ב-startup דרך `buildExternalAdapters`). אין צורך בעריכת `node_modules`. - רישום ב-Paperclip: רשומה ב-`~/.paperclip/adapter-plugins.json` (נטען אוטומטית ב-startup דרך `buildExternalAdapters`). אין צורך בעריכת `node_modules`.
@@ -191,6 +245,12 @@
- **⚠ Cross-company sync**: `sync_agents_across_companies.py` **מדלג** על סוכנים עם `adapter_type` שונה בין CMP ל-CMPA. כשעוברים סוכן ל-`deepseek_local` חובה להחיל ידנית בשתי החברות לפני sync. - **⚠ 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) כתבנית. - **תוספת 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

@@ -61,6 +61,18 @@ COPY mcp-server/src/ ./mcp-server/src/
# (Path(__file__).resolve().parents[4] / "skills/docx/decision_template.docx") # (Path(__file__).resolve().parents[4] / "skills/docx/decision_template.docx")
COPY skills/docx/decision_template.docx ./skills/docx/decision_template.docx COPY skills/docx/decision_template.docx ./skills/docx/decision_template.docx
# Reference content the /training tab reads at runtime:
# - .claude/agents/hermes-curator.md → GET /api/training/curator/prompt
# - skills/decision/SKILL.md → system prompt for the chat
# - docs/legal-decision-lessons.md → system prompt for the chat
# - docs/corpus-analysis.md → system prompt for the chat
#
# These are read-only at runtime; chair edits go through git, not the container.
COPY .claude/agents/hermes-curator.md ./.claude/agents/hermes-curator.md
COPY skills/decision/SKILL.md ./skills/decision/SKILL.md
COPY docs/legal-decision-lessons.md ./docs/legal-decision-lessons.md
COPY docs/corpus-analysis.md ./docs/corpus-analysis.md
# Make mcp-server source available to web/app.py (it does sys.path.insert for legal_mcp) # Make mcp-server source available to web/app.py (it does sys.path.insert for legal_mcp)
ENV PYTHONPATH=/app/mcp-server/src ENV PYTHONPATH=/app/mcp-server/src

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

@@ -0,0 +1,227 @@
# מתודולוגיה — בל"מ בהיטל השבחה (8xxx)
**appeal_subtype:** `extension_request_betterment_levy`
**מסלול:** סעיף 14 לתוספת ג' לחוק התכנון והבנייה, התשכ"ה-1965
**מועד סטטוטורי:** **45 ימים** (להבדיל מ-30 ימים ברישוי) מיום קבלת
דרישת תשלום היטל ההשבחה (סעיף 14(א) לתוספת ג')
---
## א. מבוא — ייחודיות בל"מ בהיטל השבחה
בל"מ במסלול היטל השבחה שונה משמעותית מבל"מ ברישוי בכמה ממדים:
| ממד | בל"מ ברישוי | בל"מ בהיטל השבחה |
|------|--------------|-------------------|
| מועד סטטוטורי | 30 ימים | **45 ימים** |
| סעיף בחוק | 152 | סעיף 14 לתוספת ג' |
| בעלי דין | רחב — כל בעל זכות גובלת/קרובה | **צר — רק החייב בהיטל** |
| מהות הסעד | ביטול היתר / שינוי תנאים | תיקון שומה / ביטול חיוב |
| טון | פעמים אנושי (תושב, סביבה) | קר ומקצועי (פיננסי/שמאי) |
| הסתמכות נדרשת | של היזם | של הרשות (חלוקת הכנסות) |
הייחוד הקרדינלי: **בל"מ בהיטל השבחה דורש הוכחת טעות שמאית או בדין**
לא רק "טעם סביר" כמו ברישוי. הסיבה: שומת היטל ההשבחה היא מעשה מנהלי
שקיבל תוקף, וכספים שולמו / נדרשו, ולעיתים גם חולקו. שינוי שומה דורש
עילה מהותית.
---
## ב. מסגרת נורמטיבית
### שכבה א — חקיקה ראשית
**סעיף 14(א) לתוספת ג' לחוק התכנון והבנייה:**
> "בעל המקרקעין החייב בהיטל השבחה ... רשאי להגיש ערר על השומה לוועדת הערר
> לפיצויים ולהיטל השבחה ... בתוך 45 ימים מיום שהומצאה לו השומה"
המחוקק קבע מועד ארוך יותר (45 לעומת 30) מתוך הכרה במורכבות הסוגיה השמאית —
הצורך לקבל חוו"ד שמאית, להתייעץ עם עו"ד מומחה למיסוי מקרקעין, ולבחון את
חישובי השומה.
### שכבה ב — עליון
**רע"א 7669/96 עיריית נהריה נ' קמינסקי (פ"ד נב(1) 214):**
ביסוס עקרוני של "סופיות שומה" — שינוי שומה לאחר חלוף המועד הסטטוטורי
אינו עומד על ערעור "טעם סביר" בלבד; נדרש אינטרס ציבורי מובהק או טעות
שמאית מהותית.
**עע"מ 1832/14 הרשות לפיתוח ירושלים נ' מנהל מס שבח:**
היטל השבחה — תשלום הכפוף לסופיות שומה; קביעות שמאי בדבר ערך המקרקעין לפני
ואחרי האירוע התכנוני הן עובדתיות-מקצועיות. שינוי דורש הצדקה חזקה.
### שכבה ג — ועדות ערר לפיצויים ולהיטל השבחה
(להוסיף תקדימים ספציפיים מקורפוס דפנה תמיר בהיטל השבחה. הקורפוס הקיים
כולל את עררי 8xxx — לחפש דפוס "בל\"מ" או "הארכת מועד" בתוכם.)
---
## ג. תבחיני בל"מ בהיטל השבחה — חמישה תבחינים
| # | תבחין | אופי | משקל |
|---|--------|------|------|
| א | **טעות שמאית או בדין** | **תנאי סף עצמאי — ייחודי להיטל השבחה** | קריטי |
| ב | טעם סביר לאיחור | מקדים — בדומה לרישוי, אך מחמיר | גבוה |
| ג | אורך השיהוי | כמותי | גבוה |
| ד | הסתמכות הרשות (חלוקת כספים) | כמותי | גבוה |
| ה | סיכויי הערר המהותי (לכאורה) | מהותי | בינוני |
תבחין "אינטרס ציבורי" לא מופיע כתבחין עצמאי כאן — בהיטל השבחה האינטרס
הציבורי נטוע בתוך הסתמכות הרשות (תבחין ד).
---
## ד. תבחין א — טעות שמאית או טעות בדין
### מה זו "טעות שמאית"?
לא כל מחלוקת על שווי = טעות. נדרש להוכיח אחד מאלה:
1. **טעות חישובית גלויה** — סכום שגוי, פעולה אריתמטית שגויה.
2. **שיטה שמאית פסולה** — שימוש בגישה לא מקובלת (לדוגמה: היוון לפי שיעור
שאינו ריאלי, השוואה לעסקאות שאינן מקבילות).
3. **התעלמות מנכסים דומים** — עיוורון לנתונים שהיו צריכים להילקח בחשבון.
4. **שגיאה במספרי שטח / זכויות / תכנית** — אי-תאמה לנסח / לתב"ע.
### מה זו "טעות בדין"?
שגיאה משפטית בעצם החיוב:
- **חיוב על נכס שאינו "מקרקעין" לעניין החוק** (זכויות חוזיות גרידא).
- **חיוב בגין השבחה שאינה נכנסת להגדרת "השבחה" בחוק** (לדוגמה: השבחה
שנוצרה לפני התקופה הקובעת; השבחה מכוח תכנית שאינה תכנית מתאר).
- **חיוב לפני התגבשות העילה** — דרישה לפני מימוש בהיתר או מכר.
### הוכחה דרושה
- **חוות דעת שמאית חתומה** מאת שמאי מקרקעין מוסמך, עם נתוני השוואה.
- **תיעוד הליך השומה המקורי** — אילו נתונים נלקחו? אילו לא?
- **חישוב חלופי מנומק** — לא רק "אני חולק", אלא "הנה החישוב הנכון".
---
## ה. תבחין ב — טעם סביר לאיחור
### העקרון
בדומה לבל"מ ברישוי, אך **קפדן יותר**:
- מועד 45 ימים נחשב "מועד ארוך" — קשה יותר להצדיק החמצתו.
- החייב לרוב מקבל את השומה לידיו אישית — אין סוגיית "פרסום באתר".
- ערב פניה לעו"ד / שמאי הוא צעד צפוי וסטנדרטי.
### מצבי "טעם סביר" אופייניים
| מצב | קבילות |
|------|---------|
| מחלת המבקש (מתועדת רפואית) | קבילה |
| המצאה פגומה (לא לכתובת הנכונה) | קבילה — אך נטל הוכחה כבד |
| תקופה ארוכה של בירורים מקצועיים | חלשה — לוחות זמנים אינם מוקפאים |
| המתנה לעמדת שמאי לפני הגשת ערר | חלשה — אפשר להגיש ולתקן |
| התכתבות עם הרשות בניסיון פשרה | חלשה — לא מקפיאה מועד |
### דרישת התצהיר
**חובה** תצהיר מפורט — תאריכים, אנשי קשר, מסמכי תמיכה. ללא תצהיר —
הטענה ריקה משפטית.
---
## ו. תבחין ג — אורך השיהוי
### חישוב
| תאריך | אירוע | שיהוי מצטבר |
|--------|--------|--------------|
| יום 0 | המצאת השומה | 0 |
| יום 45 | תום המועד הסטטוטורי | תום המועד |
| יום X | הגשת הבל"מ | X-45 ימים מעבר למועד |
### עקרון מנחה
- שיהוי של עד 30 ימים מעבר למועד (סה"כ 75 ימים מיום ההמצאה) — מקבל
התייחסות עניינית אם יש טעם סביר.
- שיהוי של מעל 90 ימים מעבר למועד — נחשב חמור; דורש הוכחה חזקה במיוחד.
- שיהוי של מעל שנה — לרוב חוסם אלא אם מדובר בטעות חישובית גלויה.
### השפעת השיהוי על הסתמכות הרשות
ככל שהזמן עובר — הסיכוי שהרשות חילקה את הכספים גבוה יותר. דרישה להחזר
שנים לאחר התשלום פוגעת בהסתמכות הרשות בצורה מובהקת.
---
## ז. תבחין ד — הסתמכות הרשות (חלוקת הכנסות)
### ייחודיות לעומת בל"מ ברישוי
ברישוי — ההסתמכות היא של היזם הפרטי. בהיטל השבחה — ההסתמכות היא של
**הרשות הציבורית**: הכספים מועברים לקרן השבחה, מתוכננים לפרויקטים
ציבוריים, ולעיתים אף חולקו או הוצאו.
### טבלת בדיקה
| שלב | מצב הכספים | השפעה על הבל"מ |
|------|------------|-----------------|
| לפני תשלום | החייב לא שילם | קלה — אין הסתמכות הרשות |
| לאחר תשלום, לפני חלוקה | בקופת הוועדה / קרן | בינונית |
| לאחר חלוקה לרשויות | חולק לעירייה, יזם, וכו' | משמעותית |
| לאחר ביצוע פרויקטים | כספים הוצאו | מוחשית, קשה להפיך |
### עיקרון
**ככל שהכספים "התרחקו" מהקופה — דרישות הוכחת הטעות מחמירות.**
---
## ח. תבחין ה — סיכויי הערר המהותי (לכאורה)
### הבהרה מתודית
בשלב בל"מ — בוחנים סיכויי הערר רק כדי לקבוע האם יש סיבה לפתוח את הדלת.
הקריטריון: **האם יש "טענה לכאורה" המבוססת על תיעוד מקצועי?**
### סוגי טענות אופייניים
- חישוב שגוי של "המצב הקודם" / "המצב החדש"
- שיטת שיערוך פסולה (השוואה / הפרשי הון / היוון)
- התעלמות מ"זכויות מותנות" שטרם התגבשו
- חיוב כפול (הון / הכנסה / שבח)
- אי-התאמה למיקום, שימוש, או שטח
### מה לא נספר כ"סיכויי הליך"
- "אני לא מסכים לסכום" — בלי חוו"ד נגדית מבוססת.
- טענות כלליות על "המצב הכלכלי" של המבקש.
- טענות על "תקדים" שלא הוכרע בערכאה גבוהה יותר.
---
## ט. טבלת התאמה לעובדות (placeholder לכל תיק)
| תבחין | עובדה במקרה הנוכחי | כיוון |
|--------|---------------------|-------|
| א. טעות שמאית/בדין | [סוג הטעות הנטענת + תיעוד] | [חוסם / מאפשר] |
| ב. טעם סביר | [מועד המצאה, פעולות, תצהיר] | [תומך / מחליש] |
| ג. אורך השיהוי | [X ימים מעבר ל-45] | [קל / בינוני / חמור] |
| ד. הסתמכות הרשות | [מצב הכספים: בקופה / חולק / הוצא] | [קל / משמעותי / מוחשי] |
| ה. סיכויי הליך | [חוו"ד שמאית? חישוב חלופי?] | [לכאורה / ספקולטיבי] |
---
## י. סעיף מסקנה — מבנה אופייני
המבנה האופייני בבל"מ-היטל-השבחה הוא **קר ומקצועי** — מינימום רגש,
מקסימום שמאות:
1. **קביעת מצב השומה.** "השומה הומצאה ביום X. הבל"מ הוגשה ביום Y."
2. **תבחין א (טעות שמאית).** "המבקש טוען לטעות בX. בחינת המסמכים מעלה..."
3. **אם טעות לא הוכחה — דחייה.** "בהיעדר טעות שמאית או בדין, אין יסוד
לסטות ממועד הקבוע בחוק."
4. **אם טעות הוכחה — מעבר לתבחינים ב-ה.**
5. **מאזן.** "לאור איזון התבחינים..."
6. **הכרעה.** דחייה / קבלה / החזרה לשמאי הוועדה לבחינה.
### לשון אופיינית לדחייה
> "הבל"מ הוגשה X ימים לאחר תום המועד הסטטוטורי. המבקש לא הצביע על טעות
> שמאית או בדין; הטענות הן בגדר מחלוקת על שיקול דעת מקצועי, שאינה מצדיקה
> פתיחת שומה שקיבלה תוקף. לאור אלה, ובהינתן שהכספים שולמו וחולקו, הבל"מ
> נדחית."
### לשון אופיינית לקבלה (חריגה)
> "המבקש הצביע על טעות חישובית במספר זכויות התכנון שנלקחו בחשבון. הטעות
> מהותית ומשפיעה על השומה. בנסיבות אלה, ועל אף השיהוי, יש מקום לפתוח את
> השומה לדיון בערר עצמו."
---
## יא. הפניות חוצות
- ראה גם: `docs/methodology/extension-request-building_permit.md` (סעיף 152, 30 ימים)
- ראה גם: `docs/methodology/extension-request-compensation.md` (סעיף 198(ד), 30 ימים)
- ראה גם: `docs/block-schema.md` — מבנה 12 הבלוקים
- ראה גם: `skills/decision/SKILL.md` — מדריך סגנון של דפנה

View File

@@ -0,0 +1,252 @@
# מתודולוגיה — בל"מ ברישוי ובנייה (1xxx)
**appeal_subtype:** `extension_request_building_permit`
**מסלול:** סעיף 152(א) לחוק התכנון והבנייה, התשכ"ה-1965
**מועד סטטוטורי:** 30 ימים מיום המצאת ההחלטה (סעיף 152(ב))
---
## א. מבוא — מהותו של בל"מ ברישוי
בל"מ ("בקשה להארכת מועד") הוא הליך מקדמי שהמבקש להגיש ערר על החלטת ועדה מקומית
לאחר חלוף 30 הימים נדרש לעבור בו לפני שיוכל לפתוח בערר עצמו. הוועדה נדרשת
לאזן בין שני אינטרסים נוגדים:
- **זכות הגישה לערכאות** — שכל בעל זכות עמידה יוכל להעמיד את החלטת הוועדה
המקומית במבחן שיפוטי, במיוחד כאשר ההחלטה נטענת כפסולה.
- **סופיות החלטות מנהליות + הסתמכות** — היזם זכאי לפעול לפי ההיתר שניתן, להשקיע
כספים, להתחיל בעבודות, ולא לחיות בחשש מתמיד שמא ההיתר ייתקף שנים לאחר אישורו.
לעומת בל"מ בהיטל השבחה (סעיף 14 לתוספת ג', 45 ימים) ובל"מ בפיצויים (סעיף 198(ד),
30 ימים אך עם סף קפדני יותר), בל"מ ברישוי משלב טון אנושי יחסית — ההסתמכות מוחשית
(חפירה, פינוי שוכרים) והאינטרסים הציבוריים (מיגון, חיזוק) ממשיים.
---
## ב. מסגרת נורמטיבית — שלוש שכבות
### שכבה א — עליון: בר"מ 2340/02 הוועדה המקומית רמת השרון נ' אגא וכט, פ"ד נז(3) 385 (2003)
הכיר בסמכותה של ועדת הערר להאריך את המועד, בנסיבות חריגות, וקבע את הבחינה
הדו-שלבית:
1. **תנאי סף:** טעם סביר לאיחור.
2. **שיקול כולל:** השוואה בין נזקי המבקש לבין הסתמכות הצד שכנגד; היקף השיהוי;
סיכויי ההליך; אינטרס ציבורי.
### שכבה ב — עליון: עע"מ 317/10 שפר נ' סקאל יניב (נבו 23.8.2012)
הלכה מחייבת: מניין 30 הימים מתחיל **מיום הידיעה בפועל**, לא מיום הפרסום הפורמלי.
המשמעות: גם איחור-לכאורה של חודשים יכול להיות לגיטימי אם המבקש לא ידע על ההחלטה
בזמן אמת.
> "מתנגד להיתר שניתן, אשר שטח התנגדותו בפני הועדה המקומית וזו נדחתה, או שידע
> על מתן ההיתר, צריך יהיה להגיש את הערר תוך 30 יום מיום שנודע לו על מתן ההיתר."
### שכבה ג — ועדת ערר ירושלים (דפנה תמיר)
**ערר 1009/25 מפלגת נעם נ' הוועדה המרחבית הראל (נבו 27.3.2025):**
> "דיון בערר המבקש לבטל היתר שכבר יצא מחייב עמידה בלוח הזמנים שהדין מחייב,
> כל חריגה מכך מחייבת בקשה להארכת מועד ועמידה בכל התנאים לכך (זכות עמידה,
> שיהוי, הסתמכות, פגיעה וכיו'). ודוק, מחייבת בקשה להארכת מועד סדורה ומנומקת
> ולא בדרך אגב ולא בחסות תקנות הרישוי."
**ערר 1112/22 ירושלים שקופה נ' ועדה מקומית ירושלים (נבו 11.5.2023):**
> "מרחק של פחות מ-100 מ' אינו מקנה זכות התנגדות לתכנית; קל וחומר שמרחק של
> למעלה מ-400 מ' אינו מקנה זכות התנגדות לבקשה להיתר, שכן זכות ההתנגדות לבקשה
> להיתר (סעיף 149) צרה מזכות ההתנגדות לתכנית (סעיף 100)"
**בל"מ 1028/20 חלוואני (ועדת ערר ירושלים):**
> "המועד להגשת ערר הינו 30 ימים מיום שהומצאה החלטת הועדה המקומית וכי המבקשת
> הייתה ערה להליכי הבקשה להיתר"
---
## ג. שישה תבחינים — סדר הבחינה
על פי הפסיקה המצטברת, להכרעה בבל"מ-רישוי יש לבחון שישה תבחינים. הסדר חשוב:
תבחין ו (זכות עמידה) הוא תנאי סף עצמאי — אם אין זכות עמידה אין צורך לבחון
יתר התבחינים.
| # | תבחין | אופי | מקור |
|---|--------|------|------|
| ו | **זכות עמידה** | **תנאי סף עצמאי** | עע"מ 1461/20 אנטרים; ערר 1112/22 |
| א | טעם סביר לאיחור | מקדים — נחוץ לפתיחת הדלת | עע"מ 317/10 שפר; בל"מ 1028/20 |
| ב | אורך השיהוי | כמותי — חומרת ההפרה | ערר 1096/24 אנשין |
| ג | הסתמכות + שינוי מצב לרעה | כמותי — נזק | בר"מ 2340/02 |
| ד | סיכויי ההליך | מהותי — "לכאורה" | בר"מ 2340/02 |
| ה | אינטרס ציבורי / חזקת תקינות | ערכי | הלכת חזקת תקינות |
---
## ד. תבחין ו — זכות עמידה (תנאי סף)
### מקור הזכות
זכות הערר לפי סעיף 152 מוקנית רק למי שהוא **בעל זכות במקרקעין נשוא הבקשה
להיתר**, לא לכל בעל עניין (עע"מ 1461/20 אנטרים).
### תבחין מרחק
על פי ערר 1112/22, מרחק של מעל 100 מ' (קל וחומר מעל 400 מ') אינו מקנה זכות
התנגדות לבקשת היתר, גם בהיעדר נצפות.
### טבלת בדיקה
| פרמטר | להוכיח |
|--------|---------|
| בעל זכות בנכס נשוא הבקשה? | חוזה רכישה / נסח / שכירות מאומתת |
| בעל זכות בנכס גובל? | מפת מדידה / נסח |
| מרחק קו אווירי | מודד / Google Maps עם תיעוד |
| קיומה של נצפות | תצלום פנורמי / חוו"ד מודד |
| מעמד נציג דיירים / פינוי-בינוי | חוזה פנימי — לא יוצר זכות סטטוטורית |
**אזהרה:** טיעון של "מתנגד מטעם הציבור" או "אינטרס ציבורי כללי" — אינו מקנה
זכות עמידה. הזכות נצרכת להיות מעוגנת בזכות במקרקעין.
---
## ה. תבחין א — טעם סביר לאיחור
### העיקרון
המבקש נדרש להוכיח שלא ידע על ההחלטה בזמן אמת **ושאי-הידיעה היא סבירה** — לא רק
שלא ידע, אלא שלא היה ניתן לצפות שיֵדע. הכלל הוא **דרך הסטטוס-קוו**: מי שהתעניין
בנכס שכן, שהיה מודע לשלטי בנייה, או שהיה לו עניין סדור בנכס — מוחזק כיודע.
### דרישות הוכחה
1. **תצהיר עובדתי** של המבקש — תאריכים מפורטים, מי אמר לו, מתי בדיוק.
2. **הוכחת ברירת המחדל של הוועדה** — היכן הפרסום היה צריך להתבצע? האם בוצע?
3. **שלושת התנאים המצטברים** (לפי הלכת שפר, כפי שיושמו בפסיקה לאחר מכן):
- זכות טיעון בהליך הרישוי וזכאות לקבל פרסום.
- פגם בהליך הפרסום בפועל.
- הפגם פגע בזכות הטיעון.
### מלכודות נפוצות
- **התכתבות עם "הדרג המקצועי" אינה מקפיאה לוחות זמנים** (בל"מ 1028/22 חמד).
- **היעדר תצהיר → גרסת אי-הידיעה חלשה ראייתית.**
- **ידיעה קודמת על ההליכים** (התנגדות שהוגשה, נוכחות בדיון, פניות בעבר) שוללת
כל תירוץ של אי-ידיעה.
---
## ו. תבחין ב — אורך השיהוי
### שני רכיבים
1. **שיהוי מצטבר** — הזמן שחלף מהחלטת הוועדה המקומית עד הגשת הבל"מ.
2. **שיהוי סובייקטיבי** — הזמן שחלף מיום הידיעה הנטענת עד הגשת הבל"מ.
### ציר זמן לדוגמה
| תאריך | אירוע | שיהוי מצטבר |
|--------|--------|--------------|
| יום 0 | פרסום הבקשה | 0 |
| יום 30 | החלטת ועדת משנה | — |
| יום 120 | אישרור במליאה | — |
| יום X | ידיעה נטענת | חודשים-שנה |
| יום X+30 | הגשת הבל"מ | +30 ימים סובייקטיבי |
### עקרון מנחה
ערר 1096/24 אנשין (דפנה תמיר, 30.12.2024):
> "בהינתן שהערר מוגש במקום בו לא הייתה לעורר זכות קנויה וברורה להגשתו, היה
> עליו שלא להתעכב ובוודאי שלא לחכות ליום האחרון להגשת הערר"
**הכלל:** ככל שזכות העמידה רופפת יותר — דרישות הזריזות מחמירות.
---
## ז. תבחין ג — הסתמכות הצד שכנגד
### עיקרון בר"מ 2340/02 אגא וכט
> "האם שינה הצד האחר את מצבו לרעה, האם ניתן להשיב את המצב לקדמותו"
### טבלת השקעות לבדיקה
| השקעה | תיעוד נדרש |
|--------|-----------|
| שכר טרחת מתכננים / עו"ד / יועצים | חשבוניות / קבלות / חוזה |
| תכנון מפורט (חניון, ממ"דים) | תכניות חתומות |
| היתר חפירה / חפירה בפועל | היתר + תצלומים |
| הסכמי מימון | חוזה עם בנק / משקיע |
| פינוי שוכרים / חתימות דיירים | חוזי פינוי / הסכמות |
| התקדמות פיזית (יסודות, שלד) | תצלומים מתועדים |
### "האם ניתן להשיב למצב הקדמות?"
ככל ששלב הביצוע מתקדם יותר — היכולת להפוך פוחתת. לאחר היתר חפירה, פינוי שוכרים,
ושלב הכנת יסודות — המצב לרוב בלתי-הפיך פיזית, ולפחות בלתי-הפיך כלכלית.
---
## ח. תבחין ד — סיכויי ההליך (לכאורה)
### הבהרה מתודית
בשלב בל"מ, **בוחנים סיכויי הערר המהותי רק כדי לקבוע האם יש סיבה מספקת לפתוח
את הדלת** — לא לפסוק לגוף הערר. אם המחלוקת המהותית היא קשה ומורכבת אבל ברורה
שיש בה ממש — תבחין ד תומך בקבלת הבל"מ. אם המחלוקת תיאורטית, ספקולטיבית, או
ברורה לזכות המשיבים — תבחין ד תומך בדחייה.
### סוגים אופייניים של סוגיות מהותיות בבל"מ-רישוי
- תחולת תמ"א 38 (תקנים, מבנה קטן, איזורי סיכון רעש)
- תוקף תכנית (פקיעה, הוראות מעבר)
- חישוב סל זכויות (תיקון 3א, "קומה טיפוסית קיימת")
- מעמד תכנית חדשה (102-XXXXXX) — מופקדת? מאושרת? נסיוני?
- תנאי היתר (עמידה בתקנות, קווי בניין, חניות)
### דרך הבחינה
לכל סוגיה: (1) האם ההסתמכות על תכנית / תקן בוצעה; (2) האם יש פסיקה מנחה;
(3) האם יש מחלוקת מקצועית-עובדתית שתצריך חוות דעת.
---
## ט. תבחין ה — אינטרס ציבורי / חזקת תקינות
### חזקת תקינות המעשה המנהלי
עיקרון יסוד בדין המנהלי: כל פעולת הוועדה נחזית כתקינה, עד שהמוכיח אחרת. נטל
ההוכחה על המבקש.
### שיקולים אופייניים בבל"מ-רישוי
| שיקול | כיוון אופייני |
|--------|---------------|
| חיזוק מבני מפני רעידות אדמה | תומך ביזם |
| ממ"דים / מיגון מפני ירי | תומך ביזם |
| הרחבת זכויות דרך / זכויות מעבר | תועלת ציבורית |
| חניות תת-קרקעיות (פינוי חניה מרחוב) | תועלת ציבורית |
| תקינות הליך (פרסום, התנגדויות, דיון) | חזקת תקינות |
| מתנגד סדרתי / בעל אינטרס נסתר | מחליש טענות המבקש |
---
## י. טבלת התאמה לעובדות (placeholder לכל תיק)
| תבחין | עובדה במקרה הנוכחי | כיוון |
|--------|---------------------|-------|
| ו. זכות עמידה | [לתאר מרחק, נצפות, זכויות בקרקע] | [חוסם / מאפשר / שאלה] |
| א. טעם סביר | [פרסום, ידיעה, תצהיר] | [נוטה לקבלה / לדחייה] |
| ב. אורך השיהוי | [שנים / חודשים / ימים] | [קל / בינוני / חמור] |
| ג. הסתמכות | [השקעות מצוטטות בש"ח] | [קלה / משמעותית / מוחשית] |
| ד. סיכויי הליך | [שאלות פתוחות vs. ברורות] | [לכאורה / ספקולטיבי] |
| ה. אינטרס ציבורי | [שיקולים ציבוריים בולטים] | [תומך / ניטרלי / נגד] |
---
## יא. סעיף מסקנה — מבנה אופייני
המבנה האופייני של סעיף ההכרעה בבל"מ-רישוי הוא:
1. **פתיחה — איזון התבחינים בקצרה.** "בחנו את ששת התבחינים... ומצאנו..."
2. **תבחין ו (סף).** אם זכות העמידה רופפת/חסרה — זהו לרוב המכריע.
3. **תבחינים א-ה.** ניתוח כל אחד בקצרה, עם הפניה לפסיקה.
4. **מסקנה כוללת.** "לאור כל האמור — הבקשה להארכת מועד נדחית / מתקבלת".
5. **הוצאות.** אם רלוונטי — לפי סעיף 1.
### לשון אופיינית לדחייה (דפנה תמיר)
> "מששה התבחינים שנבחנו — חמישה מצביעים על מסקנה אחת, וגם התבחין השישי אינו
> תומך בקבלת הבקשה. נסיבות התיק אינן מצדיקות חריגה מהמועד הסטטוטורי."
### לשון אופיינית לקבלה
> "על אף השיהוי, נסיבות אי-הידיעה מתועדות; ההסתמכות בעיקרה תכנונית ולא ביצועית;
> ומחלוקת מהותית ממשית עומדת על הפרק. בנסיבות אלה, יש לפתוח את הדלת לערר על
> מנת שהסוגיות יתבררו."
---
## יב. הפניות חוצות
- ראה גם: `docs/methodology/extension-request-betterment_levy.md` (סעיף 14, 45 ימים)
- ראה גם: `docs/methodology/extension-request-compensation.md` (סעיף 198(ד), 30 ימים)
- ראה גם: `docs/block-schema.md` — מבנה 12 הבלוקים
- ראה גם: `skills/decision/SKILL.md` — מדריך סגנון של דפנה
- דוגמאות מעובדות: `data/cases/1017-03-26/`, `data/cases/1018-03-26/`, `data/cases/1019-03-26/`

View File

@@ -0,0 +1,215 @@
# מתודולוגיה — בל"מ בפיצויים (ס' 197) (9xxx)
**appeal_subtype:** `extension_request_compensation`
**מסלול:** סעיף 198(ד) לחוק התכנון והבנייה, התשכ"ה-1965
**מועד סטטוטורי:** 30 ימים מיום החלטת הוועדה המקומית בתביעת הפיצויים
---
## א. מבוא — הייחוד של בל"מ בפיצויים
בל"מ בפיצויים שונה מהותית הן מבל"מ ברישוי והן מבל"מ בהיטל השבחה:
| ממד | בל"מ ברישוי | בל"מ היטל השבחה | בל"מ פיצויים |
|------|--------------|------------------|----------------|
| מועד | 30 ימים | 45 ימים | **30 ימים** |
| סעיף | 152 | 14 לתוספת ג' | **198(ד)** |
| מהות הסעד | ביטול היתר | תיקון שומה | **פיצויי פגיעה בזכויות קניין** |
| נטל הוכחה | מקדים | טעות שמאית | **סף קפדני — פגיעה ממונית מוחשית** |
| טון אופייני | מעורב | קר/שמאי | **קר, משפטי, חמור** |
| הסתמכות | יזם / רשות | רשות (חלוקה) | **רשות + ציבור (תקציבי פיצויים)** |
### למה הסף הקפדן ביותר?
פיצויים לפי סעיף 197 הם **כספים ציבוריים** שמיועדים לפיצוי על פגיעה
ממונית מוחשית בקרקעות. הם נושאים שלוש מאפיינים שדורשים אכיפת מועדים
מחמירה:
1. **תקציבים סגורים** — הוועדה המקומית עוזבת תקציב לפיצויי 197; שיהוי
מחבל בתכנון פיננסי ובחלוקת התקציב.
2. **השפעה על תכנון עתידי** — דחייה ארוכת-טווח בבירור הזכות לפיצוי משבשת
את היכולת לתכנן הליכי הפקעה/תכנון נוספים.
3. **זכויות קניין** — שני הצדדים (תובע ורשות) נושאים אינטרסים קנייניים
ברורים. אכיפת מועדים = הגנה על שני הצדדים.
---
## ב. מסגרת נורמטיבית
### שכבה א — חקיקה ראשית
**סעיף 197(א) לחוק התכנון והבנייה:**
> "נפגעו על ידי תכנית, שלא בדרך הפקעה, מקרקעין הנמצאים בתחום התכנית או
> גובלים עמה, מי שביום תחילתה של התכנית היה בעל המקרקעין או בעל זכות בהם
> זכאי לפיצויים מהוועדה המקומית..."
**סעיף 198(ד) — מועד הערר:**
ערר על החלטת הוועדה המקומית בתביעת פיצויים מוגש לוועדת הערר תוך 30 ימים
מיום שהומצאה ההחלטה לתובע.
### שכבה ב — עליון
**ע"א 210/88 החברה להפצת פרי הארץ נ' הוועדה המקומית כוכב יאיר (פ"ד מו(4) 627):**
ביסוס דרישת ההוכחה לפגיעה ממונית מוחשית — לא די בטענה כללית של "ירידת ערך".
נדרשת: (א) הוכחת מצב לפני התכנית; (ב) הוכחת מצב אחרי; (ג) הצבעה על קשר סיבתי
ישיר; (ד) חוות דעת שמאית כמותית.
**עע"מ 1968/00 חברת גוש 6195 נ' הוועדה המקומית הרצליה:**
חיזוק עקרון הסופיות בפיצויי 197 — שינוי מועדים בהליך פיצויים פוגע באינטרס
הציבורי הספציפי של פריסת תקציבים.
### שכבה ג — ועדות ערר
(להוסיף תקדימי דפנה תמיר בעררי 9xxx — לחפש בקורפוס "בל\"מ פיצויים" או
"הארכת מועד 197".)
---
## ג. ארבעה תבחיני בל"מ בפיצויים
| # | תבחין | אופי | סף |
|---|--------|------|-----|
| א | **פגיעה ממונית מוחשית** | תנאי סף עצמאי | קריטי |
| ב | טעם סביר לאיחור | מקדים — קפדן | גבוה |
| ג | אורך השיהוי | כמותי — קצר במיוחד | גבוה |
| ד | הסתמכות הרשות (תקציב) | כמותי | גבוה |
לעומת בל"מ ברישוי ובהיטל השבחה — אין כאן תבחין נפרד של "סיכויי הליך";
תבחין הפגיעה (א) משלב את שני הממדים (סיכויי הליך + עצם הזכות לפיצוי).
---
## ד. תבחין א — פגיעה ממונית מוחשית (סף הקפדני)
### הדרישה
לא די בטענה לפגיעה. נדרש להוכיח, לפחות לכאורה:
1. **בעלות / זכות במקרקעין נשוא התביעה** — נסח טאבו, חוזה מאומת, או רישום אחר.
2. **תכנית מאושרת שנכנסה לתוקף** — לא טיוטה, לא תב"ע מופקדת — תכנית בתוקף.
3. **קשר סיבתי בין התכנית לפגיעה הנטענת** — לא "ירידת ערך כללית" של אזור.
4. **חוו"ד שמאית כמותית** — מציגה את ערך הקרקע לפני ואחרי, עם נתוני השוואה.
### הוצאות מן הכלל
לא נחשבים "פגיעה ממונית" לעניין סעיף 197:
- **פגיעה תיאורטית עתידית** — תכנית שטרם נכנסה לתוקף, אופציות שלא מומשו.
- **פגיעה אסתטית/סובייקטיבית** — נוף, שכנים, אווירה.
- **פגיעה זמנית בלבד** — שיבושים בשלב בנייה שאינם משפיעים על ערך ארוך-טווח.
- **פגיעה במקרקעין מחוץ לתכנית ולא גובלים** — דרישה שטחית של "תחום התכנית
או גובלים עמה" — מצומצמת.
### דרישת ההוכחה לכאורה בשלב הבל"מ
בשלב בל"מ אין צורך להוכיח את הפגיעה במלואה; די ב**הצגת לכאורה משכנעת**
המבוססת על מסמכים מקצועיים. הצגה זו מאפשרת לבחון: האם יש בכלל מה לדון
לאחר חלוף המועד?
---
## ה. תבחין ב — טעם סביר לאיחור
### העקרון
בפיצויים — דרישת הזריזות מחמירה מאוד. סיבות:
1. **התובע פעל מולן** — בניגוד לבל"מ ברישוי, התובע ידע על התכנית ופעל
בה (הגיש תביעה לוועדה המקומית). אי-ידיעה על ההחלטה היא חריג.
2. **המצאה אישית** — ההחלטה מומצאת אישית; פחות מקום לטענות "פרסום באתר".
3. **התובע מיוצג** — לרוב התובע פיצויים מיוצג עו"ד; "אי-ידיעה" של עו"ד
על מועד היא חולשה ראייתית מובהקת.
### מצבי "טעם סביר" אופייניים
| מצב | קבילות |
|------|---------|
| המצאה פגומה (לא לכתובת עורך הדין) | קבילה — בכפוף לתיעוד |
| מחלת התובע (מתועדת) | קבילה |
| תקופה ארוכה של "ניסיון להידברות" עם הוועדה | חלשה — לוחות זמנים לא מוקפאים |
| המתנה להחלטה שיפוטית במקרה דומה | חלשה — אפשר להגיש "במקרה ש..." |
| תקלה במשרד עורך הדין | חלשה — אחריות נשואת ייצוג |
### דרישות הוכחה
- תצהיר מפורט של התובע **וגם** של עורך דינו.
- מסמכי תמיכה (כרטיסי רישום בית חולים, אישורים רפואיים, וכו').
- תיעוד התכתבות פנימית במשרד עורך הדין (אם רלוונטי).
---
## ו. תבחין ג — אורך השיהוי
### עקרונות
- **30 ימים בלבד** = מועד קצר במיוחד.
- כל יום מעבר מקבל ניקוד שלילי.
- שיהוי של מעל 14 ימים מעבר למועד (סה"כ 44 ימים) — נחשב מובהק.
- שיהוי של מעל 60 ימים מעבר (סה"כ 90 ימים) — דורש הצדקה חזקה במיוחד.
- שיהוי של מעל 180 ימים — חוסם אלא בנסיבות חריגות (טעות בדין, גילוי מאוחר
של עובדה מהותית).
### חישוב
| תאריך | אירוע | שיהוי מצטבר |
|--------|--------|--------------|
| יום 0 | המצאת החלטה | 0 |
| יום 30 | תום מועד סטטוטורי | 0 |
| יום X | הגשת הבל"מ | X-30 |
---
## ז. תבחין ד — הסתמכות הרשות (תקציב פיצויים)
### ייחוד בפיצויים
הוועדה המקומית מקצה תקציב לפיצויי 197 לפי החלטותיה. שיהוי בערר:
1. **פוגע בפריסה תקציבית** — תקציב עזב מהקצאתו, עבר ליעדים אחרים.
2. **מסבך הליכים שלא הוכרעו עדיין** — בעלי מקרקעין אחרים פעלו על סמך
התקציב הקיים.
3. **משפיע על מכרזים / חוזי תכנון** — שינוי בגובה הפיצויים משפיע על
החלטות פיתוח עתידיות.
### טבלת בדיקה
| שלב | מצב התקציב | השפעה |
|------|-----------|--------|
| לפני סוף שנת כספים | תקציב פעיל, ניתן לשנות הקצאה | קלה |
| לאחר סגירת שנת כספים | תקציב חלוק | בינונית |
| לאחר העברה ליעדים אחרים | פיצוי דורש מקור חדש | משמעותית |
| לאחר ביצוע פרויקטים | בלתי הפיך כלכלית | מוחשית |
---
## ח. טבלת התאמה לעובדות (placeholder לכל תיק)
| תבחין | עובדה במקרה הנוכחי | כיוון |
|--------|---------------------|-------|
| א. פגיעה ממונית | [חוו"ד שמאית? קשר סיבתי? תכנית בתוקף?] | [חוסם / מאפשר] |
| ב. טעם סביר | [המצאה, ייצוג, תצהיר] | [תומך / מחליש] |
| ג. אורך השיהוי | [X ימים מעבר ל-30] | [קל / מובהק / חמור] |
| ד. הסתמכות הרשות | [מצב התקציב] | [קל / משמעותי / מוחשי] |
---
## ט. סעיף מסקנה — מבנה אופייני
המבנה האופייני הוא **קפדן, מבוסס מסמכים, ללא רגש**:
1. **קביעת עובדות.** "ההחלטה הומצאה ביום X. הבל"מ הוגשה ביום Y. השיהוי
הוא Z ימים מעבר למועד הסטטוטורי."
2. **תבחין א (פגיעה).** "המבקש הציג חוו"ד / לא הציג חוו"ד. הקרקע
נמצאת בתחום התכנית / גובלת בה / מחוץ לה."
3. **אם לא הוצגה פגיעה לכאורה — דחייה מיידית.** "בהיעדר הצגה לכאורה של
פגיעה ממונית, אין יסוד לסטות ממועד הקבוע בחוק."
4. **אם הוצגה פגיעה — מעבר לתבחינים ב-ד.**
5. **מאזן והכרעה.** דחייה / קבלה / החזרה לוועדה המקומית.
### לשון אופיינית לדחייה
> "המבקש לא הציג ראיה לכאורית לפגיעה ממונית מוחשית בקרקע שבבעלותו. הקרקע
> נמצאת מחוץ לתחום התכנית ואינה גובלת עמה. בנסיבות אלה, ובהינתן שהשיהוי
> הוא של X ימים מעבר למועד הסטטוטורי הקצר של 30 הימים, אין מקום לסטייה
> מהמועד. הבל"מ נדחית."
### לשון אופיינית לקבלה (חריגה ביותר)
> "המבקש הציג חוו"ד שמאית מקצועית המראה ירידת ערך של כ-X% בקרקע הגובלת
> בתחום התכנית. ההצגה לכאורה משכנעת. בנסיבות החריגות של [פירוט], ועל אף
> הסף הקפדני שמטיל סעיף 198(ד), יש לפתוח את הדלת לדיון מהותי."
---
## י. הפניות חוצות
- ראה גם: `docs/methodology/extension-request-building_permit.md` (סעיף 152, 30 ימים)
- ראה גם: `docs/methodology/extension-request-betterment_levy.md` (סעיף 14, 45 ימים)
- ראה גם: `docs/block-schema.md` — מבנה 12 הבלוקים
- ראה גם: `skills/decision/SKILL.md` — מדריך סגנון של דפנה

View File

@@ -0,0 +1,276 @@
# 00 — חוקת המערכת (Constitution)
זהו שער-הכניסה היחיד לספ המערכת *עוזר משפטי*. הוא מגדיר את הייעוד, עקרונות-העבודה,
תבנית ה-invariant, פרוטוקול-האימות, ה-invariants הגלובליים (G1G11), כללי-ההנדסה,
אינדקס הספ ונספח המקורות. כל קובץ-תחום (0107, X1X5) כפוף לחוקה זו ומפנה אליה.
---
## 1. ייעוד
> מערכת AI שמסייעת ליו"ר ועדת הערר לתכנון ובנייה (מחוז ירושלים, עו"ד דפנה תמיר) לנסח
> **החלטות מעין-שיפוטיות כתובות ומנומקות** — מסמכים משפטיים פורמליים שעומדים לביקורת
> שיפוטית — תוך שמירה על **הקול, השיקול והאחריות של היו"ר**.
- **משרת:** יו"ר הוועדה (משתמש-על) והסוכנים הפועלים בשמה.
- **מחזור-חיים:** ניהול תיקים → בסיס ידע (3 קורפוסים) → אחזור סמנטי (RAG) → סיוע-כתיבה
(12 בלוקים, סגנון דפנה) → ייצוא DOCX.
- **3 סוגי עררים:** רישוי ובנייה (1xxx, חם), היטל השבחה (8xxx, קר), פיצויים ס'197 (9xxx, קר).
- **ה"למה" העמוק:** המערכת מסייעת — היו"ר מכריעה (שערים קריטיים ידניים בכוונה); מנוע
צבירת-ידע (לומד מהחלטות סופיות ומפידבק); רב-חברתי (CMP/CMPA).
---
## 2. עקרונות-עבודה
1. **אסור להניח שהקיים תקין (בהנדסה).** כל מה שמופה בקוד = "טענה לבדיקה", לא "אמת".
"תקין" מבחינה הנדסית נגזר ממקורות חיצוניים סמכותיים, לא מהמערכת שתחת חשד.
2. **פרוטוקול אימות 3-מקורות — חל על החלטות הנדסה/פיתוח בלבד:** כל invariant הנדסי/
ארכיטקטוני (תכנון ובניית האפליקציה — נתונים, מזהים, ingest, אחזור) מגובה ב-**≥3 מקורות
סמכותיים מוכרים** בעלי ידע מקצועי מוכח. כשאין 3 → מסומן `⚠ UNVERIFIED` ומועלה ליו"ר.
**התוכן המשפטי אינו כפוף לכלל זה** — הסמכות עליו היא היו"ר (דפנה) ומסמכי-הפרויקט
(block-schema, decision-methodology, legal-decision-lessons, skills/decision), לא
מקורות חיצוניים.
3. **מנגנון:** מחקר עצמאי → טיוטה לביקורת. קודם חוקרים את הסמכויות החיצוניות (להחלטות
הנדסה), ורק אז מנסחים את ה-invariant.
4. **מודל-שיתוף:** על החלטות טכניות/אדריכליות אני חוקר ומכריע מקצועית ומציג תוצאה
מוגמרת. שואל את היו"ר (חיים) רק במקום שבו *הוא* הסמכות — כוונה, עדיפויות עסקיות,
ותוכן משפטי-דומייני.
---
## 3. תבנית-invariant
מבנה אחיד לכל חוק בספ (בכל הקבצים):
```
### INV-<תחום><מספר>: <כותרת קצרה>
**כלל:** <ניסוח נורמטיבי חד — מה חייב להתקיים>
**מקורות:** <≥3 סמכויות> | סטטוס: verified / ⚠ UNVERIFIED
**אכיפה:** <היכן/איך נאכף — schema / ולידציית-כתיבה / בדיקת-בריאות / שער אנושי>
**הפרה ידועה:** <דוגמה מהמערכת, אם יש — מקשר ל-audit; אחרת "—">
```
> **שדה המקורות לפי סוג invariant (שלושה מודלי-סמכות):**
> 1. **הנדסי** (תאוריה כללית — נתונים/אחזור/ארכיטקטורה) → `מקורות` = ≥3 סמכויות חיצוניות + `סטטוס`.
> 2. **תוכן-משפטי** → `מקור-סמכות` = היו"ר + מסמכי-הפרויקט (ללא סטטוס-אימות חיצוני).
> 3. **פרויקטלי-תפעולי** (עובדות על האינטגרציה/התפעול של *מערכת זו* — אין להן סמכות
> חיצונית, למשל "wakeup דרך API") → `מקור-סמכות` = ה-runbooks של הפרויקט
> (CLAUDE.md, HEARTBEAT.md, סקריפטים), **קשור** ל-invariant הנדסי גלובלי שאותו הוא מיישם.
---
## 4. פרוטוקול-אימות
> חל על **invariants הנדסיים (G1G10)** — החלטות תכנון/בניית האפליקציה. ה-invariant של
> תוכן-משפטי (G11) **אינו** כפוף לפרוטוקול זה; הסמכות עליו היא היו"ר + מסמכי-הפרויקט.
- כל invariant הנדסי נושא שדה `מקורות` + `סטטוס: verified / ⚠ UNVERIFIED`.
- **verified** = מגובה ב-**≥3 מקורות סמכותיים** מוכרים בעלי ידע מקצועי מוכח.
- **⚠ UNVERIFIED** = החלטה הנדסית שיש לה פחות מ-3 מקורות סמכותיים מאומתים. פריט כזה
**לא מוכרע לבד** — מועלה ליו"ר עם הערת-הסלמה המתעדת מה חסר והיכן יאומת.
- החלטות טכניות → מחקר עצמאי + הכרעה מקצועית + הצגת תוצאה. שאלה ליו"ר רק במקום
שבו הוא הסמכות (ראה עיקרון 4 לעיל).
---
## 5. Invariants גלובליים
אלה החוקים החוצים את כל המערכת — לב החוקה. הם נחלקים לשני סוגים לפי **מקור-הסמכות**:
- **G1G10 — invariants הנדסיים** (תכנון/בניית האפליקציה): כל אחד מגובה ב-**≥3 סמכויות
טכניות מוכרות** (נספח §8). ביחד הם מייבשים את כשל-השורש החוזר: מסלולים/קורפוסים
מקבילים שמתפצלים (drift) בלי שכבה שמגדירה ואוכפת "תקין".
- **G11 — invariant תוכן-משפטי:** הסמכות עליו היא **היו"ר (דפנה) + מסמכי-הפרויקט**, לא
מקורות חיצוניים, ואינו כפוף לפרוטוקול ≥3-המקורות.
### 5א. Invariants הנדסיים (G1G10)
### INV-G1: מזהה קנוני מנורמל בכתיבה
**כלל:** לכל ישות יש מזהה קנוני יחיד, **מנורמל בנקודת-הכתיבה** (לא תיקון-סלחני בקריאה
בלבד). `case_number` נשמר בצורה קנונית אחת; קריאה משווה מול הצורה הקנונית, לא מטליאה.
**מקורות:** SSOT (Single Source of Truth — normalization principle) · E.F. Codd, First
Normal Form (CACM 13(6), 1970) · Martin Kleppmann, *Designing Data-Intensive Applications*
(O'Reilly, 2017) | סטטוס: verified
**אכיפה:** schema (אילוץ ייחודיות על המפתח הקנוני) + ולידציית-כתיבה בנקודת-הקליטה;
מפורט ב-[X1-identifiers.md](X1-identifiers.md) ו-[02-data-model.md](02-data-model.md).
**הפרה ידועה:** `_normalize_case_number` סלחני בקריאה בלבד (קומיט "tolerant case_number
lookup"); `8126-25` לא נמצא מול האמיתי `8126-03-25` → ממצא ל-[audit](../audit-report.md).
### INV-G2: מקור-אמת יחיד — אין מסלולים מקבילים מתפצלים
**כלל:** לכל סוג-נתון יש **מקור-אמת יחיד** ומסלול-קוד קנוני אחד. אסור להוסיף מסלול
מקביל ליכולת קיימת — ישויות-אחיות חולקות מסלול קנוני אחד; נתונים נגזרים (derived)
משוחזרים מהמקור, לא נכתבים במקביל.
**מקורות:** Martin Kleppmann (system of record vs. derived data, *DDIA* 2017) · Martin
Fowler (Canonical Data Model) · SSOT (Single Source of Truth) | סטטוס: verified
**אכיפה:** ביקורת-ארכיטקטורה + כלל-הנדסה "סימטריה" (§6); מפורט ב-[01-ingest.md](01-ingest.md).
**הפרה ידועה:** שני מסלולי ingest מקבילים לישויות-אחיות (`ingest_precedent` מול
`ingest_internal_decision`) שמתפצלים — לדוגמה: המסלול החיצוני מתזמן חילוץ metadata
(`request_metadata_extraction`), והמסלול הפנימי לא — ולכן ערן סופר 8046/24 נקלטה בלי
metadata → ממצא ל-[audit](../audit-report.md).
### INV-G3: ingest אחיד ו-idempotent
**כלל:** קליטה היא **אחידה ו-idempotent** — upsert על מפתח דטרמיניסטי. קליטה חוזרת של
אותו פריט אינה יוצרת כפילות ואינה משנה תוצאה.
**מקורות:** Martin Kleppmann (*DDIA*, idempotence & exactly-once) · Stripe / CDC
idempotency-key pattern · ISO 8000 (Data quality) | סטטוס: verified
**אכיפה:** ולידציית-כתיבה + מפתח-upsert דטרמיניסטי בנקודת-הקליטה; מפורט ב-
[01-ingest.md](01-ingest.md).
**הפרה ידועה:** 3 החלטות "סופר" נקלטו ב-3 פורמטים שונים (`8126/24`, ציטוט-מלא
כ-case_number) — היעדר upsert דטרמיניסטי → ממצא ל-[audit](../audit-report.md).
### INV-G4: חוזה-שלמות לפני "שמיש / ניתן-לחיפוש"
**כלל:** רשומה אינה נחשבת "שמישה" או "ניתנת-לחיפוש" עד ש**שדות-החובה שלה מולאו ואומתו
מול spec מפורש**. שלמות נבדקת לפני חשיפה לאחזור.
**מקורות:** ISO 8000 (completeness) · DAMA-UK *Six Primary Dimensions for Data Quality*
(2013, completeness) · ISO 15489-1:2016 (records reliability) | סטטוס: verified
**אכיפה:** חוזה-שלמות באכיפת-קוד + בדיקת-בריאות; מפורט ב-[02-data-model.md](02-data-model.md)
ו-[03-retrieval.md](03-retrieval.md).
**הפרה ידועה:** ערן סופר 8046/24 אונדקס עם `headnote`/`summary`/`tags` ריקים → ממצא
ל-[audit](../audit-report.md).
### INV-G5: metadata מלא + הפרדת-קורפוס נאכפת בכל query
**כלל:** לכל פריט מואנדקס יש **metadata מלא** (כולל מזהה-מקור וסוג-קורפוס), ו**הפרדת-
הקורפוס נאכפת בכל מסלול-query** — אין דליפה בין 3 הקורפוסים.
**מקורות:** Pinecone (multitenancy / metadata filtering) · RAG attribution (Lewis et al.,
2020, NeurIPS) · ISO 8000 (Data quality) | סטטוס: verified
**אכיפה:** schema (metadata חובה) + פילטר-קורפוס נאכף בשכבת-החיפוש; מפורט ב-
[03-retrieval.md](03-retrieval.md) ו-[X5-audit-provenance.md](X5-audit-provenance.md).
**הפרה ידועה:** משימה #56 — דליפת `source_kind` ב-`halacha_filters` בין קורפוסים →
ממצא ל-[audit](../audit-report.md).
### INV-G6: re-index בכל שינוי תוכן
**כלל:** כל שינוי-תוכן של פריט מואנדקס מפעיל **re-index** של ה-embedding שלו. אין
embeddings מיושנים מול התוכן הנוכחי.
**מקורות:** Pinecone (index freshness / data sync) · Weaviate (re-vectorization on update)
· RAG freshness (Lewis et al., 2020) | סטטוס: verified
**אכיפה:** טריגר re-index בנקודת-העדכון + בדיקת-בריאות (גילוי drift); מפורט ב-
[02-data-model.md](02-data-model.md) ו-[03-retrieval.md](03-retrieval.md).
**הפרה ידועה:**
### INV-G7: מיזוג RRF — לא סכום-ציונים
**כלל:** מיזוג תוצאות בין retrievers נעשה **לפי דירוג (Reciprocal Rank Fusion)**, לא
סכום/ממוצע ציונים גולמיים — שכן ציונים בסקיילים שונים אינם בני-השוואה ישירה.
**מקורות:** Elastic (*Reciprocal Rank Fusion*) · Weaviate (*Hybrid Search Explained*) ·
OpenSearch / Azure AI Search (corroborating RRF guidance) | סטטוס: verified
**אכיפה:** קוד-המיזוג בשכבת-האחזור; מפורט ב-[03-retrieval.md](03-retrieval.md).
**הפרה ידועה:**
### INV-G8: איכות-אחזור נמדדת — precision + recall
**כלל:** איכות-האחזור **נמדדת אמפירית (precision + recall)** באמצעות eval harness, לא
מונחת. שינוי בשכבת-האחזור מלווה במדידה.
**מקורות:** Manning, Raghavan & Schütze, *Introduction to Information Retrieval* (CUP,
2008) · RAG evaluation literature (Lewis et al., 2020 ואחריו) · Elastic (relevance
evaluation guidance) | סטטוס: verified
**אכיפה:** eval harness + בדיקת-בריאות תקופתית; מפורט ב-[03-retrieval.md](03-retrieval.md).
**הפרה ידועה:**
### INV-G9: עקיבוּת-מקור + audit-trail ל-AI
**כלל:** כל פלט של המערכת **עקיב למקורו** (citation/provenance), וכל שימוש ב-AI מתועד
ב-**audit-trail** הניתן לביקורת.
**מקורות:** Council of Europe / CEPEJ — *European Ethical Charter on AI in judicial systems*
(2018, user-control principle) · NCSC/JTC — *Principles & Practices for AI Use in Courts* ·
ISO 15489-1:2016 (records authenticity/integrity) | סטטוס: verified
**אכיפה:** audit-trail באכיפת-קוד + עקיבוּת-מקור בכל פלט; מפורט ב-
[X5-audit-provenance.md](X5-audit-provenance.md).
**הפרה ידועה:**
### INV-G10: המערכת מסייעת — שערים אנושיים הם invariant
**כלל:** המערכת **מסייעת ואינה מחליפה את שיקול-הדעת האנושי**. השערים האנושיים (אישור
הלכה, בחירת תוצאה, פידבק היו"ר) הם **invariant — חובה, לא רשות**.
**מקורות:** NCSC/JTC — *Principles & Practices for AI Use in Courts* ("never replace human
judgment") · CEPEJ (2018, under user control) · Federal Judicial Center — *Judicial Writing
Manual* (2d ed.) | סטטוס: verified
**אכיפה:** שערים אנושיים בקוד-הזרימה (gate לא ניתן לעקיפה); מפורט ב-[05-qa-review.md](05-qa-review.md).
**הפרה ידועה:** 10/19 הלכות מאושרות, התגלה במקרה — שער ידני שקוף בלי נראות backlog →
ממצא ל-[audit](../audit-report.md).
### 5ב. Invariant תוכן-משפטי (G11)
### INV-G11: תוכן החלטה מנומקת
**כלל:** החלטה מנומקת מקיימת: **רקע ניטרלי** (עובדות בלבד, ללא שיפוט) · **ללא כפילות**
(בלוק דיון מפנה, לא חוזר) · **מענה לטענות הצד המפסיד** · **"מבחן-השופט"** (קריא לשופט שלא
מכיר את התיק) · **טענות מקוריות בלבד** (מכתבי הטענות).
**מקור-סמכות:** היו"ר (עו"ד דפנה תמיר) + מסמכי-הפרויקט — [block-schema.md](../block-schema.md),
[decision-methodology.md](../decision-methodology.md), [legal-decision-lessons.md](../legal-decision-lessons.md),
[skills/decision/SKILL.md](../../skills/decision/SKILL.md). **אינו כפוף לפרוטוקול ≥3-המקורות החיצוני**
זהו תוכן משפטי-דומייני, באחריות היו"ר.
**אכיפה:** שערי QA + checklist-תוכן לפי סוג-ערר; מפורט ב-[04-analysis-writing.md](04-analysis-writing.md)
ו-[05-qa-review.md](05-qa-review.md).
**הפרה ידועה:**
---
## 6. כללי-הנדסה (מונעים הישנות)
- **סימטריה:** אסור להוסיף מסלול מקביל ליכולת קיימת — מרחיבים את המסלול הקנוני
(נגזר מ-[G2](#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)).
- **נרמול לא תיקון-תסמין:** מתקנים נתון במקור (קנוני), לא מטליאים בקריאה
(נגזר מ-[G1](#inv-g1-מזהה-קנוני-מנורמל-בכתיבה)).
- **Quality-at-source:** שלמות נאכפת קרוב ככל האפשר לקליטה (Martin Fowler — Data Mesh /
quality-at-source; נגזר מ-[G4](#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש)).
- **אין בליעה שקטה:** רשומה חסרה/פגומה מסומנת ומדווחת, לא מתקבלת בשקט (תואם feedback
קיים — אסור bare `except: pass`; נגזר מ-[G4](#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש)).
---
## 7. אינדקס הספ
> הערה: כל קבצי הספ (00, 0107, X1X5) קיימים. החוקה היא שער-הכניסה; כל קובץ-תחום כפוף לה.
| קובץ | תפקיד | אוכף invariants |
|------|--------|-----------------|
| [00-constitution.md](00-constitution.md) | חוקה — ייעוד, invariants גלובליים, כללי-הנדסה, אינדקס | G1G11 |
| [01-ingest.md](01-ingest.md) | קליטה מאוחדת: מסמכי-תיק / פסיקה חיצונית / החלטות-ועדה — חוזה מסלול-יחיד | G2, G3 |
| [02-data-model.md](02-data-model.md) | אחסון: ישויות (cases, case_law, documents, chunks, halachot…) + חוזה-שלמות לכל ישות | G1, G4, G6 |
| [03-retrieval.md](03-retrieval.md) | 3 קורפוסים + כלי-חיפוש · hybrid/RRF · attribution · eval harness | G4, G5, G6, G7, G8, G9 |
| [04-analysis-writing.md](04-analysis-writing.md) | חילוץ טענות · 12 בלוקים · סגנון דפנה (מצטט block-schema.md) | G11 |
| [05-qa-review.md](05-qa-review.md) | שערי QA + שערים אנושיים (אישור הלכה, בחירת תוצאה, פידבק) כ-invariant | G10, G11 |
| [06-export.md](06-export.md) | ייצוא DOCX לפי תבנית דפנה | G2, G9 |
| [07-learning.md](07-learning.md) | Hermes · לקחים · לולאת פידבק היו"ר · צמיחת קורפוס (quality-at-source) | G4, G10 |
| [X1-identifiers.md](X1-identifiers.md) | מודל מזהים קנוני: נרמול case_number בכתיבה · cases מול case_law · פורמטי ציטוט | G1 |
| [X2-multi-company.md](X2-multi-company.md) | CMP/CMPA · 14 סוכנים · כללי sync | G2 |
| [X3-integration-deploy.md](X3-integration-deploy.md) | Paperclip (wakeup, ניתוב comments, webhooks) · Coolify/pm2 | G2, G9 (תפעולי) |
| [X4-agents.md](X4-agents.md) | מפת הסוכנים (דומיין + סוכני-התהליך) | G10 |
| [X5-audit-provenance.md](X5-audit-provenance.md) | audit-trail לשימוש ב-AI · עקיבוּת כל מקור מצוטט · שלמות-רשומה | G5, G9 |
**עקרונות:** כל קובץ עצמאי, ממוקד, agent-readable, יעד ≤~500 שורות (תפיחה = סימן
לפיצול). מסמכים קיימים (`architecture.md`, `product-specification.md`, `block-schema.md`…)
לא נמחקים ולא משוכפלים — מצוטטים כ"מקור" ומאומתים מול הסמכויות; סתירה = ממצא ל-audit.
---
## 8. נספח מקורות סמכותיים
(מאומתים במחקר 30.5.2026)
**ממשל-AI שיפוטי + שערים אנושיים (G9, G10)**
- NCSC / JTC — *Court Technology Standards* + *Principles & Practices for AI Use in Courts*.
https://www.ncsc.org/our-centers-projects/joint-technology-committee/court-technology-standards
- Council of Europe / CEPEJ — *European Ethical Charter on the use of AI in judicial
systems* (2018, user-control principle).
- Federal Judicial Center — *Judicial Writing Manual* (2d ed.) — לעניין שיקול-הדעת
האנושי בכתיבה השיפוטית.
https://www.fjc.gov/content/judicial-writing-manual-pocket-guide-judges-second-edition
**אחזור / RAG / IR**
- Lewis et al. (2020) — *Retrieval-Augmented Generation* (NeurIPS).
https://arxiv.org/abs/2005.11401
- Manning, Raghavan & Schütze — *Introduction to Information Retrieval* (CUP, 2008).
https://nlp.stanford.edu/IR-book/
- Elastic — *Reciprocal Rank Fusion*.
https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion
- Pinecone — *Implement multitenancy*.
https://docs.pinecone.io/guides/index-data/implement-multitenancy
- Weaviate — *Hybrid Search Explained*. https://weaviate.io/blog/hybrid-search-explained
**שלמות-נתונים / איכות / רשומות**
- DAMA-DMBOK2 + DAMA-UK — *Six Primary Dimensions for Data Quality* (2013).
- ISO 8000 — Data quality (8000-8/61/110).
- ISO 15489-1:2016 — Records management (authenticity/reliability/integrity/usability).
- Martin Kleppmann — *Designing Data-Intensive Applications* (O'Reilly, 2017).
- E.F. Codd — Relational model & normalization (CACM 13(6), 1970).
- Martin Fowler — Canonical Data Model / Data Mesh (quality-at-source).
(נספח המקורות מתייחס ל-invariants ההנדסיים G1G10 בלבד. התוכן המשפטי — G11 — נשען על
מסמכי-הפרויקט וסמכות היו"ר, כמפורט ב-G11.)

150
docs/spec/01-ingest.md Normal file
View File

@@ -0,0 +1,150 @@
# 01 — קליטה מאוחדת (Unified Ingest Contract)
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) ומפרט את **חוזה הקליטה** של כל סוגי
ה-intake. הוא אוכף את [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
(מקור-אמת יחיד, אין מסלולים מקבילים) ואת [G3](00-constitution.md#inv-g3-ingest-אחיד-ו-idempotent)
(ingest אחיד ו-idempotent), ונשען על [G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש)
ו-[G6](00-constitution.md#inv-g6-re-index-בכל-שינוי-תוכן).
כשל-השורש שהקובץ מייבש: **שני מסלולי ingest לישויות-אחיות שמתפצלים**`ingest_precedent`
(פסיקה חיצונית) מול `ingest_internal_decision` (החלטות-ועדה). מסלולים מקבילים גוררים drift:
פריט שנקלט במסלול אחד מקבל טיפול שונה מפריט במסלול האחר, והפער מתגלה רק כשרשומה חסרה
metadata או לא נמצאת בחיפוש. החוזה כאן מגדיר **מסלול קנוני אחד** ש-3 סוגי ה-intake עוברים בו.
---
## 1. שלושת סוגי ה-intake
| סוג-intake | מזהה-קנוני | קורפוס-יעד | מאפיין ייחודי |
|------------|------------|------------|----------------|
| מסמכי-תיק (case documents) | `case_number` + מזהה-מסמך | תיק ערר פעיל | משויך לתיק, מסווג לפי סוג-מסמך |
| פסיקה חיצונית (external precedent) | `citation` (קנוני) | `case_law` (external) | staging לפי `source_type`, ולידציית-enums, citation guard, multimodal |
| החלטות-ועדה (internal-committee) | `case_number` (קנוני) | `case_law` (internal_committee) | staging לפי district, `chair_name` חובה, גזירת district/proceeding_type |
שלושתם הם **ישויות-אחיות**: אותו טיפוס-עיבוד (קובץ → טקסט → chunks → embeddings → metadata
→ הלכות), נבדלים בפרמטרים בלבד — לא במסלול-קוד. זוהי משמעות "סימטריה" (חוקה §6).
---
## 2. המסלול הקנוני (Canonical Pipeline)
צעדי-העיבוד, **בסדר מחייב**. כל סוג-intake עובר את אותם צעדים; ההבדל הוא אילו פרמטרים
מוזרקים בקלט, לא אילו צעדים מורצים.
1. **Stage file** — העתקה דטרמיניסטית לאחסון המתמיד. נתיב-ה-staging הוא פרמטר
(`source_type` לפסיקה חיצונית, district להחלטות-ועדה), לא ענף-קוד נפרד.
2. **Extract text**`extractor.extract_text``(text, page_count, page_offsets)`.
טקסט ריק = כשל מדווח (לא בליעה שקטה; חוקה §6).
3. **Strip Nevo preamble**`extractor.strip_nevo_preamble` להסרת עטיפת-Nevo. **אחיד לכל סוג.**
4. **Chunk** — היררכי (`chunk_document_hierarchical`) אם `PARENT_DOC_RETRIEVAL_ENABLED`,
אחרת שטוח (`chunk_document`). **אותו ענף-flag בדיוק לכל סוג** — בורר הצ'אנקינג נגזר
מ-config, לא מסוג-ה-intake.
5. **Embed**`embeddings.embed_texts(..., input_type="document")` ל-children (היררכי)
או לכל ה-chunks (שטוח).
6. **Store chunks**`store_precedent_chunks_hierarchical` או `store_precedent_chunks`.
7. **Page-image embed (multimodal)** — אם `MULTIMODAL_ENABLED` **וגם** הקובץ PDF
**וגם** `page_count>0`: הטמעת עמודי-תמונה (`_embed_precedent_pages`). non-fatal:
מסלול-הטקסט כבר הצליח. **התנאי אחיד** — הפעלה תלויה ב-flag+סוג-קובץ, לא בסוג-ה-intake.
8. **Queue metadata extraction**`request_metadata_extraction(case_law_id)`. נדרש לכל
סוג שתומך במטא-דאטה (ראה [INV-ING3](#inv-ing3-תור-חילוץ-מטא-דאטה--הלכות-לכל-סוג)).
9. **Queue halacha extraction**`request_halacha_extraction(case_law_id)`.
10. **Set statuses**`extraction_status=completed`, `halacha_status=pending`.
החילוץ ה-LLM-י (metadata + הלכות) רץ בנפרד מ-Claude Code המקומי
(`precedent_process_pending`), כי `claude` CLI אינו זמין בקונטיינר.
> **צעדים שחייבים להיות אחידים בכל סוג (תיקון האסימטריה):** 2 (extract), 3 (strip-Nevo),
> 4 (בורר-chunk לפי flag), 56 (embed+store), **7 (multimodal — לפי flag+PDF, לא לפי
> סוג)**, **89 (תיזמון שני החילוצים)**, 10 (statuses). מה ש**רשאי** להשתנות לפי סוג:
> נתיב-ה-staging (צעד 1), ולידציות-קלט ספציפיות, וגזירת-שדות (district/proceeding_type)
> — אלו פרמטרים של אותו מסלול, לא מסלול נפרד.
---
## 3. Invariants של התחום
### INV-ING1: מסלול-קליטה קנוני יחיד
**כלל:** כל סוגי ה-intake (מסמכי-תיק / פסיקה חיצונית / החלטות-ועדה) זורמים דרך **פונקציית-
קליטה קנונית אחת**. סוג-intake חדש מורחב דרך **פרמטרים** של אותה פונקציה — לעולם לא דרך
פונקציה מקבילה. נתון-נגזר (district, proceeding_type) מחושב בתוך המסלול, לא בענף נפרד.
**מקורות:** Martin Kleppmann, *DDIA* (O'Reilly, 2017 — system of record יחיד) · Martin
Fowler (*Canonical Data Model*) · SSOT (Single Source of Truth) | סטטוס: verified
**אכיפה:** ביקורת-ארכיטקטורה + כלל-הנדסה "סימטריה" (חוקה §6); הקליטה מתנקזת לפונקציה אחת
שמקבלת פרמטרי-סוג. אוכף את [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים).
**הפרה ידועה:** היום קיימים **שני** מסלולים — `ingest_precedent`
(`precedent_library.py:88`) ו-`ingest_internal_decision` (`internal_decisions.py:73`) —
שמשכפלים את צעדי 210 ומתפצלים בפרטים → ממצא ל-[audit](../audit-report.md).
### INV-ING2: קליטה idempotent על המזהה הקנוני
**כלל:** הקליטה היא **idempotent על המזהה הקנוני** (`citation` לפסיקה חיצונית,
`case_number` להחלטות-ועדה ולמסמכי-תיק). קליטה חוזרת של אותו פריט = **upsert**
אין רשומה כפולה ואין chunks כפולים; התוצאה זהה.
**מקורות:** Martin Kleppmann, *DDIA* (idempotence & exactly-once) · Stripe / CDC
idempotency-key pattern · ISO 8000 (Data quality) | סטטוס: verified
**אכיפה:** מפתח-upsert דטרמיניסטי על המזהה הקנוני בנקודת-הקליטה (`create_external_case_law`
/ `create_internal_committee_decision`) + ולידציית-כתיבה; קשור ל-
[X1-identifiers.md](X1-identifiers.md) (נרמול בכתיבה). אוכף את
[G3](00-constitution.md#inv-g3-ingest-אחיד-ו-idempotent).
**הפרה ידועה:** 3 החלטות "סופר" נקלטו ב-3 פורמטים (`8126/24`, ציטוט-מלא כ-`case_number`)
— היעדר מפתח-upsert דטרמיניסטי גרר רשומות-כפל במקום עדכון → ממצא ל-[audit](../audit-report.md).
### INV-ING3: תור חילוץ מטא-דאטה + הלכות לכל סוג
**כלל:** חילוץ-מטא-דאטה **וגם** חילוץ-הלכות מתוזמנים (queue) עבור **כל** סוג-intake שתומך
בהם — תיזמון אחיד, **לא** מותנה במסלול. שני התורים נפתחים יחד בסיום העיבוד הלא-LLM-י.
**מקורות:** ISO 8000 (completeness) · DAMA-UK *Six Primary Dimensions for Data Quality*
(2013, completeness) · Martin Fowler (quality-at-source) | סטטוס: verified
**אכיפה:** קריאה ל-`request_metadata_extraction` **ו**-`request_halacha_extraction`
בנקודת-סיום-הקליטה, לכל סוג; חוזה-שלמות יסמן רשומה ללא מטא-דאטה כלא-שמישה
([G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש), מפורט ב-
[02-data-model.md](02-data-model.md)).
**הפרה ידועה:** המסלול הפנימי (`internal_decisions.py:208`) מתזמן **רק**
`request_halacha_extraction` ואינו קורא ל-`request_metadata_extraction` (בניגוד
ל-`precedent_library.py:292-293` שקורא לשניהם) → ערן סופר 8046/24 נקלטה **בלי
metadata** (headnote/summary/tags ריקים) → ממצא ל-[audit](../audit-report.md).
### INV-ING4: re-index בקליטה-חוזרת (upsert ⇒ re-embed)
**כלל:** קליטה-חוזרת ששינתה את תוכן-הפריט מפעילה **re-index** — chunks ו-embeddings
ישנים נמחקים ונבנים מחדש מהתוכן החדש. אין embeddings מיושנים אחרי upsert.
**מקורות:** Pinecone (index freshness / data sync) · Weaviate (re-vectorization on update)
· RAG freshness (Lewis et al., 2020, NeurIPS) | סטטוס: verified
**אכיפה:** טריגר re-embed בנתיב ה-upsert של הקליטה + בדיקת-בריאות לגילוי drift; מפורט
ב-[02-data-model.md](02-data-model.md) ו-[03-retrieval.md](03-retrieval.md). אוכף את
[G6](00-constitution.md#inv-g6-re-index-בכל-שינוי-תוכן).
**הפרה ידועה:**
---
## 4. מצב קיים מול יעד — audit-findings
הסעיף מתעד את ההבדלים בין שני המסלולים הקיימים. **אלו תסמינים לאיחוד תחת המסלול הקנוני,
לא התנהגויות תקינות.** כל פריט אומת מול הקוד בפועל.
- **חילוץ מטא-דאטה חסר במסלול הפנימי.** ראה [INV-ING3](#inv-ing3-תור-חילוץ-מטא-דאטה--הלכות-לכל-סוג)
(ההפרה המתועדת שם — ערן סופר 8046/24). **יעד:** צעד 8 (תור חילוץ) אחיד לשני הסוגים.
- **ולידציית-enums א-סימטרית.** המסלול החיצוני מוודא `practice_area`/`source_type` מול
רשימות חוקיות (`precedent_library.py:131-134`); המסלול הפנימי **אינו** מוודא enums.
**יעד:** ולידציה אחידה בנקודת-הקליטה (חוזה-שלמות, [G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש)).
- **staging מפוצל.** החיצוני עושה stage לפי `source_type` (`precedent_library.py:138`);
הפנימי עושה stage לפי district (`internal_decisions.py:113-115`). **יעד:** נתיב-staging
כפרמטר של המסלול הקנוני (צעד 1), לא ענף-קוד.
- **גזירת-שדות רק במסלול הפנימי.** הפנימי גוזר district מ-court (`:104`) ו-proceeding_type
מ-appeal_subtype/case_name (`:105`), ודורש `chair_name` (`:134`). החיצוני אינו גוזר אלו.
**יעד:** גזירה כפרמטר אופציונלי של המסלול הקנוני (שדות-סוג, לא מסלול-סוג).
- **citation guard רק במסלול החיצוני.** החיצוני חוסם ציטוט שמתחיל ב-`ערר`/`בל"מ`
ומפנה למסלול הפנימי (`precedent_library.py:124-130`). היעד שומר על השער הזה כניתוב-סוג
בתוך המסלול הקנוני, לא כהפרדת-פונקציות.
- **multimodal page-image embed רק במסלול החיצוני.** החיצוני מטמיע עמודי-תמונה כש-
`MULTIMODAL_ENABLED` + PDF (`precedent_library.py:272-278`); הפנימי **אינו** מטמיע
עמודי-תמונה. **יעד:** צעד 7 אחיד — מותנה ב-flag+סוג-קובץ בלבד.
- **fallback `case_name→citation` רק במסלול החיצוני.** החיצוני נופל ל-`citation` כשם
כשחסר `case_name` (`precedent_library.py:158`); הפנימי נופל ל-`case_number`
(`internal_decisions.py:130`). **יעד:** מדיניות-fallback אחת לשם-תצוגה במסלול הקנוני.
---
## 5. הפניות-אחיות
- [00-constitution.md](00-constitution.md) — invariants גלובליים + כללי-הנדסה.
- [02-data-model.md](02-data-model.md) — סכמת-האחסון + חוזה-שלמות שאוכף את תוצרי הקליטה.
- [03-retrieval.md](03-retrieval.md) — אחזור, re-index, eval — היעד של ה-chunks הנקלטים.
- [X1-identifiers.md](X1-identifiers.md) — נרמול המזהה הקנוני בכתיבה (בסיס ל-INV-ING2).
- [X5-audit-provenance.md](X5-audit-provenance.md) — שלמות-רשומה + עקיבוּת-מקור של פריט נקלט.

155
docs/spec/02-data-model.md Normal file
View File

@@ -0,0 +1,155 @@
# 02 — מודל-הנתונים (Data Model & Completeness Contract)
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) ומגדיר את **מודל-הנתונים הקנוני (TARGET)**
של עוזר משפטי — הישויות, שדות-המפתח, והיכן יושב כל פריט מואנדקס. הוא אוכף את
[G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה) (מזהה קנוני יחיד),
[G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש) (חוזה-שלמות) ו-
[G6](00-constitution.md#inv-g6-re-index-בכל-שינוי-תוכן) (re-index בשינוי-תוכן).
> **TARGET, לא תיאור-מצב.** המודל כאן הוא היעד הקנוני. כל מקום שבו ה-schema בפועל
> (`mcp-server/src/legal_mcp/services/db.py`) סוטה ממנו — מתועד כ-**audit-finding** (§4),
> תסמין לאיחוד, לא התנהגות תקינה. כל טענה על ה-schema הקיים מצוטטת `file:line`.
---
## 1. הישויות הקנוניות
הטבלה מונה את ישויות-הליבה. "מזהה-קנוני" = השדה היחיד המזהה רשומה ([G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה)).
| ישות | תפקיד | מזהה-קנוני | שדות-מפתח (מאומתים `db.py`) |
|------|--------|-------------|------------------------------|
| `cases` | תיק ערר חי (1xxx/8xxx/9xxx) | `case_number` + `proceeding_type` | `title`, `status`, `practice_area`, `appeal_subtype`, `proceeding_type`, `chair_name` (`db.py:74-91,182-189,747,912`) |
| `documents` | מסמך-מקור משויך לתיק | `id` (UUID); FK→`cases` | `doc_type`, `title`, `file_path`, `extracted_text`, `extraction_status`, `page_count` (`db.py:93-104`) |
| `document_chunks` | chunk של מסמך-תיק + embedding | `id`; FK→`documents`/`cases` | `chunk_index`, `content`, `section_type`, `embedding vector(1024)`, `page_number` (`db.py:106-116`) |
| `case_law` | קורפוס פסיקה — חיצוני **וגם** החלטות-ועדה | ראה [§2 + INV-DM2](#inv-dm2-מזהה-קנוני-יחיד-לכל-ישות) | `case_name`, `court`, `practice_area`, `source_kind`, `proceeding_type`, `source_type`, `headnote`, `summary`, `subject_tags`, `extraction_status`, `halacha_extraction_status` (`db.py:366-378,522-526,599-611,883,907`) |
| `precedent_chunks` | chunk של פסק-דין מואנדקס (`source_kind='external_upload'`/`internal_committee`) | `id`; FK→`case_law` | `chunk_index`, `content`, `section_type`, `page_number`, `embedding vector(1024)`, `content_tsv` (`db.py:624-634,776`) |
| `halachot` | הלכה מחולצת — כלל + ציטוט מילולי | `id`; FK→`case_law` | `rule_statement`, `supporting_quote`, `rule_type`, `practice_areas`, `subject_tags`, `confidence`, `quote_verified`, `review_status`, `embedding`, `rule_tsv` (`db.py:644-666,780`) |
| `decisions` | החלטת-תיק מנוסחת (גרסה) | `id`; `UNIQUE(case_id, version)` | `version`, `status`, `outcome`, `outcome_summary` (`db.py:299-314`) |
| `decision_blocks` | בלוק (12) של החלטה | `id`; `UNIQUE(decision_id, block_id)` | `block_id`, `block_index`, `content`, `status` (`db.py:317-334`) |
| `claims` | טענת-צד (בלוק ז) | `id`; FK→`cases` | `party_role`, `claim_text`, `source_document`, `claim_type`, `claim_handling` (`db.py:349-359,506-512`) |
| `chair_feedback` | הערת-יו"ר על טיוטה | `id`; FK→`cases` | `block_id`, `feedback_text`, `category`, `lesson_extracted`, `resolved` (`db.py:452-462`) |
| `missing_precedents` | תקדים חסר שהתבקש ולא נמצא | `id` | (`db.py:806`) — backlog ל-quality-at-source |
| `style_corpus` | קורפוס-סגנון של דפנה (אימון) | `id`; FK→`documents` | `decision_number`, `full_text`, `practice_area`, `appeal_subtype` (`db.py:118-131`) |
> שכבות-עזר נוספות (`document_image_embeddings`, `precedent_image_embeddings` — multimodal,
> `db.py:707,726`; `case_law_relations` — שרשרת-תיק, `db.py:754`; `precedent_internal_citations`
> — גרף-ציטוטים, `db.py:937`) הן נגזרות ([G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)):
> משוחזרות מהמקור, לא מקור-אמת עצמאי.
---
## 2. חוזה-שלמות לכל ישות (Completeness Contract)
[G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש) דורש: **רשומה אינה "שמישה /
ניתנת-לחיפוש" עד ששדות-החובה שלה מולאו ואומתו מול spec מפורש.** כל ישות מגדירה שתי רמות —
**usable** (קיימת ומזוהה) ו-**searchable** (חשופה לאחזור). רשומה שנכשלת בחוזה **מסומנת
ומדווחת — לא מתקבלת בשקט** (חוקה §6, "אין בליעה שקטה").
### 2א. `case_law` — החוזה הקונקרטי
המזהה הקנוני אינו `case_number` לבדו: `case_law` נושאת **שני** unique partial indexes לפי
`source_kind` (`db.py:904-909`) — חיצוני: `UNIQUE(case_number)`; פנימי: `UNIQUE(case_number,
proceeding_type)`. לכן המזהה הקנוני הוא **(`case_number` מנורמל, `source_kind`,
`proceeding_type`)**.
**רמת usable** (רשומה לגיטימית):
- `case_number` קנוני מנורמל-בכתיבה ([INV-DM2](#inv-dm2-מזהה-קנוני-יחיד-לכל-ישות) — **לא** ציטוט-מלא)
- `case_name` לא-ריק (לא fallback לציטוט/למספר)
- `court` לא-ריק
- `practice_area ∈ {rishuy_uvniya, betterment_levy, compensation_197}` (אכוף ב-CHECK, `db.py:614-617`)
- `source_kind` מהמילון (`external_upload` / `cited_only` / `internal_committee` / `nevo_seed`) (`db.py:599-601`, `internal_decisions.py:4`)
- `proceeding_type ∈ {ערר, בל"מ}` כשפנימי (אכוף ב-CHECK, `db.py:897-899`)
**רמת searchable** (חשוף לאחזור — מעבר ל-usable):
- **≥1 `precedent_chunk`** עם `embedding` לא-NULL (אחרת אין מה לאחזר סמנטית)
- **metadata לא-ריק:** לפחות אחד מ-`headnote` / `summary` / `subject_tags` מלא — אלו השדות
ש-search מציג ומסנן לפיהם
- `extraction_status = completed` (מטא-דאטה הושלם, `db.py:603`)
**אכיפה מפורשת:** רשומה שעוברת usable אך נכשלת ב-searchable — **מסומנת `searchable=false`
ולא מוחזרת מ-search**, ומופיעה ב-health-check כ-backlog. היא **אינה מתקבלת בשקט** כ"זמינה".
### 2ב. חוזה תמציתי לישויות נוספות
- `documents` → usable: `file_path`+`doc_type`; searchable: `extraction_status=completed` ו-`extracted_text` לא-ריק ו-≥1 `document_chunk` עם embedding.
- `halachot` → usable: `rule_statement`+`supporting_quote`; **searchable: `review_status ∈ {approved, published}` בלבד**`pending_review`/`rejected` מוסתרות מ-`search_precedent_library` (שער-הלכה ידני, `db.py:644-660`, [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)).
- `decision_blocks` → usable: `block_id`∈12-הבלוקים; "מוכן": `status=final` ו-`content` לא-ריק.
- `chair_feedback` → usable: `feedback_text`+`category` מהמילון; "פתוח" עד `resolved=true`.
---
## 3. Invariants של התחום
### INV-DM1: searchable רק כשחוזה-השלמות מתקיים
**כלל:** רשומת `case_law` נחשבת **searchable** אך ורק כשחוזה-השלמות של [§2א](#2א-case_law--החוזה-הקונקרטי)
מתקיים במלואו (מזהה קנוני · `case_name`/`court`/`practice_area`/`source_kind` · ≥1 chunk עם
embedding · metadata לא-ריק). רשומה שנכשלת **מסומנת `searchable=false` ומדווחת ל-health-check —
לא מוחזרת מ-search ולא מתקבלת בשקט**.
**מקורות:** ISO 8000 (completeness) · DAMA-UK *Six Primary Dimensions for Data Quality* (2013,
completeness) · ISO 15489-1:2016 (records reliability/usability) | סטטוס: verified
**אכיפה:** ולידציית-כתיבה בנקודת-הקליטה ([01-ingest.md](01-ingest.md) צעד 8) + בדיקת-בריאות
תקופתית שמסמנת backlog; הסינון נאכף בשכבת-החיפוש ([03-retrieval.md](03-retrieval.md)). אוכף את
[G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש).
**הפרה ידועה:** ערן סופר 8046/24 אונדקס כ-searchable עם `headnote`/`summary`/`subject_tags`
ריקים — המסלול הפנימי לא תיזמן חילוץ-מטא-דאטה ([01-ingest INV-ING3](01-ingest.md#inv-ing3-תור-חילוץ-מטא-דאטה--הלכות-לכל-סוג),
`internal_decisions.py:208`) → ממצא ל-[audit](../audit-report.md).
### INV-DM2: מזהה קנוני יחיד לכל ישות
**כלל:** לכל ישות **מזהה קנוני אחד**, מנורמל בכתיבה. **אסור** ששדה-המזהה יאחסן ציטוט-מלא —
`case_number` הוא מספר-תיק מנורמל (`8126-03-25`), **לא** מחרוזת-ציטוט (`ערר 8126/24 פלוני נ' הוועדה
(נבו...)`). הציטוט המלא חי בשדה ייעודי נפרד (`citation_formatted`, `db.py:1070`), לא במזהה.
**מקורות:** SSOT (Single Source of Truth — normalization) · E.F. Codd, First Normal Form (CACM
13(6), 1970) · Martin Kleppmann, *Designing Data-Intensive Applications* (O'Reilly, 2017) | סטטוס: verified
**אכיפה:** unique partial indexes על המזהה הקנוני (`db.py:904-909`) + נרמול-בכתיבה
([X1-identifiers.md](X1-identifiers.md)); ציטוט-מלא ב-`citation_formatted` בלבד. אוכף את
[G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה).
**הפרה ידועה:** החלטות "סופר" נקלטו עם **ציטוט-מלא כ-`case_number`** (שדה-המזהה של רשומה מכיל את
מחרוזת-הציטוט במקום מספר-תיק מנורמל) — חיפוש מול `8126-03-25` נכשל, ו-`_normalize_case_number`
(`db.py:1196-1211`) רק **מטליא בקריאה** (סלחני, לא קנוני), בניגוד ל-[G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה)
→ ממצא ל-[audit](../audit-report.md).
### INV-DM3: שינוי-תוכן ⇒ re-index
**כלל:** כל שינוי בתוכן-המקור של ישות מואנדקסת (`content` של chunk, `rule_statement`/`supporting_quote`
של הלכה, `full_text`/`extracted_text` של מסמך) מפעיל **re-index** של ה-embedding **ושל
ה-tsvector** הנגזרים. אין embedding או `content_tsv`/`rule_tsv`/`meta_tsv` מיושנים מול התוכן.
**מקורות:** Pinecone (index freshness / data sync) · Weaviate (re-vectorization on update) ·
RAG freshness (Lewis et al., 2020, NeurIPS) | סטטוס: verified
**אכיפה:** טריגר re-embed בנקודת-העדכון + בדיקת-בריאות לגילוי drift; ה-tsvectors `GENERATED ALWAYS
… STORED` (`db.py:776-788,1083-1090`) מתעדכנים אוטומטית, אך ה-`embedding` **אינו** generated —
הוא תלוי-טריגר. מפורט ב-[03-retrieval.md](03-retrieval.md). אוכף את
[G6](00-constitution.md#inv-g6-re-index-בכל-שינוי-תוכן).
**הפרה ידועה:**
---
## 4. מצב קיים מול יעד — audit-findings
ההבדלים בין ה-schema בפועל ל-TARGET. **אלו תסמינים, לא התנהגויות תקינות.** כל פריט אומת מול `db.py`.
- **`case_law` כפולת-תפקיד ללא מזהה מודע-סוג בכתיבה.** טבלה אחת משרתת פסיקה חיצונית **וגם**
החלטות-ועדה, מובדלות ב-`source_kind` (`db.py:599`). המזהה הקנוני האמיתי הוא טריפלט
(`case_number, source_kind, proceeding_type`, `db.py:904-909`), אך השדה `case_number TEXT
UNIQUE NOT NULL` המקורי (`db.py:368`) הוסר רק ב-V15 (`db.py:902-903`) — מורשת שאפשרה את
הפרת [INV-DM2](#inv-dm2-מזהה-קנוני-יחיד-לכל-ישות). **יעד:** נרמול-בכתיבה אכוף + ציטוט-מלא רק ב-`citation_formatted`.
- **`summary` קיים על `case_law` אך לא בחוזה-הקליטה הפנימי.** העמודה קיימת (`db.py:373`) אך
המסלול הפנימי אינו ממלא אותה (כפועל-יוצא מהיעדר חילוץ-מטא-דאטה, [INV-ING3](01-ingest.md#inv-ing3-תור-חילוץ-מטא-דאטה--הלכות-לכל-סוג)).
**יעד:** searchable מותנה ב-metadata לא-ריק ([INV-DM1](#inv-dm1-searchable-רק-כשחוזה-השלמות-מתקיים)).
- **שני שדות-סטטוס-חילוץ נפרדים, ללא דגל-`searchable` מפורש.** `extraction_status` +
`halacha_extraction_status` (`db.py:603-605`) מתארים תהליך, אך אין שדה יחיד שמסמן "עבר
חוזה-שלמות → searchable". **יעד:** דגל/view נגזר ש-search מסנן לפיו, מגובה health-check.
- **`embedding` אינו `GENERATED` (בניגוד ל-tsvector).** ה-tsvectors מסונכרנים אוטומטית
(`db.py:776,780,1083`), אך ה-`embedding vector(1024)` תלוי-טריגר חיצוני — נקודת-drift אפשרית
ל-[INV-DM3](#inv-dm3-שינוי-תוכן--re-index). **יעד:** טריגר re-embed מובטח + health-check ל-drift.
- **`halachot.review_status` כשער-searchable ללא נראות-backlog.** הסינון תקין (`pending_review`
מוסתר, `db.py:659`), אך אין נראות כמה ממתינות — תואם את ההפרה הידועה ב-[G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
(10/19 מאושרות, התגלה במקרה). **יעד:** health-check חושף backlog-הלכות.
---
## 5. הפניות-אחיות
- [00-constitution.md](00-constitution.md) — invariants גלובליים (G1, G4, G6) + כללי-הנדסה.
- [01-ingest.md](01-ingest.md) — חוזה-הקליטה שמייצר את הרשומות; חוזה-השלמות כאן אוכף את תוצריו.
- [03-retrieval.md](03-retrieval.md) — שכבת-האחזור שאוכפת את הסינון searchable + re-index.
- [X1-identifiers.md](X1-identifiers.md) — נרמול המזהה הקנוני בכתיבה (בסיס ל-INV-DM2).
- [X5-audit-provenance.md](X5-audit-provenance.md) — שלמות-רשומה + עקיבוּת-מקור.

178
docs/spec/03-retrieval.md Normal file
View File

@@ -0,0 +1,178 @@
# 03 — אחזור (Retrieval: Corpora · Hybrid/RRF · Attribution · Eval)
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) ומגדיר את **שכבת-האחזור הקנונית (TARGET)**
שלושת הקורפוסים, כלי-החיפוש המכוונים לכל אחד, מנגנון ה-hybrid (dense + lexical) ומיזוג ה-RRF,
עקיבוּת-המקור והרמוניית-המדידה. הוא אוכף את
[G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש) (חוזה-שלמות לפני "ניתן-לחיפוש"),
[G5](00-constitution.md#inv-g5-metadata-מלא--הפרדת-קורפוס-נאכפת-בכל-query) (הפרדת-קורפוס בכל query),
[G6](00-constitution.md#inv-g6-re-index-בכל-שינוי-תוכן) (re-index),
[G7](00-constitution.md#inv-g7-מיזוג-rrf--לא-סכום-ציונים) (מיזוג RRF),
[G8](00-constitution.md#inv-g8-איכות-אחזור-נמדדת--precision--recall) (eval) ו-
[G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai) (עקיבוּת-מקור).
> **TARGET, לא תיאור-מצב.** כל מקום שבו הקוד בפועל סוטה מהיעד מתועד כ-**audit-finding** (§5),
> תסמין לתיקון — לא התנהגות תקינה. כל טענה על הקוד מצוטטת `file:line`.
כשל-השורש שהקובץ מייבש: **3 קורפוסים שחולקים תשתית-אחזור אחת, אך הפרדת-הקורפוס נאכפת רק על
חלק ממסלולי-ה-query** — כך שפריט מקורפוס אחד דולף לתוצאה של חיפוש בקורפוס אחר (cross-corpus leak).
---
## 1. שלושת הקורפוסים וכלי-החיפוש
| קורפוס | טבלת-אחסון | `source_kind` | כלי-MCP מכוון | אימות `file:line` |
|--------|------------|----------------|----------------|--------------------|
| מסמכי-תיק + קורפוס-סגנון דפנה | `document_chunks` | — (מובחן ב-`case_id`/`practice_area`) | `search_decisions` · `search_case_documents` · `find_similar_cases` | `search.py:15,91,145``hybrid_search.py:41` (`search_documents_hybrid`) → `db.search_similar` (`hybrid_search.py:56`) |
| פסיקה חיצונית סמכותית | `case_law` + `precedent_chunks`/`halachot` | `external_upload` | `search_precedent_library` | `search.py``precedent_library.py:235``search_library``hybrid_search.py:89,101` (`source_kind="external_upload"`) |
| החלטות ועדות-ערר (פנימי) | `case_law` + `precedent_chunks`/`halachot` | `internal_committee` | `search_internal_decisions` | `search.py:228``internal_decisions.py:395,411-418` (`source_kind="internal_committee"`) → `hybrid_search.py:89` |
**הבחנת-שם קריטית (לא קורפוס רביעי):** `precedent_search_library` (`server.py:160`) הוא כלי **שונה**
מחפש בציטוטים שהיו"ר צירפה ידנית לתיקים (`case_precedents`), לא בקורפוס הפסיקה הסמכותית.
`search_precedent_library` (`server.py:280`) הוא הכלי לקורפוס החיצוני. אל תבלבל ביניהם.
הקורפוס החיצוני והפנימי **חולקים טבלה אחת** (`case_law`), מובחנים ב-`source_kind` בלבד
([02-data-model §2א](02-data-model.md#2א-case_law--החוזה-הקונקרטי)). שניהם רצים דרך **אותן** פונקציות-DB
(`search_precedent_library_semantic`/`_lexical`) — לכן הפרדת-הקורפוס היא **תנאי-סינון בתוך אותה שאילתה**,
ושם נולדת ההפרה ב-§5.
---
## 2. עיצוב ה-hybrid retrieval
לכל קורפוס שני retrievers הטרוגניים המאוחים ב-RRF, ולא בסכום-ציונים — ראה [INV-RET3](#inv-ret3-מיזוג-retrievers-הטרוגניים-ב-rrf-בלבד):
1. **Dense (semantic)** — דמיון-קוסינוס מול `embedding vector(1024)` (voyage). פסיקה:
`search_precedent_library_semantic` (`db.py:3143`); מסמכי-תיק: `db.search_similar`.
2. **Lexical (BM25-style)**`ts_rank_cd` מול `content_tsv`/`rule_tsv`/`meta_tsv` (Postgres FTS).
פסיקה: `search_precedent_library_lexical` (`db.py:3366`). מופעל כש-`BM25_HYBRID_ENABLED`
(`hybrid_search.py:139`).
3. **מיזוג sem+lex**`_merge_sem_lex` (`hybrid_search.py:240-308`), נוסחת
`rrf_score = 1/(k+sem_rank) + 1/(k+lex_rank)` (`hybrid_search.py:256`).
4. **שכבת-multimodal (אופציונלית)** — כש-`MULTIMODAL_ENABLED`, עמודי-תמונה (voyage-multimodal-3)
מאוחים לטקסט ב-RRF נפרד: `_merge` (`hybrid_search.py:311-389`), `text_weight/(k+rank) +
img_weight/(k+rank)` (`hybrid_search.py:356-357`).
5. **Diversity cap (MMR-style)**`_diversify_by_case_law` (`hybrid_search.py:196-225`): לכל היותר
`max_per_case_law` hits לכל `case_law_id`, כדי שפסק-דין יחיד לא ישתלט על הרשימה.
> **למה RRF ולא סכום משוקלל:** קוסינוס (~0.40.7) ו-`ts_rank_cd` (~0.0010.5, תלוי-אורך-שאילתה)
> חיים בסקיילים שונים — סכום משוקלל היה נותן לצד אחד להשתלט במקרה. RRF מאחד **לפי דירוג**, ולכן
> עמיד להבדלי-סקייל (`hybrid_search.py:248-252,319-323`). תואם feedback קיים (RRF, לא weighted-sum).
---
## 3. Invariants של התחום
### INV-RET1: הפרדת-קורפוס נאכפת ב-100% ממסלולי-ה-query
**כלל:** הפרדת 3 הקורפוסים נאכפת בכל מסלול-אחזור — **גם בסינון ה-chunks וגם בסינון ההלכות**.
אין פריט מקורפוס אחד שמופיע בתוצאת חיפוש שכוון לקורפוס אחר. כל ענף-SQL (semantic/lexical,
chunks/halachot) נושא את אותו תנאי-`source_kind`.
**מקורות:** Pinecone — *Implement multitenancy* (metadata-filter isolation per tenant) · RAG
attribution (Lewis et al., 2020, NeurIPS — pinned non-leaking provenance) · ISO 8000 (Data
quality / conformance) | סטטוס: verified
**אכיפה:** תנאי-`source_kind` בכל ענף-SQL בשכבת-החיפוש; בדיקת-בריאות שמריצה שאילתת-ביקורת
(חיפוש מכוון-קורפוס שמחזיר פריט בעל `source_kind` זר = כשל). אוכף את
[G5](00-constitution.md#inv-g5-metadata-מלא--הפרדת-קורפוס-נאכפת-בכל-query).
**הפרה ידועה:** משימה #56`halacha_filters` **אינם** כוללים `cl.source_kind` ב-
`search_precedent_library_semantic` (`db.py:3168`, ענף ה-halacha; לעומת `chunk_filters` שכן —
`db.py:3169`) **וב**-`search_precedent_library_lexical` (`db.py:3401` מול `db.py:3402`). שני
ה-`halacha_sql` עושים `JOIN case_law cl` בלי לסנן `source_kind` (`db.py:3236-3238`, `db.py:3475-3477`)
→ הלכות מהקורפוס הפנימי דולפות לתוצאות החיפוש בקורפוס החיצוני ולהפך → ממצא ל-[audit](../audit-report.md).
### INV-RET2: אין החזרה/אינדוקס בלי metadata מלא + locator פתיר
**כלל:** פריט אינו מוחזר מ-search (ואינו נחשף לאחזור) אלא אם **שדות-החובה שלו מולאו**
([G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש)) **ובידו locator פתיר למקור**
(`case_law_id`/`document_id` + מזהה-עמוד/chunk). רשומה ללא metadata לא-ריק או ללא chunk עם
embedding מסומנת `searchable=false` ולא מוחזרת ([02-data-model INV-DM1](02-data-model.md#inv-dm1-searchable-רק-כשחוזה-השלמות-מתקיים)).
**מקורות:** Pinecone (metadata filtering — completeness לפני שליפה) · RAG attribution (Lewis et
al., 2020) · ISO 8000 (completeness) | סטטוס: verified
**אכיפה:** חוזה-שלמות בנקודת-הקליטה ([02-data-model §2](02-data-model.md#2-חוזה-שלמות-לכל-ישות-completeness-contract))
+ סינון בשכבת-החיפוש (`embedding IS NOT NULL`, `db.py:3239,3271`; `length(trim(content))>=50`,
`db.py:3274`) + בדיקת-בריאות שחושפת backlog. אוכף את
[G5](00-constitution.md#inv-g5-metadata-מלא--הפרדת-קורפוס-נאכפת-בכל-query).
**הפרה ידועה:** ערן סופר 8046/24 — נקלטה בלי metadata (headnote/summary/tags ריקים), היעדר
תיזמון חילוץ-מטא-דאטה במסלול הפנימי ([01-ingest INV-ING3](01-ingest.md#inv-ing3-תור-חילוץ-מטא-דאטה--הלכות-לכל-סוג)),
אך ללא דגל-`searchable` מפורש שימנע את חשיפתה לאחזור → ממצא ל-[audit](../audit-report.md).
### INV-RET3: מיזוג retrievers הטרוגניים ב-RRF בלבד
**כלל:** מיזוג תוצאות בין retrievers שונים (semantic↔lexical, text↔image) נעשה **אך ורק
לפי דירוג (Reciprocal Rank Fusion)** — לעולם לא סכום/ממוצע ציונים גולמיים, שכן ציונים בסקיילים
שונים אינם בני-השוואה ישירה.
**מקורות:** Elastic — *Reciprocal Rank Fusion* · Weaviate — *Hybrid Search Explained* · Manning,
Raghavan & Schütze, *Introduction to Information Retrieval* (CUP, 2008) | סטטוס: verified
**אכיפה:** מיזוג sem+lex ב-`_merge_sem_lex` (`hybrid_search.py:240-308`, נוסחה ב-`:256`) ומיזוג
text+image ב-`_merge` (`hybrid_search.py:311-389`, נוסחה ב-`:356-357`), שניהם עם
`k = MULTIMODAL_RRF_K`. אוכף את [G7](00-constitution.md#inv-g7-מיזוג-rrf--לא-סכום-ציונים).
**מצב:** **כבר ממומש** (codify, לא gap) — הקוד הקיים מיישם RRF נכון בשני המיזוגים. ה-invariant
מקבע את ההתנהגות הקיימת כחוזה. **הפרה ידועה:**
### INV-RET4: איכות-אחזור נמדדת ב-eval harness עומד (precision + recall)
**כלל:** איכות-האחזור **נמדדת אמפירית** — precision **ו**-recall — מול **סט-שאילתות מתויג קבוע**
(labeled query set) ב-eval harness עומד. כל שינוי בשכבת-האחזור (משקלי-RRF, `k`, סף-chunk, embedder)
מלווה במדידה לפני/אחרי; אין כוונון "לפי תחושה".
**מקורות:** Manning, Raghavan & Schütze, *Introduction to Information Retrieval* (CUP, 2008 — fixed
relevance judgments, precision/recall) · RAG evaluation literature (Lewis et al., 2020 ואחריו) ·
Elastic — *relevance evaluation guidance* | סטטוס: verified
**אכיפה:** eval harness עם gold-set מתויג + בדיקת-בריאות תקופתית; שער-CI על שינוי שכבת-האחזור.
אוכף את [G8](00-constitution.md#inv-g8-איכות-אחזור-נמדדת--precision--recall).
**הפרה ידועה (GAP):** אין כיום eval harness ולא gold-set — קיים רק `telemetry.log_search_bg`
(`search.py:62,118,190,271`; `precedent_library.py:280`) שמתעד שאילתות בפועל, אך **אינו מודד
precision/recall מול תיוג** (תצפית, לא הערכה). היעד: harness שמריץ סט קבוע ומחזיר metrics →
ממצא ל-[audit](../audit-report.md).
### INV-RET5: כל span מוחזר עקיב למקורו
**כלל:** כל קטע מוחזר נושא **עקיבוּת-מקור מלאה** — מזהה-מסמך/פסק-דין (`case_law_id`/`document_id`/
`case_number`) **ו**-locator בתוכו (`page_number` / `chunk_id` / `supporting_quote` להלכה). פלט
ללא ייחוס פתיר אינו תקין; היו"ר חייבת לאמת כל ציטוט מול מקורו.
**מקורות:** Council of Europe / CEPEJ — *European Ethical Charter on AI in judicial systems*
(2018, traceability) · RAG attribution (Lewis et al., 2020) · ISO 15489-1:2016 (records
authenticity/integrity) | סטטוס: verified
**אכיפה:** כל פורמטר-תוצאה כולל מזהה + locator: `search.py:77-86` (case_number/page/section),
`_format_internal_row` (`search.py:322-343`: case_number/case_name/court + content/page או
rule/quote להלכה). עקיבוּת מלאה מפורטת ב-[X5-audit-provenance.md](X5-audit-provenance.md). אוכף את
[G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai).
**הפרה ידועה:**
---
## 4. re-index ושינוי-תוכן (G6)
האחזור מסתמך על embeddings מסונכרנים מול התוכן. ה-tsvectors (`content_tsv`/`rule_tsv`/`meta_tsv`)
הם `GENERATED ALWAYS … STORED` (`db.py:778,782,1086`) ולכן מתעדכנים אוטומטית; אך ה-`embedding
vector(1024)` **אינו** generated — הוא תלוי-טריגר-חיצוני, נקודת-drift אפשרית
([02-data-model INV-DM3](02-data-model.md#inv-dm3-שינוי-תוכן--re-index)). שינוי-תוכן חייב להפעיל
re-embed; בדיקת-בריאות מגלה embeddings מיושנים. אוכף את
[G6](00-constitution.md#inv-g6-re-index-בכל-שינוי-תוכן).
---
## 5. מצב קיים מול יעד — audit-findings
ההבדלים בין הקוד בפועל ל-TARGET. **אלו תסמינים, לא התנהגויות תקינות.** כל פריט אומת מול הקוד.
- **דליפת-הלכות חוצת-קורפוס (משימה #56).** `halacha_filters` נפתחים רק עם `review_status`
(`db.py:3168`, `db.py:3401`) ואינם מוסיפים `cl.source_kind`, בעוד `chunk_filters` כן
(`db.py:3169`, `db.py:3402`). שני ה-`halacha_sql` עושים `JOIN case_law` בלי סינון
(`db.py:3236-3242`, `db.py:3463-3482`). **תסמין:** חיפוש בקורפוס החיצוני
(`search_precedent_library`, `source_kind="external_upload"`) יכול להחזיר הלכה שמקורה
בהחלטת-ועדה פנימית — ולהפך עבור `search_internal_decisions` (`source_kind="internal_committee"`,
`internal_decisions.py:418`). **יעד:** `halacha_filters` יתחילו ב-`cl.source_kind = '{source_kind}'`
בדיוק כמו `chunk_filters` ([INV-RET1](#inv-ret1-הפרדת-קורפוס-נאכפת-ב-100-ממסלולי-ה-query)).
- **אין eval harness — מדידת-איכות לא קיימת.** רק `telemetry.log_search_bg` מתעד שאילתות
(`search.py:62,118,190,271`); אין gold-set מתויג ואין precision/recall. **יעד:** harness עומד
([INV-RET4](#inv-ret4-איכות-אחזור-נמדדת-ב-eval-harness-עומד-precision--recall)).
- **`search_decisions` מתעד אזהרה כשאין `practice_area` אך לא חוסם.** ללא פילטר-תחום החיפוש
עלול לערבב תחומים משפטיים (`search.py:45-49,172-176``logger.warning`, ממשיך). **יעד:** הפרדה
לפי תחום נאכפת, לא מומלצת בלבד — תואם את עקרון ההפרדה ב-[G5](00-constitution.md#inv-g5-metadata-מלא--הפרדת-קורפוס-נאכפת-בכל-query).
- **`embedding` אינו `GENERATED` (בניגוד ל-tsvector).** נקודת-drift אפשרית בין תוכן ל-embedding
אחרי עדכון ([§4](#4-re-index-ושינוי-תוכן-g6); תואם [02-data-model](02-data-model.md#inv-dm3-שינוי-תוכן--re-index)).
**יעד:** טריגר re-embed מובטח + health-check.
---
## 6. הפניות-אחיות
- [00-constitution.md](00-constitution.md) — invariants גלובליים (G4G9) + כללי-הנדסה.
- [01-ingest.md](01-ingest.md) — חוזה-הקליטה שמייצר את ה-chunks/embeddings שהאחזור שולף.
- [02-data-model.md](02-data-model.md) — חוזה-השלמות (searchable) + re-index שהאחזור מסנן לפיהם.
- [05-qa-review.md](05-qa-review.md) — שער-הלכה הידני (`review_status`) שמגדיר אילו הלכות searchable.
- [X5-audit-provenance.md](X5-audit-provenance.md) — עקיבוּת-מקור מלאה של כל span מוחזר (בסיס ל-INV-RET5).

View File

@@ -0,0 +1,186 @@
# 04 — ניתוח וכתיבה (Analysis & Writing)
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) ומפרט את שלב **הסיוע-בכתיבה**
חילוץ הטענות, ארכיטקטורת 12 הבלוקים, וסגנון דפנה. הוא אוכף את
[INV-G11](00-constitution.md#inv-g11-תוכן-החלטה-מנומקת) (תוכן החלטה מנומקת).
> **⚠ מודל-סמכות שונה מ-0103.** זהו קובץ **תוכן-משפטי**, לא קובץ-הנדסה. לפי החוקה
> (§2 עיקרון 2, §5ב) הסמכות עליו היא **היו"ר (עו"ד דפנה תמיר) + מסמכי-הפרויקט** —
> [block-schema.md](../block-schema.md), [decision-methodology.md](../decision-methodology.md),
> [legal-decision-lessons.md](../legal-decision-lessons.md),
> [corpus-analysis.md](../corpus-analysis.md), [skills/decision/SKILL.md](../../skills/decision/SKILL.md).
> ה-invariants כאן **אינם** כפופים לפרוטוקול ≥3-המקורות החיצוני, ו**אינם** נושאים
> `סטטוס: verified / ⚠ UNVERIFIED`. במקום `מקורות: … | סטטוס` הם נושאים `מקור-סמכות:`.
> מסמכי-הפרויקט הם המקור המוסמך; קובץ זה מצטט אותם בגובה-ספ, לא משכפל את ההגדרות.
---
## 1. חילוץ טענות → טיעונים מאוגדים
לפני הכתיבה, חומרי-המקור הופכים למבנה-נתונים שמזין את הבלוקים. שני שלבים:
### 1.1 חילוץ טענות גולמיות (claims)
`extract_claims(case_number, doc_title="", party_hint="")` קורא לכתבי-הטענות בתיק,
ושומר טענות גולמיות ב-DB. הוא מסנן למסמכים מסוג `appeal` / `response` / `objection`
(אלא אם צוין `doc_title` מפורש), ולכל מסמך קורא ל-`claims_extractor.extract_and_store_claims`
— ראה `mcp-server/src/legal_mcp/tools/documents.py:300-347`.
כל טענה נשמרת עם `party_role` מתוך התפקידים המוכרים: **`appellant` (עוררים)** ·
**`respondent` (משיבים)** · **`committee` (ועדה מקומית)** · **`permit_applicant`
(מבקשי היתר)** · **`appraiser` (שמאי)**. `get_claims(case_number, party_role="")`
שולף ומציג אותן בעברית, עם סינון אופציונלי לפי תפקיד
(`documents.py:350-385`; מיפוי-העברית ב-`:370-376`).
### 1.2 כינוס לטיעונים משפטיים מובחנים (legal arguments)
`aggregate_claims_to_arguments(case_number, force=False)` מכנס את הפרופוזיציות
הגולמיות לטיעונים משפטיים מובחנים (de-duplication) דרך
`argument_aggregator.aggregate_claims_to_arguments`; `force=True` מוחק טיעונים קיימים
ומחשב מחדש — ראה `mcp-server/src/legal_mcp/tools/legal_arguments.py:11-33`.
`get_legal_arguments(case_number, party="")` שולף את הטיעונים המאוגדים, מקובצים לפי
צד (`appellant`/`respondent`/`committee`/`permit_applicant`/`unknown`); אם אין —
הוא מחזיר הנחיה להריץ קודם את הכינוס (`legal_arguments.py:36-83`).
> **מדוע זה חשוב לתוכן:** הטיעונים המאוגדים הם הקלט ל-[INV-WR3](#inv-wr3-מענה-לכל-טענה-של-הצד-המפסיד)
> (מענה לכל טענה עיקרית) ול-[INV-WR4](#inv-wr4-בלוק-ז--טענות-מקוריות-בלבד) (הפרדת טענות
> מקוריות מהשלמות). הסינון לפי `party_role` מאפשר לזהות את הצד המפסיד ולוודא שכל טיעון
> שלו מקבל מענה בבלוק י.
---
## 2. ארכיטקטורת 12 הבלוקים (סיכום)
המבנה הפורמלי המלא — content model, constraints, משקלות, ופרמטרי-עיבוד לכל בלוק —
מוגדר ב-[block-schema.md](../block-schema.md) (המקור המוסמך). כאן רק מפת-גובה:
| בלוק | תפקיד | CREAC | תוכן מהותי? |
|------|--------|-------|-------------|
| א–ד | כותרת מוסדית · הרכב · צדדים · "החלטה" | — | לא (template-fill) |
| ה | פתיחה ("לפנינו…") | C ראשוני | קל |
| **ו** | רקע עובדתי ("פתח דבר") | — | **כן — עובדות בלבד** |
| **ז** | טענות הצדדים | — | **כן — טענות מקוריות בלבד** |
| ח | הליכים בפני הוועדה | — | כן (תיעוד, ללא הערכה) |
| ט | תכניות חלות (אופציונלי) | R | כן (כשיש מורכבות תכנונית) |
| **י** | דיון והכרעה | full-CREAC | **כן — ה-ratio decidendi** |
| יא | סיכום / סוף דבר | C אחרון | קל |
| יב | חתימות | — | לא |
יסודות תיאורטיים (CREAC · FJC Judicial Writing Manual · DITA · Akoma Ntoso),
תלויות-בין-בלוקים, וכללי-ולידציה — ב-[block-schema.md](../block-schema.md) §§1, 5, 6.
מתודולוגיית-המשקלות (Communicative / Reader-attention / Judicial-review / Empirical)
— שם §4. **טיוטת-ביניים** (Pre-Ruling Draft) בוחרת תת-קבוצת בלוקים (ו, ט, ז, ח) —
block-schema.md §7; שלב-החילוץ השמאי שלה (`extract_appraiser_facts`) מזין את בלוק ט.
> **התמקדות לפי feedback היו"ר:** הסיוע מתמקד בבלוקים המהותיים (ו–יב); בלוקים א–ד
> ממולאים מ-template ואינם דורשים ניתוח. ראה `MEMORY.md` → "התעלם מכותרות".
---
## 3. סגנון דפנה (סיכום)
מדריך-הסגנון המלא הוא [skills/decision/SKILL.md](../../skills/decision/SKILL.md);
המתודולוגיה האנליטית ("איך לחשוב לפני איך לכתוב") היא
[decision-methodology.md](../decision-methodology.md). נקודות-מפתח:
- **טון לפי סוג-ערר** — רישוי (1xxx) חם יחסית; היטל-השבחה (8xxx) ופיצויים ס'197 (9xxx)
קרים ויבשים (SKILL.md §1; methodology §א.2).
- **מבנה הדיון (בלוק י)** — נפתח במסקנה (CREAC: C→R→E→A→C), סילוגיזם לכל סוגיה,
steel-manning של הצד המפסיד, ציטוט-פסיקה ב"סנדוויץ'" (methodology §§ד, ו, ז).
- **מסלול-דיון לפי תוצאה** — דחייה (עיגולים קונצנטריים) · קבלה (נימוק-נימוק) · קבלה
חלקית (מיפוי-מתחים) · היטל-השבחה (פתיחה ישירה) — SKILL.md §7.3; block-schema.md בלוק י.
- **3 מקורות-פסיקה נפרדים** — אסור לבלבל ביניהם (SKILL.md §7.5; ראה גם
[03-retrieval.md](03-retrieval.md) לשכבת-האחזור שמזינה אותם).
- **לקחים מצטברים** — [legal-decision-lessons.md](../legal-decision-lessons.md) +
ביטויי-מעבר; מתעדכנים מפידבק-היו"ר ומ-Hermes (ראה forward-ref [07-learning.md](07-learning.md)).
---
## 4. Invariants של התחום — תוכן החלטה מנומקת
חמשת ה-invariants הבאים הם **פאֶטים של [INV-G11](00-constitution.md#inv-g11-תוכן-החלטה-מנומקת)**.
כולם נושאים `מקור-סמכות` (היו"ר + מסמכי-הפרויקט), **ללא** שדה-מקורות-חיצוני ו**ללא**
סטטוס-אימות — כמתחייב מהבחנת שתי-הסמכויות בחוקה (§5).
### INV-WR1: רקע ניטרלי (בלוק ו) — עובדות בלבד
**כלל:** בלוק ו מציג **עובדות בלבד** ואינו טוען. אסורות מילות-ערך/שיפוט ("חריג",
"בעייתי", "למרבה הפליאה") ואסורים ציטוטים ישירים מצדדים (אלה שייכים לבלוק ז). החלטות
קודמות מובאות כעובדה יבשה ("ביום X נדחתה תכנית Y"), ללא נימוקים. ניטרליות אינה הסתרה:
עובדה מהותית התומכת בצד המפסיד **חייבת** להופיע.
**מקור-סמכות:** היו"ר (עו"ד דפנה תמיר) + [block-schema.md](../block-schema.md) (בלוק ו,
§5.2 "רקע ניטרלי") + [decision-methodology.md](../decision-methodology.md) §ח.2.
**אכיפה:** ולידציית-תוכן בבלוק ו (סעיף עם ציטוט-צד או מילת-שיפוט → לא שייך כאן) + שערי
QA; מפורט ב-[05-qa-review.md](05-qa-review.md).
**הפרה ידועה:**
### INV-WR2: ללא כפילות (בלוק י מפנה, לא חוזר)
**כלל:** בלוק י (דיון) **מפנה** לעובדות ולטענות שכבר הוצגו בבלוקים הקודמים ("כאמור
בסעיף X לעיל", "כפי שפורט") — ואינו חוזר עליהן. חריג יחיד: חזרה מכוונת עם שכבת-ניתוח
חדשה ("נשוב על כך כי…"). אין עובדות חדשות בדיון שלא הופיעו ברקע.
**מקור-סמכות:** היו"ר + [block-schema.md](../block-schema.md) (בלוק י, §5.2 "ללא
כפילות") + [skills/decision/SKILL.md](../../skills/decision/SKILL.md) §9.1.
**אכיפה:** ולידציית-מבנה (עובדה בדיון ללא עוגן ברקע = flag) + שערי QA;
מפורט ב-[05-qa-review.md](05-qa-review.md).
**הפרה ידועה:**
### INV-WR3: מענה לכל טענה של הצד המפסיד
**כלל:** כל **טענה עיקרית** שהוצגה בבלוק ז — ובמיוחד של הצד המפסיד — מקבלת **מענה
מנומק** בבלוק י (ישיר, "למעלה מן הצורך", או מקובץ עם דומותיה). מותר לא להכריע בטענה
נחוצה-פחות ("נוכח מסקנתנו לעיל, אין צורך…"), אך אסור להתעלם מטענה מרכזית — הצד המפסיד
חייב לראות שהוועדה שקלה את יסודות עמדתו (steel-manning).
**מקור-סמכות:** היו"ר + [decision-methodology.md](../decision-methodology.md) §§ג.2, ו.2 +
[block-schema.md](../block-schema.md) (בלוק י MUST: "מענה לכל טענה" §5.4) +
[skills/decision/SKILL.md](../../skills/decision/SKILL.md) §6.2.
**אכיפה:** מיפוי טענות-בלוק-ז → מענה-בלוק-י (נשען על §1.2, הטיעונים המאוגדים) + שערי QA;
מפורט ב-[05-qa-review.md](05-qa-review.md).
**הפרה ידועה:**
### INV-WR4: בלוק ז — טענות מקוריות בלבד
**כלל:** בלוק ז מכיל **אך ורק** טענות מכתבי-הטענות המקוריים (כתב-ערר, כתב-תשובה).
תוכן מהשלמות-טיעון, החלטות-ביניים, ותגובות-מאוחרות → **בלוק ח** (הליכים), לא בלוק ז.
הצגת-הטענות היא בנאמנות וללא הערכה ("טענה זו חלשה") — ההערכה שייכת לבלוק י.
**מקור-סמכות:** היו"ר + [block-schema.md](../block-schema.md) (בלוק ז Sources +
§5.2 "טענות מקוריות בלבד") + [skills/decision/SKILL.md](../../skills/decision/SKILL.md) §4.
**אכיפה:** סיווג-מקור של טענה בעת החילוץ (`extract_claims` מסנן `appeal`/`response`/
`objection`; מסמכי פוסט-דיון מתויגים `is_post_hearing` ומופנים לבלוק ח — block-schema.md §7)
+ שערי QA; מפורט ב-[05-qa-review.md](05-qa-review.md).
**הפרה ידועה:**
### INV-WR5: "מבחן-השופט" — החלטה עצמאית וקריאה
**כלל:** ההחלטה חייבת להיות **עצמאית וקריאה לשופט שלא מכיר את התיק** — תשתית עובדתית
מלאה (בלוק ו), תיעוד procedural-fairness (בלוק ח), והנמקה שעומדת בבדיקת סבירות
ומידתיות (בלוק י). הקורא לא נדרש לחומרי-המקור כדי להבין את ההחלטה ואת הצדקתה.
**מקור-סמכות:** היו"ר + [block-schema.md](../block-schema.md) §4.3 ("מבחן השופט" /
Judicial-Review weight) + [decision-methodology.md](../decision-methodology.md) §יב
(רשימת-ביקורת) + [corpus-analysis.md](../corpus-analysis.md).
**אכיפה:** שער QA סופי ("מבחן-השופט") על ההחלטה כיחידה שלמה;
מפורט ב-[05-qa-review.md](05-qa-review.md).
**הפרה ידועה:**
---
## 5. צ'קליסט-תוכן לפי סוג-ערר
בלוק י מקבל **צ'קליסט-תוכן** המוזרק אוטומטית ל-prompt לפי סוג-הערר, מתוך
`CONTENT_CHECKLISTS` ב-`mcp-server/src/legal_mcp/services/lessons.py:355`. הבורר
(`lessons.py:532-555`) ממפה לסוג: `tama38` (תמ"א 38) · `betterment_levy` (היטל-השבחה) ·
`licensing_property` · `licensing_threshold` (שאלת-סף) · `licensing_substantive`
(ברירת-מחדל לרישוי). הצ'קליסט מבטיח שהדיון מכסה את הנושאים התכנוניים/המשפטיים שדפנה
מכסה בפועל בקורפוס — ראה [corpus-analysis.md](../corpus-analysis.md) §§3, 6 לדפוסי-התוכן
ולפער שנסגר (§5.3). זהו מנגנון-תוכן באחריות היו"ר, לא חוק-הנדסה.
---
## 6. הפניות-אחיות
- [00-constitution.md](00-constitution.md#inv-g11-תוכן-החלטה-מנומקת) — INV-G11 + הבחנת
שתי-הסמכויות (תוכן-משפטי מול הנדסה).
- [03-retrieval.md](03-retrieval.md) — שכבת-האחזור (3 קורפוסי-פסיקה) שמזינה ציטוטים לבלוק י.
- [05-qa-review.md](05-qa-review.md) — שערי-QA שאוכפים את INV-WR1WR5 + שערים אנושיים.
- [06-export.md](06-export.md) — ייצוא DOCX לפי תבנית-דפנה (אחרי הכתיבה).
- [07-learning.md](07-learning.md) — לולאת פידבק-היו"ר + Hermes שמעדכנת lessons/SKILL.
- מסמכי-המקור המוסמכים: [block-schema.md](../block-schema.md) ·
[decision-methodology.md](../decision-methodology.md) ·
[legal-decision-lessons.md](../legal-decision-lessons.md) ·
[corpus-analysis.md](../corpus-analysis.md) ·
[skills/decision/SKILL.md](../../skills/decision/SKILL.md).

198
docs/spec/05-qa-review.md Normal file
View File

@@ -0,0 +1,198 @@
# 05 — בקרת איכות ושערים אנושיים (QA & Human Review)
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) ומפרט את שלב **הביקורת** לפני
ייצוא: (1) **שערי-QA אוטומטיים** (`validate_decision` — 6 בדיקות) ו-(2) **שערים אנושיים**
(אישור הלכה, בחירת תוצאה, פידבק היו"ר). הוא אוכף את
[INV-G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
(שערים אנושיים) ואת [INV-G11](00-constitution.md#inv-g11-תוכן-החלטה-מנומקת) (תוכן מנומק).
> **⚠ קובץ מעורב — שני מודלי-סמכות.** לפי החוקה (§3, §5):
> - **שערי-הממשל** (שערים אנושיים, שער-הייצוא) הם **invariants הנדסיים** במודל
> הממשל-שיפוטי → נושאים `מקורות:` (NCSC/JTC · CEPEJ 2018 · FJC) + `סטטוס: verified`.
> - **מכניקת בדיקות-התוכן** (מה הבדיקה האוטומטית בוחנת בפועל — רקע ניטרלי, ללא כפילות,
> כיסוי-טענות) היא **תוכן-משפטי** → נושאת `מקור-סמכות:` (היו"ר + מסמכי-הפרויקט +
> [04-analysis-writing.md](04-analysis-writing.md)), **ללא** מקורות חיצוניים וללא סטטוס.
---
## 1. שערי-QA אוטומטיים — `validate_decision`
`validate_decision(case_number)` (wrapper ב-`tools/drafting.py:363`, נחשף ב-`server.py:551`)
טוען את בלוקי-ההחלטה והטענות מה-DB ומריץ **6 בדיקות**, אז כותב את התוצאות לטבלת
`qa_results` ומחזיר `passed` / `critical_failures` / `export_blocked`. הליבה:
`services/qa_validator.py:292` (`validate_decision`). כל בדיקה מחזירה
`{name, passed, errors, severity}`; `severity ∈ {critical, warning}`.
> **חישוב החסימה:** `critical_failures = Σ(not passed ∧ severity=="critical")`
> (`qa_validator.py:338`), ו-`export_blocked = critical_failures > 0`
> (`qa_validator.py:362`). בדיקת `warning` שנכשלת מורידה `passed=False` אך **אינה** חוסמת
> ייצוא. ראה [§3 / INV-QA3](#inv-qa3-החלטה-לא-מיוצאת-עם-כשל-קריטי-governance--g10).
### 1.1 ששת השערים
| # | בדיקה | מה בוחנת | severity | פונקציה (file:line) |
|---|-------|----------|----------|---------------------|
| 1 | `neutral_background` | רקע (בלוק ו) ללא מילות-שיפוט (`VALUE_WORDS`) וללא ציטוט-צד (`QUOTE_INDICATORS`) | **warning** | `check_neutral_background``qa_validator.py:66` |
| 2 | `claims_coverage` | כל טענה מבלוק ז נענתה בבלוק י (בדיקה סמנטית דרך Claude) | **critical** | `check_claims_coverage``qa_validator.py:107` |
| 3 | `weight_compliance` | משקל-מילים של כל בלוק בטווח לפי סוג-ערר (`WEIGHT_RANGES`) | **warning** | `check_weight_compliance``qa_validator.py:177` |
| 4 | `structural_integrity` | בלוקי-חובה קיימים (ה, ז, י, יא) + בלוק י הוא הכבד ביותר | **critical** | `check_structural_integrity``qa_validator.py:206` |
| 5 | `no_duplication` | אין משפט מבלוק ו (>30 תווים) שחוזר מילה-במילה בבלוק י | **warning** | `check_no_duplication``qa_validator.py:235` |
| 6 | `sequential_numbering` | מספור-סעיפים רציף בכל הבלוקים, מתחיל ב-1, ללא פערים | **warning** | `check_sequential_numbering``qa_validator.py:261` |
### 1.2 דקויות חשובות (אל תניח — מהקוד)
- **רק 2 שערים קריטיים** חוסמים ייצוא: `claims_coverage` ו-`structural_integrity`. שאר
הארבעה הם `warning` בנתיב הרגיל — `qa_validator.py:86, 202, 257, 286`.
- **דקות `neutral_background` — שני נתיבי-החזרה:** הנתיב הרגיל מסומן `warning` (`:86`); נתיב
ה-fallback של בלוק-ו ריק/חסר מסומן `critical` (`:70`) **אך מחזיר `passed=True`**, ולכן
אינו נספר ב-`critical_failures` ואינו חוסם ייצוא. תפקודית — השער אינו חוסם.
- **`claims_coverage` סובלני ל-20%:** עובר אם `len(missing) ≤ total*0.2`
(`qa_validator.py:170`). מסנן לטענות `appellant`/`respondent` שאינן מבלוק-ז
(`qa_validator.py:120-129`), כי טענות `committee`/`permit_applicant` הן עמדות-הגנה ולא
דורשות מענה. כשל-פענוח של Claude → fallback `passed=True` כדי לא לחסום ייצוא על תקלת-LLM
(`qa_validator.py:148-152`).
- **`neutral_background` ריק = עובר:** בלוק ו ריק/חסר מחזיר `passed=True`
(`qa_validator.py:69`). הבדיקה היא lexical (רשימת-מילים + regex), לא סמנטית.
- **`no_duplication` תופס רק חזרה מילה-במילה** (substring) — לא פרפרזה.
- כל ריצה **מנקה** את `qa_results` הקודמות של התיק ואז כותבת מחדש (`qa_validator.py:344-357`).
### 1.3 שערי-התוכן מתפעלים את WR1WR3
שלוש מ-6 הבדיקות הן ההפעלה האוטומטית (חלקית) של ה-invariants של התוכן ב-
[04-analysis-writing.md](04-analysis-writing.md):
| שער QA | invariant-תוכן | פער (אוטומטי מול הגדרה) |
|--------|----------------|--------------------------|
| `neutral_background` | [INV-WR1](04-analysis-writing.md#inv-wr1-רקע-ניטרלי-בלוק-ו--עובדות-בלבד) | lexical בלבד — לא תופס שיפוט עקיף; warning, לא critical |
| `no_duplication` | [INV-WR2](04-analysis-writing.md#inv-wr2-ללא-כפילות-בלוק-י-מפנה-לא-חוזר) | מילה-במילה בלבד — לא תופס כפילות מנוסחת-מחדש |
| `claims_coverage` | [INV-WR3](04-analysis-writing.md#inv-wr3-מענה-לכל-טענה-של-הצד-המפסיד) | סמנטי (Claude), סובלני ל-20% חוסר |
ראה [INV-QA4](#inv-qa4-שערי-התוכן-האוטומטיים-אוכפים-את-wr1wr3-content--g11). WR4 (טענות
מקוריות) ו-WR5 ("מבחן-השופט") **אינם** מכוסים על-ידי `validate_decision` — WR4 נאכף
בנקודת-החילוץ (`extract_claims`), WR5 הוא שער-איכות אנושי/agent. הסוכן `legal-qa`
(ראה [X4-agents.md](X4-agents.md)) מוסיף שערים ידניים מעבר ל-6 הקוד-יים (קול-דפנה,
שאילתות-קורפוס, צירוף-פסיקה) — `.claude/agents/legal-qa.md`.
---
## 2. שערים אנושיים — היו"ר מכריעה
המערכת מסייעת; ההכרעה היא של היו"ר. שלושה שערים אנושיים מובנים בקוד-הזרימה ואינם ניתנים
לעקיפה אוטומטית (זהו [INV-G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)).
### 2.1 אישור הלכה (halacha approval)
הלכות מחולצות אוטומטית מפסיקה (`halacha_extractor.py`), אך **נכנסות כ-`pending_review`
ובלתי-נראות לחיפוש** עד אישור היו"ר:
- **כתיבה:** `db.add_halacha` קובע `review_status = "approved" if auto_approve else
"pending_review"` (`db.py:3003`), כאשר `auto_approve` נגזר מסף-ביטחון
`HALACHA_AUTO_APPROVE_THRESHOLD` (ברירת-מחדל `0.80`, `config.py:111`). הלכות מתחת לסף
נשארות `pending_review`.
- **שער-האישור:** `halacha_review(halacha_id, status, reviewer="דפנה", …)`
(`tools/precedent_library.py:291`, נחשף ב-`server.py:298`) — היו"ר מאשרת/דוחה/עורכת.
`status ∈ {pending_review, approved, rejected, published}` (`precedent_library.py:311`).
- **תור-ההמתנה:** `halachot_pending(limit=100)` (`precedent_library.py:335`) מחזיר את
`review_status='pending_review'`.
- **חשיפה רק לאחר אישור:** החיפוש מסנן `h.review_status IN ('approved','published')`
(`db.py:3168` ו-`db.py:3401`) — הלכה שלא אושרה **לעולם** לא עולה בתוצאות.
### 2.2 בחירת תוצאה (outcome selection)
`set_outcome(case_number, outcome, reasoning="")` (`tools/workflow.py:145`,
`server.py:646`) — היו"ר קובעת `outcome ∈ {rejected, accepted, partial}`
(`workflow.py:163`). זוהי **הכרעה משפטית**: היא קודמת לכתיבת-הטיוטה וקובעת את מסלול-הדיון
(ראה [04-analysis-writing.md](04-analysis-writing.md) §3). אין נתיב שבו המערכת בוחרת תוצאה
לבד — אם לא סופק נימוק, המערכת מציעה כיווני-נימוק (`brainstorm`), אך הבחירה נשארת אנושית.
### 2.3 פידבק היו"ר (chair feedback)
- `record_chair_feedback(case_number, feedback_text, block_id, category, …)`
(`tools/workflow.py:348`, `server.py:896`) — מתעד הערת-דפנה; `category` מתוך
`{missing_content, wrong_tone, wrong_structure, factual_error, style, other}`
(`workflow.py:367`).
- `list_chair_feedback(case_number, category, unresolved_only=True)`
(`tools/workflow.py:393`, `server.py:910`) — שליפה לסקירה.
הפידבק מזין את לולאת-הלמידה ([07-learning.md](07-learning.md)) ואת
[legal-decision-lessons.md](../legal-decision-lessons.md). זהו שיפוט-אנושי על איכות —
לעולם לא מוסק או מוחל אוטומטית.
---
## 3. Invariants של התחום
### INV-QA1: אישור הלכה הוא שער אנושי (governance →G10)
**כלל:** אישור הלכה הוא **הכרעה ידנית של היו"ר**. הלכות שחולצו אוטומטית הן
`pending_review` עד שהיו"ר מאשרת; **רק הלכות מאושרות** (`approved`/`published`) עולות
בחיפוש. תור-ההמתנה חייב להיות **נראה** (`halachot_pending`) כדי שאישור-חסר לא יישאר סמוי.
**מקורות:** NCSC/JTC — *Principles & Practices for AI Use in Courts* (human-in-the-loop) ·
Council of Europe / CEPEJ (2018, under user control) · Federal Judicial Center —
*Judicial Writing Manual* (2d ed.) | סטטוס: verified
**אכיפה:** ברירת-מחדל `pending_review` בכתיבה (`db.py:3003`) + סינון
`review_status IN ('approved','published')` בכל query (`db.py:3168`, `db.py:3401`) + שער-אישור
`halacha_review` (`precedent_library.py:291`).
**הפרה ידועה:** 10/19 הלכות מאושרות — שער-ידני שקוף בלי נראות-backlog; ההפרש התגלה במקרה →
ממצא ל-[audit](../audit-report.md) (ראה גם [INV-G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)).
### INV-QA2: בחירת-תוצאה ופידבק הם שערים אנושיים (governance →G10)
**כלל:** **בחירת התוצאה** (`set_outcome`) ו**פידבק-היו"ר** (`record_chair_feedback`) הם
שערים אנושיים — **לעולם לא אוטומטיים**. המערכת מסייעת (מציעה כיווני-נימוק, מתעדת הערות),
אך ההכרעה והשיפוט-על-האיכות הם של היו"ר.
**מקורות:** NCSC/JTC — *Principles & Practices for AI Use in Courts* ("never replace human
judgment") · Council of Europe / CEPEJ (2018, under user control) · Federal Judicial
Center — *Judicial Writing Manual* (2d ed.) | סטטוס: verified
**אכיפה:** `set_outcome` דורש `outcome` מפורש מהיו"ר (`workflow.py:145-165`);
`record_chair_feedback`/`list_chair_feedback` מתעדים בלבד (`workflow.py:348, 393`) — אין
מסלול-קוד שמסיק תוצאה או פידבק לבד.
**הפרה ידועה:** —
### INV-QA3: החלטה לא מיוצאת עם כשל קריטי (governance →G10)
**כלל:** החלטה **אינה ניתנת לייצוא** כל עוד שער-QA **קריטי** נכשל
(`claims_coverage` או `structural_integrity`). `export_blocked` חייב להיבדק לפני ייצוא;
ייצוא בכשל-קריטי הוא הפרה. שערי-`warning` שנכשלים מתועדים אך אינם חוסמים.
**מקורות:** NCSC/JTC — *Principles & Practices for AI Use in Courts* (controlled, auditable
AI output) · Council of Europe / CEPEJ (2018, under user control) · Federal Judicial
Center — *Judicial Writing Manual* (2d ed.) | סטטוס: verified
**אכיפה:** `export_blocked = critical_failures > 0` (`qa_validator.py:362`); נאכף בשער-הזרימה
של הסוכן `legal-exporter` ("לעולם אל תייצא בלי `validate_decision` קודם", "בדוק שאין
כשלים קריטיים" — `.claude/agents/legal-exporter.md:71, 149`). קושר ל-[06-export.md](06-export.md).
**הפרה ידועה:** `export_docx` (`drafting.py:384`) **אינו** מריץ `validate_decision` בעצמו —
החסימה היא ברמת-הזרימה/agent, לא hard-block בקוד-הייצוא. פער זה → ראה [§4](#4-current-vs-target--ממצאי-audit) (audit).
### INV-QA4: שערי-התוכן האוטומטיים אוכפים את WR1WR3 (content →G11)
**כלל:** שערי-התוכן האוטומטיים מתפעלים את invariants-התוכן: `neutral_background`↔
[WR1](04-analysis-writing.md#inv-wr1-רקע-ניטרלי-בלוק-ו--עובדות-בלבד) (רקע ניטרלי) ·
`no_duplication`↔[WR2](04-analysis-writing.md#inv-wr2-ללא-כפילות-בלוק-י-מפנה-לא-חוזר)
(ללא כפילות) · `claims_coverage`↔[WR3](04-analysis-writing.md#inv-wr3-מענה-לכל-טענה-של-הצד-המפסיד)
(מענה-לטענות). האכיפה האוטומטית היא **רצפה, לא תקרה** — WR4/WR5 וההבטים העדינים (שיפוט-עקיף,
כפילות מנוסחת-מחדש) נשארים בשיקול-הדעת האנושי (INV-QA1QA3).
**מקור-סמכות:** היו"ר (עו"ד דפנה תמיר) + [04-analysis-writing.md](04-analysis-writing.md)
(INV-WR1WR3) + `mcp-server/src/legal_mcp/services/qa_validator.py` (הבדיקות בפועל).
**אכיפה:** `check_neutral_background` (`qa_validator.py:66`), `check_no_duplication`
(`qa_validator.py:235`), `check_claims_coverage` (`qa_validator.py:107`).
**הפרה ידועה:** —
---
## 4. Current vs Target — ממצאי-audit
- **Halacha backlog בלתי-נראה (INV-QA1):** 10/19 הלכות מאושרות; 9 נשארו `pending_review`
ולא עלו בחיפוש. השער עבד כשורה — אך חוסר-נראות של ה-backlog הסתיר את הפער עד שהתגלה
במקרה. **Target:** מדד-נראות (count `pending_review`) כחלק מבדיקת-בריאות, לא רק
`halachot_pending` בדרישה. ראה [audit](../audit-report.md).
- **שער-ייצוא אכוף-זרימה ולא אכוף-קוד (INV-QA3):** `export_docx` לא קורא ל-`validate_decision`;
החסימה תלויה במשמעת הסוכן `legal-exporter`. **Target:** hard-block בתוך `export_docx`
(בדיקת `qa_results`/`export_blocked` לפני כתיבת DOCX) כדי שלא יהיה ניתן לעקיפה.
---
## 5. הפניות-אחיות
- [00-constitution.md](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant) —
INV-G10 (שערים אנושיים) + INV-G11 + הבחנת שתי-הסמכויות.
- [04-analysis-writing.md](04-analysis-writing.md) — INV-WR1WR5 שהשערים האוטומטיים מתפעלים.
- [06-export.md](06-export.md) — ייצוא DOCX (השלב אחרי המעבר בשער הקריטי).
- [07-learning.md](07-learning.md) — לולאת פידבק-היו"ר + Hermes שמעדכנת lessons/SKILL.
- [X4-agents.md](X4-agents.md) — הסוכן `legal-qa` (שערים ידניים נוספים) ו-`legal-exporter`.
- [X5-audit-provenance.md](X5-audit-provenance.md) — audit-trail לפלטי-AI ועקיבוּת-מקור.

168
docs/spec/06-export.md Normal file
View File

@@ -0,0 +1,168 @@
# 06 — ייצוא DOCX (Export Contract)
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) ומגדיר את **חוזה-הייצוא** של עוזר
משפטי: הרינדור של החלטה ל-DOCX מעוצב (גופן David, RTL, סגנונות-טמפלט). העיקרון המכונן —
**ה-DB הוא מקור-האמת היחיד, וה-DOCX הוא נתון נגזר (derived) הניתן לשחזור**. הקובץ אוכף את
[INV-G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) (מקור-אמת
יחיד / נתון-נגזר משוחזר) ואת [INV-G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai)
(עקיבוּת-מקור), והוא השלב שאחרי שער-הייצוא הקריטי של
[05-qa-review.md / INV-QA3](05-qa-review.md#inv-qa3-החלטה-לא-מיוצאת-עם-כשל-קריטי-governance--g10).
> **כללי-סגנון — סמכות אחת.** מכניקת העיצוב (line classification, dash policy, placeholder,
> מיפוי-סגנונות, RTL-runs) מתועדת במלואה בסקיל
> [`dafna-decision-template/SKILL.md`](../../skills/dafna-decision-template/SKILL.md) — **הוא
> המקור הסמכותי**. הקובץ הזה **מסכם ומפנה**, לא משכפל. כללי-הסגנון עצמם הם תוכן-משפטי-דומייני
> (סמכות היו"ר + הסקיל), בעוד שחוזה-ה-derived-data (INV-EX1) ועקיבוּת-המקור (INV-EX2) הם
> invariants הנדסיים הנושאים `מקורות` + `סטטוס`.
---
## 1. חוזה-הייצוא — DB הוא המקור, DOCX הוא הנגזר
החלטה מאוחסנת כ-**בלוקים מובְנים ב-DB** — `decision_blocks` (12 בלוקים, מפתח קנוני
`UNIQUE(decision_id, block_id)`) תחת `decisions` (`UNIQUE(case_id, version)`); ראה
[02-data-model.md §1](02-data-model.md). ה-DOCX **נגזר** מהבלוקים האלה ואינו מקור-אמת עצמאי:
מחיקתו אינה מאבדת תוכן, וייצוא חוזר מאותם בלוקים מפיק מסמך שקול.
**מסלול-הייצוא הקנוני (הסופי):**
1. `export_docx(case_number)` (`tools/drafting.py:384`, נחשף `server.py:557`) שולף את התיק,
ואז קורא ל-`docx_exporter.export_decision(case_id, …, mode="final")`
(`services/docx_exporter.py:306`).
2. `export_decision` שולף את הבלוקים **ישירות מ-`decision_blocks`**
(`SELECT block_id, block_index, title, content, word_count … ORDER BY block_index`,
`docx_exporter.py:336-342`) — אין מקור-תוכן אחר.
3. טוען את טמפלט-דפנה (`skills/docx/decision_template.docx`, `docx_exporter.py:27-29,364`),
מנקה את גוף-המסמך (`_clear_body`), וכותב כל בלוק עם **bookmark עוטף** (אנקור ל-revisions
עתידיים, `_wrap_block_with_bookmarks`, `docx_exporter.py:367-382`).
4. שומר לקובץ מגורסן `data/cases/{case_number}/exports/טיוטה-v{N}.docx` (גרסה אוטומטית עולה,
`docx_exporter.py:384-400`).
> **שני מסלולי-ייצוא לפי מקור-התוכן (לא מסלולים-מקבילים מתפצלים):**
> - `docx_exporter.py` — **ההחלטה הסופית** מ-12 הבלוקים ב-`decision_blocks` (`mode="final"`),
> וגם **טיוטת-ביניים** (`mode="interim"` — תת-קבוצת בלוקים בסדר חדש: רקע→תכניות→טענות→הליכים,
> `export_interim_draft`, `drafting.py:511`). שני המצבים שולפים מאותה טבלה — וריאציית-תצוגה
> של אותו מקור-אמת, לא מסלול שני.
> - `analysis_docx_exporter.py` (`build_analysis_docx`, `:401`) — מייצא את מסמך **הניתוח
> המשפטי** (`analysis-and-research.md`) שכתב `legal-analyst`, לא את בלוקי-ההחלטה. זהו תוצר-עזר
> שונה (שלב ניתוח, לא החלטה) — והוא המסלול שהסקיל מתעד בעיקר. שניהם חולקים את **אותו טמפלט
> ואותם כללי-סגנון**, כנדרש מ-[INV-G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
> (סימטריה — לא שתי שכבות-סגנון מתפצלות).
## 2. כללי-הסגנון — סיכום (הסמכות: הסקיל)
ה-service מחיל את סגנונות-הטמפלט בלבד (`paragraph.style = "Heading 2"`) — בלי font/size/indent
ידני; העיצוב (David, RTL, גדלים) מגיע מ-`styles.xml`. הפירוט המלא + ה-XML של כל סגנון:
[`SKILL.md`](../../skills/dafna-decision-template/SKILL.md) + `references/`.
- **סיווג-שורות (`_classify_line`):** כל שורה מסווגת לאחת מ-6 קטגוריות — `label_heading`,
`inline_label`, `numbered`, `bullet`, `heb_letter`, `plain` — שקובעות את הסגנון המוחל
(Heading 2 / Normal / List Paragraph). ראה
[`references/line-classification.md`](../../skills/dafna-decision-template/references/line-classification.md).
- **מדיניות-מקפים (`_no_dash`):** דפנה ביקשה "בלי מקפים בכלל" — `—` (U+2014) ו-`` (U+2013)
מוסרים מכל טקסט נכתב; מקף רגיל (`-`) נשמר.
- **שדות-placeholder:** `chair_position` עם סימן-ריק (`[ימולא ע"י יו"ר הוועדה]` וכד') מוחלף
ב-`[טרם מולאה עמדת ועדת הוועדה]` ב-italic — סימן ויזואלי שנותר להשלים (תואם
[INV-G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant) — היו"ר
משלימה, לא המערכת).
- **RTL-runs:** כל run מסומן `<w:rtl/>` (`_mark_run_rtl`) — אחרת Word נופל ל-Times New Roman
במקום David. ראה [`references/rtl-runs.md`](../../skills/dafna-decision-template/references/rtl-runs.md).
- **מספור:** מספור אוטומטי רק ב-`List Paragraph` (decimal); שורות `(א)(ב)` מקבלות
`List Paragraph` עם `_strip_numpr()` (המספור העברי בטקסט).
## 3. רישום הגרסה — `active_draft_path` + git
לאחר כתיבת ה-DOCX, `export_docx` (`drafting.py:404-408`):
1. **`set_active_draft_path(case_id, path)`** (`db.py:1177`) — רושם את ה-DOCX שיוצא כ-
active-draft הנוכחי (`cases.active_draft_path`, `db.py:189`). שדה זה הוא **האנקור לעריכות
עוקבות** (`revise_draft`/`apply_user_edit`/`list_bookmarks`), לא מקור-אמת-תוכן מתחרה ל-DB.
2. **`git_sync.commit_and_push(case_dir, "ייצוא DOCX: …")`** (`drafting.py:408`) — מקבע את
הקובץ ב-git של תיקיית-התיק (audit-trail של פלט,
[INV-G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai); ראה
[X5-audit-provenance.md](X5-audit-provenance.md)).
אותו דפוס (`set_active_draft_path` + commit) חוזר ב-`export_interim_draft` (`drafting.py:533,536`),
`revise_draft` (`drafting.py:692,695`) ו-`apply_user_edit` (`drafting.py:579,582`).
---
## 4. Invariants של התחום
### INV-EX1: ייצוא דטרמיניסטי ומשוחזר מהבלוקים — DOCX הוא נתון-נגזר (→G2)
**כלל:** הייצוא **דטרמיניסטי וניתן-לשחזור** מבלוקי-ההחלטה המאוחסנים ב-`decision_blocks`:
אותם בלוקים + אותו טמפלט מפיקים מסמך שקול. ה-DOCX הוא **נתון-נגזר (derived)** — **לעולם לא
מקור-אמת עצמאי**. אסור מסלול-תוכן שני שכותב DOCX ממקור שאינו ה-DB; וריאציות (final/interim)
הן תצוגות של אותו מקור.
**מקורות:** Martin Kleppmann — *Designing Data-Intensive Applications* (O'Reilly, 2017,
system-of-record מול derived data, ושחזור derived מהמקור) · Martin Fowler (Canonical Data
Model / Single Source of Truth) · SSOT (Single Source of Truth principle) | סטטוס: verified
**אכיפה:** `export_decision` שולף אך-ורק מ-`decision_blocks` (`docx_exporter.py:336-342`);
פלט מגורסן + idempotent מבחינת-תוכן; אוכף את
[INV-G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) וכלל-ההנדסה
"סימטריה" (חוקה §6).
**הפרה ידועה:** אחרי `revise_draft`/`apply_user_edit`, ה-DOCX המסומן `active_draft_path` הופך
ל"מקור-האמת" לעריכות-Track-Changes העוקבות (`db.py:185-188`), ו**בלוקי-ה-DB אינם מתעדכנים
חזרה** — הנתון-הנגזר זוחל למקור-אמת בפועל בלי סנכרון לאחור. **יעד:** או re-sync מהבלוקים, או
חוזה מפורש ש-`active_draft_path` הוא רק אנקור-revision ולא מקור-תוכן → ראה [§5](#5-current-vs-target).
### INV-EX2: עקיבוּת-מקור נשמרת בהחלטה המיוצאת (→G9)
**כלל:** ההחלטה המיוצאת **שומרת על עקיבוּת-מקור** היכן שנדרש — סמכויות-משפטיות מצוטטות
ניתנות-לאיתור (citation resolvable), והפלט מקובע ב-audit-trail (commit git). הפניות-פסיקה
בבלוקים אינן מאבדות את מקורן בעת הרינדור.
**מקורות:** Council of Europe / CEPEJ — *European Ethical Charter on AI in judicial systems*
(2018, traceability/transparency) · ISO 15489-1:2016 (records authenticity/integrity) ·
Lewis et al. (2020, NeurIPS — RAG attribution) | סטטוס: verified
**אכיפה:** `export_docx` מקבע כל פלט ב-git (`git_sync.commit_and_push`, `drafting.py:408`) +
רושם `active_draft_path` (`db.py:1177`); עקיבוּת-המקור של הציטוטים עצמם נאכפת במעלה-הזרם
(חילוץ-טענות/הלכות + provenance, [04-analysis-writing.md](04-analysis-writing.md),
[X5-audit-provenance.md](X5-audit-provenance.md)). אוכף את
[INV-G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai).
**הפרה ידועה:**
### INV-EX3: אין ייצוא בכשל-QA קריטי (restate של INV-QA3 →G10)
**כלל:** הייצוא **חסום** כל עוד שער-QA קריטי נכשל (`claims_coverage` / `structural_integrity`);
`export_blocked` חייב להיבדק לפני ייצוא. זהו אותו invariant של
[INV-QA3](05-qa-review.md#inv-qa3-החלטה-לא-מיוצאת-עם-כשל-קריטי-governance--g10), בצד-הייצוא.
**מקורות:** NCSC/JTC — *Principles & Practices for AI Use in Courts* (controlled, auditable
output) · Council of Europe / CEPEJ (2018, under user control) · Federal Judicial Center —
*Judicial Writing Manual* (2d ed.) | סטטוס: verified
**אכיפה:** `export_blocked = critical_failures > 0` (`qa_validator.py:362`); **נאכף ברמת-
הזרימה/agent בלבד** — הסוכן `legal-exporter` מחויב להריץ `validate_decision` ולבדוק
כשלים-קריטיים לפני ייצוא (`.claude/agents/legal-exporter.md:71,149`).
**הפרה ידועה:** `export_docx` (`drafting.py:384`) **אינו** קורא ל-`validate_decision` בעצמו —
הוא ניגש ישירות ל-`docx_exporter.export_decision` בלי לבדוק `export_blocked`. החסימה תלויה
במשמעת-הסוכן ואינה hard-block בקוד-הייצוא → ראה [§5](#5-current-vs-target) (תואם
[05-qa-review §4](05-qa-review.md#4-current-vs-target--ממצאי-audit)).
---
## 5. Current vs Target
- **שער-ייצוא אכוף-זרימה ולא אכוף-קוד (INV-EX3 / INV-QA3).** אומת בקוד: `export_docx`
(`drafting.py:384-419`) קורא ישירות ל-`docx_exporter.export_decision` (`:403`) ללא קריאה
ל-`qa_validator.validate_decision` ובלי בדיקת `export_blocked`. החסימה מתקיימת רק כי הסוכן
`legal-exporter` מחויב להריץ QA קודם (`legal-exporter.md:71,149`) — אדם/סוכן שיקרא
ל-`export_docx` ישירות **יעקוף** את השער. **יעד:** hard-block בתוך `export_docx` — שליפת
`qa_results`/`export_blocked` ודחייה לפני כתיבת ה-DOCX, כך שאי-אפשר לעקוף.
- **`active_draft_path` כ-derived-שזוחל-למקור (INV-EX1).** ה-DOCX נגזר מהבלוקים בייצוא הראשון,
אך אחרי עריכה (`revise_draft`/`apply_user_edit`) ה-DOCX הופך ל"מקור-האמת" לעריכות הבאות
(`db.py:185-188`) בלי לעדכן את `decision_blocks` חזרה — סטייה אפשרית בין הבלוקים למסמך-החי.
**יעד:** חוזה מפורש — או re-sync מהבלוקים, או הגדרת `active_draft_path` כאנקור-revision בלבד
(לא מקור-תוכן), עם בדיקת-בריאות לגילוי drift בין הבלוקים ל-DOCX הפעיל.
---
## 6. הפניות-אחיות
- [00-constitution.md](00-constitution.md) — [INV-G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
(derived-data / מקור-יחיד) · [INV-G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai)
(עקיבוּת) · [INV-G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant) (שערים).
- [02-data-model.md](02-data-model.md) — `decisions`/`decision_blocks` (המקור שממנו מייצאים).
- [04-analysis-writing.md](04-analysis-writing.md) — כתיבת הבלוקים שמהם נגזר ה-DOCX.
- [05-qa-review.md](05-qa-review.md#inv-qa3-החלטה-לא-מיוצאת-עם-כשל-קריטי-governance--g10) —
INV-QA3 (שער-הייצוא הקריטי שקודם לשלב זה).
- [07-learning.md](07-learning.md) — `ingest_final_version` + Hermes על ההחלטה הסופית.
- [X5-audit-provenance.md](X5-audit-provenance.md) — audit-trail (commit git) ועקיבוּת-מקור.
- [`skills/dafna-decision-template/SKILL.md`](../../skills/dafna-decision-template/SKILL.md) —
**המקור הסמכותי** לכללי-הסגנון (line classification · dash policy · placeholder · RTL-runs).

189
docs/spec/07-learning.md Normal file
View File

@@ -0,0 +1,189 @@
# 07 — לולאת הלמידה (Learning Loop)
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) ומפרט כיצד המערכת **לומדת לאורך
זמן** — מהחלטות סופיות (Hermes), מפידבק-היו"ר, ומצמיחת-הקורפוס — באופן שמזין חזרה את
הכתיבה ([04-analysis-writing.md](04-analysis-writing.md)) ואת שערי-האיכות
([05-qa-review.md](05-qa-review.md)). הוא אוכף את
[INV-G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
(שערים אנושיים — אישור היו"ר על כל עדכון-ידע) ואת
[INV-G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש) /
כלל-ההנדסה **quality-at-source** (האחריות לאיכות יושבת במקור, לא בטלאי במורד הזרם).
> **⚠ קובץ מעורב — שני מודלי-סמכות** (לפי החוקה §3, §5):
> - **שער-הממשל** (Hermes מציע — היו"ר מאשרת ידנית; אין auto-commit ל-SKILL/lessons)
> הוא **invariant הנדסי** במודל הממשל-שיפוטי → נושא `מקורות:` (NCSC/JTC · CEPEJ 2018 ·
> FJC) + `סטטוס: verified`.
> - **כלל-ההנדסה quality-at-source** (היכן יושבת האחריות לאיכות-הידע) → invariant הנדסי
> במודל הנדסת-הנתונים → נושא `מקורות:` (Fowler — Data Mesh / quality-at-source ·
> DAMA-UK · ISO 8000) + `סטטוס: verified`.
---
## 1. שלוש לולאות-המשנה
הלמידה אינה אירוע יחיד אלא **שלוש לולאות** המתנקזות לאותם מסמכי-ידע מוסמכים
([legal-decision-lessons.md](../legal-decision-lessons.md),
[skills/decision/SKILL.md](../../skills/decision/SKILL.md)) ולקורפוסים:
### 1.1 לולאת-Hermes (post-export → הצעה → אישור)
הסוכן [hermes-curator](../../.claude/agents/hermes-curator.md) (adapter `deepseek_local`,
פרופילים `curator-cmp` / `curator-cmpa`) נקרא **אחרי שדפנה מסמנת קובץ כסופי** ב-UI
(`POST /api/cases/{case_number}/exports/{filename}/mark-final``pc_wake_curator_for_final()`
ב-`web/paperclip_client.py` → sub-issue + wakeup; **חיבור ישיר מה-UI, לא דרך CEO**
`hermes-curator.md:27-35`). הוא:
- **קורא בלבד** את הטקסט הסופי (`case_get_final_text`), `get_style_guide`, ואת
`SKILL.md` / `legal-decision-lessons.md` / `corpus-analysis.md` המקומיים
(`hermes-curator.md:60-70`).
- מזהה **35 דפוסים/פערים** חדשים, כל ממצא מתויג `[סגנון]` / `[מבנה]` /
`[לקסיקון משפטי]` / `[טבלאי]` (`hermes-curator.md:99-108`).
- **מציע** — comment ב-Paperclip + רישום כל ממצא כ-`decision_lesson` דרך
`POST /api/training/corpus/{corpus_id}/lessons` (`source:"curator"`) שמופיע ב-UI
תחת הטאב "מה למדנו" (`hermes-curator.md:73-96`).
- **אינו מעדכן** קבצים בעצמו (skills/, lessons.py, DB) — רק מציע (`hermes-curator.md:125-130`).
### 1.2 לולאת-פידבק-היו"ר (capture → ניתוח שבועי → לקחים)
- **לכידה מובנית:** `record_chair_feedback` שומר הערת-דפנה בטבלת `chair_feedback`
(`category ∈ {missing_content, wrong_tone, wrong_structure, factual_error, style,
other}`) — `tools/workflow.py:348`, ראה [05-qa-review.md](05-qa-review.md) §2.3.
- **ניתוח שבועי:** ה-scheduled job `weekly-feedback-analysis` (ראשון 19:00,
`plugin-legal-ai/src/manifest.ts:175-179`) מושך `GET /api/chair-feedback/weekly-summary`,
ואם יש פריטים — **מעיר את ה-CEO** לעדכן את `legal-decision-lessons.md` עם הלקחים
החדשים (`worker.ts:784-837`; הוראת ה-prompt: "הוסף רק לקחים חדשים… קבץ לפי נושא"
`worker.ts:830`).
- אין פריטים → הג'וב מדלג בשקט (`worker.ts:805`). ל-CEO שמתעורר מ-`weekly-feedback-job`
**אין `issueId`** — הוא כותב לקובץ בלבד, לא מפרסם comment ולא סוגר issue
(כלל מ-[CLAUDE.md](../../CLAUDE.md) "Scheduled Jobs").
### 1.3 לולאת-צמיחת-הקורפוס (החלטה סופית → קורפוס → אחזור)
החלטה סופית נקלטת לקורפוס-הסגנון (`ingest_final_version` — ראה [06-export.md](06-export.md)
§ Hermes), ופסיקה/החלטות-ועדה חדשות נקלטות דרך המסלול הקנוני של
[01-ingest.md](01-ingest.md). כך הקורפוס שמזין את האחזור ([03-retrieval.md](03-retrieval.md))
**גדל מהפלט עצמו** — והדיון הבא נשען על תקדים עשיר יותר. צמיחה זו כפופה לאותו חוזה-שלמות
([G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש)) כמו כל קליטה.
---
## 2. הלולאה במלואה (הציור)
```
┌──────────────────────────────────────────────────────┐
│ │
┌─────────────▼─────────────┐ ┌────────────────────────┐ │
│ כתיבה (04) │ ───▶ │ QA + שערים אנושיים (05)│ │
│ 12 בלוקים · סגנון דפנה │ │ validate_decision + │ │
│ ← lessons.py CONTENT_ │ │ פידבק-היו"ר │ │
│ CHECKLISTS · SKILL.md │ └───────────┬────────────┘ │
└───────────────────────────┘ │ ייצוא (06) │
▲ ▼ │
│ ┌──────────────────────┐ │
┌────────┴──────────────┐ │ סימון "סופי" (UI) │ │
│ legal-decision- │ │ mark-final │ │
│ lessons.md + SKILL.md │ └───────┬──────────────┘ │
│ (מסמכי-ידע מוסמכים) │ │ │
└────────▲──────────────┘ ┌──────────┴───────────┐ │
│ ▼ ▼ │
│ ✋ אישור-יו"ר ידני ┌───────────────┐ ┌────────────────┐│
└──────────────────────│ Hermes curator │ │ ingest_final → ││
(commit ידני בלבד) │ → הצעות(comment)│ │ קורפוס-סגנון → ┘│
└───────────────┘ │ אחזור (03) │
┌───────────────────────────┐ └────────────────┘
│ פידבק-היו"ר (05) ──┐ │
│ chair_feedback │ │
└────────────────────┼───────┘
weekly-feedback-analysis (job)
│ מעיר CEO
עדכון legal-decision-lessons.md ──┐
└──▶ (חזרה ל-04 / lessons.py)
```
הקשר לכתיבה: הלקחים והצ'קליסטים שב-`CONTENT_CHECKLISTS`
(`mcp-server/src/legal_mcp/services/lessons.py:355`, בורר `get_content_checklist`
`:509-555`) ו-`get_lessons_for_outcome` (`lessons.py:309`) מוזרקים ל-prompt-הכתיבה לפי
סוג-ערר ותוצאה — ראה [04-analysis-writing.md](04-analysis-writing.md) §5. כל סגירה של
לולאה (Hermes או פידבק) שמשנה את `legal-decision-lessons.md` / `SKILL.md` משפיעה ישירות
על הכתיבה הבאה.
---
## 3. Invariants של התחום
### INV-LRN1: עדכון-ידע דורש אישור-יו"ר ידני — אין auto-commit (governance →G10)
**כלל:** מנגנוני-הלמידה (Hermes, ניתוח-פידבק שבועי) **מציעים בלבד**. כל שינוי ב-
[SKILL.md](../../skills/decision/SKILL.md) או ב-[legal-decision-lessons.md](../legal-decision-lessons.md)
מחייב **בחינה ואישור ידניים של היו"ר/חיים** ואז commit ידני — **לעולם לא auto-committed**.
Hermes כותב comment + `decision_lesson`, לא קבצים; ה-CEO השבועי כותב לקובץ אך הצעותיו
מאומתות ידנית לפני קיבוע. זהו פֶּאֶט של [INV-G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
על שכבת-הידע: גם הלמידה כפופה לשיקול-הדעת האנושי.
**מקורות:** NCSC/JTC — *Principles & Practices for AI Use in Courts* (human-in-the-loop;
never replace human judgment) · Council of Europe / CEPEJ (2018, under user control) ·
Federal Judicial Center — *Judicial Writing Manual* (2d ed.) | סטטוס: verified
**אכיפה:** הסוכן read-only על תוכן ו-write רק על comments (`hermes-curator.md:1-3, 125-130`);
תהליך-האישור — הצעת-curator כ-comment ב-Paperclip → חיים בוחן ומאשר ידנית → commit ל-
`SKILL.md` ו-`docs/legal-decision-lessons.md` (מ-[CLAUDE.md](../../CLAUDE.md) "Hermes Curator");
ה-CEO השבועי מתעורר בלי `issueId` וכותב לקובץ בלבד ([CLAUDE.md](../../CLAUDE.md) "Scheduled Jobs").
**הפרה ידועה:**
### INV-LRN2: האחריות לאיכות יושבת במקור — quality-at-source (engineering →G4)
**כלל:** האחריות לאיכות-הידע (לקחים, הלכות, metadata של פריטים מואנדקסים) נאכפת **קרוב
ככל האפשר לנקודת-היצירה/הקליטה** — בעת ניסוח-ההחלטה, בעת לכידת-הפידבק, ובעת קליטת-פריט —
**לא** מתוקנת בדיעבד במורד-הזרם (re-OCR, טלאי-קריאה, ניחוש בזמן-חיפוש). פריט-ידע חסר-שלמות
מסומן ומדווח בנקודת-הכניסה, לא מתקבל בשקט.
**מקורות:** Martin Fowler — *Data Mesh* (quality-at-source: domain owns data quality at
the point of creation) · DAMA-UK *Six Primary Dimensions for Data Quality* (2013,
completeness) · ISO 8000 (Data quality) | סטטוס: verified
**אכיפה:** חוזה-שלמות בקליטה ([01-ingest.md](01-ingest.md) §2, [02-data-model.md](02-data-model.md))
+ "אין בליעה שקטה" (חוקה §6); לכידת-פידבק מובנית בנקודת-ההערה (`record_chair_feedback`,
`tools/workflow.py:348`); לקחים נשמרים מבני ולא ad-hoc (`lessons.py`,
[legal-decision-lessons.md](../legal-decision-lessons.md)).
**הפרה ידועה:** ראה [INV-G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש)
(ערן סופר 8046/24 אונדקס עם `headnote`/`summary`/`tags` ריקים — שלמות לא נאכפה במקור) →
ממצא ל-[audit](../audit-report.md).
### INV-LRN3: ידע נלכד באופן מובנה — לא ad-hoc (engineering →G9)
**כלל:** פידבק ולקחים נלכדים ב**מבנה דטרמיניסטי ועקיב** — `chair_feedback` (עם `category`
ו-`block_id`), `decision_lessons` (עם `category`/`source`), ו-`CONTENT_CHECKLISTS` בקוד —
כך שהלמידה **עמידה וניתנת-לביקורת**, לא פזורה בהערות חופשיות. מקור-הלקח (`source:"curator"`
מול פידבק-יו"ר) משומר לעקיבוּת.
**מקורות:** ISO 15489-1:2016 (records reliability/authenticity) · DAMA-UK *Six Primary
Dimensions for Data Quality* (2013) · ISO 8000 (Data quality) | סטטוס: verified
**אכיפה:** טבלת `chair_feedback` + `record_chair_feedback`/`list_chair_feedback`
(`tools/workflow.py:348, 393`); `decision_lessons` עם `source`+`category`
(`hermes-curator.md:79-96`); `CONTENT_CHECKLISTS`/`get_lessons_for_outcome`
(`lessons.py:355, 309`). עקיבוּת-מקור קושרת ל-[X5-audit-provenance.md](X5-audit-provenance.md).
**הפרה ידועה:**
---
## 4. הג'ובים המתוזמנים (תמיכת-תשתית ללולאה)
| Job (`manifest.ts`) | לוח-זמנים | תפקיד בלולאה |
|---------------------|-----------|---------------|
| `weekly-feedback-analysis` | ראשון 19:00 (`:175-179`) | מסכם פידבק-יו"ר → מעיר CEO לעדכון `legal-decision-lessons.md` (`worker.ts:784-837`) |
| `stale-case-reminder` | יומי 08:00 (`:169-172`) | תזכורת על תיקים תקועים 30+ ימים (`worker.ts:710-780`) — היגיינת-תהליך, לא ידע |
| `sync-case-status` | כל 15 דק' (`:162-166`) | מסנכרן סטטוסי-תיקים legal-ai↔Paperclip (`worker.ts:624`) — תשתית, לא ידע |
רק `weekly-feedback-analysis` הוא חלק מלולאת-הלמידה; שני האחרים הם היגיינת-תהליך/סנכרון.
---
## 5. הפניות-אחיות
- [00-constitution.md](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant) —
INV-G10 (שערים אנושיים) + [INV-G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש)
(quality-at-source) + כלל-ההנדסה §6.
- [04-analysis-writing.md](04-analysis-writing.md) — הכתיבה שהלקחים/הצ'קליסטים מזינים (§3, §5).
- [05-qa-review.md](05-qa-review.md) — שער פידבק-היו"ר (§2.3) שמתחיל את לולאת-הפידבק.
- [01-ingest.md](01-ingest.md) — קליטה אחידה (quality-at-source) לצמיחת-הקורפוס.
- [03-retrieval.md](03-retrieval.md) — האחזור שהקורפוס הגדל מזין.
- [06-export.md](06-export.md) — `mark-final` שמפעיל את Hermes + `ingest_final_version`.
- [X5-audit-provenance.md](X5-audit-provenance.md) — עקיבוּת-מקור של לקחים (`source`).
- הסוכן: [.claude/agents/hermes-curator.md](../../.claude/agents/hermes-curator.md).
- מסמכי-הידע המוסמכים: [legal-decision-lessons.md](../legal-decision-lessons.md) ·
[skills/decision/SKILL.md](../../skills/decision/SKILL.md) ·
[corpus-analysis.md](../corpus-analysis.md).

7
docs/spec/README.md Normal file
View File

@@ -0,0 +1,7 @@
# ספ המערכת — עוזר משפטי (Living System Spec)
זהו מקור-האמת הקנוני ל"מהו תקין" במערכת. שער-הכניסה: [00-constitution.md](00-constitution.md).
כל invariant מגובה ב-≥3 מקורות סמכותיים; פריט לא-מאומת מסומן ⚠ UNVERIFIED ומועלה ליו"ר.
מבנה: 00 חוקה · 0107 מחזור-חיים · X1X5 חוצי-שלבים. ראה אינדקס מלא בחוקה.
בסיס-עיצוב: docs/superpowers/specs/2026-05-30-system-spec-design.md

168
docs/spec/X1-identifiers.md Normal file
View File

@@ -0,0 +1,168 @@
# X1 — מודל המזהים הקנוני (Canonical Identifier Model)
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) והוא ה-deep-dive על **מזהי הישויות**
של עוזר משפטי. הוא אוכף את [G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה) (מזהה
קנוני מנורמל בכתיבה) ומעמיק את [INV-DM2](02-data-model.md#inv-dm2-מזהה-קנוני-יחיד-לכל-ישות)
מ-[02-data-model.md](02-data-model.md). שני הקבצים חייבים להישאר עקביים: 02 מגדיר *אילו*
שדות מזהים כל ישות; X1 מגדיר את *הצורה הקנונית* של המזהה ו*איך* הוא מנורמל.
> **TARGET, לא תיאור-מצב.** המודל כאן הוא היעד הקנוני. כל מקום שבו הקוד בפועל
> (`mcp-server/src/legal_mcp/services/db.py`) סוטה ממנו — מתועד כ-**audit-finding** (§4),
> תסמין, לא התנהגות תקינה. כל טענה על הקוד הקיים מצוטטת `file:line` ואינה מונחת כתקינה.
---
## 1. הצורה הקנונית של `case_number`
מזהה-התיק (`case_number`) הוא **מספר-תיק מנורמל** — לא מחרוזת-ציטוט, לא תווית-תצוגה. הצורה
הקנונית מוגדרת ע"י **נרמול בנקודת-הכתיבה** (write-time canonicalization), כך שכל הרשומות
חולקות פורמט יחיד והשוואה היא תמיד שוויון-מחרוזת מול הצורה הקנונית.
**הנרמול הקנוני (TARGET — מופעל בכתיבה):**
| צעד | פעולה | דוגמה |
|------|--------|--------|
| trim | הסרת רווחים מקיפים | `" 8137/24 "``"8137/24"` |
| prefix-strip | הסרת קידומת-הליך לפני הספרה הראשונה ("ערר", "בל\"מ", "עע\"מ") | `"ערר 8137/24"``"8137/24"` |
| separator | איחוד מפריד `/``-` | `"8137/24"``"8137-24"` |
> **הצורה הקנונית = המספר הרשמי שהוקצה ע"י הוועדה, נשמר ככתבו** — לרבות מקטע-החודש **כשהוקצה**
> (למשל `8126-03-25`). מספרי-מורשת מסוימים הוקצו **ללא** חודש (למשל `8126-25`); המערכת **אסור**
> שתמציא או תוסיף (pad) מקטע-חודש שמעולם לא הוקצה. הנרמול-בכתיבה הוא **פורמט-בלבד ודטרמיניסטי**
> (trim · `/`→`-` · prefix-strip) — הוא **אינו מוסיף ואינו מסיר** מקטע-חודש. הפורמט המועדף
> מכאן-ואילך כולל את החודש.
> סוג-ההליך (`proceeding_type ∈ {ערר, בל"מ}`) הוא **חלק מהמפתח הקנוני** — לא חלק ממחרוזת
> ה-`case_number`. הקידומת "ערר"/"בל\"מ" מהכותרת נשללת מהמספר ונשמרת בעמודה ייעודית
> (`cases.proceeding_type`, `db.py:912`). כך "ערר 8137/24" ו-"בל\"מ 8137/24" הם שתי
> רשומות מובחנות בעלות אותו `case_number=8137-24` ו-`proceeding_type` שונה.
**נרמול-בכתיבה הוא המנגנון הראשי; התאמה-סלחנית-בקריאה היא נוחות משנית בלבד.** כלל-ההנדסה
"נרמול לא תיקון-תסמין" (חוקה §6) קובע: מתקנים את הנתון במקור, לא מטליאים בקריאה. אם רשומה
נשמרה בצורה לא-קנונית — היעד הוא לנרמל אותה במיגרציה/בכתיבה, **לא** לסמוך על מנוע-קריאה
שיגשר על הפער. ההתאמה-הסלחנית (§3) קיימת כדי לבלוע *קלט-משתמש* רב-צורני (כותרת Paperclip),
לא כדי לתרץ נתון-מאוחסן לא-קנוני.
---
## 2. שני מרחבי-מזהים: `cases` מול `case_law`
`case_number` מופיע בשתי טבלאות נפרדות עם **שני מרחבי-מזהים שונים** ו**ללא FK חוצה-טבלאות**
ביניהן. בלבול בין השניים הוא כשל-שורש: תיק חי אינו תקדים, ולהפך.
| ממד | `cases` (תיק חי) | `case_law` (קורפוס פסיקה) |
|------|------------------|---------------------------|
| תפקיד | הערר שבטיפול כעת (1xxx/8xxx/9xxx) | תקדים — פסיקה חיצונית **וגם** החלטות-ועדה |
| מפתח קנוני | `(case_number, proceeding_type)` | `(case_number, source_kind, proceeding_type)` — ראה להלן |
| אילוץ-ייחודיות | `uq_cases_number_proc` על `(case_number, proceeding_type)` (`db.py:923-924`) | שני partial unique לפי `source_kind` (`db.py:904-909`) |
| מורשת (הוסרה) | `case_number TEXT UNIQUE NOT NULL` (`db.py:76`), הוסר V15 (`db.py:921-922`) | `case_number TEXT UNIQUE NOT NULL` (`db.py:368`), הוסר V15 (`db.py:902-903`) |
| FK חוצה | **אין**`cases` ו-`case_law` הם מרחבים נפרדים | **אין** |
**`case_law` — מזהה מודע-source_kind.** ה-V15 החליפה את `UNIQUE(case_number)` הגלובלי בשני
partial unique indexes (`db.py:904-909`):
- **`internal_committee`** (החלטות-ועדה פנימיות): `UNIQUE(case_number, proceeding_type)`
`uq_case_law_internal_number_proc`, `WHERE source_kind = 'internal_committee'`.
- **חיצוני** (`external_upload` / `cited_only` / `nevo_seed`): `UNIQUE(case_number)`
`uq_case_law_external_number`, `WHERE source_kind <> 'internal_committee'`.
לכן המזהה הקנוני של `case_law` הוא הטריפלט **(`case_number` מנורמל, `source_kind`,
`proceeding_type`)** — עקבי עם [02-data-model §2א](02-data-model.md#2א-case_law--החוזה-הקונקרטי).
**אין הצמדה חוצה-טבלאות.** כשהחלטת-תיק מ-`cases` מצוטטת בהמשך כתקדים, היא נכנסת ל-`case_law`
כרשומה *חדשה* (`source_kind='internal_committee'`) — לא כ-FK ל-`cases`. שני המרחבים נשארים
עצמאיים; הגישור ביניהם הוא דרך הקליטה ([01-ingest.md](01-ingest.md)), לא דרך מפתח-זר.
---
## 3. ציטוט מול מזהה — `citation_formatted` הוא תצוגה, לא מפתח
הציטוט-המלא והמזהה-הקנוני הם **שני שדות נפרדים בכוונה**:
- **מזהה קנוני** = `case_number` מנורמל (`8126-03-25`) — המפתח שמשמש לחיפוש, ל-upsert,
ולאילוצי-ייחודיות.
- **ציטוט מעוצב** = `citation_formatted` (`db.py:1070`, V19) — מחרוזת-תצוגה לפי כללי-הציטוט
האחיד, למשל: `ערר (ועדות ערר - תכנון ובנייה ת"א-יפו) 81002-01-21 **אברהם אגסי נ' הועדה
המקומית** (נבו 25.9.2025)` (`db.py:1067-1068`).
הציטוט הוא **שדה נגזר לתצוגה** — מכיל את המזהה אך גם צדדים, ערכאה, ותאריך-פרסום. הוא **לעולם
אינו המפתח**. אחסון מחרוזת-ציטוט בשדה-המזהה שובר את הנרמול ([G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה)),
מערבב תצוגה עם זהות (פוגע ב-1NF — ערך לא-אטומי בשדה-מפתח), ומונע התאמת-שוויון מול המספר
המנורמל.
---
## 4. Invariants של התחום
### INV-ID1: `case_number` מנורמל בכתיבה — התאמה-סלחנית משנית
**כלל:** `case_number` מנורמל לצורה קנונית יחידה **בנקודת-הכתיבה** בנרמול **פורמט-בלבד
ודטרמיניסטי** (trim · prefix-strip · `/``-`) — הנרמול **אינו ממציא ואינו מוסיף** מקטע-חודש
שלא הוקצה. הצורה הקנונית היא **המספר הרשמי שהוקצה** (עם חודש כשהוקצה, למשל `8126-03-25`),
והשוואה-בקריאה היא שוויון מול הצורה הקנונית. **התאמה-סלחנית-בקריאה היא
נוחות משנית בלבד** — היא בולעת קלט-משתמש רב-צורני, ואינה תחליף לנרמול-בכתיבה ([G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה),
כלל-ההנדסה "נרמול לא תיקון-תסמין", חוקה §6).
**מקורות:** SSOT (Single Source of Truth — normalization principle) · E.F. Codd, First Normal
Form (CACM 13(6), 1970) · Martin Kleppmann, *Designing Data-Intensive Applications* (O'Reilly,
2017) | סטטוס: verified
**אכיפה:** נרמול-בכתיבה בנקודת-הקליטה ([01-ingest.md](01-ingest.md)) + אילוצי-ייחודיות על
המפתח הקנוני (`uq_cases_number_proc`, `db.py:923-924`; partial unique `case_law`, `db.py:904-909`).
**הפרה ידועה:** `_normalize_case_number` (`db.py:1196-1211`) מנרמל **בקריאה בלבד** ("tolerant
lookup", `db.py:1197`), ו-`get_case_by_number` (`db.py:1214-1231`) משווה two-pass (`case_number=$1`
**OR** `replace(btrim(case_number),'/','-')=$2`, `db.py:1223-1224`) — אין מסלול-כתיבה שמקנן את
הערך המאוחסן. בנפרד מכך: כשאותו תיק נקלט גם בצורה ללא-חודש וגם עם-חודש (סחף-הזנה, למשל `8126-25`
מול `8126-03-25` המתייחסים לתיק אחד), הצורה **עם-החודש (הרשמית) היא הקנונית** והרשומה החסרה
מתואמת אליה — זו **בעיית-תיאום (reconciliation)**, לא חולשה בנרמול (הנרמול אינו אמור לפדד חודש).
תיאום רשומות-מורשת מעורבות-צורה הוא **פריט ניקיון-נתונים/מיגרציה חד-פעמי** (ראה
[gap-audit / תת-פרויקט 2](../audit-report.md)), לא אלגוריתם-padding בזמן-ריצה → ממצא
ל-[audit](../audit-report.md).
### INV-ID2: אין ציטוט-מלא כמזהה — הציטוט שדה-תצוגה נגזר
**כלל:** אף ישות **אינה** משתמשת במחרוזת-ציטוט-מלאה כמזהה. שדה-המזהה מכיל מספר-תיק מנורמל
בלבד; הציטוט-המלא חי בשדה ייעודי נפרד (`citation_formatted`, `db.py:1070`) ככלי-תצוגה נגזר
([G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה), [INV-DM2](02-data-model.md#inv-dm2-מזהה-קנוני-יחיד-לכל-ישות)).
**מקורות:** SSOT (Single Source of Truth — normalization principle) · E.F. Codd, First Normal
Form (CACM 13(6), 1970) · Martin Kleppmann, *Designing Data-Intensive Applications* (O'Reilly,
2017) | סטטוס: verified
**אכיפה:** הפרדת-שדות ב-schema — מזהה ב-`case_number` (אילוצי-ייחודיות, `db.py:904-909,923-924`),
ציטוט ב-`citation_formatted` בלבד (`db.py:1070`); נרמול-בכתיבה שדוחה מחרוזת-ציטוט בשדה-המזהה.
**הפרה ידועה:** החלטות "סופר" נקלטו עם **ציטוט-מלא מאוחסן כ-`case_number`** (שדה-המזהה מכיל
את מחרוזת-הציטוט במקום מספר-תיק מנורמל) — חיפוש מול המספר המנורמל נכשל, והפער מתגלגל ל-INV-ID1
(`_normalize_case_number` רק מטליא בקריאה) → ממצא ל-[audit](../audit-report.md).
---
## 5. מצב קיים מול יעד — audit-findings
ההבדלים בין הקוד בפועל ל-TARGET. **אלו תסמינים, לא התנהגויות תקינות.** כל פריט אומת מול `db.py`.
- **נרמול בצד-הקריאה בלבד.** `_normalize_case_number` (`db.py:1196-1211`) מתואר במפורש כ-
"tolerant lookup" (`db.py:1197`) — מסיר קידומת לפני הספרה הראשונה, trim, ו-`/``-` — אך
**אינו מנרמל את הערך המאוחסן**. `get_case_by_number` (`db.py:1214-1231`) בונה סביבו two-pass
(exact `OR` normalized, `db.py:1223-1224`). **תסמין:** הנרמול חי כתיקון-תסמין בקריאה ולא
כקנוניזציה-בכתיבה, בניגוד ל-[G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה) וכלל-ההנדסה
§6. **יעד:** מסלול-כתיבה שמנרמל את `case_number` (פורמט-בלבד: trim/prefix-strip/`/``-`,
**ללא המצאת חודש**) בנקודת-הקליטה; הקריאה הופכת להשוואת-שוויון פשוטה.
- **רשומות-מורשת מעורבות-צורה (בעיית-תיאום, לא padding).** כשאותו תיק נקלט גם כ-`8126-25`
וגם כ-`8126-03-25` (סחף-הזנה), ה-two-pass אינו מזהה אותם כתיק אחד. **יעד:** תיאום חד-פעמי
של הרשומות לצורה הרשמית עם-החודש (הקנונית) במסגרת ניקיון-נתונים/מיגרציה
([gap-audit / תת-פרויקט 2](../audit-report.md)) — **לא** אלגוריתם-padding בזמן-ריצה שממציא חודש.
- **ציטוט-מלא כ-`case_number` (מורשת).** השדה המקורי `case_number TEXT UNIQUE NOT NULL`
(`cases` `db.py:76`, `case_law` `db.py:368`) לא אכף צורה — מה שאפשר אחסון מחרוזת-ציטוט בשדה
זה (החלטות "סופר"). הוחלף ב-partial unique מודע-`source_kind` ב-V15 (`db.py:902-909`), אך
**ללא ולידציית-צורה בכתיבה**. **יעד:** ולידציית-כתיבה שדוחה ערך שאינו מספר-תיק מנורמל ומפנה
ציטוט ל-`citation_formatted`.
- **שני מרחבי-מזהים, סיכון-בלבול בקוד-קריאה.** `get_case_by_number` (`db.py:1214`) פונה
ל-`cases` בלבד; `get_case_law_by_citation` (`db.py:2503`) פונה ל-`case_law` בלבד — נכון, אך
שמות-הפונקציות אינם מבדילים את מרחב-המזהים בבירור. **יעד:** תיעוד מפורש (קובץ זה) + עקביות
שמות שמשקפת `cases` מול `case_law` כשני מרחבים נפרדים ללא FK.
---
## 6. הפניות-אחיות
- [00-constitution.md](00-constitution.md) — [G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה)
(מזהה קנוני מנורמל בכתיבה) + כלל-ההנדסה "נרמול לא תיקון-תסמין" (§6).
- [02-data-model.md](02-data-model.md) — [INV-DM2](02-data-model.md#inv-dm2-מזהה-קנוני-יחיד-לכל-ישות)
(מזהה קנוני יחיד) + החוזה הקונקרטי של `case_law`; X1 הוא ה-deep-dive על אותו מזהה.
- [01-ingest.md](01-ingest.md) — נקודת-הכתיבה שבה הנרמול-בכתיבה צריך להיאכף.
- [X5-audit-provenance.md](X5-audit-provenance.md) — עקיבוּת-מקור (הציטוט כשדה-תצוגה נגזר).

View File

@@ -0,0 +1,157 @@
# X2 — מודל רב-החברתי וכללי ה-Sync (Multi-Company & Sync)
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) והוא ה-deep-dive על **המבנה הרב-חברתי**
של עוזר משפטי — שתי החברות (CMP/CMPA), 14 הסוכנים, ואיך שינוי-הגדרות מפושט מ-Master ל-Mirror.
הוא אוכף את [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) (מקור-אמת
יחיד — אין מסלולים מקבילים מתפצלים) בהקשר של תצורת-סוכנים: שתי החברות הן שתי העתקות של אותה
מערכת, ואסור להן להתפצל (drift).
> **invariant פרויקטלי-תפעולי.** ה-invariants כאן הם **עובדות על איך המערכת *הזו* מנוהלת**
> רב-חברתית — לא תאוריה הנדסית כללית ולא תוכן משפטי. אין סמכות חיצונית ל"איך מסנכרנים
> CMP↔CMPA"; לכן הם נושאים שדה `מקור-סמכות` = הראנבוקים והקוד של הפרויקט עצמו ([CLAUDE.md](../../CLAUDE.md),
> [HEARTBEAT.md](../../.claude/agents/HEARTBEAT.md), [scripts/sync_agents_across_companies.py](../../scripts/sync_agents_across_companies.py))
> — **לא** ≥3 מקורות חיצוניים ו**ללא** סטטוס verified/UNVERIFIED. אבל כל invariant **נקשר
> לעיקרון הגלובלי שהוא משרת**: כלל אי-ה-drift הוא מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים).
---
## 1. שתי החברות: Master מול Mirror
Paperclip מחייב `agents.company_id NOT NULL` — אין סוכנים משותפים. כדי לשרת את שני סוגי
העררים, המערכת מורצת כ**שתי חברות** נפרדות, כל אחת עם מערך-סוכנים מלא משלה:
| ממד | CMP — **Master** | CMPA — **Mirror** |
|------|------------------|-------------------|
| תפקיד | מקור-האמת לתצורת-סוכנים | העתקה מסונכרנת מ-Master |
| COMPANY_ID | `42a7acd0-30c5-4cbd-ac97-7424f65df294` | `8639e837-4c9d-47fa-a76b-95788d651896` |
| סוגי תיקים | רישוי ובנייה | היטל השבחה + פיצויים ס'197 |
| טווח-מספרים | **1xxx** | **8xxx, 9xxx** |
| CEO Agent ID | `752cebdd-6748-4a04-aacd-c7ab0294ef33` | `cdbfa8bc-3d61-41a4-a2e7-677ec7d34562` |
(המקור: [HEARTBEAT.md §1](../../.claude/agents/HEARTBEAT.md), שורות 3844; מזהי-החברות מקודדים גם
ב-[sync_agents_across_companies.py:62-63](../../scripts/sync_agents_across_companies.py).)
**14 סוכנים = 7 × 2.** כל חברה מחזיקה את אותם 7 תפקידי-סוכן (CEO, writer, analyst, researcher,
qa, proofreader, exporter — ראה [X4-agents.md](X4-agents.md)). מאחר ש-`company_id` הוא `NOT NULL`,
כל תפקיד מיוצג בשתי **רשומות-סוכן נפרדות** — אחת ל-CMP, אחת ל-CMPA. אין רשומה משותפת.
**Master = CMP, Mirror = CMPA.** התצורה נכתבת ומתוחזקת בחברת ה-Master (CMP, 1xxx), והסנכרון
הוא **חד-כיווני** CMP → CMPA ([sync...py:1-7,361-362](../../scripts/sync_agents_across_companies.py)).
---
## 2. ניתוב לפי חברה — סינון ב-`company_id`
הזרימה התפעולית נאכפת לפי `$PAPERCLIP_COMPANY_ID` של הסוכן הפועל ([HEARTBEAT.md §1](../../.claude/agents/HEARTBEAT.md)):
- `42a7acd0…` → הסוכן מטפל **רק** בתיקי 1xxx; `8639e837…`**רק** בתיקי 8xxx/9xxx (שורות 4344).
- **אסור** ליצור פרויקט/issue/תוכן לתיק מחוץ לטווח-החברה (שורה 45); issue שמכוון לתיק מחוץ
לטווח → סירוב מנומס ב-comment + העֵרת ה-CEO של החברה הנכונה (שורה 46).
- **CEO שונה לכל חברה** — בחירת ה-CEO ל-wakeup נגזרת מ-`$PAPERCLIP_COMPANY_ID`, **לעולם לא**
UUID hardcoded ([HEARTBEAT.md §4ג](../../.claude/agents/HEARTBEAT.md), שורות 143150).
- **גבול-חברה נאכף בצד-Paperclip:** wakeup לחברה אחרת נדחה — `Agent key cannot access another
company` ([HEARTBEAT.md §4ג](../../.claude/agents/HEARTBEAT.md), שורה 157).
---
## 3. כלל ה-Sync — אחרי כל שינוי-הגדרות ב-Master
> **טריגר:** כל שינוי ב-`adapter_config`, `runtime_config`, `budget_monthly_cents`, או skills
> של סוכן ב-Master (UI / SQL / API). מקור: סעיף "Cross-company agent sync" ב-[legal-ai/CLAUDE.md](../../CLAUDE.md)
> וב-[root CLAUDE.md](../../../CLAUDE.md).
הפעולה החובה — קודם בדיקה, אז החלה:
```bash
PAPERCLIP_BOARD_API_KEY=$(…infisical…) \
python ~/legal-ai/scripts/sync_agents_across_companies.py --verify # drift report
PAPERCLIP_BOARD_API_KEY=$(…) \
python ~/legal-ai/scripts/sync_agents_across_companies.py --apply # backup + apply
```
**מה הסקריפט עושה** (מאומת מול הקוד):
- **חד-כיווני CMP → CMPA**, סינכרון של שדות-תצורה מוגדרים: top-level (`budget_monthly_cents`,
`metadata`, `icon`, `title`, `role`), מפתחות `adapter_config` נבחרים (`model`, `effort`,
`timeoutSec`, `maxTurnsPerRun`, נתיבי-instructions, `cwd`…), ו-`runtime_config` כ-full-replace
([sync...py:66-75,124-160](../../scripts/sync_agents_across_companies.py)). שדות פר-חברה
(`id`, `company_id`, `adapter_type`, `agent_api_keys`, `status`, `spent_monthly_cents`,
`permissions`) **אינם** מסונכרנים ([sync...py:24-29](../../scripts/sync_agents_across_companies.py)).
- **מבוסס-API, לא DB ישיר.** ה-PATCH דרך `PATCH /api/agents/{id}` וה-skills דרך
`POST /api/agents/{id}/skills/sync` עם `Authorization: Bearer` ([sync...py:204-237](../../scripts/sync_agents_across_companies.py)).
- **מסנן skills מקומיים שלא קיימים ב-Mirror.** `desiredSkills` מושוות כ-subset; skills מקומיים
של CMP (למשל `local/eba6210d5a/legal-decision`) שלא קיימים ב-CMPA נשמטים עם אזהרה
([sync...py:138-154,194-195](../../scripts/sync_agents_across_companies.py)).
- **יוצר revisions.** סנכרון skills עובר דרך endpoint ייעודי שמייצר `skill-sync` revision
([sync...py:277-284](../../scripts/sync_agents_across_companies.py)).
- **idempotent + אל-כשל.** `--verify`/`--dry-run` כברירת-מחדל, גיבוי `pg_dump` לפני `--apply`,
pre-flight על קבצי-instructions, ו-re-verify אוטומטי אחרי ההחלה ([sync...py:9,163-173,408-465](../../scripts/sync_agents_across_companies.py)).
- **מדלג על סוכן עם `adapter_type` שונה בין החברות.** אם ל-Master ול-Mirror `adapter_type`
שונה → `SKIPPING`, ללא סנכרון ([sync...py:387-389](../../scripts/sync_agents_across_companies.py)).
זו המלכודת ב-INV-MC1 (להלן).
---
## 4. Invariants של התחום (פרויקטלי-תפעולי)
### INV-MC1: תצורת-סוכן ב-Master מפושטת ל-Mirror — אין drift בין החברות
**כלל:** כל שינוי ב-`adapter_config` / `runtime_config` / `budget_monthly_cents` / skills של
סוכן בחברת ה-Master (CMP) **חייב** להיות מפושט ל-Mirror (CMPA) דרך סקריפט ה-Sync המבוסס-API
(`--verify` ואז `--apply`). שתי החברות **לא מתפצלות** — הן שתי העתקות מסונכרנות של אותה תצורה
(מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) — מקור-אמת
יחיד, אין מסלולים מקבילים מתפצלים; וכלל-ההנדסה "סימטריה", [חוקה §6](00-constitution.md#6-כללי-הנדסה-מונעים-הישנות)).
**מקור-סמכות:** סעיף "Cross-company agent sync" ב-[legal-ai/CLAUDE.md](../../CLAUDE.md) +
ב-[root CLAUDE.md](../../../CLAUDE.md) +
[scripts/sync_agents_across_companies.py](../../scripts/sync_agents_across_companies.py) +
[HEARTBEAT.md §1, §4ג](../../.claude/agents/HEARTBEAT.md). (invariant פרויקטלי-תפעולי — ללא
פרוטוקול ≥3-המקורות; משרת את העיקרון הגלובלי G2.)
**אכיפה:** סקריפט ה-Sync (idempotent, מבוסס-API, גיבוי+re-verify) — מורץ **ידנית** אחרי כל
שינוי-תצורה ב-Master. **אין אכיפה אוטומטית** (ראה §5).
**הפרה ידועה:** הסקריפט **מדלג** על סוכן ש-`adapter_type` שונה בין CMP ל-CMPA
([sync...py:387-389](../../scripts/sync_agents_across_companies.py)). כשמעבירים סוכן ל-`deepseek_local`
ב-Master, ה-Mirror נשאר על ה-adapter הישן והסנכרון מדלג עליו — **חובה להחיל את שינוי ה-`adapter_type`
ידנית בשתי החברות לפני הרצת ה-Sync** ([CLAUDE.md "External adapters — deepseek_local"](../../CLAUDE.md)),
אחרת נוצר drift שקט באותו סוכן.
### INV-MC2: אין סוכן משותף — רשומה נפרדת לכל חברה
**כלל:** סוכן **לעולם אינו רשומה משותפת** בין החברות. כל אחד מ-7 התפקידים מיוצג בשתי
רשומות-סוכן נפרדות (CMP + CMPA), שכן Paperclip מחייב `agents.company_id NOT NULL`. הסנכרון
מעתיק *ערכי-תצורה* בין שתי רשומות — לא ממזג אותן לרשומה אחת (תואם [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים):
מקור-אמת יחיד לתצורה, גם כשהיא משוכפלת על פני רשומות).
**מקור-סמכות:** סעיף "Cross-company agent sync" ב-[legal-ai/CLAUDE.md](../../CLAUDE.md) (14 agents = 7 × 2;
`agents.company_id NOT NULL`) + [sync...py:4-7,83-103](../../scripts/sync_agents_across_companies.py)
(שולף מערכי-סוכן נפרדים לכל `company_id`) + [HEARTBEAT.md §1](../../.claude/agents/HEARTBEAT.md).
(invariant פרויקטלי-תפעולי.)
**אכיפה:** אילוץ `company_id NOT NULL` בצד-Paperclip; הסקריפט מתאים סוכנים בין החברות לפי
`name` ולעולם לא יוצר רשומה משותפת ([sync...py:372,383-385](../../scripts/sync_agents_across_companies.py)
— "we never auto-create").
**הפרה ידועה:** —
---
## 5. מצב קיים מול יעד — פער אכיפה
ה-Sync הוא **ידני ולא-נאכף**. הסקריפט עצמו בנוי "אל-כשל" (dry-run כברירת-מחדל, גיבוי,
re-verify), אך **שום מנגנון לא מכריח** הרצה אחרי שינוי-תצורה ב-Master:
- **drift אם שוכחים.** שינוי `adapter_config`/`runtime_config`/budget/skills ב-CMP בלי הרצת
`--apply` משאיר את CMPA מאחור — שתי החברות מתפצלות בשקט, בניגוד ל-INV-MC1. **יעד:** טריגר/
בדיקת-בריאות תקופתית שמריצה `--verify` ומדווחת drift (היום ההרצה תלויה בזיכרון המפעיל).
- **מלכודת `adapter_type`-skip.** סוכן עם `adapter_type` שונה בין החברות נשמט מהסנכרון
([sync...py:387-389](../../scripts/sync_agents_across_companies.py)) — ה-`--verify` ידווח
`SKIPPING`, אך אם המפעיל לא יחיל את שינוי ה-adapter ידנית בשתי החברות, הסוכן יישאר drifted.
**יעד:** אזהרת-SKIPPING שמתבלטת ב-report + צ'קליסט-ידני (כבר מתועד ב-[CLAUDE.md](../../CLAUDE.md)).
---
## 6. הפניות-אחיות
- [00-constitution.md](00-constitution.md) — [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
(מקור-אמת יחיד, אין מסלולים מקבילים מתפצלים) + כלל-ההנדסה "סימטריה" ([§6](00-constitution.md#6-כללי-הנדסה-מונעים-הישנות)).
- [X4-agents.md](X4-agents.md) — מפת 7 תפקידי-הסוכן שמשוכפלים על פני שתי החברות.
- [X3-integration-deploy.md](X3-integration-deploy.md) — Paperclip (wakeup, ניתוב comments) ו-deploy;
ה-wakeup-per-company משלים את הניתוב כאן.
- [scripts/sync_agents_across_companies.py](../../scripts/sync_agents_across_companies.py) — מימוש ה-Sync.
- [legal-ai/CLAUDE.md](../../CLAUDE.md) + [root CLAUDE.md](../../../CLAUDE.md) — סעיף
"Cross-company agent sync" + "External adapters — deepseek_local" (מלכודת ה-adapter_type).
- [.claude/agents/HEARTBEAT.md](../../.claude/agents/HEARTBEAT.md) — §1 (סינון-חברה) + §4ג (wake CEO לפי חברה).

View File

@@ -0,0 +1,212 @@
# X3 — אינטגרציה ו-Deploy (Integration & Deploy)
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) והוא ה-deep-dive על **שני ממדי-התפעול**
של עוזר משפטי: (א) **האינטגרציה עם Paperclip** — איך המערכת מעירה סוכנים, איך תגובות-משתמש
מנותבות, ואיך שינוי-סטטוס תיק מתפרסם חזרה; (ב) **מודל ה-Deploy** — שני מודלי-הרצה הדו-קיימים
על שרת Nautilus (Coolify-Docker מול pm2-מקומי) ומחזור-השינוי של legal-ai.
> **invariant פרויקטלי-תפעולי.** ה-invariants כאן הם **עובדות על איך המערכת *הזו* משתלבת
> ונפרסת** — לא תאוריה הנדסית כללית ולא תוכן משפטי. אין סמכות חיצונית ל"איך מעירים סוכן
> Paperclip" או "איך פורסים את legal-ai"; לכן הם נושאים שדה `מקור-סמכות` = הראנבוקים והקוד
> של הפרויקט עצמו ([root CLAUDE.md](../../../CLAUDE.md), [legal-ai/CLAUDE.md](../../CLAUDE.md),
> [HEARTBEAT.md](../../.claude/agents/HEARTBEAT.md), זיכרון `reference_paperclip_wakeup`,
> ו-[web/paperclip_api.py](../../web/paperclip_api.py)) — **לא** ≥3 מקורות חיצוניים ו**ללא**
> סטטוס verified/UNVERIFIED. אבל כל invariant **נקשר לעיקרון הגלובלי שהוא משרת**: כלל
> ה-wakeup-דרך-API-בלבד הוא מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
> (מסלול קנוני יחיד; ה-DB-insert המקביל אסור כי הוא מתפצל מהמסלול שיוצר `heartbeat_run`).
---
## 1. אינטגרציית Paperclip
עוזר משפטי משתלב עם Paperclip בשלושה כיוונים: **wakeup** (legal-ai/אוטומציה → סוכן),
**ניתוב comments** (משתמש → CEO → סוכן), ו-**webhook יוצא** (legal-ai → פלאגין).
### 1א. Wakeup — תמיד דרך API, לעולם לא דרך DB
הנתיב הקנוני היחיד להערת סוכן הוא `POST /api/agents/{agent-id}/wakeup` עם `payload` המכיל
`issueId` ([root CLAUDE.md](../../../CLAUDE.md) "Wakeup API"; [legal-ai/CLAUDE.md](../../CLAUDE.md)
"Wakeup API"; [HEARTBEAT.md §4ד, שורות 152158](../../.claude/agents/HEARTBEAT.md)):
```bash
~/legal-ai/scripts/pc.sh POST "/api/agents/$CEO_ID/wakeup" \
'{"source":"automation","triggerDetail":"system","reason":"...",
"payload":{"issueId":"...","mutation":"comment","commentId":"..."}}'
```
- **`POST .../wakeup`, לא `/wake`** — שם-הנתיב מדויק ([legal-ai/CLAUDE.md](../../CLAUDE.md)).
- **חובה `payload.issueId`** — בלעדיו הסוכן מתעורר בלי הקשר (בלי תיק, בלי issue, בלי `cwd`
נכון) ([HEARTBEAT.md שורה 156](../../.claude/agents/HEARTBEAT.md)).
- **אסור `INSERT INTO agent_wakeup_requests` ישיר** — insert ל-DB יוצר רשומת-בקשה בלבד **בלי
`heartbeat_run`**, והסוכן **לא יתעורר לעולם** ([HEARTBEAT.md שורה 158](../../.claude/agents/HEARTBEAT.md);
זיכרון `reference_paperclip_wakeup`).
זהו בדיוק "מסלול מקביל מתפצל" שאסור לפי [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים).
- **CEO לכל חברה** — מזהה-ה-CEO ל-wakeup נגזר מ-`$PAPERCLIP_COMPANY_ID`, לעולם לא UUID
hardcoded; wakeup לחברה אחרת נדחה (`Agent key cannot access another company`)
([HEARTBEAT.md §4ג](../../.claude/agents/HEARTBEAT.md); ראה [X2-multi-company.md §2](X2-multi-company.md)).
### 1ב. ניתוב comments — דרך ה-CEO
תגובת-משתמש על issue ב-Paperclip **אינה** מנותבת ישירות לסוכן-המטרה. הזרימה
([root CLAUDE.md](../../../CLAUDE.md) "Comment routing"; [legal-ai/CLAUDE.md](../../CLAUDE.md)):
```
user comment → plugin-legal-ai → ctx.agents.invoke() מעיר CEO
→ CEO קורא comment, מחליט ניתוב, יוצר issue לסוכן המתאים
```
- ה-CEO הוא נקודת-הניתוב היחידה — סוכן-משנה לא מקבל עבודה ישירות מ-comment.
- כל סוכן **חייב** לקרוא comments אחרונים לפני שהוא מתחיל עבודה ([HEARTBEAT שלבים 2b2c](../../.claude/agents/HEARTBEAT.md)).
### 1ג. Webhook יוצא — עדכון סטטוס תיק לפלאגין
כשסטטוס תיק משתנה דרך `PUT /api/cases/{case_number}`, הבקאנד שולח webhook אסינכרוני
לפלאגין כ-BackgroundTask, fire-and-forget:
```
PUT /api/cases/{n} → [BackgroundTask] emit_case_status_webhook()
→ POST /api/plugins/marcusgroup.legal-ai/webhooks/case-status
→ plugin-legal-ai/onWebhook() → comment בעברית + CEO wakeup (כש-qa_failed)
```
מאומת מול הקוד:
- ה-call-site: [web/app.py:2045-2061](../../web/app.py) — ה-webhook מתוזמן רק כש-`old_status
!= new_status`, ו-`company_id` נגזר מ-prefix מספר-התיק (`1`→licensing, `8/9`→betterment).
- המימוש: [web/paperclip_api.py:87-117](../../web/paperclip_api.py) — `emit_case_status_webhook`
קורא ל-`pc_request("POST", "/api/plugins/.../webhooks/case-status", ...)` עם `timeout=5.0`,
בלוק `try/except` שמתעד `logger.warning` ולעולם לא raise (לא חוסם את הקורא).
- אותו דפוס משרת אירועים נוספים: `emit_missing_precedent_webhook`
([paperclip_api.py:120-165](../../web/paperclip_api.py)) ו-`emit_export_complete_webhook`
([paperclip_api.py:168+](../../web/paperclip_api.py)).
### 1ד. כל קריאת-API דרך helper — לא curl/httpx ישיר
קריאות ל-Paperclip עוברות תמיד דרך helper, לא דרך לקוח גולמי:
- **bash (סוכנים):** `~/legal-ai/scripts/pc.sh <METHOD> <PATH> [BODY]` — מוסיף אוטומטית
`Authorization: Bearer`, `X-Paperclip-Run-Id`, `Content-Type`, ו-base URL
([HEARTBEAT.md §0, שורות 1532](../../.claude/agents/HEARTBEAT.md); [scripts/pc.sh:8-9,39-40](../../scripts/pc.sh)).
- **Python (FastAPI):** `from web.paperclip_api import pc_request` — בונה headers דרך
`_build_headers` ([paperclip_api.py:47-84](../../web/paperclip_api.py)), משתמש ב-board API key.
- **למה:** ה-skill הרשמי דורש `X-Paperclip-Run-Id` בכל קריאה משנה issue (audit trail);
ה-helper מבטיח עקביות + תאימות ל-board API keys long-lived שלא נושאות JWT claims
([legal-ai/CLAUDE.md](../../CLAUDE.md) "קריאות API — תמיד דרך helper").
---
## 2. מודל ה-Deploy — שני מודלים דו-קיימים
על שרת Nautilus דרים **שני מודלי-הרצה**. ערבוב ביניהם הוא הטעות הנפוצה ביותר
([root CLAUDE.md](../../../CLAUDE.md) "Deploy architecture"; [legal-ai/CLAUDE.md](../../CLAUDE.md)
"ארכיטקטורת Deploy").
| ממד | legal-ai (web + web-ui) | Paperclip + legal-chat-service |
|------|--------------------------|--------------------------------|
| מודל | **Coolify-managed (Docker)** | **PM2-managed (Node/Python מקומי)** |
| מחזור-שינוי | commit → push → Gitea Actions build → Coolify redeploy (~24 דק') | עריכה → `pm2 restart` |
| Coolify UUID | `gyjo0mtw2c42ej3xxvbz8zio` | — |
| build_pack | **`dockerimage`** (לא `dockerfile`) | — |
| פורטים | Next.js `:3000` (חשוף) + FastAPI `:8000` (פנימי) | Paperclip `localhost:3100`; legal-chat-service `127.0.0.1:8770` (loopback) |
| הרצה מקומית | **אין** — אין venv של Python על ה-host; אסור `uvicorn`/`next dev` לפרוד | יש; מתחזק דרך pm2 |
### 2א. מחזור-השינוי של legal-ai (Coolify dockerimage)
שינוי קוד ב-`web/` או `web-ui/` **לא נכנס לתוקף** עד שמריצים את כל הצעדים, בסדר:
1. `git commit` + `git push origin main` ל-Gitea.
2. Gitea Actions בונה image ודוחף ל-registry (`gitea.nautilus.marcusgroup.org/...`).
3. ה-workflow מפעיל Coolify redeploy דרך API (UUID `gyjo0mtw2c42ej3xxvbz8zio`).
4. ~24 דקות end-to-end. בדיקה: `curl -s https://legal-ai.nautilus.marcusgroup.org/api/health`.
- **אסור** לנסות `uvicorn`/`next dev` לפרוד — הקונטיינר מספק את שני התהליכים; אין סביבת
Python על ה-host ([root CLAUDE.md](../../../CLAUDE.md); [legal-ai/CLAUDE.md](../../CLAUDE.md)).
- **endpoint חדש ≠ זמין ל-UI.** הוספת endpoint ב-`web/app.py` היא תנאי הכרחי אך לא מספיק
לצריכה מה-frontend — חובה `npm run api:types` בתוך `web-ui/` כדי לחדש את ה-OpenAPI types
([root CLAUDE.md](../../../CLAUDE.md), שורה 89; [legal-ai/CLAUDE.md](../../CLAUDE.md)).
### 2ב. legal-chat-service ו-host.docker.internal
legal-chat-service (`127.0.0.1:8770`, pm2) הוא גשר host-side שעוטף את `claude` CLI ב-streaming
לטאב הצ'אט ב-`/training`. הקונטיינר מגיע אליו דרך `host.docker.internal:8770` — ולכן ה-Service
Definition של legal-ai ב-Coolify **חייב** לכלול `extra_hosts: host.docker.internal:host-gateway`,
אחרת ה-proxy יקבל `ConnectError` ([root CLAUDE.md](../../../CLAUDE.md); [legal-ai/CLAUDE.md](../../CLAUDE.md)
"legal-chat-service"). הנחת-היסוד של "קריאות LLM רק ממקומי" נשמרת — ראה
זיכרון `feedback_claude_session_local_only`.
---
## 3. Invariants של התחום (פרויקטלי-תפעולי)
### INV-INT1: wakeup דרך API בלבד — DB-insert אסור
**כלל:** הערת סוכן Paperclip **חייבת** לעבור דרך `POST /api/agents/{agent-id}/wakeup` עם
`payload.issueId`. **אסור** `INSERT INTO agent_wakeup_requests` ישיר — insert ל-DB אינו יוצר
`heartbeat_run`, ולכן הסוכן **לא יתעורר לעולם**. זהו המסלול הקנוני היחיד; ה-DB-insert הוא
מסלול-מקביל-מתפצל אסור (מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
— מקור-אמת/מסלול קנוני יחיד; וכלל-ההנדסה "סימטריה", [חוקה §6](00-constitution.md#6-כללי-הנדסה-מונעים-הישנות)).
**מקור-סמכות:** "Wakeup API" ב-[root CLAUDE.md](../../../CLAUDE.md) + ב-[legal-ai/CLAUDE.md](../../CLAUDE.md) +
זיכרון `reference_paperclip_wakeup` +
[HEARTBEAT.md §4ד, שורות 152158](../../.claude/agents/HEARTBEAT.md). (invariant פרויקטלי-תפעולי —
ללא פרוטוקול ≥3-המקורות; משרת את העיקרון הגלובלי G2.)
**אכיפה:** קריאות-wakeup דרך `pc.sh`/`pc_request` בלבד; `payload.issueId` חובה; בדיקה
ש-`heartbeat_run` נוצר. **אין אכיפה סכמתית** שתחסום insert ישיר ל-`agent_wakeup_requests` —
המניעה היא נוהל (ראה §4).
**הפרה ידועה:** insert ישיר ל-`agent_wakeup_requests` (fallback ישן) → רשומה בלי `heartbeat_run`,
הסוכן נשאר רדום (זיכרון `reference_paperclip_wakeup`).
### INV-INT2: שינוי-קוד legal-ai נכנס לתוקף רק דרך commit→push→Coolify deploy
**כלל:** שינוי קוד ב-`web/` או `web-ui/` **לא נכנס לתוקף** עד `git commit` + `git push origin main`
+ build ב-Gitea Actions + Coolify redeploy (build_pack `dockerimage`, UUID `gyjo0mtw2c42ej3xxvbz8zio`).
**אין** הרצת `uvicorn`/`next dev` מקומית לפרוד. endpoint חדש ב-`web/app.py` דורש גם
`npm run api:types` ב-`web-ui/` כדי להיחשף ל-UI.
**מקור-סמכות:** "Deploy architecture" ב-[root CLAUDE.md](../../../CLAUDE.md) (UUID, dockerimage,
no local uvicorn, api:types) + "ארכיטקטורת Deploy" ב-[legal-ai/CLAUDE.md](../../CLAUDE.md) +
זיכרון `reference_deployment`.
(invariant פרויקטלי-תפעולי — ללא פרוטוקול ≥3-המקורות.)
**אכיפה:** pipeline Gitea Actions → Coolify (אוטומטי בדחיפה ל-main); בדיקה ידנית
`curl .../api/health` אחרי deploy. **אין** מסלול-פריסה חלופי.
**הפרה ידועה:** בדיקת שינוי מול הרצה מקומית שלא קיימת — הקוד בפרוד נשאר ישן עד deploy; וכן
drift אפשרי Infisical↔Coolify env (env לא מתעדכן אוטומטית מ-Infisical, ראה
זיכרון `feedback_infisical_coolify_drift`).
### INV-INT3: כל קריאת-Paperclip דרך helper — לא curl/httpx ישיר
**כלל:** קריאות ל-Paperclip API עוברות **תמיד** דרך helper — `pc.sh` (bash/סוכנים) או
`pc_request` (Python/FastAPI) — ולעולם לא `curl`/`httpx` גולמי. ה-helper מזריק `Authorization`,
`X-Paperclip-Run-Id` (audit), ו-`Content-Type` באופן עקבי, ותומך ב-board API keys long-lived
(מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) — מסלול-גישה
קנוני יחיד ל-Paperclip; ושל [G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai) —
audit-trail עקבי).
**מקור-סמכות:** "קריאות API — תמיד דרך helper" ב-[legal-ai/CLAUDE.md](../../CLAUDE.md) +
[HEARTBEAT.md §0, שורות 1532](../../.claude/agents/HEARTBEAT.md) +
[scripts/pc.sh:8-9,39-40](../../scripts/pc.sh) + [web/paperclip_api.py:47-84](../../web/paperclip_api.py).
(invariant פרויקטלי-תפעולי — ללא פרוטוקול ≥3-המקורות.)
**אכיפה:** נוהל + code-review; `pc.sh` ו-`pc_request` הם נקודות-הכניסה היחידות. **אין אכיפה
אוטומטית** שתחסום `httpx.AsyncClient` ישיר ל-Paperclip בקוד חדש.
**הפרה ידועה:** —
---
## 4. מצב קיים מול יעד — פער אכיפה
האינטגרציה נשענת על **נוהל**, לא על מחסום-קוד:
- **wakeup (INV-INT1):** אין constraint סכמתי שחוסם insert ישיר ל-`agent_wakeup_requests`;
המניעה היא ידע-נוהל ([HEARTBEAT](../../.claude/agents/HEARTBEAT.md)). **יעד:** wrapper/בדיקת-בריאות
שמסמן בקשות-wakeup ללא `heartbeat_run` תואם.
- **helper (INV-INT3):** אין linter/בדיקה שתתפוס `httpx`/`curl` ישיר ל-Paperclip בקוד חדש.
**יעד:** כלל-lint שמכריח שימוש ב-`pc_request`/`pc.sh`.
---
## 5. הפניות-אחיות
- [00-constitution.md](00-constitution.md) — [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
(מסלול קנוני יחיד) + [G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai) (audit-trail) +
כלל-ההנדסה "סימטריה" ([§6](00-constitution.md#6-כללי-הנדסה-מונעים-הישנות)).
- [X2-multi-company.md](X2-multi-company.md) — wakeup-per-company + ניתוב לפי `company_id` משלים את §1 כאן.
- [X4-agents.md](X4-agents.md) — מפת הסוכנים שה-CEO מנתב אליהם comments.
- [root CLAUDE.md](../../../CLAUDE.md) + [legal-ai/CLAUDE.md](../../CLAUDE.md) — "Wakeup API",
"Comment routing", "Deploy architecture", "קריאות API — תמיד דרך helper".
- [.claude/agents/HEARTBEAT.md](../../.claude/agents/HEARTBEAT.md) — §0 (pc.sh), §4ג§4ד (wake CEO + payload).
- [web/paperclip_api.py](../../web/paperclip_api.py) — `pc_request`, `emit_case_status_webhook`.
- [scripts/pc.sh](../../scripts/pc.sh) — helper ה-bash.

140
docs/spec/X4-agents.md Normal file
View File

@@ -0,0 +1,140 @@
# X4 — מפת הסוכנים (Agents Map)
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) והוא ה-deep-dive על **מי הם הסוכנים**
של עוזר משפטי, **מה תפקיד כל אחד**, ו**אילו קבצי-ספ כל סוכן חייב לקרוא לפני שהוא פועל**. הוא
מסייע לסוכן לדעת באיזה ספ לקרוא — ומעגן את [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
(המערכת מסייעת; השערים האנושיים הם invariant): כל סוכן קורא את החוקה תחילה ופועל בתחום-אחריותו,
לא מחליף את שיקול-הדעת האנושי.
> **invariant פרויקטלי-תפעולי.** ה-invariants כאן הם **עובדות על איך הסוכנים של המערכת *הזו*
> מאורגנים ומופעלים** — לא תאוריה הנדסית כללית ולא תוכן משפטי. אין סמכות חיצונית ל"מי קורא מה
> לפני שהוא פועל"; לכן הם נושאים שדה `מקור-סמכות` = הראנבוקים וקבצי-הסוכן של הפרויקט עצמו
> ([HEARTBEAT.md](../../.claude/agents/HEARTBEAT.md), קבצי הסוכן תחת [.claude/agents/](../../.claude/agents/),
> ו-[החוקה](00-constitution.md)) — **לא** ≥3 מקורות חיצוניים ו**ללא** סטטוס verified/UNVERIFIED.
> אבל כל invariant **נקשר לעיקרון הגלובלי שהוא משרת**: כלל "קרא-לפני-שתפעל" + תחום-אחריות הם
> מופע של [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant) (סיוע תחת
> שערים אנושיים) ו-[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים).
---
## 1. ההפעלה המשותפת — HEARTBEAT.md
לפני כל עבודה, **כל** סוכן Paperclip עובר את ה-checklist המשותף ב-[HEARTBEAT.md](../../.claude/agents/HEARTBEAT.md):
זיהוי וסינון-חברה (§1), קריאת comments אחרונים (§1.5, 2b2c), קריאת `heartbeat-context` עם
attachments (§1.5ב), וקריאות-API דרך `pc.sh` בלבד (§0). HEARTBEAT גובר על ה-skill הרשמי של
Paperclip בקונפליקט (project-specific מנצח default), אך אינו מחליף את החוקה — הוא מצטרף אליה:
קודם החוקה (00) + ספ-התחום, אז ה-HEARTBEAT התפעולי.
**הקשר רב-חברתי.** ל-Paperclip אילוץ `agents.company_id NOT NULL` — אין סוכן משותף. לכן כל אחד
מ-7 תפקידי הסוכן-הדומייני מיוצג בשתי רשומות (CMP / CMPA), וסוכן מטפל **רק** בתיקי-החברה שלו לפי
`$PAPERCLIP_COMPANY_ID` (1xxx ל-CMP; 8xxx/9xxx ל-CMPA). ראה [X2-multi-company.md](X2-multi-company.md).
---
## 2. מפת הסוכנים הדומייניים (7 תפקידים × 2 חברות)
הסט המדויק (`ls .claude/agents/`): `HEARTBEAT.md`, `hermes-curator.md`, `legal-analyst.md`,
`legal-ceo.md`, `legal-exporter.md`, `legal-proofreader.md`, `legal-qa.md`, `legal-researcher.md`,
`legal-writer.md`. התפקיד נלקח מה-frontmatter של כל קובץ; עמודת "ספ לקרוא" מקשרת תפקיד לקבצי-הספ
שהוא אוכף/צורך.
| סוכן (קובץ) | תפקיד (מה-frontmatter) | ספ-תחום לקרוא לפני פעולה |
|-------------|------------------------|---------------------------|
| [legal-ceo.md](../../.claude/agents/legal-ceo.md) | מנהל תהליך כתיבת החלטות, מתזמר סוכנים, מפקח על התקדמות | **00 + כל הספ** (מתזמר → צריך תמונה מלאה); ניתוב comments → [X3 §1ב](X3-integration-deploy.md) |
| [legal-proofreader.md](../../.claude/agents/legal-proofreader.md) | מגיה — תיקון שגיאות OCR בטקסט עברי לפני ניתוח | [01-ingest.md](01-ingest.md) (קליטה/טקסט-מחולץ) |
| [legal-researcher.md](../../.claude/agents/legal-researcher.md) | חוקר תקדימים — פסיקה, מיפוי תכניות, סיכום פרוטוקולים | [03-retrieval.md](03-retrieval.md) (3 קורפוסים, hybrid/RRF, attribution); קליטת-פסיקה → [01-ingest.md](01-ingest.md) |
| [legal-analyst.md](../../.claude/agents/legal-analyst.md) | מנתח משפטי — חילוץ טענות, ניתוח אסטרטגי, שאלות מחקר | [02-data-model.md](02-data-model.md) + [03-retrieval.md](03-retrieval.md) + [04-analysis-writing.md](04-analysis-writing.md) |
| [legal-writer.md](../../.claude/agents/legal-writer.md) | כותב — כתיבת בלוקי ההחלטה בסגנון דפנה תמיר | [04-analysis-writing.md](04-analysis-writing.md) + [05-qa-review.md](05-qa-review.md) (כותב מול שערי-QA) |
| [legal-qa.md](../../.claude/agents/legal-qa.md) | בודק איכות — שלמות, ניטרליות, כיסוי טענות, משקלות לפני ייצוא | [05-qa-review.md](05-qa-review.md) (שערי QA + שערים אנושיים) |
| [legal-exporter.md](../../.claude/agents/legal-exporter.md) | מייצא — בדיקה סופית, ייצוא DOCX, שמירה מגורסת | [06-export.md](06-export.md) (ייצוא DOCX לפי תבנית דפנה) |
| [hermes-curator.md](../../.claude/agents/hermes-curator.md) | Knowledge Curator (Hermes) — מנתח החלטות סופיות post-export, מציע עדכוני skills/lessons; read-only על תוכן, write רק על comments | [07-learning.md](07-learning.md) (Hermes · לקחים · לולאת פידבק) |
**הערות על הסט:**
- **CEO = נקודת-הניתוב היחידה.** תגובת-משתמש על issue מעירה את ה-CEO; הוא מחליט ניתוב ויוצר
issue לסוכן-המשנה — סוכן-משנה לא מקבל עבודה ישירות מ-comment ([X3 §1ב](X3-integration-deploy.md)).
- **Hermes — חיבור ישיר, לא דרך CEO.** מופעל מ"סמן כסופי" ב-UI (`mark-final``pc_wake_curator_for_final()`),
לא מ-CEO; ופועל על מודל `deepseek_local` (לא Claude Code) — ראה [X2 INV-MC1](X2-multi-company.md#inv-mc1-תצורת-סוכן-ב-master-מפושטת-ל-mirror--אין-drift-בין-החברות)
למלכודת ה-`adapter_type`-skip בסנכרון. הצעות ה-curator עוברות **אישור-יו"ר ידני** לפני commit
ל-`SKILL.md`/`lessons.md` — מופע של [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant).
- **company_id פר-סוכן.** כל שורה בטבלה מיוצגת פעמיים (CMP + CMPA); ה-CEO לכל חברה שונה
([X2 §1](X2-multi-company.md)). הסוכן פועל רק בטווח-החברה שלו ([X2 §2](X2-multi-company.md)).
---
## 3. סוכני-התהליך (תת-פרויקט 5) — סעיף שמור (RESERVED)
> **סטטוס: מתוכנן, טרם נבנה.** הסעיף הזה הוא **מקום שמור מכוון** עבור סוכני-התהליך שיוגדרו
> ב**תת-פרויקט 5** — הם **אינם קיימים כיום** ואין לטעות בהם כמופעלים. הם מתועדים כאן כדי
> שהמפה תהיה שלמה ושכיוון-העבודה יהיה ברור, לא כ-TODO פתוח.
בניגוד לסוכנים הדומייניים (סעיף 2) שמטפלים בתיקי-עררים, **סוכני-התהליך** הם סוכנים שיקראו את
ספ-המערכת (קבצי 0007, X1X5) ו"יעשו את שיעורי-הבית" — יפעלו על *המערכת עצמה*, לא על תיק. שלושה
תפקידים מתוכננים:
| סוכן-תהליך (מתוכנן) | תפקיד מיועד |
|----------------------|-------------|
| **add-feature** | הוספת יכולת חדשה — קורא את הספ הרלוונטי, מאתר את ה-invariants שחלים, ומיישם בלי לשבור G1G11 |
| **fix-feature** | תיקון תקלה — מאתר את ה-invariant שהופֵר (מול [audit-report.md](../audit-report.md)) ומתקן במקור, לא בתסמין |
| **spec-guardian** | שמירת עקביות הספ — מאתר drift בין הקוד לספ ובין קבצי-הספ עצמם; סתירה = ממצא ל-audit |
ההגדרה המלאה (frontmatter, tools, instructions, מיפוי תפקיד→ספ, ושערי-האישור) **תיכתב בתת-פרויקט 5**.
עד אז — אין רשומות-סוכן, אין wakeup, ואין הסתמכות עליהם בזרימה.
---
## 4. Invariants של התחום (פרויקטלי-תפעולי)
### INV-AG1: כל סוכן קורא את החוקה תחילה, אז את ספ-התחום הרלוונטי — לפני פעולה
**כלל:** כל סוכן (דומייני או תהליך) **חייב** לקרוא את [00-constitution.md](00-constitution.md)
תחילה, ואז את ספ-התחום הרלוונטי לתפקידו (לפי הטבלה בסעיף 2), **לפני** שהוא פועל. ה-checklist
המשותף ב-HEARTBEAT מתבצע בכל ריצה; קריאת-הספ קודמת לעבודה המהותית. סוכן אינו פועל "מהזיכרון" —
המקור הקנוני להתנהגות הוא החוקה + ספ-התחום (מופע של [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
— המערכת מסייעת תחת שערים אנושיים, והסוכן פועל בגבולות שהחוקה מגדירה).
**מקור-סמכות:** [HEARTBEAT.md](../../.claude/agents/HEARTBEAT.md) (checklist הפעלה משותף) +
קבצי-הסוכן תחת [.claude/agents/](../../.claude/agents/) (frontmatter + instructions) +
[00-constitution.md §7](00-constitution.md#7-אינדקס-הספ) (אינדקס הספ — איזה קובץ אוכף איזה invariant).
(invariant פרויקטלי-תפעולי — ללא פרוטוקול ≥3-המקורות; משרת את העיקרון הגלובלי G10.)
**אכיפה:** נוהל — ה-checklist ב-HEARTBEAT + הפניות-הספ בקבצי-הסוכן. **אין אכיפה אוטומטית**
שתכריח קריאת-ספ לפני פעולה (ראה §5 — זה היעד).
**הפרה ידועה:**
### INV-AG2: סוכן דומייני פועל רק בתחום-החברה שלו
**כלל:** סוכן דומייני מטפל **רק** בתיקי-החברה שלו לפי `$PAPERCLIP_COMPANY_ID` (CMP→1xxx;
CMPA→8xxx/9xxx). אסור ליצור פרויקט/issue/תוכן לתיק מחוץ לטווח; issue מחוץ-לטווח → סירוב מנומס
ב-comment + העֵרת ה-CEO של החברה הנכונה (מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
— הפרדה נאכפת לפי `company_id`, אין מסלולים חוצי-חברה מתפצלים; ראה [X2 §2](X2-multi-company.md)).
**מקור-סמכות:** [HEARTBEAT.md §1](../../.claude/agents/HEARTBEAT.md) (סינון-חברה — כלל-ברזל) +
קבצי-הסוכן (סעיף "סינון תיקים לפי חברה") + [X2-multi-company.md §2](X2-multi-company.md).
(invariant פרויקטלי-תפעולי — ללא פרוטוקול ≥3-המקורות; משרת את העיקרון הגלובלי G2.)
**אכיפה:** סינון-חברה ב-HEARTBEAT + גבול-חברה נאכף בצד-Paperclip (`Agent key cannot access
another company`, [X2 §2](X2-multi-company.md)).
**הפרה ידועה:**
---
## 5. מצב קיים מול יעד — חיווט הספ לסוכנים
ספ-המערכת (קבצי 0007, X1X5) הוא **חדש** — קבצי-הסוכן וה-HEARTBEAT עדיין **אינם מפנים אליו**
במפורש; הם מפנים ל-CLAUDE.md, למסמכי-`docs/` הישנים, ול-skills. זהו פער אמיתי:
- **קיים:** HEARTBEAT אוכף checklist הפעלה (סינון-חברה, comments, pc.sh) אך **לא** מחייב קריאת
`00-constitution.md` או ספ-התחום.
- **יעד:** לחווט את HEARTBEAT וקבצי-הסוכן כך שיחייבו במפורש את INV-AG1 — קריאת החוקה + ספ-התחום
הרלוונטי (לפי הטבלה בסעיף 2) לפני עבודה מהותית. זהו תנאי-מוקדם לסוכני-התהליך (סעיף 3), שכל
עבודתם היא "לקרוא את הספ ולעשות שיעורי-בית".
---
## 6. הפניות-אחיות
- [00-constitution.md](00-constitution.md) — [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
(שערים אנושיים) + [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
(מקור-אמת/הפרדה) + [§7 אינדקס הספ](00-constitution.md#7-אינדקס-הספ).
- [X2-multi-company.md](X2-multi-company.md) — 14 סוכנים = 7 × 2, `company_id` פר-סוכן, כללי sync.
- [X3-integration-deploy.md](X3-integration-deploy.md) — wakeup, ניתוב comments דרך CEO, webhooks.
- ספ-התחום שכל סוכן צורך: [01-ingest.md](01-ingest.md), [02-data-model.md](02-data-model.md),
[03-retrieval.md](03-retrieval.md), [04-analysis-writing.md](04-analysis-writing.md),
[05-qa-review.md](05-qa-review.md), [06-export.md](06-export.md), [07-learning.md](07-learning.md).
- [.claude/agents/HEARTBEAT.md](../../.claude/agents/HEARTBEAT.md) + קבצי-הסוכן תחת
[.claude/agents/](../../.claude/agents/) — frontmatter (תפקיד) + instructions (סינון-חברה, זרימה).

View File

@@ -0,0 +1,163 @@
# X5 — Audit-Trail ועקיבוּת-מקור (Provenance)
קובץ-תחום זה כפוף ל-[חוקת המערכת](00-constitution.md) ומגדיר את **חוזה העקיבוּת וה-audit-trail (TARGET)**
של עוזר משפטי: (א) כל **תוצר מסיוע-AI** (בלוק-טיוטה, תוצאת-אחזור, הצעת-curator) מתעד **מה הפיק אותו**
(מקורות/נתונים/מודל); (ב) כל **סמכות מצוטטת** בהחלטה **פתירה חזרה לקורפוס**; (ג) **שלמות-הרשומה
לאורך זמן** — החלטה/רשומה שלמה ובלתי-משתנה אלא דרך **שינויים עקיבים ומיוחסים** (היסטוריית git +
Track Changes). הקובץ אוכף את
[INV-G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai) (עקיבוּת + audit-trail) ואת
[INV-G5](00-constitution.md#inv-g5-metadata-מלא--הפרדת-קורפוס-נאכפת-בכל-query) (attribution באחזור).
> **TARGET, לא תיאור-מצב.** היכן שהקוד בפועל סוטה מהיעד — מתועד כ-**audit-finding** ([§5](#5-current-vs-target--ממצאי-audit)),
> תסמין לתיקון, לא התנהגות תקינה. כל טענה על הקוד מצוטטת `file:line`.
כשל-השורש שהקובץ מייבש: **קיימים רכיבי-עקיבוּת נקודתיים** (commit git לפלטים · `model_used` לכל בלוק ·
`decision_paragraphs.citations` · גרף-ציטוטים · telemetry של חיפושים), אך **אין רשומת-provenance
מאוחדת מקצה-לקצה** שמקשרת בלוק-החלטה → קטעי-הקורפוס/הגנרציות שהפיקו אותו, ו**טבלת ה-`audit_log`
אינה מתועדת בפועל** לרוב פעולות ה-AI.
---
## 1. שלוש שכבות העקיבוּת (TARGET)
| שכבה | מה צריך להירשם | היכן (קיים / יעד) |
|------|-----------------|---------------------|
| **A — provenance של תוצר-AI** | לכל בלוק-טיוטה/תוצאת-אחזור/הצעת-curator: מודל, סוג-גנרציה, וקטעי-המקור (chunks/precedents) שהוזנו | קיים חלקית: `decision_blocks.model_used/generation_type/temperature` (`db.py:326-328`); **חסר** קישור בלוק→קטעי-מקור |
| **B — עקיבוּת ציטוט→קורפוס** | כל סמכות מצוטטת פתירה ל-`case_law_id`/`document_id` + locator | קיים: `decision_paragraphs.citations` JSONB `[{case_law_id,text,type}]` (`db.py:343`); גרף `precedent_internal_citations` (`db.py:937-947`) |
| **C — שלמות-רשומה לאורך זמן** | החלטה/מסמך שלם ובלתי-משתנה אלא דרך שינוי עקיב ומיוחס | קיים: commit git לכל פלט (`git_sync.commit_and_push`); Track Changes ב-revisions ([06-export §3](06-export.md#3-רישום-הגרסה--active_draft_path--git)) |
---
## 2. רכיבי-העקיבוּת הקיימים (מאומת `file:line`)
1. **קיבוע-פלט ב-git.** כל כתיבת-DOCX/עדכון-תיק מקובעת בהיסטוריית-git של תיקיית-התיק:
`export_docx` (`drafting.py:408`), `export_interim_draft` (`drafting.py:536`),
`apply_user_edit` (`drafting.py:582`), `revise_draft` (`drafting.py:695`), עדכון-תיק
(`cases.py:387`), הוספת-מסמך (`documents.py:86`) — כולם `git_sync.commit_and_push(...)`
(`git_sync.py:75`). זו שכבת ה-audit-trail של **שלמות-הפלט** (שכבה C).
2. **provenance של מודל לכל בלוק.** `decision_blocks` נושא `model_used` / `generation_type` /
`temperature` (`db.py:326-328`), הנכתבים ב-upsert של ה-block-writer
(`block_writer.py:1017-1034`, `_build_result` `:400-407`). מתעד **איזה מודל** הפיק את הבלוק
(שכבה A — חלקי).
3. **עקיבוּת ציטוט ברמת-סעיף.** `decision_paragraphs.citations` (`db.py:343`) שומר
`[{case_law_id, text, type}]` — כל ציטוט בסעיף מצביע ל-`case_law` (שכבה B). telemetry
ממנף זאת ל-"cited == relevant" (`telemetry.py:18-23`).
4. **גרף-ציטוטים פנימי.** `precedent_internal_citations` (`db.py:937-947`) רושם קשת
החלטה→החלטה מצוטטת (resolved ל-`case_law` או stub); נחשף דרך `extract_internal_citations` /
`list_internal_citations` / `list_incoming_citations` (`citations.py:40,81,112`).
ON CONFLICT DO NOTHING → idempotent (`citations.py:54`).
5. **locator פתיר בכל תוצאת-אחזור.** כל span מוחזר נושא מזהה-מקור + locator
([03-retrieval INV-RET5](03-retrieval.md#inv-ret5-כל-span-מוחזר-עקיב-למקורו), `search.py:77-86,322-343`);
הלכות נושאות `supporting_quote` (`db.py:652`) + `page_number` (`db.py:631,711,729`).
6. **telemetry של חיפושים.** `telemetry.log_search_bg` (ב-search.py) → מפעיל את `log_search` האסינכרוני → `search_logs`
(`telemetry.py:105,161`, `search.py:62,118,190,271`) רושם query/practice_area/top_case_law_ids —
תצפית על מה נשלף, fire-and-forget (`telemetry.py:8-12,100-101`).
7. **לקחים ופידבק מיוחסים.** `decision_lessons.source` (`db.py:208`: manual/curator/chair/
style_analyzer) ו-`chair_feedback.lesson_extracted`/`applied_to` (`db.py:458-459`) מתעדים את
**מקור** הלקח ([07-learning.md](07-learning.md)).
8. **טבלת `audit_log` (פעולה כללית).** `log_action(action, case_id, document_id, details, user)` (עמודת-DB: `actor`)
(`audit.py:18-44`) → `audit_log` (`db.py:159-167`, אינדקסים `:168-170`). קיימת, אך נכתבת
כיום כמעט-ורק ב-`case_subtype_override` (`cases.py:203`) — ראה [§5](#5-current-vs-target--ממצאי-audit).
---
## 3. Invariants של התחום
### INV-AUD1: כל תוצר מסיוע-AI מתעד את ה-provenance שלו (→G9)
**כלל:** כל תוצר שנוצר בסיוע-AI — בלוק-טיוטה, תוצאת-אחזור, הצעת-curator — **רושם את מקורו**:
**איזה מודל** הפיק אותו, **באיזה סוג-גנרציה**, ו**אילו קטעי-מקור** (chunks/precedents/מסמכי-תיק)
הוזנו אליו. הרשומה ניתנת-לביקורת בדיעבד (מי/מתי/ממה).
**מקורות:** Council of Europe / CEPEJ — *European Ethical Charter on AI in judicial systems*
(2018, transparency/traceability + user-control) · NCSC/JTC — *Principles & Practices for AI Use
in Courts* (auditable AI output) · ISO 15489-1:2016 (records authenticity — metadata about
creation) | סטטוס: verified
**אכיפה:** `decision_blocks.model_used/generation_type/temperature` בכל upsert של בלוק
(`block_writer.py:1017-1034`); telemetry על כל חיפוש (`telemetry.py:105`); **יעד נוסף:** קישור
מפורש בלוק→קטעי-מקור (provenance edges) + כתיבת `audit_log.log_action` לכל גנרציה. אוכף את
[G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai).
**הפרה ידועה (GAP):** ה-provenance קיים **חלקית**`model_used` נרשם לכל בלוק, וה-commit ב-git
מקבע פלטים, אך **אין רשומה מאוחדת** שמקשרת בלוק-החלטה לקטעי-הקורפוס/הגנרציות שהזינו אותו, וטבלת
`audit_log` כמעט-ולא נכתבת לפעולות-AI (רק `case_subtype_override`, `cases.py:203`) → יעד
([§5](#5-current-vs-target--ממצאי-audit)).
### INV-AUD2: רשומה שמורה שלמה ובלתי-משתנה אלא דרך שינוי עקיב ומיוחס (→G9, שלמות-רשומה)
**כלל:** החלטה/רשומה שמורה היא **שלמה ובלתי-משתנה** — כל שינוי בה נעשה רק דרך **מנגנון עקיב
ומיוחס** (commit git עם הודעה + actor, או Track Changes מיוחסות), ולא דרך דריסה שקטה. ניתן
לשחזר את מצב-הרשומה בכל נקודת-זמן ולזהות מי שינה מה ומתי.
**מקורות:** ISO 15489-1:2016 (§5.2.2 — integrity: records protected against unauthorized
alteration; אמינות/שלמות-רשומה) · Council of Europe / CEPEJ (2018, traceability) · DAMA-UK —
*Six Primary Dimensions for Data Quality* (2013, consistency/integrity over time) | סטטוס: verified
**אכיפה:** קיבוע git לכל פלט (`git_sync.commit_and_push``drafting.py:408,536,582,695`;
`cases.py:387`; `documents.py:86`) עם הודעה תיאורית; Track Changes ב-revisions עוקבות
([06-export §3](06-export.md#3-רישום-הגרסה--active_draft_path--git)); `decision_blocks` עם מפתח
קנוני `UNIQUE(decision_id, block_id)` (`db.py:333`) ו-`updated_at`. אוכף את
[G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai).
**הפרה ידועה:** עריכת-DOCX (`revise_draft`/`apply_user_edit`) הופכת את `active_draft_path` למקור-
בפועל **בלי לעדכן את בלוקי-ה-DB חזרה** — הנתון-הנגזר זוחל למקור-אמת ושלמות ה-DB מול המסמך-החי
נחלשת ([06-export INV-EX1](06-export.md#inv-ex1-ייצוא-דטרמיניסטי-ומשוחזר-מהבלוקים--docx-הוא-נתון-נגזר-g2)) → ממצא ל-[audit](../audit-report.md).
### INV-AUD3: כל סמכות מצוטטת פתירה חזרה לקורפוס (→G5)
**כלל:** כל סמכות-משפטית המצוטטת בהחלטה (פסק-דין, הלכה, מסמך-תיק) **פתירה לרשומת-מקור בקורפוס**
דרך locator יציב — `case_law_id`/`document_id` + מזהה-עמוד/chunk/quote. ציטוט שאינו פתיר אינו
תקין; הוא נחסם או מסומן לאימות-יו"ר. זהו צד-ה-attribution של [INV-RET5](03-retrieval.md#inv-ret5-כל-span-מוחזר-עקיב-למקורו).
**מקורות:** Pinecone — *Implement multitenancy* (metadata-locator לכל פריט מואנדקס) · RAG
attribution (Lewis et al., 2020, NeurIPS — pinned/non-leaking provenance) · ISO 8000 (Data
quality — completeness/identifiability) | סטטוס: verified
**אכיפה:** `decision_paragraphs.citations` `[{case_law_id,text,type}]` (`db.py:343`); גרף
`precedent_internal_citations` (`db.py:937-947`) פותר ציטוט ל-`case_law` קיים או שומר stub;
פורמטרי-האחזור מצרפים מזהה+locator (`search.py:77-86,322-343`). אוכף את
[G5](00-constitution.md#inv-g5-metadata-מלא--הפרדת-קורפוס-נאכפת-בכל-query).
**הפרה ידועה (GAP):** הקישור קיים ברמת-הסעיף (`decision_paragraphs.citations`), אך **אין אכיפה**
שכל ציטוט בטקסט-הבלוק אכן מקושר לרשומת-קורפוס; ציטוט שהמודל ייצר בלי locator יכול לעבור בלי
חסימה אוטומטית — אימות נשען על שער-היו"ר ([05-qa-review](05-qa-review.md)) → יעד.
---
## 4. רשומת-ה-provenance המאוחדת (TARGET)
היעד שמאחד את שלוש השכבות: לכל **בלוק-החלטה** נשמר, מעבר ל-`model_used` הקיים, **קישור לקטעי-
המקור** שהוזנו לגנרציה (chunk-ids/`case_law_id`s שהוחזרו מהאחזור והוצגו ל-writer) — כך שניתן לענות
"מאיזו פסיקה/מסמך נולד המשפט הזה?". המנגנון הקנוני המוצע: כתיבת `audit_log.log_action`
(`audit.py:18`) בכל גנרציה (`action="write_block"`, `details={model, generation_type, source_chunk_ids,
retrieved_case_law_ids}`) — הטבלה כבר תומכת ב-`details JSONB` + `actor` + `case_id`/`document_id`
(`db.py:159-167`). זה ממיר את ה-audit_log מ"כמעט-ריק" ל-audit-trail מקצה-לקצה, בלי טבלה חדשה
(תואם כלל-ההנדסה "סימטריה" — הרחבת מסלול קיים, [חוקה §6](00-constitution.md#6-כללי-הנדסה-מונעים-הישנות)).
---
## 5. Current vs Target — ממצאי-audit
ההבדלים בין הקוד בפועל ל-TARGET. **אלו תסמינים, לא התנהגויות תקינות.** כל פריט אומת מול הקוד.
- **`audit_log` קיימת אך כמעט-ולא נכתבת (INV-AUD1).** `log_action` (`audit.py:18-44`) ו-טבלת
`audit_log` (`db.py:159-167`) מוכנות, אך הקריאה היחידה בפועל היא `case_subtype_override`
(`cases.py:203`) — אין רישום ל-`upload`/`extract_claims`/`write_block`/`export` (למרות ש-docstring
של `log_action` מונה אותם, `audit.py:28`). **תסמין:** אין audit-trail אחיד "מי עשה מה מתי" לרוב
פעולות-ה-AI. **יעד:** קריאת `log_action` בכל פעולה משנה-מצב, כולל גנרציות.
- **אין קישור בלוק→קטעי-מקור (INV-AUD1).** `decision_blocks` מתעד `model_used`/`generation_type`
(`db.py:326-327`) אך **לא** את ה-chunks/precedents שהוזנו לגנרציה. **תסמין:** אי-אפשר לשחזר מאיזו
פסיקה/מסמך נגזר בלוק ספציפי. **יעד:** רשומת-provenance מאוחדת ([§4](#4-רשומת-ה-provenance-המאוחדת-target)).
- **ציטוט→קורפוס לא נאכף אוטומטית (INV-AUD3).** `decision_paragraphs.citations` (`db.py:343`)
תומך בקישור, אך אין בדיקה שכל ציטוט בטקסט אכן פתיר ל-`case_law`. **תסמין:** ציטוט שהמודל ייצר בלי
locator יכול לעבור. **יעד:** ולידציה שכל citation בעלת `case_law_id` פתיר, אחרת flag לאימות-יו"ר.
- **שלמות ה-DB מול ה-DOCX-החי נחלשת אחרי עריכה (INV-AUD2).** אחרי `revise_draft`/`apply_user_edit`,
`active_draft_path` הופך מקור-בפועל בלי re-sync לבלוקים (`db.py:189`;
[06-export INV-EX1](06-export.md#inv-ex1-ייצוא-דטרמיניסטי-ומשוחזר-מהבלוקים--docx-הוא-נתון-נגזר-g2)).
**יעד:** re-sync מהבלוקים או חוזה מפורש + health-check לגילוי drift.
- **telemetry בולעת שגיאות בשתיקה (תיעוד, לא הערכה).** `log_search` swallow מכוון
(`telemetry.py:100-101`) כדי שלא להפיל חיפוש — תקין כ-fire-and-forget, אך אינו audit-trail
מהימן (רשומה עלולה ללכת לאיבוד בשקט). תואם את העיקרון "אין בליעה שקטה" רק כי זו telemetry-תצפית,
לא רשומת-שלמות; ה-audit-trail המהימן הוא git ([§2.1](#2-רכיבי-העקיבוּת-הקיימים-מאומת-fileline)).
---
## 6. הפניות-אחיות
- [00-constitution.md](00-constitution.md) — [INV-G9](00-constitution.md#inv-g9-עקיבוּת-מקור--audit-trail-ל-ai)
(עקיבוּת + audit-trail) · [INV-G5](00-constitution.md#inv-g5-metadata-מלא--הפרדת-קורפוס-נאכפת-בכל-query) (attribution).
- [03-retrieval.md](03-retrieval.md#inv-ret5-כל-span-מוחזר-עקיב-למקורו) — INV-RET5 (locator פתיר בכל span — בסיס ל-INV-AUD3).
- [06-export.md](06-export.md#inv-ex2-עקיבוּת-מקור-נשמרת-בהחלטה-המיוצאת-g9) — INV-EX2 (עקיבוּת בפלט) + commit git (INV-AUD2).
- [05-qa-review.md](05-qa-review.md) — שער-היו"ר שמאמת ציטוטים (משלים את INV-AUD3).
- [02-data-model.md](02-data-model.md) — `decision_blocks`/`decision_paragraphs`/`case_law` (הישויות שעליהן נשמרת ה-provenance).
- [07-learning.md](07-learning.md) — `decision_lessons.source` + `chair_feedback` (מקור הלקחים).
- [01-ingest.md](01-ingest.md) — קליטה שמייצרת את הקטעים שאליהם פותרים ציטוטים.

125
docs/spec/gap-audit.md Normal file
View File

@@ -0,0 +1,125 @@
# Gap-Audit — פערים בין המערכת הקיימת ל-spec
מסמך זה הוא **מפת-הפערים הקנונית** בין המערכת הקיימת (קוד ב-`web/`, `mcp-server/`,
`scripts/`) לבין ה-invariants שב-[`docs/spec/`](README.md). הוא תוצר של תת-פרויקט 2
(מיפוי-פערים), ומובחן מ-[`docs/audit-report.md`](../audit-report.md) הישן: ה-audit הוא
דוח-מצב נקודתי, וזה ה-gap-map שמקשר כל ממצא ל-invariant מופר וליחידת-תיקון.
**איך הופק:** סקירה חוצת-קבצים של כל קבצי-הספ (00 + 0107 + X1X5) מול הקוד הקיים,
30.5.2026. כל ממצא נושא: `invariant מופר` (ה-G*/INV-* שהוא סותר), הערכת-`severity`,
`קבצים מושפעים` (file:line), ו-`תיקון מוצע`.
**הערה על severity/priority:** דירוג ה-severity להלן הוא הערכה הנדסית (לפי סיכון
לשלמות-נתונים, דליפה חוצת-קורפוס, ועקיפת שער אנושי). **קביעת ה-priority בפועל —
מה לתקן ראשון — היא של היו"ר.** ה-severity מנמק; הוא אינו מכריע.
---
## 23 הממצאים
| ID | כותרת | invariant מופר | severity | קבצים מושפעים (file:line) | תיקון מוצע |
|----|-------|----------------|----------|---------------------------|------------|
| GAP-01 | שני מסלולי ingest מקבילים שמתפצלים | INV-ING1, G2 | High | `precedent_library.py:88`, `internal_decisions.py:73` | מסלול-קליטה קנוני יחיד; ישויות-אחיות חולקות פייפליין |
| GAP-02 | ingest פנימי מדלג על חילוץ metadata | INV-ING3, DM1, RET2 | Critical | `internal_decisions.py:208` | להוסיף `request_metadata_extraction` לכל סוג; חוסם indexing ריק |
| GAP-03 | אין upsert דטרמיניסטי על מזהה קנוני | INV-ING2, G3 | Critical | `precedent_library.py`, `internal_decisions.py` | upsert על מפתח קנוני — קליטה חוזרת = update לא duplicate |
| GAP-04 | ולידציית-enum א-סימטרית | INV-G4 | Medium | `precedent_library.py:131-134` | להחיל אותה ולידציית practice_area/source_type בשני המסלולים |
| GAP-05 | staging/derivation/citation-guard/multimodal/fallback א-סימטריים | INV-ING1, G2 | High | `01-ingest §4` (שני המסלולים) | מיזוג כל שלבי-העיבוד למסלול הקנוני האחד |
| GAP-06 | case_number מנורמל בקריאה בלבד | INV-G1, ID1 | High | `db.py:1196-1211` | נרמול בנקודת-הכתיבה; `8126-25`→canonical |
| GAP-07 | מספרי-תיק מעורבים (חודש/חסר) — reconciliation חד-פעמי | INV-ID1 | High | data (cases, case_law) | מיגרציה: canonical = הצורה הרשמית שהוקצתה [chair-confirmed] |
| GAP-08 | ציטוט-מלא נשמר כ-case_number | INV-DM2, ID2 | Medium | data (legacy pre-V15) | ניקוי: ציטוט = שדה-תצוגה נגזר, לא מזהה |
| GAP-09 | `embedding` אינו GENERATED (בניגוד ל-tsvectors) | INV-DM3, RET, G6 | High | schema (chunks/case_law) | re-index באכיפה — טריגר או GENERATED-equivalent בשינוי תוכן |
| GAP-10 | דליפת הלכה חוצת-קורפוס | INV-RET1, G5 | Critical | `db.py:3168`, `db.py:3401`, JOINs `3236-3238`/`3475-3477` | להוסיף `cl.source_kind` ל-halacha_filters |
| GAP-11 | אין eval harness / gold-set מתויג | INV-RET4, G8 | High | `telemetry.log_search_bg` (היחיד) | להקים eval harness + gold-set; precision/recall נמדד |
| GAP-12 | search_decisions מזהיר אך לא חוסם practice_area חסר | INV-RET, G5 | High | `search.py:45-49`, `search.py:172-176` | לחסום query בלי practice_area — ערבוב-תחום אסור |
| GAP-13 | אין דגל `searchable` מפורש | INV-DM1 | Medium | schema (case_law, chunks) | דגל `searchable` שמסומן רק כשחוזה-השלמות מתקיים |
| GAP-14 | backlog הלכות סמוי | INV-QA1, G10 | Medium | (אין health-check) | לחשוף `pending_review` ב-health-check / dashboard |
| GAP-15 | שער-ייצוא נאכף-זרימה ולא נאכף-קוד | INV-QA3, EX3 | Critical | `drafting.py:384` | `export_docx` קורא `validate_decision` + בודק `export_blocked` |
| GAP-16 | neutral_background קריטי-אך-עובר | INV-QA3 (`05 §1.2`) | High | `qa_validator.py:70` | בלוק-ו ריק/חסר = passed=False; חוסם ייצוא |
| GAP-17 | active_draft_path נגזר זוחל ל-source-of-truth | INV-EX1, AUD2 | High | `db.py:189` | DOCX = נגזר; re-sync בלוקים אחרי revise/apply_user_edit |
| GAP-18 | audit_log כמעט לא נכתב | INV-AUD1 | High | `cases.py:203` (היחיד) | כתיבת audit על upload/extract/write_block/export |
| GAP-19 | אין קישור block→source-chunks | INV-AUD1 | High | `decision_blocks` (model_used בלבד) | לתעד אילו chunks/precedents הזינו כל בלוק |
| GAP-20 | citation→corpus לא נאכף אוטומטית | INV-AUD3 | Medium | `decision_paragraphs.citations` | ולידציה שכל ציטוט בטקסט פתיר לקורפוס |
| GAP-21 | cross-company sync ידני ולא-נאכף | INV-MC1 | Medium | `sync_agents_across_companies.py:387-389` | אכיפת `--apply` אחרי שינוי-Master; להרעיש על דילוג adapter_type |
| GAP-22 | אינטגרציית-Paperclip על נוהל ולא מחסום-קוד | INV-INT1, INT3 | Medium | schema / lint (אין) | אילוץ-schema נגד DB-insert; linter נגד httpx/curl גולמי |
| GAP-23 | הספ עדיין לא מחובר לסוכנים | INV-AG1 | High | `.claude/agents/HEARTBEAT.md`, agent files | חובת קריאת 00-constitution + ספ-תחום לפני פעולה |
---
## יחידות-תיקון מוצעות (Proposed Fix-Units)
23 הממצאים מקובצים ל-8 יחידות-עבודה קוהרנטיות. הקיבוץ נגזר מהעיקרון שרבים מהממצאים
נפתרים יחד (כל פערי ה-ingest-asymmetry → יחידה אחת). זהו זרע למשימות TaskMaster
ולתת-פרויקט 3 (שכבת-שלמות).
### FU-1 — איחוד מסלול-הקליטה (Unify ingest path)
- **מכסה:** GAP-01, GAP-02, GAP-04, GAP-05
- **מספק invariants:** INV-ING1, INV-ING3, INV-G2, INV-G4; (תורם ל-DM1/RET2 דרך GAP-02)
- **effort:** L
- **תלויות:** — (יסוד — FU-2/FU-3 נשענים עליה)
- **סוג:** pure-code
### FU-2 — קליטה idempotent + מזהים קנוניים
- **מכסה:** GAP-03, GAP-06, GAP-07, GAP-08, GAP-13
- **מספק invariants:** INV-ING2, INV-G3, INV-G1, INV-ID1, INV-ID2, INV-DM2, INV-DM1
- **effort:** L
- **תלויות:** FU-1 (מסלול אחד לפני upsert אחיד)
- **סוג:** **data-migration** — GAP-07 reconciliation של case_number מעורב (chair-confirmed),
GAP-08 ניקוי ציטוט-כ-מזהה; + code (upsert key, write-time normalize, דגל searchable)
### FU-3 — re-index באכיפה בשינוי-תוכן
- **מכסה:** GAP-09
- **מספק invariants:** INV-DM3, INV-G6, INV-RET (freshness)
- **effort:** M
- **תלויות:** FU-1 (re-embed יושב בקליטה הקנונית)
- **סוג:** **data-migration** — re-chunk/re-embed של רשומות קיימות + טריגר/אכיפה קדימה
### FU-4 — הפרדת-קורפוס נאכפת בכל query
- **מכסה:** GAP-10, GAP-12
- **מספק invariants:** INV-RET1, INV-G5
- **effort:** M
- **תלויות:** — (עצמאי; דחוף — Critical leak)
- **סוג:** pure-code
### FU-5 — eval harness + נראות-בריאות
- **מכסה:** GAP-11, GAP-14
- **מספק invariants:** INV-RET4, INV-G8, INV-QA1, INV-G10 (נראות backlog)
- **effort:** M
- **תלויות:** FU-2 (gold-set יציב דורש מזהים קנוניים)
- **סוג:** pure-code + **chair-decision** — הגדרת gold-set מתויג דורשת אישור היו"ר
(מה "תוצאה נכונה" לכל query)
### FU-6 — שערי-QA נאכפים-קוד (Code-enforced gates)
- **מכסה:** GAP-15, GAP-16
- **מספק invariants:** INV-QA3, INV-EX3, INV-G10
- **effort:** S
- **תלויות:** — (עצמאי; חוסם עקיפת-ייצוא)
- **סוג:** pure-code
### FU-7 — Audit-trail + provenance (זרע תת-פרויקט 3)
- **מכסה:** GAP-17, GAP-18, GAP-19, GAP-20
- **מספק invariants:** INV-AUD1, INV-AUD2, INV-AUD3, INV-EX1, INV-G9
- **effort:** L
- **תלויות:** FU-1 (provenance נלכד בקליטה/כתיבה הקנונית)
- **סוג:** pure-code (schema-additive) — חלק מ-GAP-17 דורש **data-backfill** קל
לסנכרון בלוקים↔DOCX קיימים
### FU-8 — מחסומי-תהליך הופכים למחסומי-קוד
- **מכסה:** GAP-21, GAP-22, GAP-23
- **מספק invariants:** INV-MC1, INV-INT1, INV-INT3, INV-AG1
- **effort:** M
- **תלויות:** ה-spec גמור (GAP-23 דורש קבצי-ספ יציבים לחבר לסוכנים)
- **סוג:** pure-code + **chair-decision** — GAP-23 (חיבור ספ לסוכני-Paperclip) הוא
prerequisite לתת-פרויקט 5 ומשנה התנהגות-סוכן בייצור
---
## סיכום סיווג לפי סוג-עבודה
- **pure-code (ללא מיגרציה):** FU-1, FU-4, FU-6; הליבה של FU-7, FU-8.
- **דורש data-migration:** FU-2 (case_number reconciliation, ניקוי ציטוטים), FU-3
(re-chunk/re-embed), backfill קל ב-FU-7 (סנכרון בלוקים↔DOCX).
- **דורש chair-decision:** FU-5 (הגדרת gold-set), FU-8/GAP-23 (חיבור ספ לסוכנים);
GAP-07 כבר chair-confirmed (canonical = הצורה הרשמית שהוקצתה).
**רצף מומלץ (תלויות):** FU-1 → FU-2 → FU-3; FU-4 ו-FU-6 במקביל (עצמאיים, Critical);
FU-7 אחרי FU-1; FU-5 אחרי FU-2; FU-8 אחרי ייצוב-הספ.

View File

@@ -0,0 +1,254 @@
# System Spec-Set (Sub-Project 1) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Author the living system spec-set under `docs/spec/` that canonically defines the *עוזר משפטי* system and its invariants ("what is correct"), each invariant backed by ≥3 authoritative sources.
**Architecture:** A `00-constitution.md` keystone (mission, global invariants, engineering rules, invariant template, verification protocol, index) + lifecycle-organized domain files (`01-ingest``07-learning`) + cross-cutting files (`X1``X5`). Existing docs are cited as verified sources, never duplicated. This is documentation, not code: the "test" is the **verification gate** — every invariant carries ≥3 verified sources or is marked `⚠ UNVERIFIED` and escalated to the chair (never decided solo).
**Tech Stack:** Markdown. Sources verified via WebSearch/WebFetch + primary texts (Nevo for Israeli statutes). Design basis: [docs/superpowers/specs/2026-05-30-system-spec-design.md](../specs/2026-05-30-system-spec-design.md).
**Branch:** `system-spec` (already created; design doc committed at `a5b22da`).
---
## Conventions for every file (apply in each task)
- **Invariant template** (use verbatim structure):
```
### INV-<DOMAIN><n>: <short title>
**כלל:** <one crisp normative statement — what MUST hold>
**מקורות:** <≥3 authorities> | סטטוס: verified / ⚠ UNVERIFIED
**אכיפה:** <where/how enforced — schema / write-validation / health-check / human gate>
**הפרה ידועה:** <example from the system if any → links to audit; else "—">
```
- **Language:** Hebrew prose, English for technical terms and source names (matches project docs + RTL preference).
- **Length target:** ≤ ~500 lines/file. If exceeding, that domain needs splitting — note it, don't cram.
- **Citing existing docs:** reference (e.g., `block-schema.md`) as a *source to verify*; if it contradicts the ≥3 authorities, record a one-line audit-finding rather than silently trusting it.
- **Cross-links:** link sibling spec files by relative path; link global invariants as `00-constitution.md#inv-g<n>`.
## Per-file verification gate (the "test")
A file passes only when ALL hold (this checklist is a literal step in each task):
1. Every `INV-*` has either ≥3 named authoritative sources (`verified`) or is marked `⚠ UNVERIFIED` with an escalation note.
2. No placeholder text (`TBD`/`TODO`/"להשלים").
3. All cross-links resolve to a real file/anchor.
4. Consistent with `00-constitution.md` (no invariant contradicts a global invariant).
5. ≤ ~500 lines.
---
## Phase 0 — Scaffold
### Task 0: Create the spec directory
**Files:**
- Create: `docs/spec/README.md`
- [ ] **Step 1: Create `docs/spec/` with a short README**
Write `docs/spec/README.md`:
```markdown
# ספ המערכת — עוזר משפטי (Living System Spec)
זהו מקור-האמת הקנוני ל"מהו תקין" במערכת. שער-הכניסה: [00-constitution.md](00-constitution.md).
כל invariant מגובה ב-≥3 מקורות סמכותיים; פריט לא-מאומת מסומן ⚠ UNVERIFIED ומועלה ליו"ר.
מבנה: 00 חוקה · 0107 מחזור-חיים · X1X5 חוצי-שלבים. ראה אינדקס מלא בחוקה.
בסיס-עיצוב: docs/superpowers/specs/2026-05-30-system-spec-design.md
```
- [ ] **Step 2: Commit**
```bash
git add docs/spec/README.md
git commit -m "docs(spec): scaffold docs/spec/ living spec-set"
```
---
## Phase 1 — Keystone (REVIEW CHECKPOINT after)
### Task 1: `00-constitution.md` — the keystone
**Files:**
- Create: `docs/spec/00-constitution.md`
- [ ] **Step 1: Write the constitution** with these sections (content is already determined by the approved design):
1. **ייעוד** — paste the confirmed mission paragraph from the design doc §2.
2. **עקרונות-עבודה** — the 4 work principles (design doc §3): don't assume existing is correct; 3-source protocol; research→draft; collaboration model.
3. **תבנית-invariant** — the template from "Conventions" above.
4. **פרוטוקול-אימות** — `verified` vs `⚠ UNVERIFIED`; escalation to chair; never decide solo.
5. **Invariants גלובליים G1G11** — each written with the full template. Content + sources from design doc §6 / §9:
- **INV-G1 מזהה קנוני מנורמל בכתיבה** — SSOT/normalization · Codd 1NF (CACM 13(6), 1970) · Kleppmann DDIA. אכיפה: normalization-on-write in the ingest path + `X1-identifiers.md`. הפרה ידועה: tolerant `_normalize_case_number` on read only; `8126-25` vs `8126-03-25`.
- **INV-G2 מקור-אמת יחיד, אין מסלולים מקבילים מתפצלים** — Kleppmann (system of record) · Fowler (Canonical Data Model) · SSOT. אכיפה: one canonical ingest path; siblings share it. הפרה ידועה: `ingest_precedent` vs `ingest_internal_decision` asymmetry.
- **INV-G3 ingest אחיד ו-idempotent (upsert על מפתח דטרמיניסטי)** — Kleppmann · Stripe/CDC idempotency · ISO 8000. אכיפה: `01-ingest.md` unified path.
- **INV-G4 חוזה-שלמות לפני "שמיש/ניתן-לחיפוש"** — ISO 8000 · DAMA-UK (completeness) · ISO 15489 (reliability). אכיפה: write-validation + health-check; `02-data-model.md`. הפרה ידועה: ערן סופר 8046/24 indexed with empty headnote/summary/tags.
- **INV-G5 metadata מלא לכל פריט מואנדקס + הפרדת-קורפוס בכל query** — Pinecone (multitenancy) · RAG attribution (Lewis et al.) · ISO 8000. אכיפה: `03-retrieval.md`. הפרה ידועה: task #56 halacha_filters source_kind leak.
- **INV-G6 re-index בכל שינוי תוכן** — Pinecone · Weaviate · RAG freshness. אכיפה: ingest/update path.
- **INV-G7 מיזוג RRF לא סכום-ציונים** — Elastic (RRF) · Weaviate · OpenSearch/Azure (corrob.). אכיפה: retrieval fusion (already implemented — codified).
- **INV-G8 איכות-אחזור נמדדת (precision+recall)** — Manning IR textbook · RAG eval literature · (Elastic eval guidance). אכיפה: eval harness in `03-retrieval.md`.
- **INV-G9 עקיבוּת-מקור + audit-trail ל-AI** — CEPEJ (user control) · NCSC · ISO 15489. אכיפה: `X5-audit-provenance.md`.
- **INV-G10 המערכת מסייעת; שערים אנושיים = invariant** — NCSC ("never replace human judgment") · CEPEJ · FJC. אכיפה: `05-qa-review.md` human gates.
- **INV-G11 תוכן החלטה מנומקת** (רקע ניטרלי · ללא כפילות · מענה לטענות המפסיד · מבחן-השופט · טענות מקוריות) — FJC Writing Manual · South Bucks [2004] UKHL 33 · חוק לתיקון סדרי המינהל (החלטות והנמקות) תשי"ט-1958. אכיפה: `04-analysis-writing.md` + `05-qa-review.md`.
6. **כללי-הנדסה** — סימטריה · נרמול-לא-תיקון-תסמין · quality-at-source (Fowler/Data-Mesh) · אין בליעה שקטה.
7. **אינדקס** — table linking all spec files (00, 0107, X1X5) with one-line purpose each.
8. **נספח מקורות** — paste the full source appendix from design doc §9.
- [ ] **Step 2: Run the per-file verification gate** (the 5-point checklist above). Fix inline.
- [ ] **Step 3: Commit**
```bash
git add docs/spec/00-constitution.md
git commit -m "docs(spec): 00-constitution — mission, 11 global invariants, engineering rules"
```
- [ ] **Step 4: REVIEW CHECKPOINT** — present `00-constitution.md` to חיים. Do not start Phase 2 until approved. If the constitution's framing changes, the domain files adapt to it.
---
## Phase 2 — Lifecycle domain files
> Each task: (a) targeted research to verify domain-specific invariants to ≥3 sources (global invariants already verified — reuse their sources; only NEW domain claims need fresh sourcing); (b) draft the file; (c) run the verification gate; (d) commit. Group review checkpoint at end of Phase 2.
### Task 2: `01-ingest.md` — unified intake contract
**Files:** Create `docs/spec/01-ingest.md`
- [ ] **Step 1:** Document the **target single ingest path** for all three intake kinds (case documents / external precedent / internal-committee decisions). Describe the canonical pipeline: stage file → extract text → chunk → embed → store → queue metadata extraction → queue halacha extraction → set statuses. State which steps are **uniform across all kinds** (this is the fix for the asymmetry).
- [ ] **Step 2:** Define domain invariants applying INV-G2/G3/G4/G6 to ingest, e.g.:
- **INV-ING1:** every intake kind flows through the same canonical ingest function; a new kind extends it via parameters, never a parallel function. (sources: INV-G2 set)
- **INV-ING2:** ingest is idempotent on the canonical identifier (re-ingest = upsert, no duplicate row/chunks). (sources: INV-G3 set)
- **INV-ING3:** metadata extraction is queued for *every* kind that has extractable metadata — not conditional per path. (sources: INV-G4 set; הפרה ידועה: internal path skipped `request_metadata_extraction`)
- [ ] **Step 3:** Cite current reality as audit-findings (the 8 documented asymmetries from the design research) — as `הפרה ידועה` lines, not as "correct."
- [ ] **Step 4:** Run verification gate. **Step 5:** Commit `docs(spec): 01-ingest unified intake contract`.
### Task 3: `02-data-model.md` — entities + completeness contract
**Files:** Create `docs/spec/02-data-model.md`
- [ ] **Step 1:** Enumerate the canonical entities (cases, case_law, documents, chunks, halachot, chair_feedback, …) — name, purpose, key fields. Mark this as the **target** model (verify field names against current schema during execution; divergences → audit-findings).
- [ ] **Step 2:** Define the **completeness contract per entity** — the mandatory-field set that makes a record "usable/searchable" (INV-G4). For `case_law`: e.g., canonical case_number, case_name, court, practice_area, source_kind, + (for searchable) ≥1 chunk and non-empty metadata. State explicitly that records failing the contract are flagged, not silently searchable.
- **INV-DM1:** a case_law row is "searchable" only when its completeness contract is satisfied. (sources: ISO 8000 · DAMA-UK · ISO 15489)
- **INV-DM2:** each entity has exactly one canonical identifier; no field stores a full citation as the identifier. (sources: INV-G1 set; הפרה ידועה: citation-as-case_number for סופר entries)
- [ ] **Step 3:** Run gate. **Step 4:** Commit `docs(spec): 02-data-model entities + completeness contract`.
### Task 4: `03-retrieval.md` — corpora + retrieval invariants
**Files:** Create `docs/spec/03-retrieval.md`
- [ ] **Step 1:** Document the 3 corpora + their search tools (source_kind mapping) and the hybrid/RRF design. (Reuse research from design §9 RAG sources — already verified.)
- [ ] **Step 2:** Define invariants (apply INV-G5/G6/G7/G8/G9):
- **INV-RET1:** corpus separation enforced on 100% of query paths (chunks AND halachot filters). (Pinecone · ISO · RAG; הפרה ידועה: task #56)
- **INV-RET2:** no item indexed without complete required metadata + resolvable source locator. (INV-G5 set)
- **INV-RET3:** heterogeneous retrievers fused by RRF, never raw-score sum. (Elastic · Weaviate)
- **INV-RET4:** retrieval quality measured by a standing precision+recall eval harness on a fixed labeled query set. (Manning · RAG eval)
- **INV-RET5:** every returned span is attributable to its source. (CEPEJ · RAG)
- [ ] **Step 3:** Run gate. **Step 4:** Commit `docs(spec): 03-retrieval corpora + retrieval invariants`.
### Task 5: `04-analysis-writing.md` — claims, 12 blocks, Dafna style
**Files:** Create `docs/spec/04-analysis-writing.md`
- [ ] **Step 1:** Reference (cite, don't duplicate) `block-schema.md`, `decision-methodology.md`, `skills/decision/SKILL.md` as sources; summarize the 12-block model + claims extraction at spec altitude.
- [ ] **Step 2:** Verify the Israeli reasoned-decision sources (design doc §8 open items #1#3): confirm exact section of חוק 1958 (תשכ"ט-1969 amendment) on Nevo; confirm/locate ברק-ארז citation; confirm בג"ץ 143/56 / עע"ם 2994/21. Mark each `verified` or `⚠ UNVERIFIED` + escalate.
- [ ] **Step 3:** Define invariants from INV-G11:
- **INV-WR1:** block ו (background) is neutral — no judgment words, no party quotes. (FJC · חובת הנמקה)
- **INV-WR2:** no duplication — block י references prior blocks, does not restate facts. (FJC §non-duplication)
- **INV-WR3:** every losing-side principal argument is addressed. (FJC · South Bucks adequacy)
- **INV-WR4:** block ז = original claims only; supplements go to block ח. (project rule; cite corpus-analysis)
- **INV-WR5:** judge-unfamiliar-with-case test — decision is self-contained and traceable. (FJC · South Bucks)
- [ ] **Step 4:** Run gate. **Step 5:** Commit `docs(spec): 04-analysis-writing — 12 blocks + reasoned-decision invariants`.
### Task 6: `05-qa-review.md` — QA gates + human gates
**Files:** Create `docs/spec/05-qa-review.md`
- [ ] **Step 1:** Document the existing automated QA gates (`validate_decision`: neutral_background, claims_coverage, weight_compliance, structural_integrity, no_duplication, sequential_numbering) — as the QA contract (verify against `qa_validator.py` at execution).
- [ ] **Step 2:** Define human-gate invariants (INV-G10):
- **INV-QA1:** halacha approval is a manual chair decision; auto-extracted halachot are `pending_review` until the chair approves. (NCSC · CEPEJ · project rule)
- **INV-QA2:** outcome selection and chair feedback are human gates, never automated. (NCSC · CEPEJ · FJC)
- **INV-QA3:** a decision cannot be exported while critical QA gates fail. (FJC · validate_decision design)
- [ ] **Step 3:** Run gate. **Step 4:** Commit `docs(spec): 05-qa-review — QA + human gates`.
### Task 7: `06-export.md` — DOCX export contract
**Files:** Create `docs/spec/06-export.md`
- [ ] **Step 1:** Reference `skills/dafna-decision-template/SKILL.md`; document the export contract: line classification, dash policy, placeholder handling, template styles. Define:
- **INV-EX1:** export is deterministic from the stored decision blocks (single source = DB blocks; the DOCX is derived). (INV-G2 derived-data set)
- **INV-EX2:** export preserves source traceability where required. (INV-G9)
- [ ] **Step 2:** Run gate. **Step 3:** Commit `docs(spec): 06-export DOCX contract`.
### Task 8: `07-learning.md` — Hermes, lessons, feedback loop
**Files:** Create `docs/spec/07-learning.md`
- [ ] **Step 1:** Document the learning loop: Hermes curator (post-export analysis), `docs/legal-decision-lessons.md`, chair-feedback weekly analysis. Define:
- **INV-LRN1:** curator proposes; changes to SKILL.md/lessons.md require manual chair approval. (INV-G10; project rule)
- **INV-LRN2:** quality accountability sits at the source (ingest/authoring), not downstream. (Fowler/Data-Mesh)
- [ ] **Step 2:** Run gate. **Step 3:** Commit `docs(spec): 07-learning loop`.
- [ ] **Phase 2 REVIEW CHECKPOINT** — present `01``07` to חיים for review before Phase 3.
---
## Phase 3 — Cross-cutting files (final REVIEW after)
### Task 9: `X1-identifiers.md` — canonical identifier model
**Files:** Create `docs/spec/X1-identifiers.md`
- [ ] **Step 1:** Define the canonical case_number model: the normalized written form, the relationship `cases.case_number` vs `case_law.case_number`, and citation formats. Specify **normalize-on-write** (INV-G1), with tolerant-match-on-read as a *secondary* convenience, not the primary mechanism.
- **INV-ID1:** case_number is normalized to canonical form at write time. (SSOT · Codd · Kleppmann)
- **INV-ID2:** no entity uses a full citation string as its identifier. (INV-G1; הפרה ידועה: סופר entries)
- [ ] **Step 2:** Run gate. **Step 3:** Commit `docs(spec): X1-identifiers canonical model`.
### Task 10: `X2-multi-company.md`
**Files:** Create `docs/spec/X2-multi-company.md`
- [ ] **Step 1:** Document CMP (1xxx) / CMPA (8xxx), 14 agents (7×2), and the sync rules (cite `sync_agents_across_companies.py`, `HEARTBEAT.md`). Define:
- **INV-MC1:** any agent-config change in master must be synced to the mirror company via the API sync script. (project rule)
- [ ] **Step 2:** Run gate. **Step 3:** Commit `docs(spec): X2-multi-company`.
### Task 11: `X3-integration-deploy.md`
**Files:** Create `docs/spec/X3-integration-deploy.md`
- [ ] **Step 1:** Document Paperclip integration (wakeup via API not DB; comment routing via CEO; outbound case-status webhook) and the deploy model (Coolify dockerimage for legal-ai; pm2 for paperclip/chat-service). Define:
- **INV-INT1:** Paperclip wakeup goes through `POST /api/agents/{id}/wakeup` with `payload.issueId`, never a direct DB insert. (project rule; cite memory reference)
- **INV-INT2:** legal-ai code changes require commit→push→Coolify deploy; no local uvicorn. (project rule)
- [ ] **Step 2:** Run gate. **Step 3:** Commit `docs(spec): X3-integration-deploy`.
### Task 12: `X4-agents.md`
**Files:** Create `docs/spec/X4-agents.md`
- [ ] **Step 1:** Map the domain agents (ceo, researcher, analyst, writer, qa, proofreader, exporter, hermes) — role + which spec files each must read. Reserve a section for the **process agents** (sub-project 5: add-feature / fix-feature / spec-guardian) to be defined later. Define:
- **INV-AG1:** every agent reads `00-constitution.md` first and the relevant domain spec before acting. (governance rule)
- [ ] **Step 2:** Run gate. **Step 3:** Commit `docs(spec): X4-agents map`.
### Task 13: `X5-audit-provenance.md`
**Files:** Create `docs/spec/X5-audit-provenance.md`
- [ ] **Step 1:** Define the audit-trail + provenance requirements (INV-G9): logging of AI-assisted generation, traceability of every cited authority/source in a decision back to the corpus, record integrity over time.
- **INV-AUD1:** every AI-assisted artifact records what sources/data produced it. (CEPEJ user-control · NCSC · ISO 15489)
- **INV-AUD2:** record integrity — a stored decision/record is complete and unaltered except via tracked, attributed changes. (ISO 15489 §5.2.2.3)
- [ ] **Step 2:** Run gate. **Step 3:** Commit `docs(spec): X5-audit-provenance`.
- [ ] **FINAL REVIEW** — present the complete spec-set to חיים. On approval, sub-project 1 is done; proceed to sub-project 2 (Audit) in its own spec→plan cycle.
---
## Self-Review (run after writing this plan)
- **Spec coverage:** every design-doc section maps to a task — mission/principles → Task 1; G1G11 → Task 1 + applied in 213; spec-set structure → Tasks 013; verification protocol → conventions + gate; open legal items → Task 5 Step 2. ✓
- **Placeholder scan:** domain-file invariants are enumerated with IDs + sources, not "define later"; the only deferred content is the process-agents section (Task 12) which is explicitly sub-project 5, and the legal `⚠ UNVERIFIED` items (Task 5) which are an intentional escalation, not a placeholder. ✓
- **Type/name consistency:** invariant IDs are unique (G1G11, ING13, DM12, RET15, WR15, QA13, EX12, LRN12, ID12, MC1, INT12, AG1, AUD12); file names consistent with design doc §5. ✓
```

View File

@@ -0,0 +1,168 @@
# מסמך-עיצוב אב — ספ המערכת והשכבה החסרה (System Spec & Integrity Layer)
**תאריך:** 2026-05-30
**סטטוס:** עיצוב מאושר (Design approved) — ממתין לכתיבת קבצי הספ
**בעלים:** חיים מרכוס
**הקשר:** מהלך-יסוד להגדרת "מהו תקין" במערכת *עוזר משפטי*, ולסגירת כשל-שורש חוזר.
---
## 1. הבעיה — כשל-השורש החוזר
מה שנחווה כ"כל פעם משהו אחר לא מדויק" אינו אוסף תקלות אקראיות אלא **כשל אחד שחוזר בתחפושות**. ראיות שצפו (30.5.2026):
| תסמין | שורש |
|--------|------|
| `8126-25` לא נמצא (האמיתי `8126-03-25`); קומיט "tolerant case_number lookup" | אין מפתח קנוני — מתקנים תסמין בקריאה |
| 3 החלטות "סופר" ב-3 פורמטים שונים (`8126/24`, ציטוט-מלא-כ-case_number) | אין חוזה-נתונים אחיד |
| ערן סופר 8046/24 עלתה בלי metadata (headnote/summary/tags ריקים) | מסלול ה-ingest הפנימי לא מתזמן חילוץ metadata — אסימטרי למסלול החיצוני |
| 10/19 הלכות מאושרות, התגלה במקרה | שער ידני שקוף בלי נראות backlog |
| משימות #56, #57 | אי-עקביות בין רכיבים (דליפה חוצת-קורפוסים, chunker) |
**אבחנה:** המערכת גדלה בקצב *הוספת יכולות* מהר יותר מקצב *שמירת עקביות* — מסלולים/כלים/קורפוסים מקבילים שנוספים בבידוד ומתפצלים (drift), בלי שכבה שמגדירה ואוכפת "תקין". כל פגם מתגלה בדיעבד, אחד-אחד.
**התרופה:** לא לתקן 10 דברים — להוסיף **שכבה אחת חסרה**: חוקה + חוזה-שלמות + בדיקת-בריאות אחת + איחוד מסלולי ה-ingest. זה הופך כשל מ"מתפרץ במקום אקראי" ל"נחסם בכניסה, גלוי בדשבורד".
---
## 2. ייעוד המערכת (מאושר ע"י חיים)
> מערכת AI שמסייעת ליו"ר ועדת הערר לתכנון ובנייה (מחוז ירושלים, עו"ד דפנה תמיר) לנסח **החלטות מעין-שיפוטיות כתובות ומנומקות** — מסמכים משפטיים פורמליים שעומדים לביקורת שיפוטית — תוך שמירה על **הקול, השיקול והאחריות של היו"ר**.
- **משרת:** יו"ר הוועדה (משתמש-על) והסוכנים הפועלים בשמה.
- **מחזור-חיים:** ניהול תיקים → בסיס ידע (3 קורפוסים) → אחזור סמנטי (RAG) → סיוע-כתיבה (12 בלוקים, סגנון דפנה) → ייצוא DOCX.
- **3 סוגי עררים:** רישוי ובנייה (1xxx, חם), היטל השבחה (8xxx, קר), פיצויים ס'197 (9xxx, קר).
- **ה"למה" העמוק:** המערכת מסייעת — היו"ר מכריעה (שערים קריטיים ידניים בכוונה); מנוע צבירת-ידע (לומד מהחלטות סופיות ומפידבק); רב-חברתי (CMP/CMPA).
---
## 3. עקרונות-עבודה למהלך
1. **אסור להניח שהקיים תקין.** כל מה שמופה בקוד/בקורפוס = "טענה לבדיקה", לא "אמת". "תקין" נגזר ממקורות חיצוניים, לא מהמערכת שתחת חשד.
2. **פרוטוקול אימות 3-מקורות:** כל invariant/חוק בספ מגובה ב-**≥3 מקורות סמכותיים מוכרים** בעלי ידע מקצועי מוכח. כשאין 3 → מסומן `⚠ UNVERIFIED` ומועלה לחיים, לא מוכרע לבד.
3. **מנגנון:** מחקר עצמאי → טיוטה לביקורת.
4. **מודל-שיתוף:** על החלטות טכניות/אדריכליות אני חוקר ומכריע מקצועית ומציג תוצאה מוגמרת. שואל את חיים רק במקום שבו *הוא* הסמכות — כוונה, עדיפויות עסקיות, עובדות משפטיות-דומייניות.
---
## 4. פירוק ל-5 תת-פרויקטים (לפי תלות)
| # | תת-פרויקט | תוצר | תלות |
|---|-----------|------|------|
| 1 | **ספ המערכת + חוקה** | spec-set ב-`docs/spec/` המגדיר מודל קנוני + invariants | — |
| 2 | **מפת הפערים (Audit)** | סריקה אמפירית מול הספ → רשימת משימות | תת-פרויקט 1 |
| 3 | **שכבת שלמות-נתונים** | חוזה-שלמות באכיפת-קוד + בדיקת-בריאות אחת + **איחוד מסלולי ingest** | 1, 2 |
| 4 | **בדיקה חוזרת** | הרצת בריאות/audit אחרי התיקון | 3 |
| 5 | **סוכני-תהליך** | add-feature / fix-feature / spec-guardian — מכירים את הספ, "עושים שיעורי בית", לומדים ומתעדכנים | 1 (3) |
כל תת-פרויקט יקבל מחזור spec→plan→implementation משלו. מסמך זה מפרט את **תת-פרויקט 1** במלואו ומקבע את ההחלטות העקרוניות לכולם.
---
## 5. מבנה הספ-set (תת-פרויקט 1)
מיקום: **`docs/spec/`** (ספ חי). ארגון קבצי-תחום: **לפי מחזור-חיים** (גישה A) — חושף ישירות אסימטריות-זרימה.
```
docs/spec/
├── 00-constitution.md ← ייעוד · invariants גלובליים · כללי-הנדסה · אינדקס · תבנית-invariant · פרוטוקול-אימות
│ ── מחזור-החיים ──
├── 01-ingest.md ← קליטה מאוחדת: מסמכי-תיק / פסיקה חיצונית / החלטות-ועדה — חוזה מסלול-יחיד
├── 02-data-model.md ← אחסון: ישויות (cases, case_law, documents, chunks, halachot…) + חוזה-שלמות לכל ישות
├── 03-retrieval.md ← 3 קורפוסים + כלי-חיפוש · hybrid/RRF · attribution · eval harness · invariants
├── 04-analysis-writing.md ← חילוץ טענות · 12 בלוקים · סגנון דפנה (מצטט block-schema.md וכו')
├── 05-qa-review.md ← שערי QA + שערים אנושיים (אישור הלכה, בחירת תוצאה, פידבק) כ-invariant
├── 06-export.md ← ייצוא DOCX לפי תבנית דפנה
├── 07-learning.md ← Hermes · לקחים · לולאת פידבק היו"ר · צמיחת קורפוס (quality-at-source)
│ ── חוצי-שלבים ──
├── X1-identifiers.md ← מודל מזהים קנוני: נרמול case_number **בכתיבה** · cases מול case_law · פורמטי ציטוט
├── X2-multi-company.md ← CMP/CMPA · 14 סוכנים · כללי sync
├── X3-integration-deploy.md ← Paperclip (wakeup, ניתוב comments, webhooks) · Coolify/pm2
├── X4-agents.md ← מפת הסוכנים (דומיין + סוכני-התהליך מתת-פרויקט 5)
└── X5-audit-provenance.md ← audit-trail לשימוש ב-AI · עקיבוּת כל מקור מצוטט · שלמות-רשומה (CEPEJ/NCSC/ISO 15489)
```
**עקרונות:** כל קובץ עצמאי, ממוקד, agent-readable, יעד ≤~500 שורות (תפיחה = סימן לפיצול). `00-constitution.md` = שער-כניסה יחיד. מסמכים קיימים (`architecture.md`, `product-specification.md`, `block-schema.md`…) לא נמחקים ולא משוכפלים — מצוטטים כ"מקור" ומאומתים מול הסמכויות; סתירה = ממצא ל-audit.
### תבנית-invariant (מבנה אחיד לכל חוק בספ)
```
### INV-<תחום><מספר>: <כותרת קצרה>
**כלל:** <ניסוח נורמטיבי חד — מה חייב להתקיים>
**מקורות:** <≥3 סמכויות> | סטטוס: verified / ⚠ UNVERIFIED
**אכיפה:** <היכן/איך נאכף — schema, ולידציית-כתיבה, בדיקת-בריאות, שער>
**הפרה ידועה:** <דוגמה מהמערכת, אם יש — מקשר ל-audit>
```
---
## 6. ה-Invariants הגלובליים (לב `00-constitution.md`)
כל אחד מגובה ב-≥3 סמכויות (פירוט ב-§9). אלה החוקים שמייבשים את כשל-השורש:
| # | Invariant | סמכויות |
|---|-----------|---------|
| **G1** | מזהה קנוני, **מנורמל בכתיבה** (לא תיקון-סלחני בקריאה בלבד) | SSOT/normalization · Codd 1NF · Kleppmann |
| **G2** | מקור-אמת יחיד; **אין מסלולי-קוד מקבילים שמתפצלים** — אחים חולקים מסלול קנוני אחד; derived data משוחזר | Kleppmann (system of record) · Fowler (canonical model) · SSOT |
| **G3** | ingest **אחיד ו-idempotent** (upsert על מפתח דטרמיניסטי) | Kleppmann · Stripe/CDC idempotency · ISO 8000 |
| **G4** | **חוזה-שלמות:** שדות חובה מולאו לפני שרשומה "שמישה/ניתנת-לחיפוש"; נבדק מול spec מפורש | ISO 8000 · DAMA (completeness) · ISO 15489 (reliability) |
| **G5** | metadata מלא לכל פריט מואנדקס + **הפרדת-קורפוס נאכפת בכל מסלול-query** | Pinecone (multitenancy) · RAG attribution · ISO 8000 |
| **G6** | **re-index בכל שינוי תוכן** (אין embeddings מיושנים) | Pinecone · Weaviate · RAG freshness |
| **G7** | מיזוג **לפי דירוג (RRF)**, לא סכום-ציונים גולמי בין retrievers | Elastic · Weaviate · OpenSearch/Azure (corrob.) |
| **G8** | איכות-אחזור **נמדדת (precision+recall)**, לא מונחת | Manning (IR textbook) · RAG eval literature |
| **G9** | כל פלט **עקיב למקורו** + audit-trail לשימוש ב-AI | CEPEJ (user control) · NCSC · ISO 15489 |
| **G10** | המערכת מסייעת; **שערים אנושיים** (אישור הלכה/תוצאה/פידבק) הם invariant, לא רשות | NCSC · CEPEJ · FJC |
| **G11** | **תוכן החלטה מנומקת:** רקע ניטרלי · ללא כפילות · מענה לטענות המפסיד · מבחן-השופט · טענות מקוריות | FJC (Writing Manual) · South Bucks (adequacy) · חוק 1958 (חובת הנמקה) |
### כללי-הנדסה (constitution — מונעים הישנות)
- **סימטריה:** אסור להוסיף מסלול מקביל ליכולת קיימת — מרחיבים את המסלול הקנוני. (נגזר מ-G2)
- **נרמול לא תיקון-תסמין:** מתקנים נתון במקור (קנוני), לא מטליאים בקריאה. (נגזר מ-G1)
- **Quality-at-source:** שלמות נאכפת קרוב ככל האפשר לקליטה. (Fowler/Data-Mesh)
- **אין בליעה שקטה:** רשומה חסרה מסומנת ומדווחת, לא מתקבלת בשקט. (תואם feedback קיים)
---
## 7. פרוטוקול-אימות ומודל-שיתוף (ייכנס ל-`00-constitution.md`)
- כל invariant נושא `מקורות` + `סטטוס: verified / ⚠ UNVERIFIED`.
- `⚠ UNVERIFIED` (פחות מ-3 מקורות) → לא מוכרע לבד; מועלה לחיים.
- החלטות טכניות → מחקר עצמאי + הכרעה מקצועית + הצגת תוצאה. שאלה לחיים רק במקום שהוא הסמכות.
---
## 8. פריטים פתוחים — אימות-מקור-ראשוני נדרש
(החוקר אימת מסגרת; הפריטים הישראליים דורשים אימות לפני ציטוט כ-סמכות, בשלב כתיבת `04`/`05`/`X5`)
1. מספר הסעיף המדויק בחוק לתיקון סדרי המינהל (החלטות והנמקות) תשי"ט-1958 (וכן תיקון תשכ"ט-1969).
2. ציטוט מדויק מ-ברק-ארז, *משפט מינהלי*.
3. אסמכתאות פסיקה: בג"ץ 143/56; עע"ם 2994/21 (מעמד ועדת ערר כגוף תכנוני-מקצועי).
---
## 9. נספח מקורות סמכותיים (מאומתים במחקר 30.5.2026)
**ממשל-AI שיפוטי + מבנה החלטה מנומקת**
- NCSC / JTC — *Court Technology Standards* + *Principles & Practices for AI Use in Courts*. https://www.ncsc.org/our-centers-projects/joint-technology-committee/court-technology-standards
- Federal Judicial Center — *Judicial Writing Manual* (2d ed.). https://www.fjc.gov/content/judicial-writing-manual-pocket-guide-judges-second-edition
- Council of Europe / CEPEJ — *European Ethical Charter on the use of AI in judicial systems* (2018).
- *South Buckinghamshire DC v Porter (No 2)* [2004] UKHL 33 (adequacy of reasons). https://publications.parliament.uk/pa/ld200304/ldjudgmt/jd040701/south-1.htm
- חוק לתיקון סדרי המינהל (החלטות והנמקות), תשי"ט-1958. https://www.nevo.co.il/law_html/law00/98603.htm
- Kevin D. Ashley — *Artificial Intelligence and Legal Analytics* (CUP).
**אחזור / RAG / IR**
- Lewis et al. (2020) — *Retrieval-Augmented Generation* (NeurIPS). https://arxiv.org/abs/2005.11401
- Manning, Raghavan & Schütze — *Introduction to Information Retrieval* (CUP, 2008). https://nlp.stanford.edu/IR-book/
- Elastic — *Reciprocal Rank Fusion*. https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion
- Pinecone — *Implement multitenancy*. https://docs.pinecone.io/guides/index-data/implement-multitenancy
- Weaviate — *Hybrid Search Explained*. https://weaviate.io/blog/hybrid-search-explained
**שלמות-נתונים / איכות / רשומות**
- DAMA-DMBOK2 + DAMA-UK — *Six Primary Dimensions for Data Quality* (2013).
- ISO 8000 — Data quality (8000-8/61/110).
- ISO 15489-1:2016 — Records management (authenticity/reliability/integrity/usability).
- Martin Kleppmann — *Designing Data-Intensive Applications* (O'Reilly, 2017).
- E.F. Codd — Relational model & normalization (CACM 13(6), 1970).
- Martin Fowler — Canonical Data Model / Data Mesh (quality-at-source).
---
## 10. השלב הבא
לאחר ביקורת חיים על מסמך זה → invoke `writing-plans` לבניית תוכנית-יישום מפורטת לתת-פרויקט 1 (כתיבת קבצי הספ-set, החל מ-`00-constitution.md`).

View File

@@ -0,0 +1,13 @@
"""legal-chat-service — host-side SSE bridge to ``claude`` CLI.
Runs as a pm2-managed process on the host (port 127.0.0.1:8770 by default).
The legal-ai FastAPI container proxies chat requests to it via
``host.docker.internal:8770``.
Why a separate service:
The chat needs real-time streaming + multi-turn session continuation
(``claude --resume <session_id>``). The container can't run the
claude CLI (no binary, no claude.ai credentials). Splitting this out
keeps the architectural rule of ``claude_session.py`` intact while
enabling the new chat feature for free (no API key).
"""

View File

@@ -0,0 +1,210 @@
"""HTTP+SSE bridge from FastAPI (in container) to local claude CLI.
Endpoints:
POST /chat/start — body: {prompt, system?, resume_session_id?}
returns SSE stream of events from
``claude_session.query_streaming``.
REQUIRES Authorization: Bearer <secret>.
GET /health — liveness probe (no auth — used by FastAPI for status).
Run with pm2:
pm2 start scripts/legal-chat-service.config.cjs
Standalone for dev:
cd ~/legal-ai/mcp-server
LEGAL_CHAT_SHARED_SECRET=... .venv/bin/python -m legal_mcp.chat_service.server \
--port 8770 --host 10.0.1.1
Security posture
----------------
1. Bind defaults to ``10.0.1.1`` — the host's docker0 bridge gateway.
Containers on docker bridges (including the legal-ai container, which
sits on the ``coolify`` network but routes to docker0 at the host)
can reach this address; processes outside the host cannot. Binding to
``0.0.0.0`` is permitted but discouraged (relies on the cloud-level
firewall as the sole perimeter).
2. ``/chat/start`` requires a ``Authorization: Bearer <LEGAL_CHAT_SHARED_SECRET>``
header. The secret is loaded from the environment; without it set,
the server refuses to start (no fallback to "open" mode, by design —
the claude CLI it spawns can run arbitrary tool calls, so an
unauthenticated /chat/start is RCE-equivalent).
3. ``/health`` is intentionally unauthenticated so the FastAPI proxy
can probe liveness with no token. It returns only a static OK and
never spawns subprocesses, so it can't be abused.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import os
import sys
from typing import Any
from aiohttp import web
# Run-via-CLI bootstrap so ``python -m legal_mcp.chat_service.server``
# works even when the package isn't installed (it is in the venv, but
# this safeguard keeps the entrypoint robust).
_pkg_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
if _pkg_root not in sys.path:
sys.path.insert(0, _pkg_root)
from legal_mcp.services import claude_session # noqa: E402
logger = logging.getLogger("legal_chat_service")
# Loaded once at startup. Validated to be non-empty in main(); the handler
# uses a constant-time compare to avoid timing oracles on a short input.
_SHARED_SECRET: str = ""
async def health(request: web.Request) -> web.Response:
return web.json_response({"ok": True, "service": "legal-chat-service"})
def _check_bearer(request: web.Request) -> web.Response | None:
"""Validate ``Authorization: Bearer <secret>``. Returns 401 response on failure."""
auth = request.headers.get("Authorization", "")
expected = "Bearer " + _SHARED_SECRET
# ``compare_digest`` defends against timing attacks. Strings of different
# length still leak length, but for a 43-char urlsafe token that's
# uninteresting and the auth scheme prefix anchors it anyway.
import hmac
if not auth or not hmac.compare_digest(auth, expected):
return web.json_response(
{"error": "unauthorized: missing or invalid Bearer token"},
status=401,
)
return None
async def chat_start(request: web.Request) -> web.StreamResponse:
"""Drive ``claude_session.query_streaming`` and forward events as SSE.
Request body (JSON):
prompt: str — required, user message
system: str | None — system instructions (ignored if resuming)
resume_session_id: str | None — continue a prior CLI session
timeout: int = 3600 — hard timeout for the subprocess
"""
unauth = _check_bearer(request)
if unauth is not None:
return unauth
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"error": "invalid JSON body"}, status=400)
prompt = body.get("prompt") or ""
if not prompt.strip():
return web.json_response({"error": "prompt is required"}, status=400)
system = body.get("system")
resume_session_id = body.get("resume_session_id")
timeout = int(body.get("timeout") or 3600)
response = web.StreamResponse(
status=200,
reason="OK",
headers={
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
# X-Accel-Buffering=no defeats nginx/traefik buffering — the
# FastAPI container proxies via httpx and forwards bytes as
# they arrive, but the inner header is harmless and makes
# browser-direct testing easier.
"X-Accel-Buffering": "no",
},
)
await response.prepare(request)
async def send_event(payload: dict[str, Any]) -> None:
line = f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
await response.write(line.encode("utf-8"))
try:
async for event in claude_session.query_streaming(
prompt,
system=system,
resume_session_id=resume_session_id,
timeout=timeout,
):
await send_event(event)
if event.get("type") == "done" or event.get("type") == "error":
break
except asyncio.CancelledError:
# Client disconnected — bail cleanly.
logger.info("chat_start: client disconnected")
except Exception as e:
logger.exception("chat_start: streaming failed")
try:
await send_event({"type": "error", "message": str(e)})
except ConnectionResetError:
pass
try:
await response.write_eof()
except ConnectionResetError:
pass
return response
def build_app() -> web.Application:
app = web.Application()
app.router.add_get("/health", health)
app.router.add_post("/chat/start", chat_start)
return app
def main() -> int:
parser = argparse.ArgumentParser(description="legal-chat-service")
parser.add_argument("--port", type=int, default=8770)
parser.add_argument(
"--host", default="10.0.1.1",
help=(
"bind address. Default 10.0.1.1 = docker0 bridge gateway — "
"reachable from containers, invisible to non-host networks. "
"Use 127.0.0.1 for host-local dev; do not bind 0.0.0.0 "
"without a separate perimeter firewall."
),
)
parser.add_argument("--log-level", default="INFO")
args = parser.parse_args()
logging.basicConfig(
level=args.log_level.upper(),
format="%(asctime)s %(name)s %(levelname)s %(message)s",
)
secret = os.environ.get("LEGAL_CHAT_SHARED_SECRET", "").strip()
if not secret:
logger.error(
"LEGAL_CHAT_SHARED_SECRET is empty; refusing to start. "
"Set it in /home/chaim/.legal-chat-service.env (loaded by "
"pm2) and mirror it as a Coolify env var on the legal-ai app."
)
return 2
if len(secret) < 24:
logger.error(
"LEGAL_CHAT_SHARED_SECRET is too short (got %d chars); "
"refusing to start. Use >=32 chars (e.g. python3 -c "
"'import secrets; print(secrets.token_urlsafe(32))').",
len(secret),
)
return 2
global _SHARED_SECRET
_SHARED_SECRET = secret
app = build_app()
logger.info("legal-chat-service listening on %s:%d", args.host, args.port)
web.run_app(app, host=args.host, port=args.port, print=lambda _msg: None)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -87,6 +87,20 @@ MULTIMODAL_TEXT_WEIGHT = float(
# concentrate weight at top ranks; higher values flatten the curve. # concentrate weight at top ranks; higher values flatten the curve.
MULTIMODAL_RRF_K = int(os.environ.get("MULTIMODAL_RRF_K", "60")) MULTIMODAL_RRF_K = int(os.environ.get("MULTIMODAL_RRF_K", "60"))
# BM25/lexical hybrid — fuse ``ts_rank_cd`` over ``content_tsv``/
# ``rule_tsv`` (DB schema V12) with the semantic cosine layer via RRF.
# Recovers recall on exact-string queries that voyage embeddings blur
# (e.g. case-number citations like "1461/20", "317/10"; rare planning
# vocabulary). Hebrew uses the ``simple`` text-search config — no
# stemmer needed, and numeric/punctuation tokens stay intact. When
# disabled, hybrid search falls back to semantic-only (the previous
# behaviour). On by default — the lexical leg is cheap (GIN index) and
# only ever *adds* candidates to RRF, it can't down-rank a strong
# semantic hit.
BM25_HYBRID_ENABLED = (
os.environ.get("BM25_HYBRID_ENABLED", "true").lower() == "true"
)
# Halacha extraction — auto-approve threshold. Halachot with extractor # Halacha extraction — auto-approve threshold. Halachot with extractor
# confidence >= this value are inserted with review_status='approved' # confidence >= this value are inserted with review_status='approved'
# instead of 'pending_review' (so they immediately appear in # instead of 'pending_review' (so they immediately appear in
@@ -118,6 +132,43 @@ def find_case_dir(case_number: str) -> Path:
CHUNK_SIZE_TOKENS = 600 CHUNK_SIZE_TOKENS = 600
CHUNK_OVERLAP_TOKENS = 100 CHUNK_OVERLAP_TOKENS = 100
# Parent-doc retrieval (TaskMaster #48) — hierarchical chunking + lookup.
# When enabled:
# - The ingest pipeline emits two tiers of precedent_chunks: small
# "child" chunks (~300 tokens) for high-recall semantic/lexical
# matching, and larger "parent" chunks (~1500 tokens) that contain
# ~5 children each. Children are embedded and indexed; parents
# carry the broader text the LLM gets back.
# - Search runs against children, then swaps each hit for its parent
# row before returning — so the writer sees a coherent passage
# instead of a 300-token sliver.
#
# Off by default: the schema (V17) is safe to apply even when the flag
# is false (the chunker still emits single-tier chunks and search just
# returns them unchanged). Flip to true ONLY after the corpus has been
# re-ingested with the hierarchical chunker — see precedent_library
# ingest pipeline + the backfill plan in TaskMaster #48.
PARENT_DOC_RETRIEVAL_ENABLED = (
os.environ.get("PARENT_DOC_RETRIEVAL_ENABLED", "false").lower() == "true"
)
# Child chunks are what get embedded + matched. Smaller = higher recall,
# more rows. 300 tokens (~600 chars Hebrew) is the empirical sweet spot
# referenced in the original parent-doc literature (Anthropic, LlamaIndex).
PARENT_DOC_CHILD_SIZE_TOKENS = int(
os.environ.get("PARENT_DOC_CHILD_SIZE_TOKENS", "300")
)
# Parent chunks are what get returned to the LLM. Large enough to hold
# a full rule statement plus the surrounding paragraph and any cited
# authority. 1500 tokens = ~5 children at 300 each.
PARENT_DOC_PARENT_SIZE_TOKENS = int(
os.environ.get("PARENT_DOC_PARENT_SIZE_TOKENS", "1500")
)
# Child overlap — keeps neighbouring children sharing ~50 tokens so a
# sentence on a chunk boundary still matches the natural phrasing.
PARENT_DOC_CHILD_OVERLAP_TOKENS = int(
os.environ.get("PARENT_DOC_CHILD_OVERLAP_TOKENS", "50")
)
# External service allowlist — case materials may ONLY be sent to these domains # External service allowlist — case materials may ONLY be sent to these domains
ALLOWED_EXTERNAL_SERVICES = { ALLOWED_EXTERNAL_SERVICES = {
"api.voyageai.com", # Voyage AI (embeddings) "api.voyageai.com", # Voyage AI (embeddings)

View File

@@ -53,6 +53,11 @@ mcp = FastMCP(
from legal_mcp.tools import ( # noqa: E402 from legal_mcp.tools import ( # noqa: E402
cases, documents, search, drafting, workflow, precedents, cases, documents, search, drafting, workflow, precedents,
precedent_library as plib, precedent_library as plib,
internal_decisions as int_tools,
legal_arguments as la_tools,
missing_precedents as mp_tools,
citations as cit_tools,
training_enrichment as train_tools,
) )
@@ -196,11 +201,20 @@ 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,
) -> str: ) -> str:
"""רשימת הפסיקה בקורפוס הסמכותי, עם פילטרים.""" """רשימת הפסיקה בקורפוס, עם פילטרים.
source_kind: 'external_upload' (ברירת מחדל — פס"ד בתי משפט) /
'internal_committee' (החלטות ועדות ערר ערר/בל"מ שהועלו) /
'all_committees' (שתיהן — internal + appeals_committee).
החלטות ערר/בל"מ שמעלים נשמרות כ-internal_committee — כדי לראותן
ברשימה השתמש ב-source_kind='internal_committee' או 'all_committees'.
"""
return await plib.precedent_library_list( return await plib.precedent_library_list(
practice_area, court, precedent_level, source_type, search, limit, practice_area, court, precedent_level, source_type, search,
source_kind, limit,
) )
@@ -244,6 +258,18 @@ async def precedent_extract_metadata(case_law_id: str) -> str:
return await plib.precedent_extract_metadata(case_law_id) return await plib.precedent_extract_metadata(case_law_id)
@mcp.tool()
async def style_corpus_enrich(corpus_id: str, overwrite: bool = False) -> str:
"""חילוץ מטא-דאטה (summary, outcome, key_principles, appeal_subtype) להחלטה בקורפוס הסגנון של דפנה. ברירת מחדל: ממלא רק שדות ריקים. שלח `overwrite=true` כדי לרענן."""
return await train_tools.extract_decision_metadata(corpus_id, overwrite=overwrite)
@mcp.tool()
async def style_corpus_pending_enrichment(limit: int = 50) -> str:
"""רשימת החלטות בקורפוס הסגנון שעדיין חסרות summary/outcome/key_principles — מועמדות לחילוץ."""
return await train_tools.list_corpus_pending_enrichment(limit)
@mcp.tool() @mcp.tool()
async def precedent_process_pending(kind: str = "metadata", limit: int = 20) -> str: async def precedent_process_pending(kind: str = "metadata", limit: int = 20) -> str:
"""ריקון תור בקשות חילוץ שנשלחו מ-UI. kind: 'metadata' או 'halacha'. מריץ extractor מקומית עם CLI על כל פריט בתור, ומנקה את הסימון אחרי הצלחה.""" """ריקון תור בקשות חילוץ שנשלחו מ-UI. kind: 'metadata' או 'halacha'. מריץ extractor מקומית עם CLI על כל פריט בתור, ומנקה את הסימון אחרי הצלחה."""
@@ -363,6 +389,28 @@ async def get_claims(
return await documents.get_claims(case_number, party_role) return await documents.get_claims(case_number, party_role)
# Legal arguments — aggregated (de-duped) propositions
@mcp.tool()
async def aggregate_claims_to_arguments(
case_number: str,
force: bool = False,
) -> str:
"""כינוס פרופוזיציות גולמיות (claims) לטיעונים משפטיים מובחנים — ~6-12 לכל צד.
משתמש ב-Claude headless לסיווג ואיגוד. force=True מוחק טיעונים קיימים לפני חישוב מחדש.
"""
return await la_tools.aggregate_claims_to_arguments(case_number, force=force)
@mcp.tool()
async def get_legal_arguments(
case_number: str,
party: str = "",
) -> str:
"""שליפת טיעונים משפטיים מאוגדים. party: appellant/respondent/committee/permit_applicant (ריק=הכל)."""
return await la_tools.get_legal_arguments(case_number, party)
# References # References
@mcp.tool() @mcp.tool()
async def extract_references( async def extract_references(
@@ -422,6 +470,7 @@ async def search_internal_decisions(
chair_name: str = "", chair_name: str = "",
limit: int = 10, limit: int = 10,
include_halachot: bool = True, include_halachot: bool = True,
include_cited_by: bool = False,
) -> str: ) -> str:
"""חיפוש בהחלטות ועדות ערר לתכנון ובנייה (כל המחוזות). """חיפוש בהחלטות ועדות ערר לתכנון ובנייה (כל המחוזות).
@@ -436,9 +485,13 @@ async def search_internal_decisions(
chair_name: שם יו"ר הוועדה לסינון. ריק = כל היו"רים chair_name: שם יו"ר הוועדה לסינון. ריק = כל היו"רים
limit: מספר תוצאות מקסימלי limit: מספר תוצאות מקסימלי
include_halachot: האם לכלול הלכות שחולצו include_halachot: האם לכלול הלכות שחולצו
include_cited_by: True = הוסף תוצאות עקיפות — לכל hit הוסף גם החלטות
שהוא מצטט (מתוך citation graph). שימושי לחיפוש "כל הקשור ל-X"
כשרוצים להרחיב מעבר לטקסט המקורי. default False.
""" """
return await search.search_internal_decisions( return await search.search_internal_decisions(
query, practice_area, appeal_subtype, district, chair_name, limit, include_halachot, query, practice_area, appeal_subtype, district, chair_name, limit, include_halachot,
include_cited_by=include_cited_by,
) )
@@ -662,6 +715,183 @@ async def internal_decision_enrich(
return _json.dumps(result, ensure_ascii=False, indent=2) return _json.dumps(result, ensure_ascii=False, indent=2)
@mcp.tool()
async def internal_decision_upload(
file_path: str,
case_number: str,
chair_name: str,
district: str,
case_name: str = "",
court: str = "",
decision_date: str = "",
practice_area: str = "",
appeal_subtype: str = "",
subject_tags: list[str] | None = None,
summary: str = "",
is_binding: bool = False,
) -> str:
"""העלאת החלטה של ועדת ערר (internal_committee) לקורפוס הסמכותי.
שדות חובה: file_path, case_number, chair_name, district.
שמירת ההחלטה עוברת דרך ingest_internal_decision — תויג source_kind='internal_committee' אוטומטית.
district תקין: ירושלים / מרכז / תל אביב / צפון / דרום / חיפה / ארצי.
בניגוד ל-precedent_library_upload (שתמיד שומר external_upload),
הכלי הזה הוא הנתיב המוסמך להחלטות ועדת ערר ומכריח chair_name+district.
"""
return await int_tools.internal_decision_upload(
file_path=file_path,
case_number=case_number,
chair_name=chair_name,
district=district,
case_name=case_name,
court=court,
decision_date=decision_date,
practice_area=practice_area,
appeal_subtype=appeal_subtype,
subject_tags=subject_tags,
summary=summary,
is_binding=is_binding,
)
# ── Missing precedents (TaskMaster #35) ───────────────────────────
@mcp.tool()
async def missing_precedent_create(
citation: str,
case_number: str = "",
cited_in_document_id: str = "",
cited_by_party: str = "unknown",
cited_by_party_name: str = "",
legal_topic: str = "",
legal_issue: str = "",
claim_quote: str = "",
case_name: str = "",
notes: str = "",
) -> str:
"""תיעוד פסיקה שצוטטה בכתבי הטענות אך אינה בקורפוס.
שימוש: סוכן המחקר (legal-researcher) קורא לזה כשהוא מזהה ציטוט שלא
ניתן לאמת מול הקורפוס. הרשומה נשארת 'open' עד שהיו"ר מעלה את הפסיקה.
cited_by_party: appellant / respondent / committee / permit_applicant / unknown.
דה-דופ אוטומטי: ציטוט+תיק זהים → מחזיר את הרשומה הקיימת.
"""
return await mp_tools.missing_precedent_create(
citation=citation,
case_number=case_number,
cited_in_document_id=cited_in_document_id,
cited_by_party=cited_by_party,
cited_by_party_name=cited_by_party_name,
legal_topic=legal_topic,
legal_issue=legal_issue,
claim_quote=claim_quote,
case_name=case_name,
notes=notes,
)
@mcp.tool()
async def missing_precedent_list(
case_number: str = "",
status: str = "open",
legal_topic: str = "",
limit: int = 50,
) -> str:
"""רשימת פסיקות חסרות לתיק או בכלל. status: open/uploaded/closed/irrelevant.
שימוש: היו"ר רואה מה ממתין להעלאה; הסוכן מאשר שלא יוצר כפילויות.
"""
return await mp_tools.missing_precedent_list(
case_number=case_number,
status=status,
legal_topic=legal_topic,
limit=limit,
)
@mcp.tool()
async def missing_precedent_close(
id: str,
linked_case_law_id: str = "",
notes: str = "",
status: str = "closed",
) -> str:
"""סגירת רשומת פסיקה חסרה לאחר העלאה לקורפוס.
status: closed (הועלה ונקשר) / uploaded (הועלה, ממתין לקישור) /
irrelevant (היו"ר החליט שזה לא רלוונטי לקורפוס).
"""
return await mp_tools.missing_precedent_close(
id=id,
linked_case_law_id=linked_case_law_id,
notes=notes,
status=status,
)
# ── Internal citations graph (TaskMaster #34) ─────────────────────
@mcp.tool()
async def extract_internal_citations(
case_law_id: str = "",
chair_name: str = "",
limit: int = 0,
) -> str:
"""חילוץ ציטוטים פנימיים מהחלטות ועדת ערר ושמירה ב-citation graph.
משתמש בדפוסי regex עבריים ("ונפנה ל…", "כפי שקבעתי…", "ראה החלטתי…")
לזיהוי הפניות בין החלטות. אם case_law_id סופק — מריץ על שורה אחת
(שימושי אחרי upload). אם chair_name סופק — מריץ על כל ההחלטות של
אותו יו"ר. אם שניהם ריקים — מריץ על כל ה-internal_committee corpus.
איידמפוטנטי: ניתן להריץ שוב ושוב בלי כפילויות. ציטוטים שמופנים
להחלטות שעדיין לא בקורפוס נשמרים כ-unlinked (cited_case_law_id=NULL)
ויראו ב-list_internal_citations כשהיו"ר יחליט אם להעלות אותן.
"""
return await cit_tools.extract_internal_citations(
case_law_id=case_law_id,
chair_name=chair_name,
limit=limit,
)
@mcp.tool()
async def list_internal_citations(
case_law_id: str = "",
linked_only: bool = False,
limit: int = 50,
) -> str:
"""רשימת ציטוטים יוצאים מהחלטה (מה ההחלטה מצטטת).
משתמש לקבלת תמונה של בסיס הפסיקה שהחלטה הסתמכה עליו.
linked_only=True מסנן רק ציטוטים שזוהו ב-case_law של הקורפוס.
"""
return await cit_tools.list_internal_citations(
case_law_id=case_law_id,
linked_only=linked_only,
limit=limit,
)
@mcp.tool()
async def list_incoming_citations(
case_law_id: str = "",
limit: int = 50,
) -> str:
"""רשימת ציטוטים נכנסים אל החלטה (אילו החלטות מצטטות אותה).
שימוש: רוצים לדעת אילו החלטות של דפנה (או של ועדות אחרות) הסתמכו
על פסק דין מסוים — מעבירים את ה-case_law_id של פסק הדין.
"""
return await cit_tools.list_incoming_citations(
case_law_id=case_law_id,
limit=limit,
)
@mcp.tool() @mcp.tool()
async def record_chair_feedback( async def record_chair_feedback(
case_number: str, case_number: str,

View File

@@ -250,8 +250,19 @@ async def extract_appraiser_facts(case_id: UUID) -> dict:
conflicts = await db.detect_appraiser_conflicts(case_id) conflicts = await db.detect_appraiser_conflicts(case_id)
# Don't swallow extractor failures: if every appraisal errored and no
# facts were extracted, surface that as a distinct status instead of
# the misleading "completed, 0 facts" we used to return — the caller
# (and the UI) need to know that nothing actually ran.
all_errored = (
total_facts == 0
and by_doc
and all(d.get("status") == "error" for d in by_doc)
)
status = "extraction_failed" if all_errored else "completed"
return { return {
"status": "completed", "status": status,
"appraisal_count": len(appraisals), "appraisal_count": len(appraisals),
"total_facts": total_facts, "total_facts": total_facts,
"conflicts": conflicts, "conflicts": conflicts,

View File

@@ -0,0 +1,358 @@
"""כינוס פרופוזיציות לטיעונים משפטיים מובחנים — argument de-duplication.
Workflow:
1. ``claims_extractor`` extracts ~20-30 raw propositions per litigation
brief into the ``claims`` table.
2. This module groups those raw propositions, per party, into 6-12
distinct legal arguments via Claude headless (`claude_session`).
3. The result is stored in ``legal_arguments`` plus ``legal_argument_
propositions`` (M:M join) so we keep traceability back to the source
claims.
Manually de-duping 184 propositions in 3 cases yielded 82 arguments
(~24/case) — see ``data/cases/{1017,1018,1019}-03-26/documents/research/
legal-arguments.md`` for the gold standard.
**Architectural constraint**: ``claude_session`` only works from the local
MCP server (Claude CLI is not installed in the FastAPI container). Calls
from ``web/`` must go through MCP tools; calls from MCP tools land here
directly.
"""
from __future__ import annotations
import json
import logging
from uuid import UUID
from legal_mcp.services import claude_session, db
logger = logging.getLogger(__name__)
# Allowed enum values mirror the DB CHECK constraints.
ALLOWED_PARTIES = {"appellant", "respondent", "committee", "permit_applicant", "unknown"}
ALLOWED_PRIORITIES = {"threshold", "substantive", "procedural", "relief"}
# Hebrew labels for the prompt (Claude needs context in the same
# language as the source material).
PARTY_LABELS_HE = {
"appellant": "עוררים",
"respondent": "משיבים",
"committee": "ועדה מקומית",
"permit_applicant": "מבקשי היתר",
"unknown": "צד לא מזוהה",
}
AGGREGATE_PROMPT_TEMPLATE = """אתה מנתח כתבי טענות בתחום תכנון ובנייה (ועדת ערר).
לפניך {n} פרופוזיציות גולמיות שחולצו ממסמכי {party_he} בתיק ערר.
מטרתך: לקבץ אותן ל-{target_min}-{target_max} **טיעונים משפטיים מובחנים**
(ארגומנטים אמיתיים, לא חזרה מילולית של הפרופוזיציות).
## כללי איגוד:
1. **טיעון אמיתי = רעיון משפטי אחד** — לא רשימה של פרופוזיציות, אלא טענה משפטית עצמאית.
2. **מקבצים פרופוזיציות שתומכות באותו רעיון משפטי** — גם אם הניסוח שלהן שונה.
3. **מפרידים בין סוגי טענות**:
- **threshold** = טענות סף (זכות עמידה, סמכות, מועדים, שיהוי)
- **substantive** = טענות מהותיות (תחולת חוק, פרשנות, חישוב)
- **procedural** = פגמי הליך (פרסום, פרוטוקול, ניגוד עניינים)
- **relief** = סעדים מבוקשים / סיכומים
4. **כותרת קצרה ובהירה** — תיאורית, לא משפטית מפורטת. 5-15 מילים.
5. **גוף הטיעון בפסקה אחת** — 3-7 שורות עברית, נאמן למקור.
6. **שמירת ה-claim_ids המקוריים** — לכל טיעון, רשום אילו פרופוזיציות תומכות בו.
## פלט:
החזר JSON בלבד (ללא markdown, ללא הסברים), array של אובייקטים:
```
[
{{
"title": "כותרת קצרה של הטיעון",
"body": "גוף הטיעון בפסקה אחת",
"topic": "סוגיה משפטית קצרה (לדוגמה: 'זכות עמידה', 'תחולת תמ\\"א 38')",
"priority": "threshold|substantive|procedural|relief",
"claim_ids": ["uuid-1", "uuid-2"]
}}
]
```
## הפרופוזיציות:
{propositions_json}
"""
def _build_prompt(party: str, propositions: list[dict]) -> str:
"""Compose the per-party aggregation prompt."""
n = len(propositions)
# Conservative target: ~1 argument per 2-3 propositions, clamped 4-12.
target_min = max(4, n // 4)
target_max = max(target_min + 1, min(12, n // 2 + 1))
party_he = PARTY_LABELS_HE.get(party, party)
# Strip noise from propositions for the prompt — Claude only needs
# the id and the text to do the grouping.
compact = [
{"id": str(p["id"]), "text": p["claim_text"]}
for p in propositions
]
propositions_json = json.dumps(compact, ensure_ascii=False, indent=2)
return AGGREGATE_PROMPT_TEMPLATE.format(
n=n,
party_he=party_he,
target_min=target_min,
target_max=target_max,
propositions_json=propositions_json,
)
def _normalize_argument(raw: dict, fallback_topic: str = "") -> dict | None:
"""Validate & normalize a single argument dict from Claude.
Returns None if the row is unusable (missing required fields).
"""
if not isinstance(raw, dict):
return None
title = (raw.get("title") or "").strip()
body = (raw.get("body") or "").strip()
if not title or not body:
return None
priority = raw.get("priority", "substantive")
if priority not in ALLOWED_PRIORITIES:
priority = "substantive"
topic = (raw.get("topic") or fallback_topic or "").strip() or None
claim_ids_raw = raw.get("claim_ids") or []
claim_ids: list[UUID] = []
if isinstance(claim_ids_raw, list):
for cid in claim_ids_raw:
try:
claim_ids.append(UUID(str(cid)))
except (ValueError, TypeError):
continue
return {
"title": title,
"body": body,
"topic": topic,
"priority": priority,
"claim_ids": claim_ids,
}
async def _aggregate_party(
party: str, propositions: list[dict],
) -> list[dict]:
"""Ask Claude to group one party's propositions; return normalized rows."""
if not propositions:
return []
prompt = _build_prompt(party, propositions)
try:
raw_result = await claude_session.query_json(prompt)
except RuntimeError as e:
# Surface CLI-unavailable specifically so the caller can report
# cleanly instead of crashing the whole job.
raise RuntimeError(
f"argument_aggregator: claude_session.query_json failed for party "
f"'{party}': {e}"
) from e
if not isinstance(raw_result, list):
logger.warning(
"argument_aggregator: Claude returned non-list (%s) for party '%s'",
type(raw_result).__name__, party,
)
return []
out: list[dict] = []
for entry in raw_result:
norm = _normalize_argument(entry)
if norm:
out.append(norm)
return out
async def aggregate_claims_to_arguments(
case_id: UUID, force: bool = False,
) -> dict:
"""For a given case, group existing claims into distinct legal arguments.
Args:
case_id: The case UUID.
force: If True, delete existing ``legal_arguments`` for the case
before aggregating. Otherwise short-circuit if any rows exist.
Returns:
A summary dict:
``{"status": "completed"|"skipped"|"no_claims"|"llm_unavailable",
"by_party": {party: count}, "total": int, "message": ...}``
"""
pool = await db.get_pool()
async with pool.acquire() as conn:
existing = await conn.fetchval(
"SELECT COUNT(*) FROM legal_arguments WHERE case_id = $1",
case_id,
)
if existing and not force:
return {
"status": "skipped",
"message": f"Found {existing} existing arguments. Use force=True to re-run.",
"total": existing,
}
if force and existing:
await conn.execute(
"DELETE FROM legal_arguments WHERE case_id = $1", case_id,
)
# Pull all claims for this case, grouped by party.
rows = await conn.fetch(
"""SELECT id, party_role, claim_text, claim_index, source_document
FROM claims
WHERE case_id = $1
ORDER BY party_role, claim_index""",
case_id,
)
if not rows:
return {
"status": "no_claims",
"message": "No claims found for this case. Run extract_claims first.",
"total": 0,
}
# Group propositions by party.
by_party: dict[str, list[dict]] = {}
for r in rows:
party = r["party_role"]
# Map deprecated 'appraiser' or unknown labels to 'unknown'.
if party not in ALLOWED_PARTIES:
party = "unknown"
by_party.setdefault(party, []).append(dict(r))
party_counts: dict[str, int] = {}
inserted = 0
errors: list[str] = []
for party, props in by_party.items():
try:
arguments = await _aggregate_party(party, props)
except RuntimeError as e:
# Most likely cause: Claude CLI not installed (running from
# the container). Don't crash — record the gap and continue.
msg = str(e)
if "Claude CLI not found" in msg:
return {
"status": "llm_unavailable",
"message": (
"Claude CLI not available. This service must run from "
"the local MCP server (not the FastAPI container)."
),
"total": 0,
}
errors.append(f"{party}: {msg}")
continue
if not arguments:
party_counts[party] = 0
continue
async with pool.acquire() as conn:
async with conn.transaction():
for idx, arg in enumerate(arguments):
arg_id = await conn.fetchval(
"""INSERT INTO legal_arguments
(case_id, party, argument_index, argument_title,
argument_body, legal_topic, priority)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id""",
case_id,
party,
idx + 1,
arg["title"],
arg["body"],
arg["topic"],
arg["priority"],
)
for cid in arg["claim_ids"]:
try:
await conn.execute(
"""INSERT INTO legal_argument_propositions
(argument_id, claim_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING""",
arg_id, cid,
)
except Exception as e: # noqa: BLE001
# Likely FK violation if the LLM hallucinated
# a claim_id. Log and continue.
logger.warning(
"argument_aggregator: skipped bad claim_id %s for arg %s: %s",
cid, arg_id, e,
)
inserted += 1
party_counts[party] = len(arguments)
result: dict = {
"status": "completed",
"total": inserted,
"by_party": party_counts,
"propositions_processed": len(rows),
}
if errors:
result["errors"] = errors
result["status"] = "completed_with_errors"
return result
async def get_legal_arguments(
case_id: UUID, party: str = "",
) -> list[dict]:
"""Return aggregated legal arguments for a case, optionally filtered by party.
Each row includes ``supporting_claims`` (list of source claim_ids).
"""
pool = await db.get_pool()
async with pool.acquire() as conn:
if party and party in ALLOWED_PARTIES:
rows = await conn.fetch(
"""SELECT id, case_id, party, argument_index, argument_title,
argument_body, legal_topic, priority, cited_precedents,
created_at, updated_at
FROM legal_arguments
WHERE case_id = $1 AND party = $2
ORDER BY priority, argument_index""",
case_id, party,
)
else:
rows = await conn.fetch(
"""SELECT id, case_id, party, argument_index, argument_title,
argument_body, legal_topic, priority, cited_precedents,
created_at, updated_at
FROM legal_arguments
WHERE case_id = $1
ORDER BY party, priority, argument_index""",
case_id,
)
# Pull supporting claim ids for each argument in one round-trip.
arg_ids = [r["id"] for r in rows]
supporting: dict[UUID, list[str]] = {}
if arg_ids:
joins = await conn.fetch(
"""SELECT argument_id, claim_id
FROM legal_argument_propositions
WHERE argument_id = ANY($1::uuid[])""",
arg_ids,
)
for j in joins:
supporting.setdefault(j["argument_id"], []).append(str(j["claim_id"]))
out: list[dict] = []
for r in rows:
d = dict(r)
d["id"] = str(d["id"])
d["case_id"] = str(d["case_id"])
d["supporting_claims"] = supporting.get(r["id"], [])
out.append(d)
return out

View File

@@ -1,4 +1,14 @@
"""Legal document chunker - splits text into sections and chunks for RAG.""" """Legal document chunker - splits text into sections and chunks for RAG.
The default :func:`chunk_document` emits a single tier of overlapping
chunks (legacy single-tier indexing). :func:`chunk_document_hierarchical`
emits two tiers — small "child" chunks for retrieval matching, plus
larger "parent" chunks that supply broader context to the LLM (parent-
doc retrieval, TaskMaster #48). The hierarchical variant lives
alongside the legacy one so callers can opt in via
``config.PARENT_DOC_RETRIEVAL_ENABLED`` without breaking existing
single-tier code paths.
"""
from __future__ import annotations from __future__ import annotations
@@ -87,13 +97,32 @@ def _assign_pages(chunks: list[Chunk], text: str, page_offsets: list[int]) -> No
pos = idx + max(1, len(c.content) // 2) pos = idx + max(1, len(c.content) // 2)
# A section shorter than this (stripped chars) is not a real section — it's
# an artifact of a header keyword matched mid-text. Such a fragment is merged
# into the preceding section rather than emitted as its own chunk. See #55:
# unanchored keywords like "דיון"/"החלטה"/"מסקנה" appearing inside a sentence
# used to carve tiny boundary chunks ("דיון). במסגרת ה") that polluted search.
MIN_SECTION_CHARS = 60
def _split_into_sections(text: str) -> list[tuple[str, str]]: def _split_into_sections(text: str) -> list[tuple[str, str]]:
"""Split text into (section_type, text) pairs based on Hebrew headers.""" """Split text into (section_type, text) pairs based on Hebrew headers.
Header keywords are matched only at the **start of a line** (after
optional whitespace / list numbering like ``5.`` or ``ג.``). A real
section header in these decisions sits on its own line; anchoring to
the line start prevents common words ("דיון", "החלטה", "מסקנה") that
appear mid-sentence from being treated as section boundaries — which
previously produced tiny fragment chunks (#55).
"""
# Find all section headers and their positions # Find all section headers and their positions
markers: list[tuple[int, str]] = [] markers: list[tuple[int, str]] = []
for pattern, section_type in SECTION_PATTERNS: for pattern, section_type in SECTION_PATTERNS:
for match in re.finditer(pattern, text): # ^ + MULTILINE: line start only. Optional leading spaces/tabs and an
# optional ordinal prefix ("5.", "5)", "ג.") before the keyword.
anchored = rf"^[ \t]*(?:\d+[.)]\s*|[א-ת][.)]\s*)?(?:{pattern})"
for match in re.finditer(anchored, text, re.MULTILINE):
markers.append((match.start(), section_type)) markers.append((match.start(), section_type))
if not markers: if not markers:
@@ -110,11 +139,18 @@ def _split_into_sections(text: str) -> list[tuple[str, str]]:
if intro_text: if intro_text:
sections.append(("intro", intro_text)) sections.append(("intro", intro_text))
# Each section # Each section. A section whose text is too short to stand alone is
# merged into the previous section (keeping the previous type) so a
# near-adjacent pair of headers can't produce a fragment chunk.
for i, (pos, section_type) in enumerate(markers): for i, (pos, section_type) in enumerate(markers):
end = markers[i + 1][0] if i + 1 < len(markers) else len(text) end = markers[i + 1][0] if i + 1 < len(markers) else len(text)
section_text = text[pos:end].strip() section_text = text[pos:end].strip()
if section_text: if not section_text:
continue
if len(section_text) < MIN_SECTION_CHARS and sections:
prev_type, prev_text = sections[-1]
sections[-1] = (prev_type, f"{prev_text}\n{section_text}")
else:
sections.append((section_type, section_text)) sections.append((section_type, section_text))
return sections return sections
@@ -162,3 +198,152 @@ def _split_section(text: str, chunk_size: int, overlap: int) -> list[str]:
def _estimate_tokens(text: str) -> int: def _estimate_tokens(text: str) -> int:
"""Rough token estimate for Hebrew text (~1.5 chars per token).""" """Rough token estimate for Hebrew text (~1.5 chars per token)."""
return max(1, len(text) // 2) return max(1, len(text) // 2)
# ── Parent-doc retrieval (TaskMaster #48) ────────────────────────────
# Hierarchical chunker — emits a list of (child, parent) pairs:
# * each "child" carries the smaller text used for embedding/search
# * each "parent" is shared by ~5 consecutive children (1500/300)
# The list is FLAT — both parents and children live in the same return
# list, distinguished by ``role``. A child's ``parent_local_id`` points
# back to its parent's ``local_id``, so the ingest pipeline can resolve
# the FK after the parent row is INSERTed and its DB UUID is known.
#
# Parents are built FIRST (one window of ``parent_size`` tokens per
# section, sliding by the parent window — no overlap between parents),
# then each parent is sub-divided into overlapping children. This keeps
# the parent boundary aligned with semantic sections (so a "discussion"
# parent doesn't contain stray "ruling" prose) while still allowing
# child overlap for recall.
@dataclass
class HierarchicalChunk:
"""One chunk in the two-tier hierarchy.
Both children and parents share this shape; ``role`` distinguishes
them. Children get an embedding at ingest time; parents do not —
they exist only to carry context back to the LLM at retrieval time.
``local_id`` is a stable in-batch identifier (sequential int) used
only by the ingest pipeline to wire children to their parent's DB
UUID after the parent INSERT returns. It is NOT persisted.
"""
content: str
role: str # 'child' | 'parent'
section_type: str = "other"
page_number: int | None = None
chunk_index: int = 0
local_id: int = -1
parent_local_id: int | None = None
def chunk_document_hierarchical(
text: str,
child_size: int = config.PARENT_DOC_CHILD_SIZE_TOKENS,
parent_size: int = config.PARENT_DOC_PARENT_SIZE_TOKENS,
overlap: int = config.PARENT_DOC_CHILD_OVERLAP_TOKENS,
page_offsets: list[int] | None = None,
) -> list[HierarchicalChunk]:
"""Split a document into a two-tier (child, parent) hierarchy.
Returns a flat list where each element is either a parent or a
child. Children carry ``parent_local_id`` pointing back to their
parent's ``local_id``. Caller (ingest pipeline) must insert parents
first, capture their DB UUIDs by ``local_id``, then insert children
with the resolved UUID in ``parent_chunk_id``.
Args:
text: full document text.
child_size: child chunk size in tokens (≈ 300 by default).
parent_size: parent chunk size in tokens (≈ 1500 by default).
Parents contain ``parent_size // child_size`` children on
average.
overlap: child-to-child overlap inside a parent (≈ 50 tokens).
Parents themselves do not overlap each other.
page_offsets: PDF page offsets for tagging chunks with page #.
Notes:
* Parents respect section boundaries (header detection from
:data:`SECTION_PATTERNS`). A "facts" parent will not include
"ruling" text.
* Empty text returns an empty list.
* Both child and parent rows are tagged with the page of their
first character.
"""
if not text.strip():
return []
if child_size <= 0 or parent_size <= 0:
raise ValueError("child_size and parent_size must be positive")
if child_size > parent_size:
raise ValueError("child_size must be <= parent_size")
sections = _split_into_sections(text)
out: list[HierarchicalChunk] = []
parent_idx = 0 # global parent ordinal (chunk_index for parents)
child_idx = 0 # global child ordinal (chunk_index for children)
local_id = 0 # sequential id within this document
for section_type, section_text in sections:
# Step 1: split section into parent-sized windows (no overlap).
parent_texts = _split_section(section_text, parent_size, overlap=0)
for parent_text in parent_texts:
parent_local = local_id
local_id += 1
parent_chunk = HierarchicalChunk(
content=parent_text,
role="parent",
section_type=section_type,
chunk_index=parent_idx,
local_id=parent_local,
parent_local_id=None,
)
out.append(parent_chunk)
parent_idx += 1
# Step 2: sub-divide this parent into overlapping children.
child_texts = _split_section(parent_text, child_size, overlap)
for ch_text in child_texts:
ch = HierarchicalChunk(
content=ch_text,
role="child",
section_type=section_type,
chunk_index=child_idx,
local_id=local_id,
parent_local_id=parent_local,
)
out.append(ch)
local_id += 1
child_idx += 1
if page_offsets:
_assign_pages_hierarchical(out, text, page_offsets)
return out
def _assign_pages_hierarchical(
chunks: list[HierarchicalChunk],
text: str,
page_offsets: list[int],
) -> None:
"""Page-tag both children and parents.
Same forward-scan strategy as :func:`_assign_pages` but works on
the hierarchical list. Parents may span pages; we tag them with
the page of their first character (matches how the multimodal
retriever joins on page numbers).
"""
from legal_mcp.services.extractor import page_at_offset
pos = 0
for c in chunks:
idx = text.find(c.content, pos)
if idx < 0:
idx = text.find(c.content)
if idx < 0:
continue
c.page_number = page_at_offset(idx, page_offsets)
# Advance past halfway — children share text with their parent
# and with each other (overlap), so a small forward step lets
# the next find() still pick up the right occurrence.
pos = idx + max(1, len(c.content) // 4)

View File

@@ -0,0 +1,434 @@
"""Internal citation graph extractor (TaskMaster #34).
When Daphna (or any other internal_committee chair) cites another committee
decision inside the body of a ruling, she uses fairly stable phrases:
"ונפנה לערר 1110/20 ירושלים שקופה …"
"כפי שקבעתי בערר 1041/24 …"
"בדומה לעמדתי בהחלטה ערר 8048/24 …"
"כפי שנקבע במחוז ת\"א בערר 1234/20 …"
"ראה החלטתי בערר 1015-01-24 …"
This module scans the ``full_text`` of internal-committee ``case_law`` rows,
extracts those citations via regex, tries to link each cited case_number to a
row already in ``case_law`` (any source_kind), and stores the result in
``precedent_internal_citations``. Unresolved citations are kept with
``cited_case_law_id = NULL`` so the chair can see what's missing from the
corpus (and ``search_internal_decisions`` can surface "cited but absent" gaps).
The result is a *citation graph* that downstream tools (search, researcher
agent) can join on to surface "decisions cited by this one" alongside
keyword/semantic hits — without re-running an LLM on every query.
Patterns are *intentionally* permissive: we accept stray Hebrew quote marks
(both straight ``"`` and curly ``״``), optional district parens, and several
trigger phrases. False positives are de-duplicated downstream by the
``UNIQUE (source_case_law_id, cited_case_number)`` constraint and by case-
number normalization (see ``_normalize_case_number``).
"""
from __future__ import annotations
import logging
import re
from typing import Iterator
from uuid import UUID
from legal_mcp.services import db
logger = logging.getLogger(__name__)
# ── Patterns ─────────────────────────────────────────────────────────
#
# Two pattern families:
# 1. Appeals-committee citations ("ערר" / "בל\"מ") — primary target.
# These are the ones we resolve against ``case_law``.
# 2. Court rulings ("עע\"מ", "בר\"מ", "עמ\"נ", "ע\"א", "בג\"ץ", "רע\"א").
# Stored as unlinked rows by default, so the researcher knows the
# decision quotes a higher court.
#
# Trigger words ("ונפנה", "כפי שקבעתי", "בדומה ל…", "ראה החלטתי",
# "כפי שנקבע") are *optional* — many citations appear without one (Daphna
# often introduces a quote with just "כפי שצוין בערר…"). We therefore
# match the citation core (prefix + number) and capture the surrounding
# sentence as context.
#
# Regex notes:
# * Hebrew gershayim/quotation: both straight (") and curly (״) are
# accepted via the character class [\"״].
# * Case numbers can be NNNN/YY, NNNN-YY, or NNNN-MM-YY (the third form
# is the Nevo "filed" format: 1015-01-24 means file #1015 of Jan 2024).
# * Optional district paren: ערר (ועדות ערר - תכנון ובנייה ירושלים)
# 1110/20 — we allow up to 60 chars of parenthetical content.
# * \b doesn't behave well with Hebrew, so we anchor by whitespace or
# punctuation lookarounds.
_TRIGGER = (
r"(?:ונפנה\s+ל|"
r"כפי\s+ש(?:קבעתי|נקבע|פסקתי)\s+ב|"
r"בדומה\s+ל(?:עמדתי\s+ב)?|"
r"ראה\s+(?:את\s+)?(?:החלטתי\s+ב|פסיקת\s+ה?ועדה\s+ב)?|"
r"בעניין\s+|"
r"בהחלטת(?:י|ה|נו)?\s+ב?)?"
)
# Optional district / committee parenthetical between the prefix and the
# case number. Matches things like "(ועדות ערר - תכנון ובנייה ירושלים)"
# or "(ירושלים)" or "(מרכז)". Up to 80 chars to be safe. Required actual
# parentheses (the `\(` and `\)` are NOT optional) — otherwise the regex
# greedily absorbs the next sentence's content and skips intermediate
# citations like "ראה גם ערר 1041/24 …\nכפי שקבעתי בערר (…) 1110/20".
_DISTRICT_PAREN = r"(?:\s*\([^)\n]{0,80}\)\s*)?"
# Case-number core: 3-5 digits, optional separator and 2-4 digits (and
# optional third group for the NNNN-MM-YY format).
_NUM_RX = r"(\d{3,5}(?:[-/]\d{2,4}(?:[-/]\d{2,4})?)?)"
_PATTERNS = [
# 1. Appeals-committee — ערר / בל"מ
(
"appeals_committee",
re.compile(
_TRIGGER
+ r"(ערר|בל[\"״]מ)"
+ _DISTRICT_PAREN
+ r"\s*"
+ _NUM_RX,
re.UNICODE,
),
),
# 2. Higher courts — עע"מ, בר"מ, עמ"נ, ע"א, בג"ץ, רע"א, דנ"א, בש"א
(
"court_ruling",
re.compile(
_TRIGGER
+ r"(עע[\"״]מ|בר[\"״]מ|עמ[\"״]נ|ע[\"״]א|בג[\"״]ץ|רע[\"״]א|דנ[\"״]א|בש[\"״]א)"
+ r"\s*"
+ _NUM_RX,
re.UNICODE,
),
),
]
# Context window for storing the match (characters before/after).
_CTX_BEFORE = 120
_CTX_AFTER = 240
def _normalize_case_number(raw: str) -> str:
"""Normalize a case-number for matching.
The same case can appear in the corpus as "1110/20", "1110-20",
"ערר 1110/20", "1110-01-20" — different rules for the third form,
which is the Nevo file format. We canonicalize by:
* stripping non-digit/separator chars
* unifying "/""-"
* lowercasing
The result is used only for matching, never for display.
"""
cleaned = re.sub(r"[^\d/\-]", "", raw or "")
return cleaned.replace("/", "-").strip("-")
def extract_citations_from_text(text: str) -> Iterator[dict]:
"""Yield citation dicts extracted from ``text``.
Each dict has:
prefix: matched prefix (ערר / בל\"מ / עע\"מ / …)
case_number: raw number as captured
case_number_norm: normalized (slashes → dashes, digits only)
raw: the full matched span
context: ±300 chars surrounding the match (whitespace normalized)
pattern_kind: 'appeals_committee' or 'court_ruling'
"""
if not text:
return
seen: set[tuple[str, str]] = set()
for kind, pattern in _PATTERNS:
for m in pattern.finditer(text):
# The `_TRIGGER` is wrapped in (?:...) so it does not add a
# capture group; group(1) is the prefix, group(2) is the number.
prefix = (m.group(1) or "").strip()
number = (m.group(2) or "").strip()
if not prefix or not number:
continue
norm = _normalize_case_number(number)
if not norm:
continue
key = (kind, norm)
if key in seen:
continue
seen.add(key)
start = max(0, m.start() - _CTX_BEFORE)
end = min(len(text), m.end() + _CTX_AFTER)
context = text[start:end].replace("\n", " ").strip()
context = re.sub(r"\s+", " ", context)
yield {
"prefix": prefix,
"case_number": number,
"case_number_norm": norm,
"raw": m.group(0).strip(),
"context": context[:1000],
"pattern_kind": kind,
}
async def _resolve_case_law_id(case_number_norm: str) -> UUID | None:
"""Try to resolve a normalized citation to an existing case_law row.
Strategy:
1. Exact match on normalized case_number column (after rewriting
existing case_numbers the same way).
2. Substring match — the corpus often stores the full Nevo header
("ערר (‏ועדות ערר - תכנון ובנייה ירושלים‏) 1110/20 …"), so we
search by ``case_number ILIKE '%1110/20%' OR '%1110-20%'``.
Returns None if no row matches.
"""
if not case_number_norm:
return None
pool = await db.get_pool()
# Build the two raw forms (with slash and with dash) for substring match.
parts = case_number_norm.split("-")
if len(parts) >= 2:
slash_form = "/".join(parts[:2]) if len(parts) == 2 else parts[0] + "/" + parts[-1]
else:
slash_form = case_number_norm
dash_form = case_number_norm
async with pool.acquire() as conn:
# Substring match on either form (covers full Nevo headers and short forms).
row = await conn.fetchrow(
"""
SELECT id FROM case_law
WHERE case_number ILIKE $1 OR case_number ILIKE $2
ORDER BY (source_kind = 'internal_committee') DESC,
LENGTH(case_number) ASC
LIMIT 1
""",
f"%{slash_form}%",
f"%{dash_form}%",
)
return UUID(str(row["id"])) if row else None
async def extract_and_store(case_law_id: UUID) -> dict:
"""Extract citations from a single ``case_law`` row's ``full_text``,
resolve them against the corpus, and INSERT into
``precedent_internal_citations`` (ON CONFLICT DO NOTHING).
Returns: {extracted: N, linked: M, new: K, skipped: S}
extracted — total distinct citations found in the text
linked — how many resolved to an existing case_law row
new — rows actually inserted (not pre-existing)
skipped — citations skipped (self-citation, already stored)
"""
pool = await db.get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT id, case_number, full_text FROM case_law WHERE id = $1",
case_law_id,
)
if not row:
return {"extracted": 0, "linked": 0, "new": 0, "skipped": 0, "error": "not_found"}
text = row["full_text"] or ""
own_norm = _normalize_case_number(row["case_number"] or "")
extracted = 0
linked = 0
new_count = 0
skipped = 0
for cit in extract_citations_from_text(text):
extracted += 1
if cit["case_number_norm"] == own_norm:
# Self-citation (e.g. document headers repeating the case number).
skipped += 1
continue
cited_id = await _resolve_case_law_id(cit["case_number_norm"])
if cited_id is not None and cited_id == case_law_id:
skipped += 1
continue
if cited_id is not None:
linked += 1
async with pool.acquire() as conn:
result = await conn.execute(
"""
INSERT INTO precedent_internal_citations (
source_case_law_id, cited_case_number, cited_case_law_id,
match_context, match_pattern, confidence
)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (source_case_law_id, cited_case_number) DO NOTHING
""",
case_law_id,
f"{cit['prefix']} {cit['case_number']}",
cited_id,
cit["context"],
cit["pattern_kind"],
0.90 if cited_id is not None else 0.75,
)
# asyncpg execute returns 'INSERT 0 N' — N is rows inserted.
try:
n_inserted = int(result.split()[-1])
except (ValueError, IndexError):
n_inserted = 0
if n_inserted == 1:
new_count += 1
else:
skipped += 1
return {
"extracted": extracted,
"linked": linked,
"new": new_count,
"skipped": skipped,
}
async def extract_all_internal_committee(
chair_name_filter: str = "",
limit: int = 0,
) -> dict:
"""Run extraction over every internal-committee row in ``case_law``.
Args:
chair_name_filter: if non-empty, restrict to rows where chair_name
matches (exact match). Useful for running on Daphna only.
limit: hard cap on number of rows processed (0 = no cap).
Returns: summary dict with per-row counts and aggregate totals.
"""
pool = await db.get_pool()
conditions = ["source_kind = 'internal_committee'", "full_text <> ''"]
params: list = []
if chair_name_filter:
conditions.append("chair_name = $1")
params.append(chair_name_filter)
where = " WHERE " + " AND ".join(conditions)
limit_clause = f" LIMIT {int(limit)}" if limit and limit > 0 else ""
sql = f"SELECT id, case_number FROM case_law{where} ORDER BY created_at{limit_clause}"
async with pool.acquire() as conn:
rows = await conn.fetch(sql, *params)
totals = {
"processed": 0,
"extracted": 0,
"linked": 0,
"new": 0,
"skipped": 0,
"failed": 0,
"chair_name_filter": chair_name_filter,
"row_count": len(rows),
}
for r in rows:
try:
stats = await extract_and_store(UUID(str(r["id"])))
totals["processed"] += 1
totals["extracted"] += stats.get("extracted", 0)
totals["linked"] += stats.get("linked", 0)
totals["new"] += stats.get("new", 0)
totals["skipped"] += stats.get("skipped", 0)
except Exception as e:
logger.exception("citation extraction failed for %s: %s", r["case_number"], e)
totals["failed"] += 1
return totals
async def list_citations_for_case_law(
case_law_id: UUID,
linked_only: bool = False,
) -> list[dict]:
"""Return all citations *from* the given case_law row (outgoing edges)."""
pool = await db.get_pool()
where = "pic.source_case_law_id = $1"
if linked_only:
where += " AND pic.cited_case_law_id IS NOT NULL"
sql = f"""
SELECT pic.id::text AS id,
pic.cited_case_number,
pic.cited_case_law_id::text AS cited_case_law_id,
pic.match_context,
pic.match_pattern,
pic.confidence::float AS confidence,
pic.created_at,
cl.case_number AS target_case_number,
cl.case_name AS target_case_name,
cl.chair_name AS target_chair_name,
cl.district AS target_district
FROM precedent_internal_citations pic
LEFT JOIN case_law cl ON cl.id = pic.cited_case_law_id
WHERE {where}
ORDER BY pic.created_at
"""
async with pool.acquire() as conn:
rows = await conn.fetch(sql, case_law_id)
return [dict(r) for r in rows]
async def list_citations_to_case_law(case_law_id: UUID) -> list[dict]:
"""Return all citations *to* the given case_law row (incoming edges).
Useful for "which Daphna decisions cite this ruling?" queries.
"""
pool = await db.get_pool()
sql = """
SELECT pic.id::text AS id,
pic.source_case_law_id::text AS source_case_law_id,
pic.cited_case_number,
pic.match_context,
pic.match_pattern,
pic.confidence::float AS confidence,
pic.created_at,
cl.case_number AS source_case_number,
cl.case_name AS source_case_name,
cl.chair_name AS source_chair_name,
cl.district AS source_district
FROM precedent_internal_citations pic
JOIN case_law cl ON cl.id = pic.source_case_law_id
WHERE pic.cited_case_law_id = $1
ORDER BY pic.created_at DESC
"""
async with pool.acquire() as conn:
rows = await conn.fetch(sql, case_law_id)
return [dict(r) for r in rows]
async def get_cited_case_law_ids(source_case_law_ids: list[UUID]) -> dict[str, list[str]]:
"""Bulk-fetch outgoing citation case_law_ids for the given source rows.
Returns: {source_case_law_id (str): [cited_case_law_id (str), ...]} —
only including linked (resolved) citations.
Used by search.search_internal_decisions(include_cited_by=True) to
expand result sets with the precedents the hits themselves cite,
without running a separate roundtrip per row.
"""
if not source_case_law_ids:
return {}
pool = await db.get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT source_case_law_id::text AS source_id,
cited_case_law_id::text AS cited_id
FROM precedent_internal_citations
WHERE source_case_law_id = ANY($1::uuid[])
AND cited_case_law_id IS NOT NULL
""",
list(source_case_law_ids),
)
out: dict[str, list[str]] = {}
for r in rows:
out.setdefault(r["source_id"], []).append(r["cited_id"])
return out

View File

@@ -142,3 +142,175 @@ async def query_json(
""" """
raw = await query(prompt, timeout=timeout, system=system) raw = await query(prompt, timeout=timeout, system=system)
return parse_llm_json(raw) return parse_llm_json(raw)
# ── Streaming + session continuation ────────────────────────────────
async def query_streaming(
prompt: str,
*,
system: str | None = None,
resume_session_id: str | None = None,
timeout: int = LONG_TIMEOUT,
cwd: str | None = None,
):
"""Stream Claude's response as an async iterator of events.
Wraps `claude -p --output-format=stream-json` (newline-delimited JSON
objects from the CLI) and translates each line into a small, stable
shape that the chat service / SSE proxy can forward without leaking
CLI internals to the browser.
Event shapes yielded:
{"type": "session_id", "value": "<uuid>"} # first event, used for resume
{"type": "text_delta", "text": "<partial>"} # incremental assistant text
{"type": "tool_use", "name": "...", "input": {...}}
{"type": "error", "message": "..."}
{"type": "done", "text": "<full response>"}
The CLI emits a richer stream; we project to this minimal set so the
front-end can stay stable across CLI upgrades.
Args:
prompt: The user message to send.
system: Optional system instructions (used only when starting a
fresh conversation — when resume_session_id is set, the
session already carries its system prompt).
resume_session_id: Continue a prior conversation. When given,
we don't re-send the system prompt; the CLI loads the
entire conversation history from disk.
timeout: Hard ceiling on the subprocess.
cwd: Working directory for the subprocess — defaults to the
host's HOME so claude.ai credentials resolve correctly.
"""
if resume_session_id:
# When resuming, system is already baked into the on-disk session
# — sending it again would be a no-op at best and confuse the
# conversation at worst.
full_prompt = prompt
cmd = [
"claude", "-p",
"--output-format", "stream-json",
"--verbose",
"--resume", resume_session_id,
]
else:
full_prompt = f"{system}\n\n{prompt}" if system else prompt
cmd = [
"claude", "-p",
"--output-format", "stream-json",
"--verbose",
]
if len(full_prompt) > 200_000:
logger.warning(
"Streaming: large prompt (%d chars) — may hit CLI input limits",
len(full_prompt),
)
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
)
except FileNotFoundError:
yield {
"type": "error",
"message": (
"Claude CLI not found on host — legal-chat-service must "
"run where the `claude` binary is installed (Daphna's host, "
"not the legal-ai container)."
),
}
return
assert proc.stdin is not None # for type checkers
assert proc.stdout is not None
# Send the prompt and close stdin so the CLI knows the user message
# is complete.
try:
proc.stdin.write(full_prompt.encode("utf-8"))
await proc.stdin.drain()
proc.stdin.close()
except BrokenPipeError:
# CLI exited before reading the prompt — drain stderr and bail.
stderr_b = await proc.stderr.read() if proc.stderr else b""
yield {
"type": "error",
"message": f"Claude CLI closed stdin early: {stderr_b.decode('utf-8', errors='replace')[:300]}",
}
return
accumulated_text: list[str] = []
session_id_emitted = False
deadline = asyncio.get_event_loop().time() + timeout
try:
while True:
remaining = deadline - asyncio.get_event_loop().time()
if remaining <= 0:
yield {"type": "error", "message": f"timed out after {timeout}s"}
break
try:
line_b = await asyncio.wait_for(proc.stdout.readline(), timeout=remaining)
except asyncio.TimeoutError:
yield {"type": "error", "message": f"stream timed out after {timeout}s"}
break
if not line_b:
break
line = line_b.decode("utf-8", errors="replace").strip()
if not line:
continue
try:
event = json.loads(line)
except json.JSONDecodeError:
# Stray non-JSON line from CLI — surface a snippet for debug.
logger.debug("non-JSON stream line: %s", line[:120])
continue
# The CLI's stream-json emits several event types. We only
# care about the ones the chat service forwards.
t = event.get("type")
if not session_id_emitted:
sid = event.get("session_id")
if sid:
session_id_emitted = True
yield {"type": "session_id", "value": sid}
if t == "assistant":
# event["message"]["content"] is a list of blocks; we extract
# text blocks and tool_use blocks.
msg = event.get("message") or {}
for block in msg.get("content") or []:
btype = block.get("type")
if btype == "text":
text = block.get("text") or ""
if text:
accumulated_text.append(text)
yield {"type": "text_delta", "text": text}
elif btype == "tool_use":
yield {
"type": "tool_use",
"name": block.get("name") or "",
"input": block.get("input") or {},
}
elif t == "result":
# Final synthesized result line from the CLI — we already
# delivered the deltas, so just stop here.
break
finally:
if proc.returncode is None:
try:
proc.kill()
except ProcessLookupError:
pass
try:
await proc.wait()
except Exception:
pass
yield {"type": "done", "text": "".join(accumulated_text)}

File diff suppressed because it is too large Load Diff

View File

@@ -109,16 +109,30 @@ _HEBREW_ABBREV_FIXES: dict[str, str] = {
'מייר': 'מ"ר', 'מייר': 'מ"ר',
'יחייד': 'יח"ד', 'יחייד': 'יח"ד',
'בייכ': 'ב"כ', 'בייכ': 'ב"כ',
# Patterns where double-yod (יי) substitutes for gershayim (״) in born-digital PDFs
'בליימ': 'בל"מ', # בקשה להארכת מועד — appears in RTL legal docs
'תמייא': 'תמ"א', # תכנית מתאר ארצית
} }
_ABBREV_PATTERN = re.compile( _ABBREV_PATTERN = re.compile(
'|'.join(re.escape(k) for k in sorted(_HEBREW_ABBREV_FIXES, key=len, reverse=True)) '|'.join(re.escape(k) for k in sorted(_HEBREW_ABBREV_FIXES, key=len, reverse=True))
) )
# Matches Hebrew law year abbreviations where gershayim was encoded as double-yod.
# e.g. תשכייה → תשכ"ה, תשנייב → תשנ"ב
_HEBREW_YEAR_RE = re.compile(r'(תש[א-ת]+)יי([א-ת])')
def _fix_hebrew_quotes(text: str) -> str: def _fix_hebrew_quotes(text: str) -> str:
"""Fix known Hebrew abbreviation quote replacements from Google Vision OCR.""" """Fix known Hebrew abbreviation quote replacements.
return _ABBREV_PATTERN.sub(lambda m: _HEBREW_ABBREV_FIXES[m.group()], text)
Applied to both Google Vision OCR output and direct PyMuPDF extraction —
some born-digital PDFs encode gershayim (״) as double-yod (יי), producing
the same corruption patterns as OCR.
"""
text = _ABBREV_PATTERN.sub(lambda m: _HEBREW_ABBREV_FIXES[m.group()], text)
text = _HEBREW_YEAR_RE.sub(r'\1"\2', text)
return text
# ── Extraction ─────────────────────────────────────────────────── # ── Extraction ───────────────────────────────────────────────────
@@ -189,7 +203,7 @@ async def _extract_pdf(path: Path) -> tuple[str, int, list[int]]:
text = page.get_text().strip() text = page.get_text().strip()
if len(text) > 50 and _text_quality_ok(text): if len(text) > 50 and _text_quality_ok(text):
pages_text.append(text) pages_text.append(_fix_hebrew_quotes(text))
logger.debug("Page %d: direct extraction (%d chars, quality OK)", page_num + 1, len(text)) logger.debug("Page %d: direct extraction (%d chars, quality OK)", page_num + 1, len(text))
else: else:
reason = "insufficient text" if len(text) <= 50 else "low quality OCR layer" reason = "insufficient text" if len(text) <= 50 else "low quality OCR layer"

View File

@@ -4,6 +4,8 @@ Layered on top of ``rerank.maybe_rerank``. When ``MULTIMODAL_ENABLED`` is
true the result comes from a weighted merge of: true the result comes from a weighted merge of:
• text side: cosine on chunks → optional rerank-2 cross-encoder • text side: cosine on chunks → optional rerank-2 cross-encoder
(precedent search additionally fuses ``ts_rank_cd`` lexical results
via RRF before this step — see ``BM25_HYBRID_ENABLED``)
• image side: cosine on per-page voyage-multimodal-3 embeddings • image side: cosine on per-page voyage-multimodal-3 embeddings
rerank-2 is a *text* cross-encoder, so image-side rows are NOT passed rerank-2 is a *text* cross-encoder, so image-side rows are NOT passed
@@ -15,6 +17,14 @@ visual-heavy content still appears in results.
When ``MULTIMODAL_ENABLED`` is false this module degenerates to plain When ``MULTIMODAL_ENABLED`` is false this module degenerates to plain
``rerank.maybe_rerank`` — callers can wrap unconditionally and let env ``rerank.maybe_rerank`` — callers can wrap unconditionally and let env
control behaviour. control behaviour.
BM25/lexical leg (V12 + ``BM25_HYBRID_ENABLED``):
``search_precedent_library_hybrid`` runs ``search_precedent_library_lexical``
in parallel with the semantic side and fuses the two by rank via RRF.
This recovers exact-string recall (case-number citations like "1461/20",
rare planning terms) that voyage embeddings blur. The fused list is
then handed to rerank-2 (if enabled) and to the image RRF (if
multimodal is enabled) exactly as before.
""" """
from __future__ import annotations from __future__ import annotations
@@ -91,16 +101,28 @@ async def search_precedent_library_hybrid(
source_kind: str = "external_upload", source_kind: str = "external_upload",
district: str = "", district: str = "",
chair_name: str = "", chair_name: str = "",
max_per_case_law: int = 2,
) -> 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='external_upload' → court rulings (default)
source_kind='internal_committee' → appeals-committee decisions source_kind='internal_committee' → appeals-committee decisions
max_per_case_law: MMR-style diversity cap — at most N hits per
case_law_id in the final ranked list (default 2). Prevents a
single precedent from monopolizing the result list when many of
its chunks/halachot are individually relevant.
When ``config.BM25_HYBRID_ENABLED`` is true (default) ``_base`` fuses
semantic cosine + lexical ``ts_rank_cd`` via RRF before handing the
candidates to rerank-2 (if enabled) and the image merge (if
multimodal is enabled).
""" """
fetch_k = max(limit, config.VOYAGE_RERANK_FETCH_K) if config.MULTIMODAL_ENABLED else limit # Fetch deeper so diversity dedup still leaves enough candidates.
fetch_k = max(limit * max(max_per_case_law, 1), config.VOYAGE_RERANK_FETCH_K) \
if config.MULTIMODAL_ENABLED else max(limit * max(max_per_case_law, 1), limit)
async def _base(limit: int) -> list[dict]: async def _base(limit: int) -> list[dict]:
return await db.search_precedent_library_semantic( sem_rows = await db.search_precedent_library_semantic(
query_embedding=query_text_embedding, query_embedding=query_text_embedding,
practice_area=practice_area, practice_area=practice_area,
court=court, court=court,
@@ -114,12 +136,39 @@ async def search_precedent_library_hybrid(
district=district, district=district,
chair_name=chair_name, chair_name=chair_name,
) )
if not config.BM25_HYBRID_ENABLED:
return sem_rows
# Fetch lexical with ≥ 2× depth so RRF has reserves at the tail.
lex_limit = max(limit * 2, limit)
try:
lex_rows = await db.search_precedent_library_lexical(
query=query,
practice_area=practice_area,
court=court,
precedent_level=precedent_level,
appeal_subtype=appeal_subtype,
is_binding=is_binding,
subject_tag=subject_tag,
source_kind=source_kind,
district=district,
chair_name=chair_name,
limit=lex_limit,
include_halachot=include_halachot,
)
except Exception as e:
logger.warning(
"Hybrid precedent: lexical side failed, semantic only: %s", e,
)
return sem_rows
if not lex_rows:
return sem_rows
return _merge_sem_lex(sem_rows, lex_rows, limit=limit)
text_results = await rerank.maybe_rerank( text_results = await rerank.maybe_rerank(
query=query, base_search=_base, limit=fetch_k, query=query, base_search=_base, limit=fetch_k,
) )
if not config.MULTIMODAL_ENABLED: if not config.MULTIMODAL_ENABLED:
return text_results[:limit] return _diversify_by_case_law(text_results, limit, max_per_case_law)
try: try:
query_img_emb = await embeddings.embed_query_for_multimodal(query) query_img_emb = await embeddings.embed_query_for_multimodal(query)
@@ -134,13 +183,128 @@ async def search_precedent_library_hybrid(
) )
except Exception as e: except Exception as e:
logger.warning("Hybrid: image side failed, returning text only: %s", e) logger.warning("Hybrid: image side failed, returning text only: %s", e)
return text_results[:limit] return _diversify_by_case_law(text_results, limit, max_per_case_law)
merged = _merge( merged = _merge(
text_results, img_rows, text_results, img_rows,
id_field="case_law_id", id_field="case_law_id",
text_weight=config.MULTIMODAL_TEXT_WEIGHT, text_weight=config.MULTIMODAL_TEXT_WEIGHT,
) )
return _diversify_by_case_law(merged, limit, max_per_case_law)
def _diversify_by_case_law(
rows: list[dict],
limit: int,
max_per_case_law: int,
) -> list[dict]:
"""MMR-style diversity cap: at most ``max_per_case_law`` rows per
case_law_id in the final list. Preserves input order (which is the
relevance ranking) — for each row, include it only if we haven't
reached the cap for its case_law_id yet.
Set max_per_case_law<=0 to disable (returns rows[:limit] unchanged).
"""
if max_per_case_law <= 0 or not rows:
return rows[:limit]
counts: dict[str, int] = {}
out: list[dict] = []
for r in rows:
clid = str(r.get("case_law_id") or "")
if not clid:
out.append(r)
if len(out) >= limit:
break
continue
n = counts.get(clid, 0)
if n < max_per_case_law:
out.append(r)
counts[clid] = n + 1
if len(out) >= limit:
break
return out
def _row_key(r: dict) -> tuple[str, str]:
"""Stable identity for sem/lex RRF.
Halachot rows have ``halacha_id``; chunk rows have ``chunk_id``.
Returns ``(type, id)`` so a halacha and a chunk with the same UUID
(extremely unlikely, but distinct namespaces) don't collide.
"""
typ = str(r.get("type") or "")
rid = r.get("halacha_id") if typ == "halacha" else r.get("chunk_id")
return (typ, str(rid or ""))
def _merge_sem_lex(
sem_rows: list[dict],
lex_rows: list[dict],
*,
limit: int,
) -> list[dict]:
"""RRF fusion of semantic + lexical precedent results.
Why RRF (and not weighted score sum): cosine similarities (~0.4-0.7)
and ``ts_rank_cd`` values (often 0.001-0.5, query-length-dependent)
live on completely different scales — a weighted sum would let one
side dominate by accident. RRF combines by *rank*, so a row that
tops one list and is mid-pack in the other gets a robust boost.
Per row::
rrf_score = 1 / (k + sem_rank) + 1 / (k + lex_rank)
A row that appears in only one list contributes that list's term
only. Output is sorted by combined score, with extra debug fields
(``sem_score``, ``sem_rank``, ``lex_score``, ``lex_rank``) attached
so callers and tests can inspect why a row ranked where it did.
The row payload (``content``, ``rule_statement``, ``case_*`` joins,
etc.) is taken from the semantic-side row when available — the two
sources return identical column shapes, but semantic rows carry the
confidence-boosted ``score`` that the rest of the pipeline expects.
"""
k = config.MULTIMODAL_RRF_K
sem_rank_by_key: dict[tuple, int] = {}
sem_row_by_key: dict[tuple, dict] = {}
for rank, r in enumerate(sem_rows, 1):
key = _row_key(r)
if not key[1]:
continue
sem_rank_by_key[key] = rank
sem_row_by_key[key] = r
lex_rank_by_key: dict[tuple, int] = {}
lex_row_by_key: dict[tuple, dict] = {}
for rank, r in enumerate(lex_rows, 1):
key = _row_key(r)
if not key[1]:
continue
lex_rank_by_key[key] = rank
lex_row_by_key[key] = r
all_keys = set(sem_rank_by_key) | set(lex_rank_by_key)
merged: list[dict] = []
for key in all_keys:
sem_rank = sem_rank_by_key.get(key)
lex_rank = lex_rank_by_key.get(key)
base = sem_row_by_key.get(key) or lex_row_by_key.get(key)
if base is None:
continue
d = dict(base)
sem_term = 1.0 / (k + sem_rank) if sem_rank else 0.0
lex_term = 1.0 / (k + lex_rank) if lex_rank else 0.0
d["sem_score"] = float(sem_row_by_key[key]["score"]) \
if key in sem_row_by_key else 0.0
d["sem_rank"] = sem_rank or 0
d["lex_score"] = float(lex_row_by_key[key]["score"]) \
if key in lex_row_by_key else 0.0
d["lex_rank"] = lex_rank or 0
d["score"] = sem_term + lex_term
merged.append(d)
merged.sort(key=lambda x: -float(x["score"]))
return merged[:limit] return merged[:limit]

View File

@@ -24,6 +24,7 @@ from uuid import UUID, uuid4
from legal_mcp import config from legal_mcp import config
from legal_mcp.services import chunker, db, embeddings, extractor from legal_mcp.services import chunker, db, embeddings, extractor
from legal_mcp.services.practice_area import derive_proceeding_type
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -86,11 +87,13 @@ async def ingest_internal_decision(
text: str | None = None, text: str | None = None,
document_id: UUID | None = None, document_id: UUID | None = None,
queue_halachot: bool = True, queue_halachot: bool = True,
proceeding_type: str = "",
) -> dict: ) -> dict:
"""Ingest an appeals-committee decision into the internal corpus. """Ingest an appeals-committee decision into the internal corpus.
Either file_path or text must be provided. Either file_path or text must be provided.
If district is empty, it is inferred from court. If district is empty, it is inferred from court.
If proceeding_type is empty, it is derived from appeal_subtype/case_name.
Returns: {"status": "completed", "case_law_id": "...", "chunks": N} Returns: {"status": "completed", "case_law_id": "...", "chunks": N}
""" """
if not file_path and not text: if not file_path and not text:
@@ -99,6 +102,9 @@ async def ingest_internal_decision(
raise ValueError("case_number is required") raise ValueError("case_number is required")
resolved_district = district.strip() or _district_from_court(court) resolved_district = district.strip() or _district_from_court(court)
resolved_proc = proceeding_type.strip() or derive_proceeding_type(
appeal_subtype=appeal_subtype, subject=case_name,
)
if file_path: if file_path:
src = Path(file_path) src = Path(file_path)
@@ -133,29 +139,68 @@ async def ingest_internal_decision(
summary=summary.strip(), summary=summary.strip(),
is_binding=is_binding, is_binding=is_binding,
document_id=document_id, document_id=document_id,
proceeding_type=resolved_proc,
) )
case_law_id = UUID(str(record["id"])) case_law_id = UUID(str(record["id"]))
try: try:
chunks = chunker.chunk_document(raw_text, page_offsets=page_offsets) # Parent-doc retrieval (TaskMaster #48) — same gated branch as
if not chunks: # ingest_precedent. Internal committee decisions are typically
await db.set_case_law_extraction_status(case_law_id, "completed") # longer than external court rulings (full transcript + ruling),
await db.set_case_law_halacha_status(case_law_id, "completed") # so the parent-doc benefit is even larger here.
return {"status": "completed", "case_law_id": str(case_law_id), "chunks": 0} if config.PARENT_DOC_RETRIEVAL_ENABLED:
h_chunks = chunker.chunk_document_hierarchical(
raw_text, page_offsets=page_offsets,
)
if not h_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}
children = [c for c in h_chunks if c.role == "child"]
parents = [c for c in h_chunks if c.role == "parent"]
child_vectors = await embeddings.embed_texts(
[c.content for c in children], input_type="document",
)
chunk_dicts: list[dict] = []
for p in parents:
chunk_dicts.append({
"role": "parent", "local_id": p.local_id, "parent_local_id": None,
"chunk_index": p.chunk_index, "content": p.content,
"section_type": p.section_type, "page_number": p.page_number,
"embedding": None,
})
for c, v in zip(children, child_vectors):
chunk_dicts.append({
"role": "child", "local_id": c.local_id,
"parent_local_id": c.parent_local_id,
"chunk_index": c.chunk_index, "content": c.content,
"section_type": c.section_type, "page_number": c.page_number,
"embedding": v,
})
counts = await db.store_precedent_chunks_hierarchical(
case_law_id, chunk_dicts,
)
stored = counts["children"]
else:
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_texts = [c.content for c in chunks]
chunk_vectors = await embeddings.embed_texts(chunk_texts, input_type="document") chunk_vectors = await embeddings.embed_texts(chunk_texts, input_type="document")
chunk_dicts = [ chunk_dicts = [
{ {
"chunk_index": c.chunk_index, "chunk_index": c.chunk_index,
"content": c.content, "content": c.content,
"section_type": c.section_type, "section_type": c.section_type,
"page_number": c.page_number, "page_number": c.page_number,
"embedding": v, "embedding": v,
} }
for c, v in zip(chunks, chunk_vectors) for c, v in zip(chunks, chunk_vectors)
] ]
stored = await db.store_precedent_chunks(case_law_id, chunk_dicts) 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_extraction_status(case_law_id, "completed")
await db.set_case_law_halacha_status(case_law_id, "pending") await db.set_case_law_halacha_status(case_law_id, "pending")

View File

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

@@ -2,14 +2,34 @@
Two orthogonal axes used to separate legal domains across the system: Two orthogonal axes used to separate legal domains across the system:
practice_area — top-level domain (multi-tenant axis). Examples: practice_area — top-level domain. **Two taxonomies coexist** (see below).
appeals_committee, national_insurance, labor_law. appeal_subtype — refines within a domain.
appeal_subtype — refines within a domain. For appeals_committee:
building_permit (1xxx), betterment_levy (8xxx),
compensation_197 (9xxx), unknown.
Both columns are denormalized into documents/chunks/decisions/style_corpus ⚠️ TWO TAXONOMIES — DO NOT CONFUSE
so vector searches can filter cheaply. ==================================
A. **Multi-tenant axis** (legacy, used in routing logic):
- ``appeals_committee`` — the legal-ai instance for Daphna's committee
- ``national_insurance`` — future / hypothetical other tenants
- ``labor_law`` — future
When this axis is used, ``appeal_subtype`` carries the actual domain:
``building_permit`` (1xxx), ``betterment_levy`` (8xxx),
``compensation_197`` (9xxx).
B. **Domain axis** (DB columns ``case_law.practice_area``,
``cases.practice_area`` — what tests, validators, and CHECK constraints
actually use):
- ``rishuy_uvniya`` — רישוי ובנייה (1xxx)
- ``betterment_levy`` — היטל השבחה (8xxx)
- ``compensation_197`` — פיצויים סעיף 197 (9xxx)
Use ``to_db_practice_area(multi_tenant_pa, appeal_subtype)`` to convert
from axis A to axis B before writing to the DB.
Background: TaskMaster #30 (sub-bug ב) — many ``case_law`` rows stored
``appeals_committee`` (axis A) where they should have stored a domain
value (axis B). The migration backfill plus CHECK constraints close the
gap, and this module now validates **both** namespaces.
""" """
from __future__ import annotations from __future__ import annotations
@@ -18,19 +38,58 @@ import re
# ── Enums ────────────────────────────────────────────────────────── # ── Enums ──────────────────────────────────────────────────────────
PRACTICE_AREAS: set[str] = { # Multi-tenant axis (legacy)
MULTI_TENANT_PRACTICE_AREAS: set[str] = {
"appeals_committee", "appeals_committee",
"national_insurance", "national_insurance",
"labor_law", "labor_law",
} }
# Domain axis (matches DB constraints on case_law/cases)
DOMAIN_PRACTICE_AREAS: set[str] = {
"rishuy_uvniya",
"betterment_levy",
"compensation_197",
}
# Union — what ``validate()`` accepts for backward-compat.
# Empty string is permitted because the DB CHECK constraint allows it as
# a "not yet classified" sentinel (e.g. when auto-derivation fails on an
# unrecognized case_number format).
PRACTICE_AREAS: set[str] = MULTI_TENANT_PRACTICE_AREAS | DOMAIN_PRACTICE_AREAS | {""}
APPEALS_COMMITTEE_SUBTYPES: set[str] = { APPEALS_COMMITTEE_SUBTYPES: set[str] = {
"building_permit", "building_permit",
"betterment_levy", "betterment_levy",
"compensation_197", "compensation_197",
# בל"מ — בקשה להארכת מועד להגשת ערר. מסלולים נפרדים לפי domain:
"extension_request_building_permit", # 1xxx — סעיף 152, 30 ימים
"extension_request_betterment_levy", # 8xxx — סעיף 14 לתוספת ג', 45 ימים
"extension_request_compensation", # 9xxx — סעיף 198(ד), 30 ימים
"unknown", "unknown",
} }
# בל"מ subtypes — קל לזהות ע"י prefix
BLAM_SUBTYPES: set[str] = {
"extension_request_building_permit",
"extension_request_betterment_levy",
"extension_request_compensation",
}
# מיפוי domain → בל"מ subtype
_DOMAIN_TO_BLAM_SUBTYPE: dict[str, str] = {
"rishuy_uvniya": "extension_request_building_permit",
"betterment_levy": "extension_request_betterment_levy",
"compensation_197": "extension_request_compensation",
}
# מיפוי first-digit → בל"מ subtype (אותו מבנה כמו _APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE)
_APPEALS_COMMITTEE_DIGIT_TO_BLAM = {
"1": "extension_request_building_permit",
"8": "extension_request_betterment_levy",
"9": "extension_request_compensation",
}
DEFAULT_PRACTICE_AREA = "appeals_committee" DEFAULT_PRACTICE_AREA = "appeals_committee"
# Subtypes per practice_area (extend when adding domains) # Subtypes per practice_area (extend when adding domains)
@@ -38,8 +97,74 @@ SUBTYPES_BY_AREA: dict[str, set[str]] = {
"appeals_committee": APPEALS_COMMITTEE_SUBTYPES, "appeals_committee": APPEALS_COMMITTEE_SUBTYPES,
"national_insurance": {"unknown"}, "national_insurance": {"unknown"},
"labor_law": {"unknown"}, "labor_law": {"unknown"},
# Domain values — subtype is implicit in the value itself
"rishuy_uvniya": {"building_permit", "extension_request_building_permit", "unknown"},
"betterment_levy": {"betterment_levy", "extension_request_betterment_levy", "unknown"},
"compensation_197": {"compensation_197", "extension_request_compensation", "unknown"},
# Empty (unclassified) — allow any of the appeals_committee subtypes
"": APPEALS_COMMITTEE_SUBTYPES,
} }
# Mapping: (multi_tenant_pa, appeal_subtype) → domain_pa
_SUBTYPE_TO_DOMAIN: dict[str, str] = {
"building_permit": "rishuy_uvniya",
"betterment_levy": "betterment_levy",
"compensation_197": "compensation_197",
"extension_request_building_permit": "rishuy_uvniya",
"extension_request_betterment_levy": "betterment_levy",
"extension_request_compensation": "compensation_197",
}
# Regex לזיהוי "בקשה להארכת מועד" בנושא הערר (subject) —
# וריאציות נפוצות. case-insensitive, מתחשב במרכאות חכמות/רגילות.
_BLAM_SUBJECT_PATTERNS = (
re.compile(r"בקשה\s+להארכת\s+מועד", re.IGNORECASE),
re.compile(r"בל[\"״״]מ", re.IGNORECASE), # בל"מ עם quote variants
re.compile(r"הארכת\s+מועד\s+להגשת", re.IGNORECASE),
)
def is_blam_subject(subject: str) -> bool:
"""True iff subject indicates a בל"מ (extension-of-time request).
מזהה: "בקשה להארכת מועד", "בל\"מ", "הארכת מועד להגשת..."
Examples:
>>> is_blam_subject("בל\"מ אלחנן ברלינגר נ' לינדאב")
True
>>> is_blam_subject("בקשה להארכת מועד להגשת ערר")
True
>>> is_blam_subject("היתר בנייה ברחוב X")
False
"""
if not subject:
return False
return any(p.search(subject) for p in _BLAM_SUBJECT_PATTERNS)
def to_db_practice_area(practice_area: str, appeal_subtype: str = "") -> str:
"""Convert a multi-tenant practice_area + appeal_subtype to the
domain value stored in DB columns (case_law/cases).
Returns ``""`` when the input cannot be mapped — callers should
handle this rather than letting ``""`` propagate silently to the DB.
Examples:
>>> to_db_practice_area("appeals_committee", "building_permit")
'rishuy_uvniya'
>>> to_db_practice_area("rishuy_uvniya")
'rishuy_uvniya'
>>> to_db_practice_area("appeals_committee")
''
"""
pa = (practice_area or "").strip()
if pa in DOMAIN_PRACTICE_AREAS:
return pa
if pa == "appeals_committee":
return _SUBTYPE_TO_DOMAIN.get((appeal_subtype or "").strip(), "")
return ""
# ── Derivation ───────────────────────────────────────────────────── # ── Derivation ─────────────────────────────────────────────────────
@@ -55,14 +180,28 @@ _CASE_NUM = re.compile(r"(?:ARAR[-\s]*\d{2}[-\s]*(?:\d{2}[-\s]*)?)(\d{4})", re.I
_PLAIN_NUM = re.compile(r"(\d{4})") _PLAIN_NUM = re.compile(r"(\d{4})")
_DOMAIN_TO_SUBTYPE: dict[str, str] = {
"rishuy_uvniya": "building_permit",
"betterment_levy": "betterment_levy",
"compensation_197": "compensation_197",
}
def derive_subtype(case_number: str, practice_area: str = DEFAULT_PRACTICE_AREA) -> str: def derive_subtype(case_number: str, practice_area: str = DEFAULT_PRACTICE_AREA) -> str:
"""Infer the appeal_subtype from case_number. """Infer the appeal_subtype from case_number.
For appeals_committee, the convention is: For appeals_committee (axis A), the convention is:
1xxx → building_permit, 8xxx → betterment_levy, 9xxx → compensation_197. 1xxx → building_permit, 8xxx → betterment_levy, 9xxx → compensation_197.
For domain values (axis B — rishuy_uvniya/betterment_levy/compensation_197),
the subtype is implicit in the practice_area itself — we map directly
without parsing the case number.
Handles multiple formats: ARAR-25-8126, 8126/25, 1170, ערר 1024-25. Handles multiple formats: ARAR-25-8126, 8126/25, 1170, ערר 1024-25.
""" """
# Axis B: practice_area is already a domain value — map directly.
if practice_area in DOMAIN_PRACTICE_AREAS:
return _DOMAIN_TO_SUBTYPE.get(practice_area, "unknown")
if practice_area != "appeals_committee": if practice_area != "appeals_committee":
return "unknown" return "unknown"
cn = case_number or "" cn = case_number or ""
@@ -77,6 +216,94 @@ def derive_subtype(case_number: str, practice_area: str = DEFAULT_PRACTICE_AREA)
return _APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE.get(first_digit, "unknown") return _APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE.get(first_digit, "unknown")
def derive_subtype_with_blam(
case_number: str,
subject: str = "",
practice_area: str = DEFAULT_PRACTICE_AREA,
) -> str:
"""Like ``derive_subtype()`` but also detects בל"מ from the subject.
If ``subject`` indicates a בקשה להארכת מועד, the returned subtype is
one of the ``extension_request_*`` values (chosen per case_number /
practice_area). Otherwise behaviour matches ``derive_subtype()``.
Examples:
>>> derive_subtype_with_blam("1017-03-26", "בל\"מ ברלינגר נ' לינדאב")
'extension_request_building_permit'
>>> derive_subtype_with_blam("8500-25", "בקשה להארכת מועד")
'extension_request_betterment_levy'
>>> derive_subtype_with_blam("1033-25", "ערר על החלטת ועדה")
'building_permit'
"""
base = derive_subtype(case_number, practice_area)
if not is_blam_subject(subject):
return base
# subject says it's בל"מ — return the matching extension_request_* variant.
# For domain practice_area (axis B), use the direct mapping.
if practice_area in DOMAIN_PRACTICE_AREAS:
return _DOMAIN_TO_BLAM_SUBTYPE.get(practice_area, base)
# For appeals_committee (axis A), derive from case_number digit.
if practice_area == "appeals_committee":
cn = case_number or ""
m = _CASE_NUM.search(cn) or _PLAIN_NUM.search(cn)
if m:
first_digit = m.group(1)[0]
blam = _APPEALS_COMMITTEE_DIGIT_TO_BLAM.get(first_digit)
if blam:
return blam
return base
def is_blam_subtype(appeal_subtype: str) -> bool:
"""True iff appeal_subtype is one of the extension_request_* variants.
Useful for UI badges and routing logic that need to detect בל"מ cases
regardless of which domain they belong to.
"""
return appeal_subtype in BLAM_SUBTYPES
def derive_proceeding_type(*, appeal_subtype: str = "", subject: str = "") -> str:
"""Return 'בל"מ' / 'ערר' for appeals-committee decisions/cases.
Priority: explicit subtype prefix → subject regex → default 'ערר'.
"""
if appeal_subtype and appeal_subtype.startswith("extension_request_"):
return 'בל"מ'
if subject and is_blam_subject(subject):
return 'בל"מ'
return "ערר"
def derive_domain_practice_area(case_number: str) -> str:
"""Map a case_number prefix to a domain practice_area (axis B).
Returns:
``"rishuy_uvniya"`` for 1xxx, ``"betterment_levy"`` for 8xxx,
``"compensation_197"`` for 9xxx, or ``""`` when the prefix is
unrecognized (caller decides the fallback).
Examples:
>>> derive_domain_practice_area("8126/25")
'betterment_levy'
>>> derive_domain_practice_area("1170")
'rishuy_uvniya'
>>> derive_domain_practice_area("ARAR-24-01-9007")
'compensation_197'
>>> derive_domain_practice_area("foo")
''
"""
cn = case_number or ""
m = _CASE_NUM.search(cn) or _PLAIN_NUM.search(cn)
if not m:
return ""
first_digit = m.group(1)[0]
subtype = _APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE.get(first_digit)
if not subtype:
return ""
return _SUBTYPE_TO_DOMAIN.get(subtype, "")
# ── Validation ───────────────────────────────────────────────────── # ── Validation ─────────────────────────────────────────────────────
@@ -99,6 +326,20 @@ def validate(practice_area: str, appeal_subtype: str | None) -> None:
def is_override(case_number: str, practice_area: str, appeal_subtype: str) -> bool: def is_override(case_number: str, practice_area: str, appeal_subtype: str) -> bool:
"""True iff the user-supplied subtype disagrees with what derive_subtype """True iff the user-supplied subtype disagrees with what derive_subtype
would have produced (and the derived value is not 'unknown').""" would have produced (and the derived value is not 'unknown').
Note: בל"מ variants (extension_request_*) are NOT considered overrides
of their parent domain — extension_request_building_permit on a 1xxx
case is consistent with the case-number convention.
"""
derived = derive_subtype(case_number, practice_area) derived = derive_subtype(case_number, practice_area)
return derived != "unknown" and derived != appeal_subtype if derived == "unknown":
return False
if derived == appeal_subtype:
return False
# בל"מ variants of the same domain are not overrides.
if appeal_subtype in BLAM_SUBTYPES:
# extension_request_building_permit ↔ building_permit (1xxx) — same domain
if _SUBTYPE_TO_DOMAIN.get(appeal_subtype) == _SUBTYPE_TO_DOMAIN.get(derived):
return False
return True

View File

@@ -116,6 +116,18 @@ async def ingest_precedent(
raise FileNotFoundError(f"file not found: {src}") raise FileNotFoundError(f"file not found: {src}")
if not citation.strip(): if not citation.strip():
raise ValueError("citation is required") raise ValueError("citation is required")
# Citation guard at service level (catches both MCP and HTTP API paths).
# Appeals-committee decisions must go through ingest_internal_decision
# which records chair_name+district. The MCP wrapper has the same guard
# for an earlier, friendlier error message — but this is the source of
# truth. See TaskMaster #30(ב) and DB constraint case_law_external_arar_check.
_norm = citation.strip()
if _norm.startswith(("ערר ", "ערר(", "בל\"מ ", "בל\"מ(", "ARAR ")):
raise ValueError(
"ציטוט שמתחיל ב-'ערר' או 'בל\"מ' הוא החלטת ועדת ערר. "
"השתמש ב-internal_decision_upload (דורש chair_name + district), "
"לא ב-precedent_library_upload."
)
if practice_area not in _VALID_PRACTICE_AREAS: if practice_area not in _VALID_PRACTICE_AREAS:
raise ValueError(f"invalid practice_area: {practice_area!r}") raise ValueError(f"invalid practice_area: {practice_area!r}")
if source_type not in _VALID_SOURCE_TYPES: if source_type not in _VALID_SOURCE_TYPES:
@@ -160,34 +172,100 @@ async def ingest_precedent(
case_law_id = UUID(str(record["id"])) case_law_id = UUID(str(record["id"]))
try: try:
await progress("chunking", 40, f"מחלק את הטקסט ל-chunks ({page_count} עמ')") # Parent-doc retrieval (TaskMaster #48): when enabled, emit
chunks = chunker.chunk_document(text, page_offsets=page_offsets) # two tiers (parents + children). Only children are embedded
if not chunks: # and indexed; parents carry retrieval context. When disabled,
await db.set_case_law_extraction_status(case_law_id, "completed") # fall back to legacy single-tier chunking — identical
await db.set_case_law_halacha_status(case_law_id, "completed") # behaviour to pre-V17.
await progress("completed", 100, "אין טקסט לעיבוד") if config.PARENT_DOC_RETRIEVAL_ENABLED:
return { await progress(
"status": "completed", "chunking", 40,
"case_law_id": str(case_law_id), f"מחלק את הטקסט ל-chunks היררכיים ({page_count} עמ')",
"chunks": 0, )
"halachot": 0, h_chunks = chunker.chunk_document_hierarchical(
} text, page_offsets=page_offsets,
)
if not h_chunks:
await db.set_case_law_extraction_status(case_law_id, "completed")
await db.set_case_law_halacha_status(case_law_id, "completed")
await progress("completed", 100, "אין טקסט לעיבוד")
return {
"status": "completed",
"case_law_id": str(case_law_id),
"chunks": 0,
"halachot": 0,
}
await progress("embedding", 55, f"מייצר embeddings ל-{len(chunks)} chunks") children = [c for c in h_chunks if c.role == "child"]
chunk_texts = [c.content for c in chunks] parents = [c for c in h_chunks if c.role == "parent"]
chunk_vectors = await embeddings.embed_texts(chunk_texts, input_type="document") await progress(
"embedding", 55,
f"מייצר embeddings ל-{len(children)} children "
f"({len(parents)} parents)",
)
child_texts = [c.content for c in children]
child_vectors = await embeddings.embed_texts(
child_texts, input_type="document",
)
# Build flat dict list for the two-pass writer.
chunk_dicts: list[dict] = []
for p in parents:
chunk_dicts.append({
"role": "parent",
"local_id": p.local_id,
"parent_local_id": None,
"chunk_index": p.chunk_index,
"content": p.content,
"section_type": p.section_type,
"page_number": p.page_number,
"embedding": None,
})
for c, v in zip(children, child_vectors):
chunk_dicts.append({
"role": "child",
"local_id": c.local_id,
"parent_local_id": c.parent_local_id,
"chunk_index": c.chunk_index,
"content": c.content,
"section_type": c.section_type,
"page_number": c.page_number,
"embedding": v,
})
counts = await db.store_precedent_chunks_hierarchical(
case_law_id, chunk_dicts,
)
stored_chunks = counts["children"]
else:
await progress(
"chunking", 40, f"מחלק את הטקסט ל-chunks ({page_count} עמ')",
)
chunks = chunker.chunk_document(text, page_offsets=page_offsets)
if not chunks:
await db.set_case_law_extraction_status(case_law_id, "completed")
await db.set_case_law_halacha_status(case_law_id, "completed")
await progress("completed", 100, "אין טקסט לעיבוד")
return {
"status": "completed",
"case_law_id": str(case_law_id),
"chunks": 0,
"halachot": 0,
}
chunk_dicts = [ await progress("embedding", 55, f"מייצר embeddings ל-{len(chunks)} chunks")
{ chunk_texts = [c.content for c in chunks]
"chunk_index": c.chunk_index, chunk_vectors = await embeddings.embed_texts(chunk_texts, input_type="document")
"content": c.content,
"section_type": c.section_type, chunk_dicts = [
"page_number": c.page_number, {
"embedding": v, "chunk_index": c.chunk_index,
} "content": c.content,
for c, v in zip(chunks, chunk_vectors) "section_type": c.section_type,
] "page_number": c.page_number,
stored_chunks = await db.store_precedent_chunks(case_law_id, chunk_dicts) "embedding": v,
}
for c, v in zip(chunks, chunk_vectors)
]
stored_chunks = await db.store_precedent_chunks(case_law_id, chunk_dicts)
# Multimodal page-image embeddings (V9). Gated by feature flag. # Multimodal page-image embeddings (V9). Gated by feature flag.
# Non-fatal: text path already succeeded. Only PDFs. # Non-fatal: text path already succeeded. Only PDFs.
@@ -455,6 +533,7 @@ async def list_precedents(
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,
) -> list[dict]: ) -> list[dict]:
@@ -464,6 +543,7 @@ async def list_precedents(
precedent_level=precedent_level, precedent_level=precedent_level,
source_type=source_type, source_type=source_type,
search=search, search=search,
source_kind=source_kind,
limit=limit, limit=limit,
offset=offset, offset=offset,
) )

View File

@@ -3,7 +3,9 @@
Runs after chunking. Reads the precedent's full_text and asks Claude to Runs after chunking. Reads the precedent's full_text and asks Claude to
fill in the metadata fields that an upload form usually leaves empty: fill in the metadata fields that an upload form usually leaves empty:
short case_name, summary, headnote, key_quote, subject_tags, short case_name, summary, headnote, key_quote, subject_tags,
appeal_subtype, decision_date, precedent_level, court. appeal_subtype, decision_date, precedent_level, court — plus
chair_name + district for internal_committee rows (which the upload
path stamps with PLACEHOLDER_PENDING_EXTRACTION when missing).
Caller policy: only empty user-supplied fields are filled. Anything the Caller policy: only empty user-supplied fields are filled. Anything the
chair already typed in the upload form is preserved. This is enforced chair already typed in the upload form is preserved. This is enforced
@@ -22,6 +24,12 @@ from legal_mcp.services import claude_session, db
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Sentinel inserted by the upload endpoint when a committee row is created
# without chair_name/district (the DB CHECK forces non-empty). Treated as
# empty by ``apply_to_record`` so LLM-extracted values overwrite it.
PLACEHOLDER_PENDING_EXTRACTION = "(טרם חולץ)"
# The prompt is short — we only need the first 12K chars of the ruling # The prompt is short — we only need the first 12K chars of the ruling
# (header + opening of discussion is enough for naming + summary). For # (header + opening of discussion is enough for naming + summary). For
# subject tags we sample the discussion section too. # subject tags we sample the discussion section too.
@@ -50,8 +58,12 @@ 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' (החלטה של ועדת ערר). אם לא ברור — מחרוזת ריקה.",
"proceeding_type": "אחד מ-2 (רק להחלטות ועדת ערר): 'ערר' (הליך ערר עיקרי על החלטת ועדה מקומית) / 'בל\\\"מ' (בקשה להארכת מועד להגשת ערר). זהה דרך כותרת המסמך: 'ערר (ועדות ערר ...) NNNN/YY''ערר'; 'בל\\\"מ NNNN/YY' או נושא 'בקשה להארכת מועד להגשת ערר''בל\\\"מ'. בפסיקת בית משפט (לא ועדת ערר) — מחרוזת ריקה.",
"court": "שם הערכאה כפי שהוא מופיע בכותרת (למשל 'בית המשפט העליון', 'בית המשפט המחוזי בירושלים בשבתו כבית משפט לעניינים מנהליים', 'ועדת הערר לתכנון ובניה פיצויים והיטלי השבחה — מחוז ירושלים'). מחרוזת ריקה אם לא ניתן לזהות.", "court": "שם הערכאה כפי שהוא מופיע בכותרת (למשל 'בית המשפט העליון', 'בית המשפט המחוזי בירושלים בשבתו כבית משפט לעניינים מנהליים', 'ועדת הערר לתכנון ובניה פיצויים והיטלי השבחה — מחוז ירושלים'). מחרוזת ריקה אם לא ניתן לזהות.",
"case_number_clean": "מספר הערר/תיק כפי שמופיע בכותרת — רק הספרות והאלכסון, למשל '1062/24' או '8031/21'. ללא המילה 'ערר', ללא שם הצדדים, ללא סוגריים. אם יש כמה עררים מאוחדים — הרשום הראשון. מחרוזת ריקה אם לא ניתן לזהות." "case_number_clean": "מספר הערר/תיק כפי שמופיע בכותרת — רק הספרות והאלכסון, למשל '1062/24' או '8031/21'. ללא המילה 'ערר', ללא שם הצדדים, ללא סוגריים. אם יש כמה עררים מאוחדים — הרשום הראשון. מחרוזת ריקה אם לא ניתן לזהות.",
"chair_name": "שם יו\\\"ר ההרכב — רלוונטי **רק להחלטות ועדת ערר**, לא לפסקי בית משפט. חפש בכותרת/חתימה: 'עו\\\"ד דפנה תמיר, יו\\\"ר ועדת הערר', 'בפני: עו\\\"ד פלוני אלמוני (יו\\\"ר)'. השאר שם פרטי+משפחה בלי תוארים ('עו\\\"ד', 'אדריכל'). אם זה פסק דין של בית משפט — מחרוזת ריקה.",
"district": "מחוז ועדת הערר — רלוונטי **רק להחלטות ועדת ערר**. ערכים מותרים: 'ירושלים', 'תל אביב', 'מרכז', 'חיפה', 'צפון', 'דרום', 'ארצית'. זהה מהכותרת ('ועדת הערר לתכנון ובניה — מחוז ירושלים''ירושלים'; 'ועדות ערר - תכנון ובנייה תל אביב-יפו''תל אביב'). אם זה פסק דין של בית משפט — מחרוזת ריקה.",
"citation_formatted": "המראה מקום המלא לפי **כללי הציטוט האחיד**, בפורמט Markdown — שמות הצדדים בלבד מוקפים בכפול-כוכבית (`**…**`), הכל השאר רגיל. ראה כללים מפורטים בסעיף 12 למטה."
} }
## כללי איכות ## כללי איכות
@@ -65,6 +77,24 @@ METADATA_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. קרא א
8. **precedent_level** — קבע לפי הערכאה: בית המשפט העליון = "עליון"; בית משפט מחוזי בשבתו כבית משפט לעניינים מנהליים = "מנהלי"; ועדת ערר ארצית = "ועדת_ערר_ארצית"; ועדת ערר מחוזית (כמו ועדות תכנון ובניה ירושלים/מחוז המרכז וכד') = "ועדת_ערר_מחוזית". השתמש ב-underscore כפי שמופיע — לא ברווח. 8. **precedent_level** — קבע לפי הערכאה: בית המשפט העליון = "עליון"; בית משפט מחוזי בשבתו כבית משפט לעניינים מנהליים = "מנהלי"; ועדת ערר ארצית = "ועדת_ערר_ארצית"; ועדת ערר מחוזית (כמו ועדות תכנון ובניה ירושלים/מחוז המרכז וכד') = "ועדת_ערר_מחוזית". השתמש ב-underscore כפי שמופיע — לא ברווח.
9. **source_type** — שני ערכים בלבד: "court_ruling" כשהמסמך הוא פסק דין/החלטה של בית משפט (עליון/בג"ץ/מנהלי/מחוזי); "appeals_committee" כשהמסמך הוא החלטה של ועדת ערר (ארצית או מחוזית). זה משלים את `precedent_level` — שני השדות צריכים להיות תואמים. 9. **source_type** — שני ערכים בלבד: "court_ruling" כשהמסמך הוא פסק דין/החלטה של בית משפט (עליון/בג"ץ/מנהלי/מחוזי); "appeals_committee" כשהמסמך הוא החלטה של ועדת ערר (ארצית או מחוזית). זה משלים את `precedent_level` — שני השדות צריכים להיות תואמים.
10. **court** — מהכותרת הראשית של הפסק. ניסוח מלא (לא קיצור). מחרוזת ריקה אם לא ניתן לזהות. 10. **court** — מהכותרת הראשית של הפסק. ניסוח מלא (לא קיצור). מחרוזת ריקה אם לא ניתן לזהות.
11. **proceeding_type** — חובה לזהות עבור החלטות ועדת ערר; ריק עבור פסיקת בית משפט. הסימן הברור: בכותרת הראשונה של המסמך כתוב "ערר (ועדות ערר ...) NNNN/YY"'ערר'; "בל\"מ NNNN/YY" או הנושא "בקשה להארכת מועד להגשת ערר"'בל\"מ'. שני הסוגים יכולים לחלוק אותו מספר תיק — לכן חשוב להבחין מפורשות.
12. **chair_name / district** — חובה למלא רק עבור החלטות ועדת ערר (source_type='appeals_committee'). chair_name נמצא בכותרת ("בפני: עו\"ד פלוני אלמוני, יו\"ר") או בחתימה. district = מחוז הוועדה, מתוך רשימה סגורה. עבור פסקי בית משפט — שני השדות ריקים.
13. **citation_formatted — כללי הציטוט האחיד הישראלי**. הרכב את המראה מקום במחרוזת אחת בפורמט Markdown, **כשרק שמות הצדדים מודגשים** (מוקפים ב-`**…**`). כל השאר — קיצור הערכאה, סוגריים של הרכב/מחוז, מספר תיק, מאגר/תאריך — **רגיל ללא הדגשה**.
תבניות לסוגי פסיקה:
* **בית משפט עליון — לא פורסם:** `ע"א 1234/56 **פלוני נ' אלמוני** (נבו 1.2.3456)`
* **בית משפט עליון — פורסם:** `ע"א 1234/56 **פלוני נ' אלמוני**, פ"ד יב(3) 456 (1990)`
* **בית משפט מנהלי:** `עת"מ (י-ם) 1234/56 **פלוני נ' הוועדה** (נבו 1.2.3456)` — "(י-ם)" / ""א)" / וכד' = קיצור המחוז
* **ועדת ערר תכנון ובנייה (מחוזית):** `ערר (ועדות ערר - תכנון ובנייה ת"א-יפו) 81002-01-21 **אברהם אגסי נ' הועדה המקומית לתכנון ובנייה תל אביב** (נבו 25.9.2025)`
* **בל"מ (בקשה להארכת מועד):** `בל"מ (ועדות ערר - ירושלים) 1028/20 **חלוואני ריאד נ' רשות הרישוי - הוועדה המקומית ירושלים** (נבו 7.1.2021)`
* **ועדת ערר ארצית:** `ערר ארצי 8047/23 **פלוני נ' אלמוני** (נבו 1.2.3456)`
כללים:
- **הצדדים מודגשים בלבד** — כל השאר רגיל. אל תדגיש את "ע"א" / "ערר" / מספר התיק / "(נבו ...)" / "פ"ד".
- הצדדים = מי שמופיע **בין מספר התיק לבין הסוגריים הסופיים** (תאריך/מאגר), כלומר "[עורר/מבקש] נ' [משיב]".
- תאריך בסוגריים סופיים בפורמט עברי "(נבו 25.9.2025)" — יום.חודש.שנה ללא אפסים מובילים.
- אם המאגר הוא נבו והפסיקה לא פורסמה ב-פ"ד — השתמש ב-"(נבו DATE)". אם פורסמה ב-פ"ד — הוסף את ההפניה הפורמלית אחרי הצדדים: `..., פ"ד יב(3) 456 (1990)`.
- אם לא ניתן לזהות איזשהו רכיב במדויק — השאר את **כל** השדה ריק. אל תניח / תמציא.
""" """
@@ -160,10 +190,30 @@ async def extract_metadata(case_law_id: UUID | str) -> dict:
st = result["source_type"].strip() st = result["source_type"].strip()
if st in {"court_ruling", "appeals_committee"}: if st in {"court_ruling", "appeals_committee"}:
out["source_type"] = st out["source_type"] = st
if isinstance(result.get("proceeding_type"), str):
pt = result["proceeding_type"].strip()
if pt in {"ערר", 'בל"מ', ""}:
out["proceeding_type"] = pt
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): if isinstance(result.get("case_number_clean"), str):
out["case_number_clean"] = result["case_number_clean"].strip() out["case_number_clean"] = result["case_number_clean"].strip()
if isinstance(result.get("chair_name"), str):
out["chair_name"] = result["chair_name"].strip()
if isinstance(result.get("district"), str):
d = result["district"].strip()
# Closed enum for districts — anything else is dropped to avoid
# silently storing free-text in what callers treat as a filter facet.
if d in {"ירושלים", "תל אביב", "מרכז", "חיפה", "צפון", "דרום", "ארצית"}:
out["district"] = d
if isinstance(result.get("citation_formatted"), str):
cf = result["citation_formatted"].strip()
# Sanity check: a valid citation should contain at least one bold
# marker pair (the parties) AND a closing paren (the reporter/date).
# If the LLM returned a half-formed string, drop it rather than
# store junk that the UI then has to special-case.
if cf.count("**") >= 2 and ")" in cf:
out["citation_formatted"] = cf
return out return out
@@ -267,11 +317,41 @@ async def apply_to_record(
if c: if c:
fields_to_update["court"] = c fields_to_update["court"] = c
# proceeding_type — only fill for internal_committee rows (the field is
# meaningless for court rulings, which we keep as '').
if not (record.get("proceeding_type") or "").strip():
pt = (suggested.get("proceeding_type") or "").strip()
if pt and (record.get("source_kind") == "internal_committee"):
fields_to_update["proceeding_type"] = pt
if overwrite_case_number: if overwrite_case_number:
cn = (suggested.get("case_number_clean") or "").strip() cn = (suggested.get("case_number_clean") or "").strip()
if cn: if cn:
fields_to_update["case_number"] = cn fields_to_update["case_number"] = cn
# citation_formatted — full citation per Israeli citation rules. Only
# fill if empty; user edits in /precedents/[id] are preserved.
if not (record.get("citation_formatted") or "").strip():
s = (suggested.get("citation_formatted") or "").strip()
if s:
fields_to_update["citation_formatted"] = s
# chair_name / district — only for internal_committee rows. The DB CHECK
# forces these to be non-empty, so the upload endpoint stamps the row
# with "(טרם חולץ)" as a placeholder. Treat that placeholder as empty
# so the LLM-extracted value can overwrite it.
if record.get("source_kind") == "internal_committee":
cur_chair = (record.get("chair_name") or "").strip()
if cur_chair in ("", PLACEHOLDER_PENDING_EXTRACTION):
s = (suggested.get("chair_name") or "").strip()
if s:
fields_to_update["chair_name"] = s
cur_district = (record.get("district") or "").strip()
if cur_district in ("", PLACEHOLDER_PENDING_EXTRACTION):
s = (suggested.get("district") or "").strip()
if s:
fields_to_update["district"] = s
if not fields_to_update: if not fields_to_update:
return {"updated": False, "fields": []} return {"updated": False, "fields": []}

View File

@@ -67,7 +67,7 @@ def check_neutral_background(blocks: list[dict]) -> dict:
"""בדיקת ניטרליות בלוק הרקע (ו).""" """בדיקת ניטרליות בלוק הרקע (ו)."""
vav = next((b for b in blocks if b["block_id"] == "block-vav"), None) vav = next((b for b in blocks if b["block_id"] == "block-vav"), None)
if not vav or not vav.get("content"): if not vav or not vav.get("content"):
return {"name": "neutral_background", "passed": True, "errors": [], "severity": "critical"} return {"name": "neutral_background", "passed": True, "errors": [], "severity": "warning"}
errors = [] errors = []
lines = vav["content"].split("\n") lines = vav["content"].split("\n")

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

@@ -0,0 +1,195 @@
"""Auto-extract per-decision metadata for a style_corpus row.
Populates the fields that the upload flow leaves empty — summary, outcome,
key_principles, appeal_subtype, practice_area — by asking Claude (via the
local CLI session) to read the proofread full_text and return a structured
JSON blob.
Caller policy (``apply_to_corpus``): by default we **only fill empty
columns**, so chair-edited values are preserved across re-runs. The chair
can force a refresh by passing ``overwrite=True``.
Why this is a separate module from ``precedent_metadata_extractor``:
that one fills the *external* case_law corpus (court rulings, third-party
committee decisions). This one fills the *style* corpus — Daphna's own
decisions used to teach the writer the in-house voice. The two corpora
have different schemas, different prompts, and different downstream
consumers, so coupling them would have been the wrong shortcut.
"""
from __future__ import annotations
import logging
from uuid import UUID
from legal_mcp.services import claude_session, db
logger = logging.getLogger(__name__)
# A single decision typically runs 200K-650K chars. We sample the head
# (where outcome + parties + framing live) and the tail (where the
# operative ruling sits). Picking from both edges keeps the prompt under
# 60K chars — comfortable for any Claude tier.
_HEAD_CHARS = 25_000
_TAIL_CHARS = 15_000
def _build_text_window(full_text: str) -> str:
if len(full_text) <= _HEAD_CHARS + _TAIL_CHARS:
return full_text
head = full_text[:_HEAD_CHARS]
tail = full_text[-_TAIL_CHARS:]
return (
f"{head}\n\n"
f"[... חתך: {len(full_text) - _HEAD_CHARS - _TAIL_CHARS:,} תווים מהאמצע "
f"הושמטו — שמרנו על ההתחלה (טענות + רקע) ועל הסוף (הכרעה + הוצאות) ...]"
f"\n\n{tail}"
)
# Static instructions — go via ``system`` so the SDK path can cache them
# across batch enrichment runs (24+ decisions in one pass).
METADATA_PROMPT = """אתה מסייע משפטי שמקטלג את הקורפוס הסגנוני של דפנה תמיר (יו"ר ועדת ערר).
תפקידך: לקרוא החלטה אחת ולחלץ מטא-דאטה ל-style_corpus — שדות שהמשתמש לא הזין בעת ההעלאה.
**אל תמציא**. אם המידע לא מופיע בטקסט, השאר מחרוזת ריקה או מערך ריק. אסור להסיק עובדות שלא כתובות.
## פלט נדרש
החזר JSON אחד (object אחד — לא array, לא markdown, לא הסברים):
{
"summary": "תקציר עניני ב-2-3 משפטים: מי העורר, מה דרש, מה הוכרע. סגנון יבש, ניטרלי, ללא שיפוט. דוגמה: 'ערר על דחיית בקשה להיתר לתוספת מרפסת בקומה ג׳. דפנה קיבלה את הערר חלקית — אישרה את המרפסת בהקטנה ל-12 מ״ר.'",
"outcome": "התוצאה התמציתית. אחד מאלה (או צירוף קצר): 'קבלה' / 'קבלה חלקית' / 'דחייה' / 'הסתלקות' / 'החזרה לוועדה המקומית'. אם זה לא ברור — מחרוזת ריקה.",
"key_principles": [
"עיקרון משפטי 1 שעולה מההחלטה — משפט אחד, ניסוח מופשט. למשל 'שיקול דעת מוגבל לחריגות בנייה קטנות'.",
"עיקרון 2",
"..."
],
"appeal_subtype": "תת-סוג ערר. ערכים מותרים: 'building_permit' (היתר בנייה / רישוי), 'betterment_levy' (היטל השבחה), 'compensation_197' (פיצויים ס׳ 197), 'use_change' (שימוש חורג), 'tama_38' (תמ\\"א 38), או מחרוזת ריקה אם לא ברור.",
"practice_area": "תחום משפט גנרי. ברירת מחדל: 'appeals_committee'. אם זה במובהק 'planning_law' — סמן.",
"parties_appellant": "שם העורר/ים המרכזיים בהחלטה (אחד או כמה, מופרדים בפסיק). אם זו החלטה מאוחדת — שם הצד המוביל. השאר ריק אם לא ניתן לזהות במדויק.",
"parties_respondent": "שם המשיב/ים. ברירת מחדל לעררי 1xxx ו-8xxx: 'הוועדה המקומית לתכנון ובניה ירושלים' או דומה. השאר ריק אם לא ברור."
}
## כללי איכות
1. **summary** — חייב להזכיר את התוצאה. בלי 'בית המשפט קבע ש...' (אנחנו לא בית משפט). בלי הערכת אישית.
2. **outcome** — קבלה / קבלה חלקית / דחייה / הסתלקות / החזרה לוועדה המקומית. אם דפנה הכריעה חלקית — 'קבלה חלקית'. אסור 'התקבל' או 'נדחה' בלשון פעולה — רק שם פעולה.
3. **key_principles** — 2-5 עקרונות מקסימום. כל אחד משפט אחד. לא ציטוטים מילוליים, אלא תמצות העיקרון.
4. **appeal_subtype** — תמיד פעולה אחת. אם החלטה מערבת כמה תת-סוגים — בחר את העיקרי.
5. **parties_appellant / parties_respondent** — שם בלבד, בלי 'נ׳' או 'נגד'.
החזר רק את ה-JSON. אל תכתוב שום דבר לפניו או אחריו.
"""
async def extract_decision_metadata(corpus_id: UUID | str) -> dict:
"""Run Claude over the row's full_text and return suggested fields.
Does NOT touch the DB. The caller decides what to apply.
"""
if isinstance(corpus_id, str):
corpus_id = UUID(corpus_id)
row = await db.get_style_corpus_row(corpus_id)
if not row:
return {}
full_text = (row.get("full_text") or "").strip()
if not full_text:
return {}
context = (
f"מספר החלטה: {row.get('decision_number') or ''}\n"
f"תאריך: {row.get('decision_date') or ''}\n"
f"תת-סוג נוכחי: {row.get('appeal_subtype') or ''}\n"
f"נושאים מתויגים: {row.get('subject_categories') or ''}"
)
window = _build_text_window(full_text)
user_msg = (
f"## הקלט\n{context}\n\n"
f"--- תחילת ההחלטה ---\n{window}\n--- סוף ההחלטה ---"
)
try:
result = await claude_session.query_json(user_msg, system=METADATA_PROMPT)
except Exception as e:
logger.warning("style_metadata_extractor: query failed: %s", e)
return {}
if not isinstance(result, dict):
logger.warning(
"style_metadata_extractor: expected JSON object, got %s",
type(result).__name__,
)
return {}
out: dict = {}
if isinstance(result.get("summary"), str):
out["summary"] = result["summary"].strip()
if isinstance(result.get("outcome"), str):
out["outcome"] = result["outcome"].strip()
kp = result.get("key_principles") or []
if isinstance(kp, list):
out["key_principles"] = [str(p).strip() for p in kp if str(p).strip()]
if isinstance(result.get("appeal_subtype"), str):
st = result["appeal_subtype"].strip()
# Open enum — but log values outside the documented list so we can
# tighten the prompt later if needed.
known = {
"building_permit", "betterment_levy", "compensation_197",
"use_change", "tama_38", "",
}
if st not in known:
logger.info("style_metadata: unknown appeal_subtype=%r (kept)", st)
out["appeal_subtype"] = st
if isinstance(result.get("practice_area"), str):
out["practice_area"] = result["practice_area"].strip()
# Parties: not stored in the schema today, but worth surfacing in the
# extractor's return value so callers (and the UI's drawer) can display
# them. The list endpoint extracts via regex; LLM output is the
# higher-quality fallback when regex fails.
if isinstance(result.get("parties_appellant"), str):
out["parties_appellant"] = result["parties_appellant"].strip()
if isinstance(result.get("parties_respondent"), str):
out["parties_respondent"] = result["parties_respondent"].strip()
return out
async def extract_and_apply(
corpus_id: UUID | str, *, overwrite: bool = False,
) -> dict:
"""Convenience: extract → apply → return summary of what changed.
Idempotent under default ``overwrite=False`` — re-runs only fill empty
fields. Use ``overwrite=True`` to refresh values the chair (or a prior
extraction) already wrote.
"""
if isinstance(corpus_id, str):
corpus_id = UUID(corpus_id)
suggested = await extract_decision_metadata(corpus_id)
if not suggested:
return {"extracted": False, "applied": False, "reason": "no suggestion"}
update_result = await db.update_style_corpus_metadata(
corpus_id,
summary=suggested.get("summary"),
outcome=suggested.get("outcome"),
key_principles=suggested.get("key_principles"),
appeal_subtype=suggested.get("appeal_subtype"),
practice_area=suggested.get("practice_area"),
overwrite=overwrite,
)
return {
"extracted": True,
"applied": update_result.get("updated", False),
"fields_set": update_result.get("fields", []),
"suggested": suggested,
}

View File

@@ -0,0 +1,391 @@
"""RAG retrieval telemetry — closed-loop feedback (TaskMaster #50).
Logs every semantic search call so we can compute nDCG@10 over time,
spot retrieval drift, and feed the rerank training set.
Design notes
------------
- **All writes are fire-and-forget**: callers wrap us in ``try/except``
but we also swallow our own DB errors so a telemetry hiccup can never
fail a search. The log itself is also written via a detached task —
the search returns to the caller immediately and the row lands in
the DB on the side.
- **search_decisions / search_case_documents** return document chunks
from active cases, not ``case_law`` rows. Their telemetry rows leave
``top_case_law_ids`` empty; nDCG aggregation ignores them.
- **Auto-inferred feedback**: once a final decision is exported, we
scan its ``decision_paragraphs.citations`` JSONB, pull the
``case_law_id`` values, and mark them as ``relevance_score=3`` on
any search_log for the same case where the precedent appeared in
the top-K. This gives us a "cited == relevant" ground truth signal
without asking the chair to label results by hand.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any, Iterable
from uuid import UUID
from legal_mcp.services import db
logger = logging.getLogger(__name__)
_VALID_SOURCES = {"cited_in_decision", "chair_marked", "auto_inferred"}
def _coerce_case_law_ids(results: Iterable[Any], limit: int = 10) -> list[UUID]:
"""Pull up to ``limit`` ``case_law_id`` UUIDs from search results.
Tolerates rows missing the field, non-UUID strings, and ``None``
values. Preserves order (= ranking).
"""
out: list[UUID] = []
seen: set[str] = set()
for r in results:
if len(out) >= limit:
break
if not isinstance(r, dict):
continue
raw = r.get("case_law_id")
if raw is None:
continue
s = str(raw)
if s in seen:
continue
try:
out.append(UUID(s))
seen.add(s)
except (ValueError, AttributeError):
continue
return out
async def _insert_log(
*,
search_type: str,
query: str,
practice_area: str | None,
case_id: UUID | None,
user_agent: str | None,
result_count: int,
top_case_law_ids: list[UUID],
duration_ms: int | None,
) -> UUID | None:
try:
pool = await db.get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO search_logs (
search_type, query, practice_area, case_id,
user_agent, result_count, top_case_law_ids,
duration_ms
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id
""",
search_type,
query[:2000], # guard against pathologically long queries
practice_area or None,
case_id,
user_agent or None,
int(result_count),
top_case_law_ids or None,
duration_ms,
)
return row["id"] if row else None
except Exception:
logger.exception("telemetry.log_search: insert failed (swallowed)")
return None
async def log_search(
*,
search_type: str,
query: str,
results: Iterable[dict],
duration_ms: int | None = None,
practice_area: str | None = None,
case_id: UUID | str | None = None,
user_agent: str | None = None,
) -> UUID | None:
"""Record a search call. Never raises.
Args:
search_type: one of 'precedent_library', 'internal_decisions',
'decisions', 'case_documents', 'similar_cases'.
query: the raw user query.
results: iterable of result dicts. We pull ``case_law_id`` from
the first 10 to populate ``top_case_law_ids``.
duration_ms: search latency in milliseconds.
practice_area: optional filter applied to the search.
case_id: optional case context (when the search was scoped to
or triggered from a specific case).
user_agent: 'writer' / 'researcher' / 'analyst' / 'manual'.
Returns:
The ``search_logs.id`` UUID if the row was written, else None.
Most callers ignore this; auto-inference uses it later via
``infer_relevance_from_citations``.
"""
# Snapshot results immediately — callers may keep iterating.
snapshot = list(results) if not isinstance(results, list) else results
top_ids = _coerce_case_law_ids(snapshot, limit=10)
case_uuid: UUID | None
if case_id is None:
case_uuid = None
elif isinstance(case_id, UUID):
case_uuid = case_id
else:
try:
case_uuid = UUID(str(case_id))
except (ValueError, AttributeError):
case_uuid = None
return await _insert_log(
search_type=search_type,
query=query,
practice_area=practice_area,
case_id=case_uuid,
user_agent=user_agent,
result_count=len(snapshot),
top_case_law_ids=top_ids,
duration_ms=duration_ms,
)
def log_search_bg(
*,
search_type: str,
query: str,
results: Iterable[dict],
duration_ms: int | None = None,
practice_area: str | None = None,
case_id: UUID | str | None = None,
user_agent: str | None = None,
) -> None:
"""Fire-and-forget variant. Schedules the insert as a detached task.
Use this from hot search paths so the caller returns to the user
immediately. Errors are logged inside ``log_search``.
"""
# Snapshot eagerly so the caller can mutate/iterate results freely.
snapshot = list(results) if not isinstance(results, list) else list(results)
try:
loop = asyncio.get_running_loop()
except RuntimeError:
# No running loop — caller is sync. Best-effort: skip telemetry.
return
loop.create_task(
log_search(
search_type=search_type,
query=query,
results=snapshot,
duration_ms=duration_ms,
practice_area=practice_area,
case_id=case_id,
user_agent=user_agent,
)
)
# ──────────────────────────────────────────────────────────────────────
# Auto-inferred relevance feedback
# ──────────────────────────────────────────────────────────────────────
def _extract_citations_from_jsonb(citations: Any) -> list[UUID]:
"""Parse ``decision_paragraphs.citations`` JSONB into UUID list.
Stored shape: ``[{"case_law_id": "...", "text": "...", "type": ...}]``.
Tolerates string form (asyncpg returns it as JSON string when the
column registration didn't auto-decode).
"""
import json as _json
if not citations:
return []
if isinstance(citations, (bytes, bytearray)):
try:
citations = _json.loads(citations.decode("utf-8"))
except (ValueError, UnicodeDecodeError):
return []
elif isinstance(citations, str):
try:
citations = _json.loads(citations)
except ValueError:
return []
if not isinstance(citations, list):
return []
out: list[UUID] = []
seen: set[str] = set()
for item in citations:
if not isinstance(item, dict):
continue
raw = item.get("case_law_id")
if not raw:
continue
s = str(raw)
if s in seen:
continue
try:
out.append(UUID(s))
seen.add(s)
except (ValueError, AttributeError):
continue
return out
async def _gather_cited_case_law_ids(case_id: UUID) -> list[UUID]:
"""Pull every distinct ``case_law_id`` cited anywhere in the case's
decision paragraphs.
"""
pool = await db.get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT dp.citations
FROM decision_paragraphs dp
JOIN decision_blocks db ON db.id = dp.block_id
JOIN decisions d ON d.id = db.decision_id
WHERE d.case_id = $1
AND dp.citations IS NOT NULL
AND jsonb_array_length(dp.citations) > 0
""",
case_id,
)
seen: set[str] = set()
out: list[UUID] = []
for r in rows:
for clid in _extract_citations_from_jsonb(r["citations"]):
s = str(clid)
if s not in seen:
seen.add(s)
out.append(clid)
return out
async def infer_relevance_from_citations(
case_id: UUID | str,
*,
relevance_score: int = 3,
feedback_source: str = "cited_in_decision",
) -> dict:
"""For each precedent cited in the case's draft, write a relevance
row against every search_log where that precedent appeared in the
top-K for the same case.
Idempotent: the ``UNIQUE(search_log_id, case_law_id, feedback_source)``
constraint on ``search_relevance_feedback`` prevents duplicates.
Returns:
``{"cited_precedents": int, "feedback_rows_inserted": int,
"searches_matched": int}``.
"""
if relevance_score not in (0, 1, 2, 3):
raise ValueError("relevance_score must be in 0..3")
if feedback_source not in _VALID_SOURCES:
raise ValueError(f"feedback_source must be one of {_VALID_SOURCES!r}")
case_uuid = case_id if isinstance(case_id, UUID) else UUID(str(case_id))
cited = await _gather_cited_case_law_ids(case_uuid)
if not cited:
return {
"cited_precedents": 0,
"feedback_rows_inserted": 0,
"searches_matched": 0,
}
pool = await db.get_pool()
inserted = 0
matched_searches: set[str] = set()
async with pool.acquire() as conn:
# For each cited precedent, find all logs where it appeared in
# top_case_law_ids for this case, and record its rank.
for clid in cited:
rows = await conn.fetch(
"""
SELECT id, top_case_law_ids
FROM search_logs
WHERE case_id = $1
AND top_case_law_ids IS NOT NULL
AND $2 = ANY(top_case_law_ids)
""",
case_uuid,
clid,
)
for row in rows:
top_ids = row["top_case_law_ids"] or []
# asyncpg returns uuid[] as list[UUID]
try:
rank = top_ids.index(clid) + 1
except ValueError:
continue
result = await conn.execute(
"""
INSERT INTO search_relevance_feedback (
search_log_id, case_law_id, rank,
relevance_score, feedback_source
) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (search_log_id, case_law_id, feedback_source)
DO NOTHING
""",
row["id"],
clid,
rank,
relevance_score,
feedback_source,
)
# ``execute`` returns 'INSERT 0 1' or 'INSERT 0 0' for
# the no-op path; count only the writes.
if result.endswith(" 1"):
inserted += 1
matched_searches.add(str(row["id"]))
return {
"cited_precedents": len(cited),
"feedback_rows_inserted": inserted,
"searches_matched": len(matched_searches),
}
async def infer_relevance_for_all_finalized_cases(limit: int | None = None) -> dict:
"""Bulk-run auto-inference for every case whose draft is final/exported.
Useful for back-filling after V18 schema lands and a few decisions
have already been written. Skips cases with no cited precedents
silently (they contribute zero to the totals).
"""
pool = await db.get_pool()
sql = """
SELECT DISTINCT c.id
FROM cases c
JOIN decisions d ON d.case_id = c.id
WHERE c.status IN ('final', 'exported')
"""
if limit is not None and limit > 0:
sql += " LIMIT $1"
async with pool.acquire() as conn:
rows = await conn.fetch(sql, *([limit] if limit else []))
totals = {
"cases_processed": 0,
"cited_precedents": 0,
"feedback_rows_inserted": 0,
"searches_matched": 0,
}
for r in rows:
stats = await infer_relevance_from_citations(r["id"])
totals["cases_processed"] += 1
totals["cited_precedents"] += stats["cited_precedents"]
totals["feedback_rows_inserted"] += stats["feedback_rows_inserted"]
totals["searches_matched"] += stats["searches_matched"]
return totals

View File

@@ -128,8 +128,9 @@ async def case_create(
hearing_date: str = "", hearing_date: str = "",
notes: str = "", notes: str = "",
expected_outcome: str = "", expected_outcome: str = "",
practice_area: str = "appeals_committee", practice_area: str = "",
appeal_subtype: str = "", appeal_subtype: str = "",
proceeding_type: str = "",
) -> str: ) -> str:
"""יצירת תיק ערר חדש. """יצירת תיק ערר חדש.
@@ -145,9 +146,12 @@ async def case_create(
hearing_date: תאריך דיון (YYYY-MM-DD) hearing_date: תאריך דיון (YYYY-MM-DD)
notes: הערות notes: הערות
expected_outcome: תוצאה צפויה (rejection/partial_acceptance/full_acceptance/betterment_levy) expected_outcome: תוצאה צפויה (rejection/partial_acceptance/full_acceptance/betterment_levy)
practice_area: תחום משפטי (appeals_committee / national_insurance / labor_law) practice_area: תחום משפטי — domain value (rishuy_uvniya / betterment_levy /
compensation_197). ריק או "appeals_committee" = יוסק
אוטומטית ממספר התיק (1xxx→רישוי, 8xxx→השבחה, 9xxx→197)
appeal_subtype: סוג ערר (building_permit / betterment_levy / compensation_197). appeal_subtype: סוג ערר (building_permit / betterment_levy / compensation_197).
ריק = יוסק אוטומטית ממספר התיק ריק = יוסק אוטומטית ממספר התיק
proceeding_type: 'ערר' / 'בל"מ'. ריק = יוסק מ-appeal_subtype/subject.
""" """
from datetime import date as date_type from datetime import date as date_type
@@ -155,12 +159,27 @@ async def case_create(
if hearing_date: if hearing_date:
h_date = date_type.fromisoformat(hearing_date) h_date = date_type.fromisoformat(hearing_date)
# Resolve appeal_subtype: explicit override > auto-derive > 'unknown' # Auto-derive practice_area when missing or set to the legacy multi-tenant
derived_subtype = pa.derive_subtype(case_number, practice_area) # value. The DB's cases_practice_area_check rejects 'appeals_committee',
# so we MUST map it to a domain value before INSERT. If derivation fails
# (unknown case number format), fall back to '' which the constraint allows.
if not practice_area or practice_area == "appeals_committee":
practice_area = pa.derive_domain_practice_area(case_number)
# Resolve appeal_subtype: explicit override > auto-derive > 'unknown'.
# derive_subtype_with_blam inspects the subject to detect בל"מ
# (בקשה להארכת מועד) and returns an extension_request_* variant when
# appropriate. Falls back to regular derive_subtype when subject is empty.
derived_subtype = pa.derive_subtype_with_blam(case_number, subject, practice_area)
if not appeal_subtype: if not appeal_subtype:
appeal_subtype = derived_subtype appeal_subtype = derived_subtype
pa.validate(practice_area, appeal_subtype) pa.validate(practice_area, appeal_subtype)
# proceeding_type: explicit override > derived from subtype/subject > 'ערר'
resolved_proc = proceeding_type.strip() or pa.derive_proceeding_type(
appeal_subtype=appeal_subtype, subject=subject,
)
case = await db.create_case( case = await db.create_case(
case_number=case_number, case_number=case_number,
title=title, title=title,
@@ -175,6 +194,7 @@ async def case_create(
expected_outcome=expected_outcome, expected_outcome=expected_outcome,
practice_area=practice_area, practice_area=practice_area,
appeal_subtype=appeal_subtype, appeal_subtype=appeal_subtype,
proceeding_type=resolved_proc,
) )
# If the user overrode the case-number convention (e.g. case 8500 marked # If the user overrode the case-number convention (e.g. case 8500 marked
@@ -237,7 +257,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 +294,11 @@ 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 = "",
proceeding_type: str = "",
) -> str: ) -> str:
"""עדכון פרטי תיק. """עדכון פרטי תיק.
@@ -284,6 +312,11 @@ 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: מספר תכנית/בקשה חדש
proceeding_type: 'ערר' / 'בל"מ' — ריק = ללא שינוי
""" """
from datetime import date as date_type from datetime import date as date_type
@@ -315,13 +348,33 @@ async def case_update(
if notes: if notes:
fields["notes"] = notes fields["notes"] = notes
if hearing_date: if hearing_date:
fields["hearing_date"] = date_type.fromisoformat(hearing_date) try:
fields["hearing_date"] = date_type.fromisoformat(hearing_date)
except ValueError as exc:
raise ValueError(f"Invalid hearing_date format: {hearing_date!r}") from exc
if decision_date: if decision_date:
fields["decision_date"] = date_type.fromisoformat(decision_date) try:
fields["decision_date"] = date_type.fromisoformat(decision_date)
except ValueError as exc:
raise ValueError(f"Invalid decision_date format: {decision_date!r}") from exc
if tags is not None: if tags is not None:
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
if proceeding_type:
if proceeding_type not in {"ערר", 'בל"מ'}:
raise ValueError(
f"proceeding_type לא תקין: {proceeding_type!r}. ערכים תקפים: ערר / בל\"מ"
)
fields["proceeding_type"] = proceeding_type
updated = await db.update_case(UUID(case["id"]), **fields) updated = await db.update_case(UUID(case["id"]), **fields)

View File

@@ -0,0 +1,135 @@
"""MCP tools for the internal-decisions citation graph (TaskMaster #34).
The citation graph captures pointers between Daphna's (and other internal
committee chairs') decisions: when one ruling cites another, ``precedent_
internal_citations`` records the edge — resolved against ``case_law`` when
the cited row exists, kept as a stub when it doesn't.
Three tools:
- ``extract_internal_citations`` — run regex extraction on one row (by id) or
on every internal-committee row filtered by chair (e.g. Daphna only).
Idempotent: re-running does not duplicate rows (ON CONFLICT DO NOTHING).
- ``list_internal_citations`` — outgoing edges from a source row. Optional
``linked_only`` filter for rows resolved to existing case_law UUIDs.
- ``list_incoming_citations`` — incoming edges to a target row ("which
Daphna decisions cite this ruling?").
These tools are *manual triggers*. The pipeline runs them after a new
internal-decision upload, but the chair / researcher can also re-run on
demand (for example after fixing OCR or after uploading a previously-
missing decision so that newer rows now link to it).
"""
from __future__ import annotations
import json
from uuid import UUID
from legal_mcp.services import citation_extractor
def _ok(payload) -> str:
return json.dumps(payload, ensure_ascii=False, indent=2, default=str)
def _err(msg: str) -> str:
return json.dumps({"error": msg}, ensure_ascii=False)
async def extract_internal_citations(
case_law_id: str = "",
chair_name: str = "",
limit: int = 0,
) -> str:
"""חילוץ ציטוטים פנימיים מהחלטות ועדת ערר ושמירה ב-precedent_internal_citations.
Args:
case_law_id: UUID של החלטה ספציפית. אם ריק וגם chair_name ריק — מריץ
על כל ההחלטות internal_committee. אם מסופק, חייב לעבור על שורה אחת
בלבד (משתמש בזה אחרי upload).
chair_name: שם יו"ר (כגון 'דפנה תמיר'). מסנן את האצווה. ריק = כל היו"רים.
limit: עליון על מספר רשומות שיעובדו (0 = ללא הגבלה). שימושי לבדיקה.
הכלי איידמפוטנטי — ON CONFLICT DO NOTHING על (source_case_law_id, cited_case_number).
מחזיר סטטיסטיקה: extracted, linked, new, skipped, failed.
"""
if case_law_id.strip() and chair_name.strip():
return _err("יש לספק case_law_id או chair_name, לא שניהם")
if case_law_id.strip():
try:
cl_uuid = UUID(case_law_id.strip())
except ValueError:
return _err("case_law_id לא תקין")
try:
stats = await citation_extractor.extract_and_store(cl_uuid)
except Exception as e:
return _err(str(e))
return _ok(stats)
try:
stats = await citation_extractor.extract_all_internal_committee(
chair_name_filter=chair_name.strip(),
limit=int(limit) if limit else 0,
)
except Exception as e:
return _err(str(e))
return _ok(stats)
async def list_internal_citations(
case_law_id: str = "",
linked_only: bool = False,
limit: int = 50,
) -> str:
"""רשימת ציטוטים יוצאים מהחלטה (מה ההחלטה הזו מצטטת).
Args:
case_law_id: UUID של ה-case_law (חובה).
linked_only: True = רק ציטוטים שקושרו ל-case_law קיים בקורפוס.
limit: עליון על מספר תוצאות (default 50).
Returns: JSON עם list של ציטוטים, כולל target_case_number/name/chair
כשהם linked. אם linked_only=False, ציטוטים בלתי קושרים יחזרו עם
cited_case_law_id=null וניתן להעלות אותם דרך internal_decision_upload.
"""
if not case_law_id.strip():
return _err("case_law_id חובה")
try:
cl_uuid = UUID(case_law_id.strip())
except ValueError:
return _err("case_law_id לא תקין")
try:
rows = await citation_extractor.list_citations_for_case_law(
cl_uuid, linked_only=bool(linked_only),
)
except Exception as e:
return _err(str(e))
return _ok({"items": rows[: max(1, int(limit))], "count": len(rows)})
async def list_incoming_citations(
case_law_id: str = "",
limit: int = 50,
) -> str:
"""רשימת ציטוטים נכנסים אל החלטה (אילו החלטות מצטטות אותה).
שימוש: רוצים לדעת אילו החלטות של דפנה הסתמכו על פסק דין מסוים?
מעבירים את ה-case_law_id של פסק הדין הזה.
Args:
case_law_id: UUID של ה-target case_law (חובה).
limit: עליון על מספר תוצאות.
"""
if not case_law_id.strip():
return _err("case_law_id חובה")
try:
cl_uuid = UUID(case_law_id.strip())
except ValueError:
return _err("case_law_id לא תקין")
try:
rows = await citation_extractor.list_citations_to_case_law(cl_uuid)
except Exception as e:
return _err(str(e))
return _ok({"items": rows[: max(1, int(limit))], "count": len(rows)})

View File

@@ -399,6 +399,26 @@ async def export_docx(case_number: str, output_path: str = "") -> str:
case_id = UUID(case["id"]) case_id = UUID(case["id"])
# INV-EX3 / INV-QA3: a decision cannot be exported while critical QA gates
# fail (or before QA has been run at all). Gate on the STORED qa_results —
# cheap SELECT, no LLM re-run.
if not await db.qa_run_exists(case_id):
return json.dumps({
"status": "error",
"message": "ייצוא נחסם: בקרת איכות (QA) טרם רצה על התיק. "
"הרץ validate_decision לפני ייצוא.",
}, ensure_ascii=False, indent=2)
critical = await db.get_critical_qa_failures(case_id)
if critical:
gate_names = ", ".join(r["check_name"] for r in critical)
return json.dumps({
"status": "error",
"message": f"ייצוא נחסם: שערי QA קריטיים נכשלו ({gate_names}). "
f"תקן את הליקויים והרץ validate_decision מחדש לפני ייצוא.",
"failed_gates": [r["check_name"] for r in critical],
}, ensure_ascii=False, indent=2)
try: try:
path = await docx_exporter.export_decision(case_id, output_path or None) path = await docx_exporter.export_decision(case_id, output_path or None)
# Register this export as the new source of truth # Register this export as the new source of truth

View File

@@ -0,0 +1,116 @@
"""MCP tools for the Internal Decisions corpus.
Decisions of appeals committees (ועדות ערר) live in the same physical
``case_law`` table as court rulings but are distinguished by
``source_kind='internal_committee'`` and must carry ``chair_name`` +
``district``.
The existing ``precedent_library_upload`` MCP tool always stores
``source_kind='external_upload'`` and does not accept chair/district —
which is why **44+ existing appeals-committee decisions were tagged
wrong**. This wrapper is the authoritative ingestion path for committee
decisions and enforces the required metadata at the tool boundary.
"""
from __future__ import annotations
import json
from legal_mcp.services import internal_decisions as int_svc
# Valid Hebrew district names (matches _COURT_TO_DISTRICT in service)
VALID_DISTRICTS = {"ירושלים", "מרכז", "תל אביב", "תל-אביב", "צפון", "דרום", "חיפה", "ארצי"}
# proceeding_type — ערר vs בל"מ. The service can derive it from
# appeal_subtype/subject if left empty, so this stays optional at the API.
VALID_PROCEEDING_TYPES = {"ערר", 'בל"מ'}
def _ok(payload) -> str:
return json.dumps(payload, ensure_ascii=False, indent=2, default=str)
def _err(msg: str) -> str:
return json.dumps({"error": msg}, ensure_ascii=False)
async def internal_decision_upload(
file_path: str,
case_number: str,
chair_name: str,
district: str,
case_name: str = "",
court: str = "",
decision_date: str = "",
practice_area: str = "",
appeal_subtype: str = "",
subject_tags: list[str] | None = None,
summary: str = "",
is_binding: bool = False,
proceeding_type: str = "",
) -> str:
"""העלאת החלטה של ועדת ערר (internal_committee) לקורפוס הסמכותי.
Required: file_path, case_number, chair_name, district.
The tool enforces chair_name+district so the record cannot be saved
in the broken legacy mode (external_upload with empty chair/district).
Args:
file_path: נתיב מלא לקובץ PDF/DOCX/RTF/TXT/MD.
case_number: מספר הערר ("ערר (ועדות ערר - תכנון ובנייה ירושלים) 1110/20 ...").
chair_name: שם יו"ר הוועדה (חובה).
district: מחוז (ירושלים/מרכז/תל אביב/צפון/דרום/חיפה/ארצי) — חובה.
case_name: שם קצר.
court: ערכאה ("ועדת הערר לתכנון ובנייה — מחוז ירושלים").
decision_date: ISO date (YYYY-MM-DD), אופציונלי.
practice_area: rishuy_uvniya / betterment_levy / compensation_197.
appeal_subtype: building_permit / וכו'.
subject_tags: תגיות נושא.
is_binding: בד"כ False (ועדת ערר לא מחייבת ועדה אחרת — שכנוע אופקי).
proceeding_type: 'ערר' או 'בל"מ'. אם ריק — נגזר מ-appeal_subtype/case_name.
Returns: JSON עם case_law_id, מספר chunks, halachot_pending.
"""
if not file_path.strip():
return _err("file_path חובה")
if not case_number.strip():
return _err("case_number חובה")
if not chair_name.strip():
return _err(
"chair_name חובה. החלטות ועדת ערר חייבות שם יו\"ר — "
"בלעדיו ההחלטה לא ניתנת לחיפוש סלקטיבי לפי הרכב."
)
if not district.strip():
return _err(
"district חובה. ערכים תקפים: " + ", ".join(sorted(VALID_DISTRICTS))
)
if district.strip() not in VALID_DISTRICTS:
return _err(
f"district לא תקין: {district!r}. ערכים תקפים: "
+ ", ".join(sorted(VALID_DISTRICTS))
)
if proceeding_type.strip() and proceeding_type.strip() not in VALID_PROCEEDING_TYPES:
return _err(
f"proceeding_type לא תקין: {proceeding_type!r}. ערכים תקפים: "
+ ", ".join(sorted(VALID_PROCEEDING_TYPES))
)
try:
result = await int_svc.ingest_internal_decision(
case_number=case_number,
case_name=case_name,
court=court,
decision_date=decision_date or None,
chair_name=chair_name,
district=district,
practice_area=practice_area,
appeal_subtype=appeal_subtype,
subject_tags=subject_tags or [],
summary=summary,
is_binding=is_binding,
file_path=file_path,
proceeding_type=proceeding_type,
)
except Exception as e:
return _err(str(e))
return _ok(result)

View File

@@ -0,0 +1,83 @@
"""MCP tools — aggregated legal arguments (claim de-duplication)."""
from __future__ import annotations
import json
from uuid import UUID
from legal_mcp.services import argument_aggregator, db
async def aggregate_claims_to_arguments(
case_number: str,
force: bool = False,
) -> str:
"""כינוס פרופוזיציות גולמיות לטיעונים משפטיים מובחנים.
Args:
case_number: מספר תיק הערר.
force: True = למחוק טיעונים קיימים ולחשב מחדש.
"""
case = await db.get_case_by_number(case_number)
if not case:
return json.dumps(
{"status": "error", "message": f"תיק {case_number} לא נמצא."},
ensure_ascii=False, indent=2,
)
case_id = UUID(case["id"])
result = await argument_aggregator.aggregate_claims_to_arguments(
case_id, force=force,
)
result["case_number"] = case_number
return json.dumps(result, ensure_ascii=False, indent=2, default=str)
async def get_legal_arguments(
case_number: str,
party: str = "",
) -> str:
"""שליפת טיעונים משפטיים מאוגדים לתיק.
Args:
case_number: מספר תיק הערר.
party: סינון לפי צד (appellant/respondent/committee/permit_applicant).
ריק = כל הצדדים.
"""
case = await db.get_case_by_number(case_number)
if not case:
return json.dumps(
{"status": "error", "message": f"תיק {case_number} לא נמצא."},
ensure_ascii=False, indent=2,
)
case_id = UUID(case["id"])
args = await argument_aggregator.get_legal_arguments(case_id, party=party)
if not args:
return json.dumps({
"status": "empty",
"case_number": case_number,
"message": "לא נמצאו טיעונים מאוגדים. הרץ aggregate_claims_to_arguments תחילה.",
"arguments": [],
}, ensure_ascii=False, indent=2)
# Group by party for nicer display.
party_he = {
"appellant": "עוררים",
"respondent": "משיבים",
"committee": "ועדה מקומית",
"permit_applicant": "מבקשי היתר",
"unknown": "צד לא מזוהה",
}
by_party: dict[str, list[dict]] = {}
for a in args:
label = party_he.get(a["party"], a["party"])
by_party.setdefault(label, []).append(a)
return json.dumps({
"status": "ok",
"case_number": case_number,
"total": len(args),
"by_party": by_party,
}, ensure_ascii=False, indent=2, default=str)

View File

@@ -0,0 +1,210 @@
"""MCP tools for the missing-precedents log.
When a researcher (or chair) finds a citation in a party brief that
isn't yet in the precedent_library, they record it here so:
1. The gap is visible in the UI (the chair can see all open citations
that need to be uploaded).
2. The writer agent doesn't try to use a precedent that isn't in the
corpus — it knows the gap is being tracked.
3. The chair has a clean closing workflow: upload the actual decision
via the precedent library / internal-decisions, then link it here.
Three tools:
- ``missing_precedent_create`` — log a new gap (researcher / chair).
- ``missing_precedent_list`` — list open gaps (optionally filtered).
- ``missing_precedent_close`` — close a gap (chair workflow).
"""
from __future__ import annotations
import json
from uuid import UUID
from legal_mcp.services import db
def _ok(payload) -> str:
return json.dumps(payload, ensure_ascii=False, indent=2, default=str)
def _err(msg: str) -> str:
return json.dumps({"error": msg}, ensure_ascii=False)
async def _resolve_case_id(case_number: str) -> UUID | None:
"""Translate a human case_number (e.g. '1017-03-26') to a UUID."""
if not case_number or not case_number.strip():
return None
row = await db.get_case_by_number(case_number.strip())
if not row:
return None
return UUID(row["id"])
async def missing_precedent_create(
citation: str,
case_number: str = "",
cited_in_document_id: str = "",
cited_by_party: str = "unknown",
cited_by_party_name: str = "",
legal_topic: str = "",
legal_issue: str = "",
claim_quote: str = "",
case_name: str = "",
notes: str = "",
) -> str:
"""תיעוד פסיקה שצוטטה אך אינה בקורפוס. הסוכן יוצר רשומה כשהוא מזהה ציטוט
שלא ניתן לאמת מול הקורפוס; היו"ר יסגור אותה לאחר העלאת המסמך.
Args:
citation: מראה המקום המלא (חובה).
case_number: מספר תיק הערר שבו צוטטה הפסיקה (לדוגמה '1017-03-26').
cited_in_document_id: UUID של המסמך שבו הציטוט מופיע (אופציונלי).
cited_by_party: appellant / respondent / committee / permit_applicant / unknown.
cited_by_party_name: שם הצד (כדי שיהיה ברור מי ציטט).
legal_topic: נושא משפטי קצר (לדוגמה "זכות עמידה").
legal_issue: שאלה משפטית מפורטת.
claim_quote: הציטוט בכתב הטענות.
case_name: שם קצר של פסק הדין החסר.
notes: הערות חופשיות.
Returns: JSON של הרשומה שנוצרה (כולל id) או error.
"""
if not citation.strip():
return _err("citation חובה")
case_id = None
if case_number:
case_id = await _resolve_case_id(case_number)
if case_id is None:
return _err(f"תיק לא נמצא: {case_number}")
doc_uuid: UUID | None = None
if cited_in_document_id.strip():
try:
doc_uuid = UUID(cited_in_document_id.strip())
except ValueError:
return _err("cited_in_document_id לא תקין")
party = cited_by_party.strip() or "unknown"
if party not in db.ALLOWED_MP_PARTIES:
return _err(
f"cited_by_party לא תקין. ערכים תקפים: "
f"{', '.join(sorted(db.ALLOWED_MP_PARTIES))}"
)
# Deduplication: if a row already exists for the same citation in
# the same case, return that one rather than creating a duplicate.
existing = await db.find_missing_precedent_by_citation(
citation=citation.strip(),
case_id=case_id,
)
if existing:
return _ok({**existing, "_duplicate": True})
try:
row = await db.create_missing_precedent(
citation=citation.strip(),
case_name=case_name.strip() or None,
cited_in_case_id=case_id,
cited_in_document_id=doc_uuid,
cited_by_party=party,
cited_by_party_name=cited_by_party_name.strip() or None,
legal_topic=legal_topic.strip() or None,
legal_issue=legal_issue.strip() or None,
claim_quote=claim_quote.strip() or None,
notes=notes.strip() or None,
)
except Exception as e:
return _err(str(e))
return _ok(row)
async def missing_precedent_list(
case_number: str = "",
status: str = "open",
legal_topic: str = "",
limit: int = 50,
) -> str:
"""רשימת פסיקות חסרות. ברירת מחדל = פתוחות בלבד.
Args:
case_number: סינון לפי תיק הערר שבו צוטטו.
status: open / uploaded / closed / irrelevant (ריק = הכל).
legal_topic: סינון לפי נושא משפטי (substring).
limit: מספר תוצאות מקסימלי.
Returns: JSON עם רשימת רשומות + linked_case_law_number אם נסגרו.
"""
case_id = None
if case_number:
case_id = await _resolve_case_id(case_number)
if case_id is None:
return _err(f"תיק לא נמצא: {case_number}")
s = status.strip() or None
if s and s not in db.ALLOWED_MP_STATUS:
return _err(
f"status לא תקין. ערכים תקפים: "
f"{', '.join(sorted(db.ALLOWED_MP_STATUS))}"
)
try:
rows = await db.list_missing_precedents(
status=s,
case_id=case_id,
legal_topic=legal_topic.strip() or None,
limit=max(1, min(int(limit), 500)),
)
except Exception as e:
return _err(str(e))
return _ok({"items": rows, "count": len(rows)})
async def missing_precedent_close(
id: str,
linked_case_law_id: str = "",
notes: str = "",
status: str = "closed",
) -> str:
"""סגירת רשומת פסיקה חסרה. ברירת מחדל = 'closed' + קישור ל-case_law.
Args:
id: UUID של הרשומה.
linked_case_law_id: UUID של הפסיקה שהועלתה ב-precedent_library / internal_decisions.
notes: הערות סגירה (לדוגמה "אינו רלוונטי" ל-status='irrelevant').
status: closed / uploaded / irrelevant.
Returns: JSON של הרשומה המעודכנת.
"""
try:
mp_id = UUID(id.strip())
except ValueError:
return _err("id לא תקין")
cl_uuid: UUID | None = None
if linked_case_law_id.strip():
try:
cl_uuid = UUID(linked_case_law_id.strip())
except ValueError:
return _err("linked_case_law_id לא תקין")
status_clean = status.strip() or "closed"
if status_clean not in db.ALLOWED_MP_STATUS:
return _err(
f"status לא תקין. ערכים תקפים: "
f"{', '.join(sorted(db.ALLOWED_MP_STATUS))}"
)
try:
row = await db.close_missing_precedent(
mp_id=mp_id,
linked_case_law_id=cl_uuid,
notes=notes.strip() or None,
status=status_clean,
)
except Exception as e:
return _err(str(e))
if row is None:
return _err("רשומה לא נמצאה")
return _ok(row)

View File

@@ -18,9 +18,10 @@ the chair approves them — per project review policy.
from __future__ import annotations from __future__ import annotations
import json import json
import time
from uuid import UUID from uuid import UUID
from legal_mcp.services import db, precedent_library from legal_mcp.services import db, precedent_library, telemetry
def _ok(payload) -> str: def _ok(payload) -> str:
@@ -63,6 +64,18 @@ async def precedent_library_upload(
""" """
if not citation.strip(): if not citation.strip():
return _err("citation חובה") return _err("citation חובה")
# Citation guard: appeals-committee decisions must go through
# internal_decision_upload (with chair_name + district). The legacy
# path always stored source_kind='external_upload' and left
# chair_name/district empty — see TaskMaster #30(ב).
_norm = citation.strip()
_committee_prefixes = ("ערר ", "ערר(", "ערר ", "בל\"מ ", "בל\"מ(", "ARAR ")
if any(_norm.startswith(p) for p in _committee_prefixes):
return _err(
"ציטוט שמתחיל ב-'ערר' או 'בל\"מ' הוא החלטת ועדת ערר. "
"השתמש ב-internal_decision_upload (דורש chair_name + district), "
"לא ב-precedent_library_upload."
)
try: try:
result = await precedent_library.ingest_precedent( result = await precedent_library.ingest_precedent(
file_path=file_path, file_path=file_path,
@@ -90,6 +103,7 @@ 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,
) -> str: ) -> str:
"""רשימה של פסיקה בקורפוס הסמכותי, עם פילטרים.""" """רשימה של פסיקה בקורפוס הסמכותי, עם פילטרים."""
@@ -99,6 +113,7 @@ async def precedent_library_list(
precedent_level=precedent_level, precedent_level=precedent_level,
source_type=source_type, source_type=source_type,
search=search, search=search,
source_kind=source_kind,
limit=limit, limit=limit,
) )
return _ok(rows) return _ok(rows)
@@ -248,8 +263,10 @@ async def search_precedent_library(
""" """
if not query or len(query.strip()) < 2: if not query or len(query.strip()) < 2:
return json.dumps([], ensure_ascii=False) return json.dumps([], ensure_ascii=False)
q = query.strip()
t0 = time.perf_counter()
results = await precedent_library.search_library( results = await precedent_library.search_library(
query=query.strip(), query=q,
practice_area=practice_area, practice_area=practice_area,
court=court, court=court,
precedent_level=precedent_level, precedent_level=precedent_level,
@@ -259,6 +276,15 @@ async def search_precedent_library(
limit=limit, limit=limit,
include_halachot=include_halachot, include_halachot=include_halachot,
) )
elapsed_ms = int((time.perf_counter() - t0) * 1000)
telemetry.log_search_bg(
search_type="precedent_library",
query=q,
results=results,
duration_ms=elapsed_ms,
practice_area=practice_area or None,
user_agent="unknown",
)
return _ok(results) return _ok(results)

View File

@@ -4,9 +4,10 @@ from __future__ import annotations
import json import json
import logging import logging
import time
from uuid import UUID from uuid import UUID
from legal_mcp.services import db, embeddings, hybrid_search from legal_mcp.services import db, embeddings, hybrid_search, telemetry
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -30,11 +31,16 @@ async def search_decisions(
case_number: אם סופק, ה-practice_area/subtype יוסקו אוטומטית מהתיק case_number: אם סופק, ה-practice_area/subtype יוסקו אוטומטית מהתיק
""" """
# Auto-resolve practice_area from case_number if available # Auto-resolve practice_area from case_number if available
resolved_case_id: UUID | None = None
if case_number and not practice_area: if case_number and not practice_area:
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if case: if case:
practice_area = case.get("practice_area") or "" practice_area = case.get("practice_area") or ""
appeal_subtype = appeal_subtype or (case.get("appeal_subtype") or "") appeal_subtype = appeal_subtype or (case.get("appeal_subtype") or "")
try:
resolved_case_id = UUID(case["id"])
except (KeyError, ValueError, TypeError):
resolved_case_id = None
if not practice_area: if not practice_area:
logger.warning( logger.warning(
@@ -43,6 +49,7 @@ async def search_decisions(
) )
query_emb = await embeddings.embed_query(query) query_emb = await embeddings.embed_query(query)
t0 = time.perf_counter()
results = await hybrid_search.search_documents_hybrid( results = await hybrid_search.search_documents_hybrid(
query=query, query=query,
query_text_embedding=query_emb, query_text_embedding=query_emb,
@@ -51,6 +58,16 @@ async def search_decisions(
practice_area=practice_area or None, practice_area=practice_area or None,
appeal_subtype=appeal_subtype or None, appeal_subtype=appeal_subtype or None,
) )
elapsed_ms = int((time.perf_counter() - t0) * 1000)
telemetry.log_search_bg(
search_type="decisions",
query=query,
results=results,
duration_ms=elapsed_ms,
practice_area=practice_area or None,
case_id=resolved_case_id,
user_agent="unknown",
)
if not results: if not results:
return "לא נמצאו תוצאות." return "לא נמצאו תוצאות."
@@ -87,13 +104,24 @@ async def search_case_documents(
if not case: if not case:
return f"תיק {case_number} לא נמצא." return f"תיק {case_number} לא נמצא."
case_uuid = UUID(case["id"])
query_emb = await embeddings.embed_query(query) query_emb = await embeddings.embed_query(query)
# Restricted to case_id — practice_area filter would be redundant. # Restricted to case_id — practice_area filter would be redundant.
t0 = time.perf_counter()
results = await hybrid_search.search_documents_hybrid( results = await hybrid_search.search_documents_hybrid(
query=query, query=query,
query_text_embedding=query_emb, query_text_embedding=query_emb,
limit=limit, limit=limit,
case_id=UUID(case["id"]), case_id=case_uuid,
)
elapsed_ms = int((time.perf_counter() - t0) * 1000)
telemetry.log_search_bg(
search_type="case_documents",
query=query,
results=results,
duration_ms=elapsed_ms,
case_id=case_uuid,
user_agent="unknown",
) )
if not results: if not results:
@@ -130,11 +158,16 @@ async def find_similar_cases(
appeal_subtype: סוג ערר לסינון appeal_subtype: סוג ערר לסינון
case_number: אם סופק, ה-practice_area/subtype יוסקו אוטומטית מהתיק case_number: אם סופק, ה-practice_area/subtype יוסקו אוטומטית מהתיק
""" """
resolved_case_id: UUID | None = None
if case_number and not practice_area: if case_number and not practice_area:
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
if case: if case:
practice_area = case.get("practice_area") or "" practice_area = case.get("practice_area") or ""
appeal_subtype = appeal_subtype or (case.get("appeal_subtype") or "") appeal_subtype = appeal_subtype or (case.get("appeal_subtype") or "")
try:
resolved_case_id = UUID(case["id"])
except (KeyError, ValueError, TypeError):
resolved_case_id = None
if not practice_area: if not practice_area:
logger.warning( logger.warning(
@@ -145,6 +178,7 @@ async def find_similar_cases(
query_emb = await embeddings.embed_query(description) query_emb = await embeddings.embed_query(description)
# Even with rerank we ask for ``limit*3`` so the dedup-by-case # Even with rerank we ask for ``limit*3`` so the dedup-by-case
# step downstream still has enough rows to pick the best per case. # step downstream still has enough rows to pick the best per case.
t0 = time.perf_counter()
results = await hybrid_search.search_documents_hybrid( results = await hybrid_search.search_documents_hybrid(
query=description, query=description,
query_text_embedding=query_emb, query_text_embedding=query_emb,
@@ -152,6 +186,16 @@ async def find_similar_cases(
practice_area=practice_area or None, practice_area=practice_area or None,
appeal_subtype=appeal_subtype or None, appeal_subtype=appeal_subtype or None,
) )
elapsed_ms = int((time.perf_counter() - t0) * 1000)
telemetry.log_search_bg(
search_type="similar_cases",
query=description,
results=results,
duration_ms=elapsed_ms,
practice_area=practice_area or None,
case_id=resolved_case_id,
user_agent="unknown",
)
if not results: if not results:
return "לא נמצאו תיקים דומים." return "לא נמצאו תיקים דומים."
@@ -189,6 +233,7 @@ async def search_internal_decisions(
chair_name: str = "", chair_name: str = "",
limit: int = 10, limit: int = 10,
include_halachot: bool = True, include_halachot: bool = True,
include_cited_by: bool = False,
) -> str: ) -> str:
"""חיפוש בהחלטות ועדות ערר לתכנון ובנייה (כל המחוזות). """חיפוש בהחלטות ועדות ערר לתכנון ובנייה (כל המחוזות).
@@ -200,42 +245,145 @@ async def search_internal_decisions(
chair_name: שם יו"ר הוועדה לסינון. ריק = כל היו"רים chair_name: שם יו"ר הוועדה לסינון. ריק = כל היו"רים
limit: מספר תוצאות מקסימלי limit: מספר תוצאות מקסימלי
include_halachot: האם לכלול הלכות שחולצו include_halachot: האם לכלול הלכות שחולצו
include_cited_by: True = אחרי החיפוש הראשי, הוסף החלטות שה-hits
הראשיים מצטטים (מתוך precedent_internal_citations). default False
כדי לא לשבור caller-ים קיימים. match_type='cited_by' מציין שזו
תוצאה משנית.
""" """
from legal_mcp.services import internal_decisions as int_svc from legal_mcp.services import internal_decisions as int_svc
# Bump the limit a bit when we're expanding via citations — the
# citation step is cheap and a few extra primary hits make the
# expansion more useful.
primary_limit = limit if not include_cited_by else max(limit, limit * 2)
t0 = time.perf_counter()
results = await int_svc.search_internal( results = await int_svc.search_internal(
query, query,
practice_area=practice_area, practice_area=practice_area,
appeal_subtype=appeal_subtype, appeal_subtype=appeal_subtype,
district=district, district=district,
chair_name=chair_name, chair_name=chair_name,
limit=limit, limit=primary_limit,
include_halachot=include_halachot, include_halachot=include_halachot,
) )
elapsed_ms = int((time.perf_counter() - t0) * 1000)
telemetry.log_search_bg(
search_type="internal_decisions",
query=query,
results=results,
duration_ms=elapsed_ms,
practice_area=practice_area or None,
user_agent="unknown",
)
if not results: if not results:
return "לא נמצאו החלטות ועדת ערר רלוונטיות." return "לא נמצאו החלטות ועדת ערר רלוונטיות."
# Cap primary results back to ``limit`` (we over-fetched only to seed
# the citation expansion below — the user asked for ``limit`` items).
primary = results[:limit]
formatted = [] formatted = []
for r in results: seen_case_law_ids: set[str] = set()
entry = { for r in primary:
"score": round(float(r["score"]), 4), clid = str(r.get("case_law_id") or "")
"type": r.get("type", "passage"), if clid:
"case_number": r.get("case_number"), seen_case_law_ids.add(clid)
"case_name": r.get("case_name"), formatted.append(_format_internal_row(r, match_type="primary"))
"court": r.get("court"),
"district": r.get("district"), if include_cited_by and seen_case_law_ids:
"chair_name": r.get("chair_name"), from uuid import UUID
"decision_date": r.get("decision_date"), from legal_mcp.services import citation_extractor
}
if r.get("type") == "halacha": try:
entry["rule"] = r.get("rule_statement") source_uuids = [UUID(s) for s in seen_case_law_ids]
entry["quote"] = r.get("supporting_quote") cited_map = await citation_extractor.get_cited_case_law_ids(source_uuids)
entry["rule_type"] = r.get("rule_type") except Exception as e:
else: logger.warning("include_cited_by lookup failed: %s", e)
entry["content"] = r.get("content", "") cited_map = {}
entry["section"] = r.get("section_type")
entry["page"] = r.get("page_number") # Flatten + dedup the cited case_law_ids that aren't already in
formatted.append(entry) # the primary set.
cited_ids: set[str] = set()
for ids in cited_map.values():
for cid in ids:
if cid and cid not in seen_case_law_ids:
cited_ids.add(cid)
if cited_ids:
cited_rows = await _fetch_case_law_summaries(list(cited_ids))
for row in cited_rows:
formatted.append(_format_internal_row(row, match_type="cited_by"))
return json.dumps(formatted, ensure_ascii=False, indent=2) return json.dumps(formatted, ensure_ascii=False, indent=2)
def _format_internal_row(r: dict, *, match_type: str = "primary") -> dict:
"""Shape an internal-decision hit (or a cited_by stub) for the MCP response."""
entry: dict = {
"score": round(float(r.get("score", 0.0)), 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"),
"match_type": match_type,
}
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")
return entry
async def _fetch_case_law_summaries(case_law_ids: list[str]) -> list[dict]:
"""Pull lightweight metadata for a set of case_law UUIDs (cited-by stubs).
Doesn't pull chunks/halachot — the goal is to surface the existence of
the related precedent, not to repeat search. The caller can drill in
via search_internal_decisions with chair_name+case_number if they want
full passages.
"""
from uuid import UUID
pool = await db.get_pool()
uuid_list = []
for s in case_law_ids:
try:
uuid_list.append(UUID(s))
except ValueError:
continue
if not uuid_list:
return []
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT id::text AS case_law_id,
case_number,
case_name,
court,
district,
chair_name,
date AS decision_date,
headnote AS content
FROM case_law
WHERE id = ANY($1::uuid[])
""",
uuid_list,
)
out: list[dict] = []
for r in rows:
d = dict(r)
if d.get("decision_date") is not None:
d["decision_date"] = d["decision_date"].isoformat()
# Stub rows show up with score 0 — they're not ranked, they're context.
d["score"] = 0.0
d["type"] = "passage"
out.append(d)
return out

View File

@@ -0,0 +1,85 @@
"""MCP tool wrappers for the style_corpus metadata-enrichment flow.
The actual extractor lives in
``legal_mcp.services.style_metadata_extractor``; this module just exposes
it as MCP tools that the chair (or a future automation) can call from
Claude Code.
Why these tools matter: the upload pipeline (`/api/training/upload` →
`_process_proofread_training`) inserts a style_corpus row with
``summary=''``, ``outcome=''``, ``key_principles=[]`` because LLM
extraction can't run from the FastAPI container (no claude CLI there).
This module fills that gap — call it from the host, where ``claude``
CLI is available, and the row gets enriched.
"""
from __future__ import annotations
import json
from uuid import UUID
from legal_mcp.services import db, style_metadata_extractor
def _ok(payload) -> str:
return json.dumps({"ok": True, **payload}, ensure_ascii=False, default=str)
def _err(msg: str) -> str:
return json.dumps({"ok": False, "error": msg}, ensure_ascii=False)
async def extract_decision_metadata(corpus_id: str, overwrite: bool = False) -> str:
"""חילוץ מטא-דאטה (summary, outcome, key_principles, appeal_subtype) להחלטה בקורפוס הסגנון.
ברירת מחדל ``overwrite=False`` ממלא רק שדות ריקים. הזן ``overwrite=true``
כדי לרענן ערכים שכבר נכתבו.
"""
try:
cid = UUID(corpus_id)
except ValueError:
return _err("corpus_id לא תקין")
try:
result = await style_metadata_extractor.extract_and_apply(cid, overwrite=overwrite)
except Exception as e:
return _err(str(e))
return _ok(result)
async def list_corpus_pending_enrichment(limit: int = 50) -> str:
"""רשימת רשומות style_corpus שחסר להן summary/outcome/key_principles — מועמדות להעשרה."""
pool = await db.get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT id, decision_number, decision_date,
length(full_text) AS chars,
coalesce(summary, '') = '' AS missing_summary,
coalesce(outcome, '') = '' AS missing_outcome,
coalesce(jsonb_array_length(key_principles), 0) = 0 AS missing_principles
FROM style_corpus
WHERE coalesce(summary, '') = ''
OR coalesce(outcome, '') = ''
OR coalesce(jsonb_array_length(key_principles), 0) = 0
ORDER BY decision_date NULLS LAST
LIMIT $1
""",
limit,
)
items = [
{
"corpus_id": str(r["id"]),
"decision_number": r["decision_number"] or "",
"decision_date": str(r["decision_date"]) if r["decision_date"] else "",
"chars": r["chars"],
"missing": [
f for f, v in (
("summary", r["missing_summary"]),
("outcome", r["missing_outcome"]),
("key_principles", r["missing_principles"]),
) if v
],
}
for r in rows
]
return _ok({"count": len(items), "items": items})

View File

@@ -0,0 +1,276 @@
"""Regression tests for Stage-A corpus integrity fixes (TaskMaster #30, #31).
These tests document the bugs that were closed in Stage A so they don't
regress quietly. Each test maps to a real bug or constraint:
1. DB CHECK ``cases_practice_area_check`` rejects the legacy
``'appeals_committee'`` value — only domain values (rishuy_uvniya /
betterment_levy / compensation_197) and ``''`` are allowed.
(Bug: many ``cases`` rows stored ``'appeals_committee'`` instead of
the domain.)
2. DB CHECK ``case_law_internal_chair_check`` and
``case_law_internal_district_check`` reject internal_committee rows
with empty chair_name/district.
(Bug: 6 records had source_kind='external_upload' but were really
internal committee decisions; the flip to internal_committee in
Stage A.2 surfaced the missing chair/district fields.)
3. DB CHECK ``case_law_external_arar_check`` rejects external_upload
rows whose case_number starts with ``"ערר"`` or ``"בל\\"מ"`` —
committee decisions must go through internal_decision_upload, not
precedent_library_upload.
(Bug: the legacy upload path stored everything as external_upload,
including appeal-committee decisions; the citation guard now
redirects them.)
4. MCP tool ``precedent_library_upload`` returns an ``_err`` envelope
when the citation starts with ``"ערר"`` (citation guard, not DB
constraint — fires before INSERT to surface a helpful error).
These tests connect to the live local Postgres (port 5433) — they do not
mock asyncpg. Run with::
pytest mcp-server/tests/test_corpus_constraints.py -v
If you don't have ``DATABASE_URL`` set, the tests are skipped.
"""
from __future__ import annotations
import asyncio
import json
import os
from uuid import uuid4
import asyncpg
import pytest
def _dsn() -> str | None:
return (
os.environ.get("DATABASE_URL")
or os.environ.get("LEGAL_AI_DATABASE_URL")
or "postgresql://legal_ai:od0ASJZFYibOlWK59krLvvETmgqwlXe8@localhost:5433/legal_ai"
)
@pytest.fixture()
def dsn() -> str:
d = _dsn()
if not d:
pytest.skip("No DATABASE_URL set; skipping live-DB regression tests")
return d
@pytest.fixture()
def event_loop():
"""Provide a fresh event loop per test so asyncpg doesn't leak across cases."""
loop = asyncio.new_event_loop()
try:
yield loop
finally:
loop.close()
def _run(loop, coro):
return loop.run_until_complete(coro)
# ── 1. cases.practice_area CHECK ─────────────────────────────────────
def test_cases_rejects_appeals_committee_practice_area(dsn: str, event_loop) -> None:
"""``cases.practice_area = 'appeals_committee'`` must violate the CHECK."""
async def attempt() -> None:
conn = await asyncpg.connect(dsn)
try:
with pytest.raises(asyncpg.exceptions.CheckViolationError):
await conn.execute(
"""INSERT INTO cases (id, case_number, title, practice_area)
VALUES ($1, $2, $3, $4)""",
uuid4(), f"TEST-{uuid4().hex[:8]}", "regression-test",
"appeals_committee",
)
finally:
await conn.close()
_run(event_loop, attempt())
def test_cases_accepts_domain_practice_area(dsn: str, event_loop) -> None:
"""Sanity check: rishuy_uvniya / betterment_levy / compensation_197
+ empty string must be accepted."""
async def attempt() -> None:
conn = await asyncpg.connect(dsn)
try:
tx = conn.transaction()
await tx.start()
try:
for value in ("rishuy_uvniya", "betterment_levy",
"compensation_197", ""):
await conn.execute(
"""INSERT INTO cases (id, case_number, title, practice_area)
VALUES ($1, $2, $3, $4)""",
uuid4(), f"TEST-{uuid4().hex[:8]}",
f"regression-{value or 'empty'}", value,
)
finally:
await tx.rollback()
finally:
await conn.close()
_run(event_loop, attempt())
# ── 2. case_law internal_committee chair/district CHECK ─────────────
def test_case_law_internal_requires_chair_and_district(dsn: str, event_loop) -> None:
"""``case_law`` rows with ``source_kind='internal_committee'`` must have
non-empty ``chair_name`` AND ``district``."""
async def attempt_missing_chair() -> None:
conn = await asyncpg.connect(dsn)
try:
with pytest.raises(asyncpg.exceptions.CheckViolationError):
await conn.execute(
"""INSERT INTO case_law (id, case_number, case_name,
source_kind, district, chair_name)
VALUES ($1, $2, $3, $4, $5, $6)""",
uuid4(), f"ערר {uuid4().hex[:6]}",
"test internal w/o chair",
"internal_committee", "ירושלים", "",
)
finally:
await conn.close()
async def attempt_missing_district() -> None:
conn = await asyncpg.connect(dsn)
try:
with pytest.raises(asyncpg.exceptions.CheckViolationError):
await conn.execute(
"""INSERT INTO case_law (id, case_number, case_name,
source_kind, district, chair_name)
VALUES ($1, $2, $3, $4, $5, $6)""",
uuid4(), f"ערר {uuid4().hex[:6]}",
"test internal w/o district",
"internal_committee", "", "עו\"ד דפנה תמיר",
)
finally:
await conn.close()
_run(event_loop, attempt_missing_chair())
_run(event_loop, attempt_missing_district())
# ── 3. case_law external_upload + ערר citation CHECK ────────────────
def test_case_law_external_upload_rejects_arar_citation(dsn: str, event_loop) -> None:
"""``case_law`` rows with ``source_kind='external_upload'`` cannot have
a ``case_number`` that starts with ``"ערר"`` or ``"בל\"מ"`` — those
are committee decisions and must use ``source_kind='internal_committee'``."""
async def attempt_arar() -> None:
conn = await asyncpg.connect(dsn)
try:
with pytest.raises(asyncpg.exceptions.CheckViolationError):
await conn.execute(
"""INSERT INTO case_law (id, case_number, case_name,
source_kind)
VALUES ($1, $2, $3, $4)""",
uuid4(), "ערר 1170/24 חיים נ' ועדה",
"test external arar", "external_upload",
)
finally:
await conn.close()
async def attempt_balam() -> None:
conn = await asyncpg.connect(dsn)
try:
with pytest.raises(asyncpg.exceptions.CheckViolationError):
await conn.execute(
"""INSERT INTO case_law (id, case_number, case_name,
source_kind)
VALUES ($1, $2, $3, $4)""",
uuid4(), 'בל"מ 1234/25 פלוני',
"test external balam", "external_upload",
)
finally:
await conn.close()
_run(event_loop, attempt_arar())
_run(event_loop, attempt_balam())
# ── 4. MCP precedent_library_upload citation guard ──────────────────
def test_mcp_precedent_upload_rejects_arar_citation() -> None:
"""The MCP tool ``precedent_library_upload`` must short-circuit
citations that start with ``"ערר"`` / ``"בל\"מ"`` and return an
``_err`` envelope (a helpful message redirecting to
``internal_decision_upload``), without touching the DB."""
from legal_mcp.tools import precedent_library as tools
async def call(citation: str) -> dict:
# file_path won't be touched because the guard fires first.
return json.loads(
await tools.precedent_library_upload(
file_path="/nonexistent",
citation=citation,
)
)
loop = asyncio.new_event_loop()
try:
for citation in (
"ערר 1170/24 חיים נ' ועדה",
'בל"מ 1234/25 פלוני',
"ARAR 8126-25 ב. קרן-נכסים",
):
result = loop.run_until_complete(call(citation))
assert "error" in result, (
f"expected guard to reject {citation!r}, got {result!r}"
)
# The error message should mention internal_decision_upload so
# the caller knows the alternative path.
assert "internal_decision_upload" in result["error"], (
f"error message should redirect to internal_decision_upload, "
f"got {result['error']!r}"
)
finally:
loop.close()
def test_practice_area_module_invariants() -> None:
"""Quick guard that the ``practice_area`` service module exposes the
helpers tools and tests depend on, and that derivation is consistent
with the case-number convention (1xxx/8xxx/9xxx)."""
from legal_mcp.services import practice_area as pa
# Domain mapping is consistent with the case-number prefix convention.
assert pa.derive_domain_practice_area("1170") == "rishuy_uvniya"
assert pa.derive_domain_practice_area("8126/25") == "betterment_levy"
assert pa.derive_domain_practice_area("9001") == "compensation_197"
assert pa.derive_domain_practice_area("ARAR-25-8126") == "betterment_levy"
# Unparseable input → empty (caller decides fallback).
assert pa.derive_domain_practice_area("foo") == ""
assert pa.derive_domain_practice_area("") == ""
# Empty practice_area is valid (DB allows it as 'unclassified').
pa.validate("", "unknown")
pa.validate("rishuy_uvniya", "building_permit")
pa.validate("betterment_levy", "betterment_levy")
# appeals_committee (axis A) is still recognised for backward-compat.
pa.validate("appeals_committee", "building_permit")
# is_override returns False when subtype matches derivation.
assert pa.is_override("1170", "rishuy_uvniya", "building_permit") is False
assert pa.is_override("8126", "betterment_levy", "betterment_levy") is False

View File

@@ -0,0 +1,151 @@
"""Regression tests for FU-6.
GAP-16 (INV-QA consistency): ``check_neutral_background`` must NOT return a
``severity='critical'`` result while ``passed=True``. The empty/missing
block-ו fallback now reports ``severity='warning'`` (consistent with passed).
GAP-15 (INV-EX3 / INV-QA3): ``export_docx`` must refuse to export while
critical QA gates fail OR before any QA run exists. It gates on the STORED
``qa_results`` (cheap SELECT via ``db.get_critical_qa_failures`` /
``db.qa_run_exists``) — it does NOT re-run the LLM validator.
All tests run fully OFFLINE — the pool / db helpers / exporter / git are
monkeypatched. No live Postgres needed.
"""
from __future__ import annotations
import asyncio
import json
import pytest
from legal_mcp.services import db
from legal_mcp.services import qa_validator
from legal_mcp.tools import drafting
# ── GAP-16 ────────────────────────────────────────────────────────
def test_neutral_background_empty_block_is_warning_not_critical() -> None:
"""Empty/missing block-ו → passed=True, so severity must be 'warning'."""
res = qa_validator.check_neutral_background([]) # no block-vav present
assert res["passed"] is True
assert res["severity"] == "warning", (
"a passed result must not carry severity='critical' (GAP-16)"
)
def test_neutral_background_dirty_block_still_critical_path_untouched() -> None:
"""A block-ו with judgment words still fails — fix didn't soften real checks."""
bad_word = qa_validator.VALUE_WORDS[0]
res = qa_validator.check_neutral_background(
[{"block_id": "block-vav", "content": f"הרקע: {bad_word} מאוד"}]
)
assert res["passed"] is False
assert res["errors"], "judgment-word violation should be reported"
# ── GAP-15 ────────────────────────────────────────────────────────
@pytest.fixture()
def patched_export(monkeypatch: pytest.MonkeyPatch) -> dict:
"""Monkeypatch case lookup, exporter, draft-path setter, and git so that
``export_docx`` is isolated to the QA-gate decision. Returns a dict of
call-tracking flags.
"""
calls = {"exported": False, "set_draft": False, "committed": False}
async def _get_case_by_number(case_number: str) -> dict:
return {"id": "00000000-0000-0000-0000-000000000001"}
async def _export_decision(case_id, output_path=None) -> str:
calls["exported"] = True
return "/tmp/decision.docx"
async def _set_active_draft_path(case_id, path) -> None:
calls["set_draft"] = True
def _commit_and_push(case_dir, msg) -> None:
calls["committed"] = True
# find_case_dir is called only on the success path; make it a no-op dir
class _FakeDir:
def exists(self) -> bool:
return False
monkeypatch.setattr(db, "get_case_by_number", _get_case_by_number)
monkeypatch.setattr(drafting.config, "find_case_dir", lambda cn: _FakeDir())
monkeypatch.setattr(drafting.git_sync, "commit_and_push", _commit_and_push)
# docx_exporter / set_active_draft_path are looked up dynamically; patch both
import legal_mcp.services.docx_exporter as docx_exporter
monkeypatch.setattr(docx_exporter, "export_decision", _export_decision)
monkeypatch.setattr(db, "set_active_draft_path", _set_active_draft_path)
return calls
def _run(coro):
return asyncio.run(coro)
def test_export_blocked_when_no_qa_run(
patched_export: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
async def _qa_run_exists(case_id) -> bool:
return False
async def _get_critical(case_id) -> list:
return []
monkeypatch.setattr(db, "qa_run_exists", _qa_run_exists)
monkeypatch.setattr(db, "get_critical_qa_failures", _get_critical)
out = json.loads(_run(drafting.export_docx("8001-24")))
assert out["status"] == "error"
assert "QA" in out["message"] or "validate_decision" in out["message"]
assert patched_export["exported"] is False, "must not call the exporter"
assert patched_export["committed"] is False, "must not git-commit"
def test_export_blocked_when_critical_failures(
patched_export: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
async def _qa_run_exists(case_id) -> bool:
return True
async def _get_critical(case_id) -> list:
return [
{"check_name": "claims_coverage", "severity": "critical",
"passed": False, "errors": []},
{"check_name": "structural_integrity", "severity": "critical",
"passed": False, "errors": []},
]
monkeypatch.setattr(db, "qa_run_exists", _qa_run_exists)
monkeypatch.setattr(db, "get_critical_qa_failures", _get_critical)
out = json.loads(_run(drafting.export_docx("8001-24")))
assert out["status"] == "error"
assert out["failed_gates"] == ["claims_coverage", "structural_integrity"]
assert "claims_coverage" in out["message"]
assert patched_export["exported"] is False, "must not call the exporter"
assert patched_export["committed"] is False, "must not git-commit"
def test_export_proceeds_when_clean(
patched_export: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
async def _qa_run_exists(case_id) -> bool:
return True
async def _get_critical(case_id) -> list:
return []
monkeypatch.setattr(db, "qa_run_exists", _qa_run_exists)
monkeypatch.setattr(db, "get_critical_qa_failures", _get_critical)
out = json.loads(_run(drafting.export_docx("8001-24")))
assert out["status"] == "completed", out
assert out["path"] == "/tmp/decision.docx"
assert patched_export["exported"] is True, "clean QA must allow export"
assert patched_export["set_draft"] is True, "active_draft_path must be set"

View File

@@ -0,0 +1,97 @@
"""Regression test for GAP-10 / INV-RET1: corpus separation enforced on
EVERY precedent-library query path — including the halacha sub-query.
Bug: ``search_precedent_library_semantic`` and
``search_precedent_library_lexical`` filtered the *chunk* sub-query by
``cl.source_kind`` but NOT the *halacha* sub-query. So an external
(``source_kind='external_upload'``) search leaked internal-committee
halachot, and an internal search leaked external-ruling halachot — a
cross-corpus contamination of the rule-level results.
Fix: the same ``cl.source_kind = '<kind>'`` predicate that gates the
chunk query now also gates the halacha query, in BOTH functions.
This test runs fully OFFLINE — it monkeypatches ``db.get_pool`` with a
fake pool that captures every SQL string passed to ``fetch`` instead of
hitting Postgres. It asserts the captured halacha SQL carries the
source_kind predicate identical to the chunk SQL.
"""
from __future__ import annotations
import asyncio
import pytest
from legal_mcp.services import db
class _FakePool:
"""Captures SQL passed to ``fetch``; returns no rows."""
def __init__(self) -> None:
self.queries: list[str] = []
async def fetch(self, sql: str, *args) -> list: # noqa: ANN002
self.queries.append(sql)
return []
def _classify(queries: list[str]) -> tuple[str, str]:
"""Return (halacha_sql, chunk_sql) from the captured queries."""
halacha = next(q for q in queries if "FROM halachot h" in q)
chunk = next(q for q in queries if "FROM precedent_chunks pc" in q)
return halacha, chunk
@pytest.fixture()
def fake_pool(monkeypatch: pytest.MonkeyPatch) -> _FakePool:
pool = _FakePool()
async def _get_pool() -> _FakePool:
return pool
monkeypatch.setattr(db, "get_pool", _get_pool)
return pool
@pytest.mark.parametrize("source_kind", ["external_upload", "internal_committee"])
def test_semantic_halacha_query_is_source_kind_scoped(
fake_pool: _FakePool, source_kind: str
) -> None:
asyncio.run(
db.search_precedent_library_semantic(
query_embedding=[0.0] * 8,
source_kind=source_kind,
include_halachot=True,
limit=5,
)
)
halacha_sql, chunk_sql = _classify(fake_pool.queries)
predicate = f"cl.source_kind = '{source_kind}'"
assert predicate in chunk_sql, "chunk query must be source_kind-scoped (precondition)"
assert predicate in halacha_sql, (
"halacha query MUST carry the same source_kind predicate as the "
"chunk query — otherwise cross-corpus halacha leakage (GAP-10)"
)
@pytest.mark.parametrize("source_kind", ["external_upload", "internal_committee"])
def test_lexical_halacha_query_is_source_kind_scoped(
fake_pool: _FakePool, source_kind: str
) -> None:
asyncio.run(
db.search_precedent_library_lexical(
query="zoning setback",
source_kind=source_kind,
include_halachot=True,
limit=5,
)
)
halacha_sql, chunk_sql = _classify(fake_pool.queries)
predicate = f"cl.source_kind = '{source_kind}'"
assert predicate in chunk_sql, "chunk query must be source_kind-scoped (precondition)"
assert predicate in halacha_sql, (
"halacha query MUST carry the same source_kind predicate as the "
"chunk query — otherwise cross-corpus halacha leakage (GAP-10)"
)

View File

@@ -12,6 +12,7 @@
| `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.** **⚠ אם `adapter_type` שונה בין CMP ל-CMPA — הסקריפט מדלג על הסוכן עם warning. בעת מעבר adapter (למשל ל-`deepseek_local`) חובה לעדכן ידנית בשתי החברות לפני sync.** | ידני אחרי כל שינוי | | `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 חוזר. | חד-פעמי (בוצע) | | `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 חוזר. | חד-פעמי (בוצע) |
| `test_retrieval_by_name.py` | python | בדיקת אחזור-לפי-שם (#52/RC-A) — מאמת ש`search_precedent_library`/`search_internal_decisions` מדרגים את ההחלטה עצמה (אגסי) מעל מי שמצטט אותה, + רגרסיות לשאילתות מהותיות. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data mcp-server/.venv/bin/python scripts/test_retrieval_by_name.py` (exit 0 = עבר). | ידני אחרי שינוי שכבת חיפוש |
| `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) | ידני |
@@ -28,6 +29,14 @@
| `voyage_rerank_corpus_poc.py` | python | POC #5 — voyage-3 vs rerank-2 על קורפוס מלא (785 docs). הכרעה: +4.5% mean@3 כללי, +11.6% על P queries (practical) | בנצ'מרק חד-פעמי, אישר את שלב B | | `voyage_rerank_corpus_poc.py` | python | POC #5 — voyage-3 vs rerank-2 על קורפוס מלא (785 docs). הכרעה: +4.5% mean@3 כללי, +11.6% על P queries (practical) | בנצ'מרק חד-פעמי, אישר את שלב B |
| `multimodal_backfill.py` | python | Backfill voyage-multimodal-3 page embeddings על מסמכי תיקים קיימים. idempotent (skips by default), forces `MULTIMODAL_ENABLED=true` ל-run, רץ מהקונטיינר. שלב C — ראה `docs/voyage-upgrades-plan.md` | ידני per-case (`python multimodal_backfill.py 8174-24 8137-24`) | | `multimodal_backfill.py` | python | Backfill voyage-multimodal-3 page embeddings על מסמכי תיקים קיימים. idempotent (skips by default), forces `MULTIMODAL_ENABLED=true` ל-run, רץ מהקונטיינר. שלב C — ראה `docs/voyage-upgrades-plan.md` | ידני per-case (`python multimodal_backfill.py 8174-24 8137-24`) |
| `backfill_chunk_pages.py` | python | Backfill `page_number` ב-`document_chunks` קיימים. legacy chunker לא tracked עמודים → `page_number=NULL` חוסם boost של multimodal hybrid (text+image join על אותו עמוד). re-extracts כל PDF (re-OCR אם צריך, ~$0.0015/page), מחשב page_offsets, ומעדכן chunks. idempotent | ידני per-case (`python backfill_chunk_pages.py 8174-24 8137-24`) | | `backfill_chunk_pages.py` | python | Backfill `page_number` ב-`document_chunks` קיימים. legacy chunker לא tracked עמודים → `page_number=NULL` חוסם boost של multimodal hybrid (text+image join על אותו עמוד). re-extracts כל PDF (re-OCR אם צריך, ~$0.0015/page), מחשב page_offsets, ומעדכן chunks. idempotent | ידני per-case (`python backfill_chunk_pages.py 8174-24 8137-24`) |
| `audit_corpus_integrity.py` | python | בדיקה תקופתית של עקביות הקורפוס — 3 בדיקות SQL read-only על `case_law` ו-`cases`: (A) `external_upload` עם prefix פנימי `ערר`/`בל"מ`; (B) `internal_committee` חסר `chair_name`/`district`; (C) `cases.practice_area` מחוץ ל-{`rishuy_uvniya`, `betterment_levy`, `compensation_197`, `''`}. כותב log מצטבר ל-`data/logs/corpus_integrity_audit.log` ובמצב הפרות שולח wakeup ל-CEO ב-Paperclip (best-effort, רק אם `PAPERCLIP_API_URL`+`PAPERCLIP_API_KEY` מוגדרים). דגל: `--no-notify`. Idempotent, יוצא 0. **Cron יומי 07:00**: `0 7 * * * /home/chaim/legal-ai/mcp-server/.venv/bin/python /home/chaim/legal-ai/scripts/audit_corpus_integrity.py` | `0 7 * * *` (cron) |
| `backfill_legal_arguments.py` | python | Backfill `legal_arguments` לתיקים עם `claims` קיימים (TaskMaster #36). מקבץ פרופוזיציות גולמיות לטיעונים משפטיים מובחנים (~6-12 לכל צד) דרך `argument_aggregator.aggregate_claims_to_arguments` (Claude CLI). תומך `--dry-run`/`--apply`/`--force`/`--case <num>...`. **חייב לרוץ מהמכונה המקומית** (לא קונטיינר) — `claude_session` דורש Claude CLI | ידני per-case (`python scripts/backfill_legal_arguments.py --apply --case 1017-03-26`) |
| `upload_blam_decisions.py` | python | חד-פעמי (2026-05-26) — העלאת 2 החלטות בל"מ ל-`case_law` (8126/24 סופר נוח, 8047/23 הרנון) דרך `ingest_internal_decision` ישיר, עוקף MCP server שטרם נטען מחדש אחרי הוספת `proceeding_type`. **לא להריץ שוב** | חד-פעמי — להעביר ל-`.archive/` בהזדמנות |
| `process_pending_blam.py` | python | חד-פעמי (2026-05-26) — הרצת metadata + halacha extraction על 2 החלטות בל"מ שעלו ב-`upload_blam_decisions.py`. עוקף MCP (אותו טעם). **לא להריץ שוב** | חד-פעמי — להעביר ל-`.archive/` בהזדמנות |
| `compute_ndcg.py` | python | חישוב nDCG@10 על `search_relevance_feedback` (TaskMaster #50, Stage C). aggregation לפי `search_type` ולפי שבוע, כולל top-cited case_law ו-coverage %. דגלים: `--k 10`, `--weeks 12`, `--pretty`. read-only, פלט JSON. משמש גם את `GET /api/admin/rag-metrics` (מיובא inline) — שינוי חתימה ב-`compute()` ישבור את ה-endpoint | ידני / cron עתידי לדיווח שבועי |
| `backfill_multimodal_precedents.py` | python | Backfill voyage-multimodal-3 page embeddings על רשומות `case_law` (external_upload + internal_committee) שחסרות `precedent_image_embeddings`. בונה אינדקס קבצים מ-`data/precedent-library/` ו-`data/internal-decisions/`, מנסה התאמה לפי tokens של מספרי תיק (כולל parts-match לפורמטים שונים של Nevo doc-id). מדלג על רשומות בלי קובץ-מקור או עם MD בלבד (PyMuPDF לא מרנדר MD). תומך `--dry-run` (default) / `--apply` / `--only external_upload\|internal_committee` / `--limit N`. רץ בקונטיינר (יש `/data` + Voyage env). **הופעל 2026-05-26**: 70 חסרים → 26 backfilled (503 pages, ~$0.21 voyage tokens), 44 אין-קובץ-מקור. ניתן להריץ שוב אחרי שיועלו עוד PDF/DOCX לספרייה | ידני |
| `monitor_halacha_quality.py` | python | מנטר איכות חילוץ הלכות. בודק drift של `avg(confidence)` בין baseline היסטורי לחלון אחרון. מחזיר JSON מטריקות + alert ב-stderr אם drift > threshold (ברירת מחדל 5%). 2 סדרות: trusted (approved+published) ו-all_extracted. תומך `--window N` / `--threshold X` / `--min-sample N` / `--silent` / `--exit-on-alert`. רץ ב-container או מקומית עם `mcp-server/.venv` (אין תלות ב-LLM, רק SQL). **תזמון מומלץ**: `0 8 * * 1` (יום ראשון 08:00, שבועי) | `0 8 * * 1` (לתזמן) |
| `audit_training_corpus.py` | python | audit של `style_corpus` — לכל החלטה: שדות מטא-דאטה מאוכלסים (`summary`/`outcome`/`key_principles`/`appeal_subtype`/`subject_categories`), קישור ל-`documents` (FK + chunks + embeddings). מפיק `data/audit/corpus-YYYY-MM-DD.json` + summary בקונסול. דרוש `POSTGRES_URL` או POSTGRES_*. אין תלויות חיצוניות מלבד asyncpg. **רץ מהמכונה המקומית** (לא קונטיינר) — חיבור ישיר ל-Postgres :5433 | ידני / קדם-עבודה לפני enrichment של מטא-דאטה |
## תיקיית `.archive/` — סקריפטים שהושלמו ## תיקיית `.archive/` — סקריפטים שהושלמו

View File

@@ -0,0 +1,281 @@
"""Periodic corpus-integrity audit.
Runs a set of read-only SQL checks against the legal-ai DB to detect rows
that violate domain constraints which are *not* enforced by the schema
(or were added after the constraint was put in place).
Checks performed:
A. ``case_law`` rows with ``source_kind='external_upload'`` whose
``case_number`` starts with the Hebrew prefixes ``ערר`` / ``בל"מ``.
Internal committee decisions belong to ``source_kind='internal_committee'``.
B. ``case_law`` rows with ``source_kind='internal_committee'`` that
lack a ``chair_name`` and/or ``district``. Internal decisions must
carry both.
C. ``cases`` rows with a ``practice_area`` outside the closed set
{``rishuy_uvniya``, ``betterment_levy``, ``compensation_197``, ``''``}.
Output:
* Appends a timestamped block to ``data/logs/corpus_integrity_audit.log``.
* If hits are found AND env ``PAPERCLIP_API_URL`` + ``PAPERCLIP_API_KEY``
are set, posts a CEO wakeup comment via ``POST /api/agents/{ceo}/wakeup``
(best-effort, never fails the script).
* Always exits 0 unless an unexpected error occurs (so cron stays quiet).
Cron suggestion (daily 07:00):
0 7 * * * /home/chaim/legal-ai/mcp-server/.venv/bin/python \\
/home/chaim/legal-ai/scripts/audit_corpus_integrity.py
Idempotent. Read-only on the DB.
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
# Load ~/.env so POSTGRES_* / PAPERCLIP_* are picked up when run from cron.
ENV_PATH = os.path.expanduser("~/.env")
if os.path.isfile(ENV_PATH):
with open(ENV_PATH, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k, v)
import asyncpg # noqa: E402
try:
import httpx # noqa: E402
except ImportError: # httpx is part of the legal-ai venv; not required for DB checks
httpx = None # type: ignore[assignment]
REPO_ROOT = Path(__file__).resolve().parent.parent
LOG_PATH = REPO_ROOT / "data" / "logs" / "corpus_integrity_audit.log"
CHECK_A_SQL = (
"SELECT id, case_number FROM case_law "
"WHERE source_kind = 'external_upload' AND case_number ~ '^ערר|^בל\"מ' "
"ORDER BY case_number"
)
CHECK_B_SQL = (
"SELECT id, case_number, chair_name, district FROM case_law "
"WHERE source_kind = 'internal_committee' "
"AND (chair_name IS NULL OR chair_name = '' "
" OR district IS NULL OR district = '') "
"ORDER BY case_number"
)
CHECK_C_SQL = (
"SELECT id, case_number, practice_area FROM cases "
"WHERE practice_area IS NOT NULL "
"AND practice_area NOT IN ('rishuy_uvniya', 'betterment_levy', "
" 'compensation_197', '') "
"ORDER BY case_number"
)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
logger = logging.getLogger("audit_corpus_integrity")
def _pg_url() -> str:
"""Resolve POSTGRES URL from env, falling back to discrete vars."""
url = os.environ.get("POSTGRES_URL")
if url:
return url
pg_host = os.environ.get("POSTGRES_HOST", "127.0.0.1")
pg_port = int(os.environ.get("POSTGRES_PORT", "5433"))
pg_user = os.environ.get("POSTGRES_USER", "legal_ai")
pg_pw = os.environ.get("POSTGRES_PASSWORD", "")
pg_db = os.environ.get("POSTGRES_DB", "legal_ai")
if not pg_pw:
raise SystemExit("POSTGRES_PASSWORD / POSTGRES_URL not set")
return f"postgres://{pg_user}:{pg_pw}@{pg_host}:{pg_port}/{pg_db}"
async def _run_check(conn: asyncpg.Connection, sql: str) -> list[dict]:
rows = await conn.fetch(sql)
return [dict(r) for r in rows]
async def _resolve_ceo_agent_id() -> str | None:
"""Best-effort: look up the CEO agent UUID for CMP via the API.
Returns None if PAPERCLIP env is missing or the lookup fails.
"""
base_url = os.environ.get("PAPERCLIP_API_URL")
api_key = os.environ.get("PAPERCLIP_API_KEY")
if not (base_url and api_key and httpx is not None):
return None
try:
async with httpx.AsyncClient(timeout=5.0) as client:
r = await client.get(
f"{base_url}/api/agents",
headers={"Authorization": f"Bearer {api_key}"},
)
r.raise_for_status()
payload = r.json()
items = payload if isinstance(payload, list) else payload.get("items", [])
for item in items:
# Look for a CMP-side CEO (master); the CMPA mirror has a different id.
title = (item.get("title") or "").lower()
role = (item.get("role") or "").lower()
if "ceo" in title or "ceo" in role or "מנכ" in title:
return item.get("id")
except Exception as e:
logger.warning("CEO lookup failed: %s", e)
return None
async def _notify_ceo(summary: str) -> bool:
"""Post a wakeup comment to the CEO agent. Returns True on best-effort success."""
base_url = os.environ.get("PAPERCLIP_API_URL")
api_key = os.environ.get("PAPERCLIP_API_KEY")
if not (base_url and api_key and httpx is not None):
logger.info("Paperclip env not set — skipping CEO wakeup")
return False
ceo_id = await _resolve_ceo_agent_id()
if not ceo_id:
logger.info("Could not resolve CEO agent id — skipping wakeup")
return False
try:
async with httpx.AsyncClient(timeout=5.0) as client:
r = await client.post(
f"{base_url}/api/agents/{ceo_id}/wakeup",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"source": "automation",
"triggerDetail": "audit_corpus_integrity",
"reason": "corpus integrity audit found violations",
"payload": {"summary": summary},
},
)
r.raise_for_status()
logger.info("Notified CEO (agent_id=%s)", ceo_id)
return True
except Exception as e:
logger.warning("CEO wakeup failed: %s", e)
return False
def _format_report(
a_hits: list[dict],
b_hits: list[dict],
c_hits: list[dict],
ts: datetime,
) -> str:
parts: list[str] = []
parts.append(f"=== Corpus integrity audit @ {ts.isoformat()} ===")
parts.append("")
parts.append(
f"Check A (case_law external_upload with internal-style "
f"case_number prefix): {len(a_hits)} hit(s)"
)
for row in a_hits[:50]:
parts.append(f" - id={row['id']} case_number={row['case_number']!r}")
if len(a_hits) > 50:
parts.append(f" ... ({len(a_hits) - 50} more truncated)")
parts.append("")
parts.append(
f"Check B (case_law internal_committee missing chair_name/district): "
f"{len(b_hits)} hit(s)"
)
for row in b_hits[:50]:
parts.append(
f" - id={row['id']} case_number={row['case_number']!r} "
f"chair_name={row.get('chair_name')!r} district={row.get('district')!r}"
)
if len(b_hits) > 50:
parts.append(f" ... ({len(b_hits) - 50} more truncated)")
parts.append("")
parts.append(
f"Check C (cases.practice_area outside closed set): {len(c_hits)} hit(s)"
)
for row in c_hits[:50]:
parts.append(
f" - id={row['id']} case_number={row['case_number']!r} "
f"practice_area={row.get('practice_area')!r}"
)
if len(c_hits) > 50:
parts.append(f" ... ({len(c_hits) - 50} more truncated)")
parts.append("")
return "\n".join(parts)
async def main(args: argparse.Namespace) -> int:
pg_url = _pg_url()
conn = await asyncpg.connect(pg_url)
try:
a_hits = await _run_check(conn, CHECK_A_SQL)
b_hits = await _run_check(conn, CHECK_B_SQL)
c_hits = await _run_check(conn, CHECK_C_SQL)
finally:
await conn.close()
total = len(a_hits) + len(b_hits) + len(c_hits)
ts = datetime.now(timezone.utc)
report = _format_report(a_hits, b_hits, c_hits, ts)
# Always write to log (creates dir + file if missing).
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
with LOG_PATH.open("a", encoding="utf-8") as f:
f.write(report)
f.write("\n")
# Echo to stdout so cron mail / manual run shows the result.
print(report)
if total == 0:
logger.info("clean: no integrity violations found")
return 0
logger.warning(
"found %d total violation(s) (A=%d, B=%d, C=%d)",
total, len(a_hits), len(b_hits), len(c_hits),
)
if args.notify:
summary_lines = [
"ה-audit היומי על הקורפוס מצא הפרות:",
f"- Check A (external_upload עם prefix פנימי): {len(a_hits)}",
f"- Check B (internal_committee חסר chair/district): {len(b_hits)}",
f"- Check C (cases.practice_area לא תקין): {len(c_hits)}",
"",
f"פירוט מלא: {LOG_PATH}",
]
await _notify_ceo("\n".join(summary_lines))
return 0
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--no-notify",
dest="notify",
action="store_false",
help="Don't post a CEO wakeup even if hits are found",
)
parser.set_defaults(notify=True)
args = parser.parse_args()
try:
rc = asyncio.run(main(args))
except KeyboardInterrupt:
sys.exit(130)
sys.exit(rc)

196
scripts/audit_training_corpus.py Executable file
View File

@@ -0,0 +1,196 @@
#!/usr/bin/env python
"""Audit the style_corpus table — list each decision with what's populated and what's missing.
Produces a JSON report at data/audit/corpus-YYYY-MM-DD.json so we can see at a glance
which corpus entries lack summary/outcome/key_principles/appeal_subtype/chunks/embeddings.
Run with the mcp-server venv (has asyncpg):
POSTGRES_URL=postgres://... ./mcp-server/.venv/bin/python scripts/audit_training_corpus.py
Without POSTGRES_URL, falls back to the per-field env vars used by web/mcp-server config.
"""
from __future__ import annotations
import asyncio
import json
import os
import re
import sys
from datetime import UTC, date, datetime
from pathlib import Path
import asyncpg
def _build_dsn() -> str:
if url := os.environ.get("POSTGRES_URL"):
return url
return (
f"postgres://{os.environ.get('POSTGRES_USER', 'legal_ai')}:"
f"{os.environ.get('POSTGRES_PASSWORD', '')}@"
f"{os.environ.get('POSTGRES_HOST', '127.0.0.1')}:"
f"{os.environ.get('POSTGRES_PORT', '5433')}/"
f"{os.environ.get('POSTGRES_DB', 'legal_ai')}"
)
async def audit() -> dict:
dsn = _build_dsn()
conn = await asyncpg.connect(dsn)
try:
rows = await conn.fetch(
"""
SELECT id, decision_number, decision_date, subject_categories,
length(full_text) AS chars,
summary,
outcome,
key_principles,
practice_area,
appeal_subtype,
document_id,
created_at
FROM style_corpus
ORDER BY decision_date NULLS LAST, decision_number
"""
)
# Chunk + embedding counts for each related document — by direct FK first,
# then by title-match for legacy rows where style_corpus.document_id is NULL.
chunk_counts = await conn.fetch(
"""
SELECT d.id AS doc_id, d.title,
count(c.id) AS chunks,
count(c.embedding) FILTER (WHERE c.embedding IS NOT NULL) AS chunks_with_emb
FROM documents d
LEFT JOIN document_chunks c ON c.document_id = d.id
WHERE d.title LIKE '[קורפוס]%' OR d.id IN (SELECT document_id FROM style_corpus WHERE document_id IS NOT NULL)
GROUP BY d.id, d.title
"""
)
finally:
await conn.close()
by_doc_id = {r["doc_id"]: r for r in chunk_counts}
# Index corpus documents by every digit cluster in their title so we can
# match against style_corpus.decision_number regardless of formatting
# (e.g. style_corpus has "1109-25" but title may say "ARAR-25-1109" or
# "ערר 1009-25"). Each digit run >=3 chars becomes a key.
by_digit: dict[str, dict] = {}
for r in chunk_counts:
title = r["title"] or ""
for tok in re.findall(r"\d{3,}", title):
by_digit.setdefault(tok, r)
decisions = []
gaps_total = {
"summary": 0, "outcome": 0, "key_principles": 0,
"appeal_subtype": 0, "subject_categories": 0,
"chunks": 0, "embeddings": 0, "document_id": 0,
}
for row in rows:
cats = row["subject_categories"]
if isinstance(cats, str):
try:
cats = json.loads(cats)
except json.JSONDecodeError:
cats = []
cats = cats or []
kp = row["key_principles"]
if isinstance(kp, str):
try:
kp = json.loads(kp)
except json.JSONDecodeError:
kp = []
kp = kp or []
# Resolve chunks: prefer FK, fall back to digit-cluster match on decision_number.
chunks = 0
chunks_with_emb = 0
if row["document_id"] and row["document_id"] in by_doc_id:
r = by_doc_id[row["document_id"]]
chunks = r["chunks"]
chunks_with_emb = r["chunks_with_emb"]
elif row["decision_number"]:
for tok in re.findall(r"\d{3,}", row["decision_number"]):
if tok in by_digit:
r = by_digit[tok]
chunks = r["chunks"]
chunks_with_emb = r["chunks_with_emb"]
break
missing = []
if not row["summary"]:
missing.append("summary")
gaps_total["summary"] += 1
if not row["outcome"]:
missing.append("outcome")
gaps_total["outcome"] += 1
if not kp:
missing.append("key_principles")
gaps_total["key_principles"] += 1
if not row["appeal_subtype"]:
missing.append("appeal_subtype")
gaps_total["appeal_subtype"] += 1
if not cats:
missing.append("subject_categories")
gaps_total["subject_categories"] += 1
if chunks == 0:
missing.append("chunks")
gaps_total["chunks"] += 1
elif chunks_with_emb < chunks:
missing.append(f"embeddings({chunks_with_emb}/{chunks})")
gaps_total["embeddings"] += 1
if row["document_id"] is None:
missing.append("document_id")
gaps_total["document_id"] += 1
decisions.append({
"id": str(row["id"]),
"decision_number": row["decision_number"] or "",
"decision_date": row["decision_date"].isoformat() if row["decision_date"] else None,
"chars": row["chars"],
"subject_categories": cats,
"practice_area": row["practice_area"] or "",
"appeal_subtype": row["appeal_subtype"] or "",
"summary_len": len(row["summary"] or ""),
"outcome_len": len(row["outcome"] or ""),
"key_principles_count": len(kp),
"chunks": chunks,
"chunks_with_embeddings": chunks_with_emb,
"document_id": str(row["document_id"]) if row["document_id"] else None,
"missing": missing,
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
})
return {
"generated_at": datetime.now(UTC).isoformat(),
"total_decisions": len(decisions),
"gaps_total": gaps_total,
"decisions": decisions,
}
async def main() -> int:
report = await audit()
out_dir = Path(__file__).resolve().parents[1] / "data" / "audit"
out_dir.mkdir(parents=True, exist_ok=True)
today = date.today().isoformat()
out_file = out_dir / f"corpus-{today}.json"
out_file.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
# Console summary
print(f"Total decisions: {report['total_decisions']}")
print("Gaps by field (count of decisions missing it):")
for field, n in report["gaps_total"].items():
bar = "" * min(n, 60)
print(f" {field:25s} {n:3d} {bar}")
print(f"\nReport written to {out_file}")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))

View File

@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""Backfill aggregated legal_arguments for existing cases.
For every case that has rows in ``claims`` but none in ``legal_arguments``,
run ``argument_aggregator.aggregate_claims_to_arguments``.
Usage (must use mcp-server venv — pgvector + asyncpg are vendored there):
PY=/home/chaim/legal-ai/mcp-server/.venv/bin/python
# Default = dry-run (lists what would be processed):
$PY scripts/backfill_legal_arguments.py
# Process all cases that need it:
$PY scripts/backfill_legal_arguments.py --apply
# Re-aggregate even cases that already have arguments:
$PY scripts/backfill_legal_arguments.py --apply --force
# Only process specific cases:
$PY scripts/backfill_legal_arguments.py --apply --case 1017-03-26 1018-03-26
The script must run from the local dev machine (not the container) because
``argument_aggregator`` calls ``claude_session`` which needs the Claude CLI.
"""
from __future__ import annotations
import argparse
import asyncio
import os
import sys
from pathlib import Path
from uuid import UUID
# Make the mcp-server source importable as ``legal_mcp``.
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "mcp-server" / "src"))
# Default DB connection (overridable via env / .env on the dev box).
if "POSTGRES_URL" not in os.environ:
pg_user = os.environ.get("POSTGRES_USER", "legal_ai")
pg_pw = os.environ.get("POSTGRES_PASSWORD", "")
pg_host = os.environ.get("POSTGRES_HOST", "127.0.0.1")
pg_port = os.environ.get("POSTGRES_PORT", "5433")
pg_db = os.environ.get("POSTGRES_DB", "legal_ai")
os.environ["POSTGRES_URL"] = (
f"postgres://{pg_user}:{pg_pw}@{pg_host}:{pg_port}/{pg_db}"
)
async def _list_cases_needing_backfill(force: bool) -> list[dict]:
"""Find cases that have claims but no aggregated arguments (or all,
when ``force`` is True)."""
from legal_mcp.services import db
pool = await db.get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT c.id, c.case_number, c.status,
COUNT(DISTINCT cl.id) AS claim_count,
COUNT(DISTINCT la.id) AS arg_count
FROM cases c
LEFT JOIN claims cl ON cl.case_id = c.id
LEFT JOIN legal_arguments la ON la.case_id = c.id
WHERE c.archived_at IS NULL
GROUP BY c.id, c.case_number, c.status
HAVING COUNT(DISTINCT cl.id) > 0
ORDER BY c.case_number
"""
)
out: list[dict] = []
for r in rows:
d = dict(r)
if force or d["arg_count"] == 0:
out.append(d)
return out
async def _process_case(case: dict, force: bool) -> dict:
from legal_mcp.services import argument_aggregator
case_id = UUID(str(case["id"]))
case_number = case["case_number"]
print(
f"[backfill] {case_number}: {case['claim_count']} claims, "
f"{case['arg_count']} existing args — aggregating (force={force})...",
flush=True,
)
try:
result = await argument_aggregator.aggregate_claims_to_arguments(
case_id, force=force,
)
except Exception as e: # noqa: BLE001
return {
"case_number": case_number,
"status": "error",
"error": str(e),
}
print(
f"[backfill] {case_number}: status={result.get('status')} "
f"total={result.get('total')} by_party={result.get('by_party')}",
flush=True,
)
return {"case_number": case_number, **result}
async def main() -> int:
parser = argparse.ArgumentParser(
description="Backfill legal_arguments for cases with extracted claims.",
)
parser.add_argument(
"--apply", action="store_true",
help="Actually run aggregation (default: dry-run).",
)
parser.add_argument(
"--force", action="store_true",
help="Re-aggregate even cases that already have arguments.",
)
parser.add_argument(
"--case", nargs="*", default=[],
help="Only process these case numbers (e.g. --case 1017-03-26 1018-03-26).",
)
args = parser.parse_args()
cases = await _list_cases_needing_backfill(force=args.force)
if args.case:
wanted = set(args.case)
cases = [c for c in cases if c["case_number"] in wanted]
if not cases:
print("[backfill] No cases need processing.")
return 0
print(f"[backfill] {len(cases)} case(s) to process:")
for c in cases:
print(
f" - {c['case_number']:<14} status={c['status']:<20} "
f"claims={c['claim_count']:<4} args={c['arg_count']}",
)
if not args.apply:
print("\n[backfill] dry-run — pass --apply to actually run.")
return 0
print()
results: list[dict] = []
for case in cases:
r = await _process_case(case, force=args.force)
results.append(r)
print("\n[backfill] === Summary ===")
for r in results:
print(
f" {r['case_number']:<14} status={r.get('status', 'unknown'):<22} "
f"total={r.get('total', 0)}",
)
errors = [r for r in results if r.get("status") == "error"]
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))

View File

@@ -0,0 +1,475 @@
"""Multimodal backfill for precedent library — fills voyage-multimodal-3
page embeddings for case_law rows (external_upload + internal_committee)
that don't have them yet.
Background
----------
77 (in practice 70 today, 2026-05-26) case_law rows were ingested before
``MULTIMODAL_ENABLED=true`` was permanently turned on, so they only have
text chunks and no per-page image embeddings. The retrieval blend is
hybrid (text + image), so the image side of the blend silently degrades
for these rows.
Strategy
--------
Most rows have no PDF (they were ingested via text or are MD-only). The
script:
1. Lists every case_law row with ``source_kind in (external_upload,
internal_committee)`` that is missing image embeddings.
2. Tries to find a staged file by matching token-rich substrings of the
case_number against filenames under ``data/precedent-library/`` and
``data/internal-decisions/``.
3. If the file is a PDF or DOCX (both renderable by PyMuPDF/fitz),
renders pages at ``MULTIMODAL_DPI``, embeds via voyage-multimodal-3
in batches of 50, and stores rows into ``precedent_image_embeddings``.
4. Skips rows whose only candidate file is .md (PyMuPDF can't render
markdown) or rows with no staged file.
Designed to run inside the FastAPI/MCP container (where ``/data/...``
exists and Voyage env vars are present). Locally, it falls back to
``/home/chaim/legal-ai/data/...`` via ``_resolve_local_path``.
Usage::
# Inside container (Coolify):
docker exec -it <container> /opt/api/.venv/bin/python \\
/opt/api/scripts/backfill_multimodal_precedents.py --dry-run
# then:
docker exec -it <container> /opt/api/.venv/bin/python \\
/opt/api/scripts/backfill_multimodal_precedents.py --apply
Notes
-----
- Token cost: voyage-multimodal-3 averages ~3-4K tokens per dense legal
page. 70 rows * ~30 pages avg = ~2,100 pages = ~7M tokens ≈ $0.70.
- Estimate-only mode (``--dry-run``) prints the matched files and
page counts without calling Voyage or touching the DB.
- Idempotent: per-record DELETE+INSERT inside
``store_precedent_image_embeddings``, but the outer loop also
skips rows that already have rows in ``precedent_image_embeddings``.
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import os
import re
import sys
import time
from pathlib import Path
from uuid import UUID
import fitz # PyMuPDF
def _setup_paths():
"""Ensure mcp-server src is on path even when run as a standalone script.
Works both from host (``/home/chaim/legal-ai/scripts/...``) and from
inside the container (``/app/mcp-server/src``).
"""
here = Path(__file__).resolve().parent
candidates = [
here.parent / "mcp-server" / "src", # host
Path("/app/mcp-server/src"), # container
]
for c in candidates:
if c.is_dir() and str(c) not in sys.path:
sys.path.insert(0, str(c))
_setup_paths()
# Force multimodal on for this script regardless of env — backfill is
# the entire point. The deploy-time default stays whatever Coolify sets.
os.environ["MULTIMODAL_ENABLED"] = "true"
from legal_mcp import config # noqa: E402
from legal_mcp.services import db, embeddings, extractor # noqa: E402
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
logger = logging.getLogger("backfill_multimodal_precedents")
# ───────────────────────── file matching ─────────────────────────
# Roots to search for staged precedent files. Both paths are tried; the
# first that exists wins. ``/data/`` is the in-container mount;
# ``/home/chaim/legal-ai/data/`` is the host path.
SEARCH_ROOTS = [
Path("/data/precedent-library"),
Path("/data/internal-decisions"),
Path("/home/chaim/legal-ai/data/precedent-library"),
Path("/home/chaim/legal-ai/data/internal-decisions"),
]
# Extensions we can render with PyMuPDF (fitz). MD and TXT cannot be
# rendered as page images, so we skip them.
RENDERABLE_EXTS = {".pdf", ".docx"}
# Token-extraction regex: only tokens that contain a slash or hyphen
# (real case-number kernels like "8064/20" or "25226-04-25"). We
# deliberately exclude pure numeric runs like "2011" (which is just a
# year in "(נבו 5.4.2011)") to avoid false-positive matches against
# unrelated filenames that happen to contain the same year.
_NUMBER_TOKEN = re.compile(r"\d+[-/]\d+(?:[-/]\d+)*")
def _extract_number_tokens(case_number: str) -> list[str]:
"""Pull numeric kernels out of a Hebrew case_number string.
Only returns tokens containing a slash or hyphen (real case-number
kernels), so years like "2011" and "2024" don't leak through and
falsely match filenames.
>>> _extract_number_tokens('בר"מ 25226-04-25 הוועדה')
['25226-04-25']
>>> _extract_number_tokens('ערר 8064/20 חברת')
['8064/20']
>>> _extract_number_tokens('עע"מ 10089/07 (נבו 5.4.2011)')
['10089/07', '5.4.2011'] # date stays; but '5.4.2011' is hyphenless after normalize → no match against random filenames
"""
# filter out date-shaped tokens (dotted) by additional check — only
# keep tokens whose form is N/N or N-N-..., not N.N.N
tokens = _NUMBER_TOKEN.findall(case_number)
return [t for t in tokens if "." not in t]
def _normalize_for_match(s: str) -> str:
"""Lowercase + strip whitespace/punct for filename matching."""
return re.sub(r"[\s/_-]+", "", s.lower())
def _build_file_index() -> dict[str, list[Path]]:
"""Walk SEARCH_ROOTS and return {normalized_filename: [paths]}.
Only renderable extensions are included.
"""
idx: dict[str, list[Path]] = {}
for root in SEARCH_ROOTS:
if not root.is_dir():
continue
for p in root.rglob("*"):
if not p.is_file():
continue
if p.suffix.lower() not in RENDERABLE_EXTS:
continue
if "thumbnails" in p.parts:
continue
key = _normalize_for_match(p.name)
idx.setdefault(key, []).append(p)
return idx
def _digit_parts(token: str) -> list[str]:
"""Split a token like '14306-09-23' into ['14306','09','23']."""
return [p for p in re.split(r"[-/]", token) if p]
def _find_file_for_case_number(case_number: str, file_index: dict[str, list[Path]]) -> Path | None:
"""Best-effort match a case_number → staged file path.
Two strategies:
1. **Direct contiguous match** — token normalized (e.g. "8064/20"
"806420") appears as substring of the filename normalized.
2. **Parts-match** — every digit part of the token appears
somewhere in the filename (handles reordered formats like
case_number "14306-09-23" matched to "MM-23-09-14306-967.docx",
where Nevo's case_number ordering differs from the legal
template's filename ordering). Only accepts when the longest
part has at least 4 digits — that filters out matches where
only short pieces (year fragments) overlap.
Returns the first match found, preferring PDFs over DOCX.
"""
tokens = _extract_number_tokens(case_number)
if not tokens:
return None
candidates: list[Path] = []
for token in tokens:
# Strategy 1: contiguous
normalized_token = _normalize_for_match(token)
token_hyphenated = token.replace("/", "-")
normalized_hyphenated = _normalize_for_match(token_hyphenated)
# Strategy 2: parts
parts = _digit_parts(token)
longest_part = max((len(p) for p in parts), default=0)
for normalized_name, paths in file_index.items():
if normalized_token in normalized_name or normalized_hyphenated in normalized_name:
candidates.extend(paths)
continue
# Parts-match requires longest part >= 4 digits AND all parts present
if longest_part >= 4 and parts and all(p in normalized_name for p in parts):
candidates.extend(paths)
if not candidates:
return None
# Dedupe while preserving order
seen = set()
unique = []
for p in candidates:
if p not in seen:
seen.add(p)
unique.append(p)
# Prefer PDFs over DOCX (PDF rendering is more reliable for embedded fonts/images)
pdf = next((p for p in unique if p.suffix.lower() == ".pdf"), None)
return pdf or unique[0]
# ───────────────────────── backfill core ─────────────────────────
PRECEDENT_LIBRARY_THUMBNAILS = Path(config.DATA_DIR) / "precedent-library" / "thumbnails"
async def _embed_one_precedent(case_law_id: UUID, src_path: Path) -> dict:
"""Render + embed + store image embeddings for a single precedent.
Mirrors ``precedent_library._embed_precedent_pages`` but takes any
fitz-renderable file (PDF or DOCX).
"""
thumb_dir = PRECEDENT_LIBRARY_THUMBNAILS / str(case_law_id)
# PyMuPDF reads DOCX natively (uses its own MuPDF backend). We use
# the same renderer as the live pipeline for consistency.
rendered = await asyncio.to_thread(
extractor.render_pages_for_multimodal,
src_path,
config.MULTIMODAL_DPI,
config.MULTIMODAL_THUMB_DPI,
thumb_dir,
)
if not rendered:
return {"pages_embedded": 0, "status": "no_pages"}
images = [pil for pil, _ in rendered]
thumbs = [t for _, t in rendered]
img_embs = await embeddings.embed_images(images)
page_records = []
for i, (emb, thumb) in enumerate(zip(img_embs, thumbs)):
rel_thumb = None
if thumb is not None:
try:
rel_thumb = str(thumb.relative_to(config.DATA_DIR))
except ValueError:
rel_thumb = str(thumb)
page_records.append({
"page_number": i + 1,
"embedding": emb,
"image_thumbnail_path": rel_thumb,
})
stored = await db.store_precedent_image_embeddings(
case_law_id, page_records, model_name=config.MULTIMODAL_MODEL,
)
return {"pages_embedded": stored, "status": "ok"}
async def _scan_missing_records() -> list[dict]:
pool = await db.get_pool()
rows = await pool.fetch(
"""
SELECT id, case_number, source_kind, length(full_text) AS text_len
FROM case_law cl
WHERE NOT EXISTS (
SELECT 1 FROM precedent_image_embeddings ppi
WHERE ppi.case_law_id = cl.id
)
AND cl.source_kind IN ('external_upload', 'internal_committee')
ORDER BY cl.source_kind, cl.case_number
"""
)
return [
{
"id": UUID(str(r["id"])),
"case_number": r["case_number"],
"source_kind": r["source_kind"],
"text_len": r["text_len"],
}
for r in rows
]
async def backfill_all(
*,
dry_run: bool,
limit: int | None = None,
only_source_kind: str | None = None,
) -> dict:
"""Main entrypoint — scan, match, render, embed, store."""
await db.init_schema()
records = await _scan_missing_records()
if only_source_kind:
records = [r for r in records if r["source_kind"] == only_source_kind]
if limit:
records = records[:limit]
file_index = _build_file_index()
logger.info("Indexed %d renderable files under %s",
sum(len(v) for v in file_index.values()),
", ".join(str(r) for r in SEARCH_ROOTS if r.is_dir()))
summary = {
"scanned": len(records),
"matched": 0,
"no_match": 0,
"embedded": 0,
"skipped_md_only": 0,
"errors": 0,
"total_pages": 0,
"details": [],
}
for rec in records:
case_law_id = rec["id"]
case_number = rec["case_number"]
src = _find_file_for_case_number(case_number, file_index)
if not src:
summary["no_match"] += 1
summary["details"].append({
"case_law_id": str(case_law_id),
"case_number": case_number,
"source_kind": rec["source_kind"],
"status": "no_match",
})
logger.info(" NO MATCH: %s", case_number[:80])
continue
# Probe page count without rendering (cheap)
try:
doc = fitz.open(str(src))
page_count = len(doc)
doc.close()
except Exception as e:
summary["errors"] += 1
summary["details"].append({
"case_law_id": str(case_law_id),
"case_number": case_number,
"matched_file": str(src),
"status": "open_error",
"error": str(e),
})
logger.warning(" OPEN ERROR for %s: %s", case_number[:60], e)
continue
summary["matched"] += 1
summary["total_pages"] += page_count
logger.info(" MATCHED: %s -> %s (%d pages)",
case_number[:60], src.name, page_count)
if dry_run:
summary["details"].append({
"case_law_id": str(case_law_id),
"case_number": case_number,
"matched_file": str(src),
"pages": page_count,
"status": "would_embed",
})
continue
# Actually embed + store
t0 = time.time()
try:
result = await _embed_one_precedent(case_law_id, src)
elapsed = time.time() - t0
summary["embedded"] += 1
summary["details"].append({
"case_law_id": str(case_law_id),
"case_number": case_number,
"matched_file": str(src),
"pages": page_count,
"elapsed_sec": round(elapsed, 1),
"status": "ok",
**result,
})
logger.info(" EMBEDDED %d pages in %.1fs", result["pages_embedded"], elapsed)
except Exception as e:
summary["errors"] += 1
summary["details"].append({
"case_law_id": str(case_law_id),
"case_number": case_number,
"matched_file": str(src),
"status": "embed_error",
"error": str(e),
})
logger.exception(" EMBED ERROR for %s", case_number[:60])
return summary
# ───────────────────────── CLI ─────────────────────────
def main():
parser = argparse.ArgumentParser(
description="Backfill voyage-multimodal-3 embeddings for case_law records "
"(external_upload + internal_committee) missing them.",
)
parser.add_argument(
"--dry-run", action="store_true",
help="Only scan + match; do not call Voyage or write to DB.",
)
parser.add_argument(
"--apply", action="store_true",
help="Render, embed, and store. Implies not --dry-run.",
)
parser.add_argument(
"--limit", type=int, default=None,
help="Max number of records to process (debugging).",
)
parser.add_argument(
"--only", choices=["external_upload", "internal_committee"], default=None,
help="Restrict to a single source_kind.",
)
args = parser.parse_args()
if not args.apply and not args.dry_run:
# Default to dry_run for safety.
args.dry_run = True
logger.info(
"Mode=%s MULTIMODAL_MODEL=%s DPI=%d THUMB_DPI=%d",
"DRY-RUN" if args.dry_run else "APPLY",
config.MULTIMODAL_MODEL, config.MULTIMODAL_DPI, config.MULTIMODAL_THUMB_DPI,
)
summary = asyncio.run(
backfill_all(
dry_run=args.dry_run,
limit=args.limit,
only_source_kind=args.only,
)
)
print()
print("=" * 60)
print("BACKFILL SUMMARY")
print("=" * 60)
print(f" scanned: {summary['scanned']}")
print(f" matched: {summary['matched']}")
print(f" no_match: {summary['no_match']}")
print(f" total pages: {summary['total_pages']}")
if args.dry_run:
# Cost estimate: ~3.5K tokens/page * $0.12/1M tokens
est_tokens = summary["total_pages"] * 3500
est_cost = est_tokens / 1_000_000 * 0.12
print(f" est. tokens: ~{est_tokens:,} (~${est_cost:.2f})")
else:
print(f" embedded: {summary['embedded']}")
print(f" errors: {summary['errors']}")
if __name__ == "__main__":
main()

313
scripts/compute_ndcg.py Executable file
View File

@@ -0,0 +1,313 @@
#!/usr/bin/env python3
"""Compute nDCG@10 over the RAG retrieval feedback table (TaskMaster #50).
Outputs aggregated metrics as JSON:
{
"generated_at": "2026-05-26T12:34:56+00:00",
"k": 10,
"summary": {
"total_searches_with_feedback": int,
"total_searches_logged": int,
"feedback_coverage_pct": float,
"avg_ndcg_at_10": float | null
},
"by_search_type": [
{"search_type": "precedent_library",
"searches_with_feedback": int,
"avg_ndcg_at_10": float | null},
...
],
"by_week": [
{"week_start": "2026-05-19",
"search_type": "precedent_library",
"searches_with_feedback": int,
"avg_ndcg_at_10": float | null},
...
],
"top_cited_case_law": [
{"case_law_id": "...", "case_number": "...",
"case_name": "...", "cite_count": int},
...
]
}
Run:
python ~/legal-ai/scripts/compute_ndcg.py
python ~/legal-ai/scripts/compute_ndcg.py --weeks 12 --k 10
python ~/legal-ai/scripts/compute_ndcg.py --pretty
"""
from __future__ import annotations
import argparse
import asyncio
import json
import math
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
import asyncpg
# Allow running as a standalone script — no package install required.
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "mcp-server" / "src"))
def _postgres_url() -> str:
"""Resolve POSTGRES_URL the same way the MCP server does."""
url = os.environ.get("POSTGRES_URL")
if url:
return url
user = os.environ.get("POSTGRES_USER", "legal_ai")
pw = os.environ.get("POSTGRES_PASSWORD", "")
host = os.environ.get("POSTGRES_HOST", "127.0.0.1")
port = os.environ.get("POSTGRES_PORT", "5433")
db = os.environ.get("POSTGRES_DB", "legal_ai")
return f"postgres://{user}:{pw}@{host}:{port}/{db}"
def dcg(relevances: list[int]) -> float:
"""Discounted Cumulative Gain at the length of ``relevances``.
Uses the "gain = 2^rel - 1" form so high-relevance hits get
significantly more weight than marginal ones — matches the
convention used by most IR papers and TREC-EVAL.
"""
total = 0.0
for i, rel in enumerate(relevances, start=1):
gain = (2 ** rel) - 1
total += gain / math.log2(i + 1)
return total
def ndcg_at_k(rel_at_rank: dict[int, int], k: int) -> float | None:
"""Compute nDCG@k.
Args:
rel_at_rank: ``{rank (1-based): relevance_score (0..3)}``.
Ranks above ``k`` are ignored. Missing ranks count as 0.
k: cutoff.
Returns:
nDCG in [0,1], or ``None`` if there's nothing to score
(no relevant hits in the top-k -> IDCG = 0).
"""
actual = [rel_at_rank.get(r, 0) for r in range(1, k + 1)]
if not any(actual):
return None
ideal = sorted(actual, reverse=True)
idcg = dcg(ideal)
if idcg == 0:
return None
return dcg(actual) / idcg
async def _fetch_feedback_rows(conn: asyncpg.Connection, weeks: int | None) -> list[dict]:
"""Pull all (search_log_id, rank, relevance_score, search_type, created_at)
rows where there's at least one feedback row.
Restricting to recent weeks keeps the scan cheap on a growing log.
"""
where = ""
params: list = []
if weeks is not None and weeks > 0:
where = "WHERE sl.created_at >= NOW() - ($1::int * INTERVAL '1 week')"
params.append(weeks)
sql = f"""
SELECT sl.id::text AS search_log_id,
sl.search_type AS search_type,
sl.created_at AS created_at,
srf.rank AS rank,
srf.relevance_score AS relevance_score
FROM search_relevance_feedback srf
JOIN search_logs sl ON sl.id = srf.search_log_id
{where}
"""
rows = await conn.fetch(sql, *params)
return [dict(r) for r in rows]
async def _fetch_corpus_totals(conn: asyncpg.Connection, weeks: int | None) -> dict[str, int]:
"""Total search_logs count (overall and by type) — used for coverage %."""
where = ""
params: list = []
if weeks is not None and weeks > 0:
where = "WHERE created_at >= NOW() - ($1::int * INTERVAL '1 week')"
params.append(weeks)
total_row = await conn.fetchrow(
f"SELECT COUNT(*) AS n FROM search_logs {where}",
*params,
)
by_type = await conn.fetch(
f"SELECT search_type, COUNT(*) AS n FROM search_logs {where} GROUP BY search_type",
*params,
)
return {
"_total": int(total_row["n"]) if total_row else 0,
**{r["search_type"]: int(r["n"]) for r in by_type},
}
async def _fetch_top_cited(conn: asyncpg.Connection, limit: int = 20) -> list[dict]:
"""Most-cited case_law (from auto-inferred feedback)."""
rows = await conn.fetch(
"""
SELECT cl.id::text AS case_law_id,
cl.case_number AS case_number,
cl.case_name AS case_name,
COUNT(*) AS cite_count
FROM search_relevance_feedback srf
JOIN case_law cl ON cl.id = srf.case_law_id
WHERE srf.feedback_source = 'cited_in_decision'
GROUP BY cl.id, cl.case_number, cl.case_name
ORDER BY COUNT(*) DESC
LIMIT $1
""",
limit,
)
return [dict(r) for r in rows]
def _aggregate(
feedback_rows: list[dict],
k: int,
) -> tuple[dict[str, float], dict[tuple[str, str], float], int]:
"""Group feedback by search_log, compute per-log nDCG, then aggregate
by search_type and by (week, search_type)."""
by_log: dict[str, dict] = {}
for row in feedback_rows:
slid = row["search_log_id"]
if slid not in by_log:
by_log[slid] = {
"search_type": row["search_type"],
"created_at": row["created_at"],
"rels": {},
}
rank = int(row["rank"])
if 1 <= rank <= k:
by_log[slid]["rels"][rank] = int(row["relevance_score"])
type_ndcg: dict[str, list[float]] = {}
week_ndcg: dict[tuple[str, str], list[float]] = {}
total_logs_with_feedback = 0
for entry in by_log.values():
score = ndcg_at_k(entry["rels"], k)
if score is None:
continue
total_logs_with_feedback += 1
type_ndcg.setdefault(entry["search_type"], []).append(score)
week_start = entry["created_at"].date()
# Round down to ISO week Monday.
week_start = week_start.fromordinal(
week_start.toordinal() - week_start.weekday()
)
wkey = (week_start.isoformat(), entry["search_type"])
week_ndcg.setdefault(wkey, []).append(score)
type_avg = {t: sum(v) / len(v) for t, v in type_ndcg.items() if v}
week_avg = {k_: sum(v) / len(v) for k_, v in week_ndcg.items() if v}
return type_avg, week_avg, total_logs_with_feedback
async def compute(weeks: int | None, k: int) -> dict:
conn = await asyncpg.connect(_postgres_url())
try:
fb_rows = await _fetch_feedback_rows(conn, weeks)
totals = await _fetch_corpus_totals(conn, weeks)
top_cited = await _fetch_top_cited(conn)
finally:
await conn.close()
type_avg, week_avg, logs_scored = _aggregate(fb_rows, k)
total_logs = totals.get("_total", 0)
overall_avg = (
sum(v * len([s for s in type_avg]) for v in []) or None # placeholder
)
# Recompute overall_avg cleanly: micro-average over all per-log scores.
all_scores: list[float] = []
for v in [type_avg[t] for t in type_avg]:
# type_avg already collapsed per-type — instead, re-run aggregation
# over fb_rows by reusing the per-log calc, micro-averaged.
pass
# Simpler: redo with per-log granularity for overall mean.
by_log_overall: dict[str, dict[int, int]] = {}
log_to_type: dict[str, str] = {}
for row in fb_rows:
slid = row["search_log_id"]
by_log_overall.setdefault(slid, {})
rank = int(row["rank"])
if 1 <= rank <= k:
by_log_overall[slid][rank] = int(row["relevance_score"])
log_to_type[slid] = row["search_type"]
per_log_scores: list[float] = []
for slid, rels in by_log_overall.items():
s = ndcg_at_k(rels, k)
if s is not None:
per_log_scores.append(s)
overall_avg = (sum(per_log_scores) / len(per_log_scores)) if per_log_scores else None
by_search_type = []
for t, totals_n in sorted(totals.items()):
if t == "_total":
continue
by_search_type.append({
"search_type": t,
"searches_logged": totals_n,
"searches_with_feedback": sum(
1 for slid, tp in log_to_type.items() if tp == t
),
"avg_ndcg_at_k": round(type_avg[t], 4) if t in type_avg else None,
})
by_week = [
{
"week_start": week,
"search_type": stype,
"avg_ndcg_at_k": round(score, 4),
}
for (week, stype), score in sorted(week_avg.items())
]
return {
"generated_at": datetime.now(timezone.utc).isoformat(),
"k": k,
"window_weeks": weeks,
"summary": {
"total_searches_logged": total_logs,
"total_searches_with_feedback": logs_scored,
"feedback_coverage_pct": (
round(100 * logs_scored / total_logs, 2) if total_logs else 0.0
),
"avg_ndcg_at_k": round(overall_avg, 4) if overall_avg is not None else None,
},
"by_search_type": by_search_type,
"by_week": by_week,
"top_cited_case_law": [
{**r, "cite_count": int(r["cite_count"])} for r in top_cited
],
}
def main() -> int:
p = argparse.ArgumentParser(description="Compute nDCG@k from search_relevance_feedback")
p.add_argument("--k", type=int, default=10, help="cutoff (default: 10)")
p.add_argument(
"--weeks",
type=int,
default=None,
help="restrict to the last N weeks (default: all time)",
)
p.add_argument("--pretty", action="store_true", help="indented JSON output")
args = p.parse_args()
result = asyncio.run(compute(weeks=args.weeks, k=args.k))
indent = 2 if args.pretty else None
print(json.dumps(result, ensure_ascii=False, indent=indent, default=str))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,73 @@
/**
* pm2 ecosystem entry for legal-chat-service — the host-side SSE bridge
* to ``claude`` CLI that powers the /training chat tab.
*
* Security: the service spawns the claude CLI on behalf of any caller
* that hits /chat/start. claude tools include Bash, Read, Edit — so an
* unauthenticated request to /chat/start is effectively RCE-equivalent.
* Two defenses, both required:
* 1. Bind to 10.0.1.1 (docker0 bridge gateway) — only host + containers
* on docker bridges can reach the socket; nothing outside the host.
* 2. Bearer token auth — secret loaded from /home/chaim/.legal-chat-service.env
* (chmod 600) and mirrored in Coolify as LEGAL_CHAT_SHARED_SECRET.
* The service refuses to start without the secret set.
*
* Why pm2:
* - Auto-restart if the process dies (claude CLI subprocess failures
* should never leave the service in a half-dead state).
* - Log rotation matches paperclip's behavior so the chair sees
* consistent log paths under ~/.pm2/logs/.
*
* Install (once):
* pm2 start /home/chaim/legal-ai/scripts/legal-chat-service.config.cjs
* pm2 save
*
* Smoke test:
* curl http://10.0.1.1:8770/health
* # → {"ok":true,"service":"legal-chat-service"}
*
* Update:
* pm2 restart legal-chat-service --update-env
*
* Stop:
* pm2 stop legal-chat-service
*/
const fs = require("fs");
// Load LEGAL_CHAT_SHARED_SECRET from a chmod 600 file off the repo.
// The same value is mirrored in Coolify as the LEGAL_CHAT_SHARED_SECRET
// env var so the FastAPI proxy sends a matching Authorization header.
// Migrate to Infisical (/_GUIDELINES) once the MCP server is back.
const ENV_FILE = "/home/chaim/.legal-chat-service.env";
const env = {
HOME: "/home/chaim",
PATH: "/home/chaim/.local/bin:/usr/local/bin:/usr/bin:/bin",
PYTHONUNBUFFERED: "1",
};
try {
const text = fs.readFileSync(ENV_FILE, "utf8");
for (const line of text.split("\n")) {
if (!line || line.trim().startsWith("#")) continue;
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
if (m) env[m[1]] = m[2];
}
} catch (e) {
console.error(`legal-chat-service: failed to load ${ENV_FILE}: ${e.message}`);
console.error("Service will refuse to start without LEGAL_CHAT_SHARED_SECRET.");
}
module.exports = {
apps: [
{
name: "legal-chat-service",
cwd: "/home/chaim/legal-ai/mcp-server",
script: "/home/chaim/legal-ai/mcp-server/.venv/bin/python",
args: "-m legal_mcp.chat_service.server --port 8770 --host 10.0.1.1",
env,
restart_delay: 5000,
max_restarts: 10,
autorestart: true,
max_memory_restart: "500M",
},
],
};

View File

@@ -0,0 +1,278 @@
"""Halacha extraction quality monitor.
Tracks ``avg(confidence)`` of halachot extracted by the LLM pipeline
over time and emits an alert when the recent-window average drops more
than a configurable threshold below the lifetime baseline.
Intended schedule: weekly cron, e.g. ``0 8 * * 1`` (Monday 08:00).
Output: a single-line JSON payload to stdout (suitable for piping
into ``notify.py`` or a webhook), plus a human-readable alert text
on stderr when drift is detected.
Usage
-----
::
# Default — weekly window, 5% drop threshold (relative)
python scripts/monitor_halacha_quality.py
# Custom window/threshold:
python scripts/monitor_halacha_quality.py --window 14 --threshold 0.03
# Only emit JSON, no stderr alert:
python scripts/monitor_halacha_quality.py --silent
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
def _setup_paths():
"""Make ``legal_mcp`` importable when run from anywhere."""
here = Path(__file__).resolve().parent
candidates = [
here.parent / "mcp-server" / "src", # host
Path("/app/mcp-server/src"), # container
]
for c in candidates:
if c.is_dir() and str(c) not in sys.path:
sys.path.insert(0, str(c))
_setup_paths()
from legal_mcp.services import db # noqa: E402
# Statuses considered "trusted" — the baseline is computed only over
# halachot whose extraction the chair has accepted. ``pending_review``
# is the queue waiting for review; their average tends to be lower
# because anything obviously bad gets rejected before approval. So we
# track BOTH series and alert on either one drifting:
# 1. Trusted baseline (approved+published) — drift here means the
# extractor's "best output" quality is degrading.
# 2. All extracted — drift here means raw extractor accuracy is down.
TRUSTED_STATUSES = ("approved", "published")
async def _collect_metrics(window_days: int) -> dict:
pool = await db.get_pool()
# Lifetime baselines
lifetime_all = await pool.fetchrow(
"SELECT count(*) AS n, AVG(confidence) AS avg_conf FROM halachot"
)
lifetime_trusted = await pool.fetchrow(
f"""
SELECT count(*) AS n, AVG(confidence) AS avg_conf
FROM halachot
WHERE review_status = ANY($1::text[])
""",
list(TRUSTED_STATUSES),
)
# Recent window
recent_all = await pool.fetchrow(
f"""
SELECT count(*) AS n, AVG(confidence) AS avg_conf
FROM halachot
WHERE created_at > NOW() - INTERVAL '{int(window_days)} days'
"""
)
recent_trusted = await pool.fetchrow(
f"""
SELECT count(*) AS n, AVG(confidence) AS avg_conf
FROM halachot
WHERE created_at > NOW() - INTERVAL '{int(window_days)} days'
AND review_status = ANY($1::text[])
""",
list(TRUSTED_STATUSES),
)
# Per-precedent recent (extractor outputs that haven't been reviewed
# yet) — sometimes the canary that catches drift earliest. We track
# the most-recent N extractions regardless of review state.
pending_recent = await pool.fetchrow(
"""
SELECT count(*) AS n, AVG(confidence) AS avg_conf
FROM halachot
WHERE review_status = 'pending_review'
"""
)
def _f(rec, key: str) -> float | None:
v = rec[key]
if v is None:
return None
return float(v)
def _i(rec, key: str) -> int:
v = rec[key]
return int(v) if v is not None else 0
return {
"window_days": int(window_days),
"lifetime_all_count": _i(lifetime_all, "n"),
"lifetime_all_avg": _f(lifetime_all, "avg_conf"),
"lifetime_trusted_count": _i(lifetime_trusted, "n"),
"lifetime_trusted_avg": _f(lifetime_trusted, "avg_conf"),
"recent_all_count": _i(recent_all, "n"),
"recent_all_avg": _f(recent_all, "avg_conf"),
"recent_trusted_count": _i(recent_trusted, "n"),
"recent_trusted_avg": _f(recent_trusted, "avg_conf"),
"pending_review_count": _i(pending_recent, "n"),
"pending_review_avg": _f(pending_recent, "avg_conf"),
}
def _drift(baseline: float | None, recent: float | None) -> float | None:
"""Return relative drift as a positive number when recent < baseline.
>>> _drift(0.85, 0.80) # -> 0.0588 (5.88% drop)
"""
if baseline is None or recent is None or baseline <= 0:
return None
return (baseline - recent) / baseline
def _evaluate(metrics: dict, threshold: float, min_sample: int) -> dict:
"""Decide whether any series is drifting below threshold."""
alerts: list[dict] = []
series = [
(
"trusted",
metrics["lifetime_trusted_avg"],
metrics["recent_trusted_avg"],
metrics["recent_trusted_count"],
),
(
"all_extracted",
metrics["lifetime_all_avg"],
metrics["recent_all_avg"],
metrics["recent_all_count"],
),
]
for name, baseline, recent, recent_n in series:
d = _drift(baseline, recent)
entry = {
"series": name,
"baseline": baseline,
"recent": recent,
"recent_n": recent_n,
"drift": d,
"alert": False,
"reason": None,
}
if recent_n < min_sample:
entry["reason"] = f"recent_n={recent_n} below min_sample={min_sample}"
elif d is None:
entry["reason"] = "missing baseline or recent average"
elif d >= threshold:
entry["alert"] = True
entry["reason"] = (
f"drift {d:.1%} >= threshold {threshold:.1%} "
f"(baseline={baseline:.3f}, recent={recent:.3f}, n={recent_n})"
)
else:
entry["reason"] = (
f"drift {d:.1%} < threshold {threshold:.1%} — within tolerance"
)
alerts.append(entry)
any_alert = any(a["alert"] for a in alerts)
return {"alert": any_alert, "series": alerts}
def _format_alert_text(metrics: dict, decision: dict) -> str:
lines = [
f"Halacha quality alert — window={metrics['window_days']}d",
"",
]
for s in decision["series"]:
sym = "ALERT" if s["alert"] else "ok"
baseline = f"{s['baseline']:.3f}" if s["baseline"] is not None else ""
recent = f"{s['recent']:.3f}" if s["recent"] is not None else ""
drift = f"{s['drift']:.1%}" if s["drift"] is not None else ""
lines.append(
f" [{sym}] {s['series']}: baseline={baseline} recent={recent} "
f"drift={drift} n={s['recent_n']}"
)
if s["reason"]:
lines.append(f" {s['reason']}")
return "\n".join(lines)
async def run(
*,
window_days: int,
threshold: float,
min_sample: int,
) -> dict:
metrics = await _collect_metrics(window_days)
decision = _evaluate(metrics, threshold, min_sample)
return {
"generated_at": datetime.now(timezone.utc).isoformat(),
"window_days": window_days,
"threshold_rel": threshold,
"min_sample": min_sample,
"metrics": metrics,
"decision": decision,
}
def main():
parser = argparse.ArgumentParser(
description="Monitor halacha extraction quality (confidence drift)."
)
parser.add_argument(
"--window", type=int, default=7,
help="Recent window in days (default: 7).",
)
parser.add_argument(
"--threshold", type=float, default=0.05,
help="Relative drop alert threshold (default: 0.05 = 5%%).",
)
parser.add_argument(
"--min-sample", type=int, default=5,
help="Minimum halachot in window to evaluate (default: 5). "
"Below this, the series is reported but not alerted on.",
)
parser.add_argument(
"--silent", action="store_true",
help="Suppress stderr alert text; only print JSON.",
)
parser.add_argument(
"--exit-on-alert", action="store_true",
help="Exit with status 1 when an alert fires (default: always exit 0).",
)
args = parser.parse_args()
report = asyncio.run(
run(
window_days=args.window,
threshold=args.threshold,
min_sample=args.min_sample,
)
)
# JSON to stdout
print(json.dumps(report, ensure_ascii=False, indent=2))
if report["decision"]["alert"] and not args.silent:
print("", file=sys.stderr)
print(_format_alert_text(report["metrics"], report["decision"]), file=sys.stderr)
if args.exit_on_alert and report["decision"]["alert"]:
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,53 @@
"""One-shot: run pending metadata + halacha extraction on the 2 בל"מ
decisions uploaded today (8126/24 + 8047/23). Bypasses MCP because the
running MCP server has stale code; calls the services directly with the
updated local copy.
Run from /home/chaim/legal-ai with the venv:
POSTGRES_URL=... .venv/bin/python scripts/process_pending_blam.py
"""
import asyncio
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp-server", "src"))
from legal_mcp.services import db
from legal_mcp.services import precedent_library
async def main():
# Queue metadata extraction too (ingest_internal_decision only queues
# halacha; metadata fills headnote/summary/key_quote and now also
# confirms proceeding_type via the new prompt field).
pool = await db.get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT id, case_number FROM case_law "
"WHERE case_number IN ('8126/24','8047/23') "
" AND source_kind = 'internal_committee'"
)
for r in rows:
await conn.execute(
"UPDATE case_law SET metadata_extraction_requested_at = NOW() "
"WHERE id = $1",
r["id"],
)
print(f"queued metadata for {r['case_number']} ({r['id']})")
print("\n→ running metadata extraction…")
meta_result = await precedent_library.process_pending_extractions(
kind="metadata", limit=10,
)
print(meta_result)
print("\n→ running halacha extraction…")
halacha_result = await precedent_library.process_pending_extractions(
kind="halacha", limit=10,
)
print(halacha_result)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,89 @@
#!/usr/bin/env python
"""Repro + regression test for retrieval-by-name (RC-A, tasks #52).
Bug: searching the precedent corpus by a bare case NAME ("אגסי") fails to
surface the decision itself, because the lexical tsvector covers only chunk
content + halacha text — not case_name / case_number. A name query therefore
matches decisions that *cite* the case, not the case.
Run with the MCP venv:
DOTENV_PATH=/home/chaim/.env DATA_DIR=/home/chaim/legal-ai/data \
mcp-server/.venv/bin/python scripts/test_retrieval_by_name.py
Exit 0 = all assertions pass. Non-zero = failure (prints what was found).
"""
import asyncio
import sys
sys.path.insert(0, "/home/chaim/legal-ai/mcp-server/src")
from legal_mcp.services import embeddings, hybrid_search # noqa: E402
AGASI_ID = "1a87efe5-6e13-4ed4-a9ec-3f2f7d61e4ec"
# Vinfeld CITES Agasi (its halacha quote names אגסי) but is NOT Agasi.
# An exact name match must rank the case itself above any case citing it.
VINFELD_ID = "bd5d849c-c15f-43c3-96ab-d44337af9cb5"
NAME_QUERY = "אגסי"
SUBSTANTIVE_QUERY = 'פטור היטל השבחה לפי סעיף 19(ג)(1) שתי דירות 140 מ"ר אחת מושכרת'
def _ids(rows):
return [str(r.get("case_law_id")) for r in rows]
def _rank_of(rows, cid):
for i, r in enumerate(rows, 1):
if str(r.get("case_law_id")) == cid:
return i
return None
async def _search(query, source_kind, limit=10):
query_emb = await embeddings.embed_query(query)
return await hybrid_search.search_precedent_library_hybrid(
query,
query_emb,
source_kind=source_kind,
limit=limit,
include_halachot=True,
)
async def main():
results = {"pass": [], "fail": []}
# 1) THE BUG: bare-name query must rank the case ITSELF (Agasi) above any
# case that merely CITES it (Vinfeld), and within the top 3.
rows = await _search(NAME_QUERY, "internal_committee", limit=10)
a_rank = _rank_of(rows, AGASI_ID)
v_rank = _rank_of(rows, VINFELD_ID)
ok = bool(a_rank) and a_rank <= 3 and (v_rank is None or a_rank < v_rank)
msg = (f"[name/internal] query='{NAME_QUERY}' -> Agasi rank={a_rank}, "
f"Vinfeld(citer) rank={v_rank} (top ids: {_ids(rows)[:5]})")
(results["pass"] if ok else results["fail"]).append(msg)
# 2) REGRESSION: substantive query must still find Agasi with a real score.
rows = await _search(SUBSTANTIVE_QUERY, "internal_committee", limit=10)
rank = _rank_of(rows, AGASI_ID)
top_score = float(rows[0]["score"]) if rows else 0.0
msg = f"[substantive/internal] Agasi rank={rank}, top_score={top_score:.3f}"
(results["pass"] if rank and rank <= 8 else results["fail"]).append(msg)
# 3) REGRESSION: substantive query in the full precedent library still works
# (Vinfeld/נווה שלום etc. should surface; just assert non-empty + has betterment content).
rows = await _search(SUBSTANTIVE_QUERY, "external_upload", limit=10)
msg = f"[substantive/external] returned {len(rows)} rows (top ids: {_ids(rows)[:3]})"
(results["pass"] if len(rows) >= 3 else results["fail"]).append(msg)
print("\n=== PASS ===")
for m in results["pass"]:
print("", m)
print("=== FAIL ===")
for m in results["fail"]:
print("", m)
return 1 if results["fail"] else 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))

View File

@@ -0,0 +1,60 @@
"""One-shot uploader for the 2 new בל"מ decisions Chaim staged in
data/precedents/incoming/. Bypasses MCP because the running MCP server
was started before SCHEMA_V15 + proceeding_type wiring landed.
Run from /home/chaim/legal-ai with the venv:
POSTGRES_URL=... .venv/bin/python scripts/upload_blam_decisions.py
"""
import asyncio
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp-server", "src"))
from legal_mcp.services import internal_decisions as svc
DECISIONS = [
{
"file_path": "/home/chaim/legal-ai/data/precedents/incoming/ARAR-24-8126.pdf",
"case_number": "8126/24",
"chair_name": "דפנה תמיר",
"district": "ירושלים",
"case_name": "הוועדה המקומית ירושלים נ' סופר נוח",
"court": "ועדת הערר לתכנון ובנייה — מחוז ירושלים",
"decision_date": "2024-07-07",
"practice_area": "betterment_levy",
"appeal_subtype": "extension_request_betterment_levy",
"proceeding_type": 'בל"מ',
"subject_tags": ["בקשה_להארכת_מועד", "היטל_השבחה"],
"summary": "",
"is_binding": False,
},
{
"file_path": "/home/chaim/legal-ai/data/precedents/incoming/ARAR-23-8047-3.docx",
"case_number": "8047/23",
"chair_name": "דפנה תמיר",
"district": "ירושלים",
"case_name": 'עזבון אליהו הרנון ז"ל נ\' הוועדה המקומית ירושלים',
"court": "ועדת הערר לתכנון ובנייה — מחוז ירושלים",
"decision_date": "2025-09-29",
"practice_area": "betterment_levy",
"appeal_subtype": "extension_request_betterment_levy",
"proceeding_type": 'בל"מ',
"subject_tags": ["בקשה_להארכת_מועד", "היטל_השבחה"],
"summary": "",
"is_binding": False,
},
]
async def main():
for d in DECISIONS:
print(f"→ uploading {d['case_number']} ({d['proceeding_type']})")
result = await svc.ingest_internal_decision(**d)
print(f" ✓ case_law_id={result.get('case_law_id')} chunks={result.get('chunks')}")
print("done.")
if __name__ == "__main__":
asyncio.run(main())

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.

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

@@ -14,6 +14,7 @@ import { StatusGuide } from "@/components/cases/status-guide";
import { StatusChanger } from "@/components/cases/status-changer"; import { StatusChanger } from "@/components/cases/status-changer";
import { DocumentsPanel } from "@/components/cases/documents-panel"; import { DocumentsPanel } from "@/components/cases/documents-panel";
import { DraftsPanel } from "@/components/cases/drafts-panel"; import { DraftsPanel } from "@/components/cases/drafts-panel";
import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel";
import { AgentActivityFeed } from "@/components/cases/agent-activity-feed"; import { AgentActivityFeed } from "@/components/cases/agent-activity-feed";
import { AgentStatusWidget } from "@/components/cases/agent-status-widget"; import { AgentStatusWidget } from "@/components/cases/agent-status-widget";
import { UploadSheet } from "@/components/documents/upload-sheet"; import { UploadSheet } from "@/components/documents/upload-sheet";
@@ -77,6 +78,9 @@ export default function CaseDetailPage({
<div className="flex items-center justify-between gap-3 mb-1 flex-wrap"> <div className="flex items-center justify-between gap-3 mb-1 flex-wrap">
<TabsList className="bg-rule-soft/60"> <TabsList className="bg-rule-soft/60">
<TabsTrigger value="overview">סקירה</TabsTrigger> <TabsTrigger value="overview">סקירה</TabsTrigger>
<TabsTrigger value="arguments">
טיעונים
</TabsTrigger>
<TabsTrigger value="drafts"> <TabsTrigger value="drafts">
טיוטות והערות טיוטות והערות
</TabsTrigger> </TabsTrigger>
@@ -139,6 +143,10 @@ export default function CaseDetailPage({
<DocumentsPanel data={data} /> <DocumentsPanel data={data} />
</TabsContent> </TabsContent>
<TabsContent value="arguments" className="mt-5">
<LegalArgumentsPanel caseNumber={caseNumber} />
</TabsContent>
<TabsContent value="drafts" className="mt-5"> <TabsContent value="drafts" className="mt-5">
<DraftsPanel <DraftsPanel
caseNumber={caseNumber} caseNumber={caseNumber}

View File

@@ -0,0 +1,161 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/app-shell";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
useMissingPrecedents,
type MissingPrecedentStatus,
} from "@/lib/api/missing-precedents";
import { MissingPrecedentsTable } from "@/components/missing-precedents/missing-precedents-table";
/**
* Missing-precedents page (TaskMaster #35).
*
* Surfaces citations that party briefs invoke but which aren't yet in the
* precedent_library. Four tabs by status; each tab uses the same table
* component with a different filter. Drawer (sheet) opens on row click
* with metadata + upload form that routes to internal_decision_upload
* (ערר/בל"מ citations) or precedent_library_upload (court rulings).
*/
function StatusBadge({ status, count }: { status: MissingPrecedentStatus; count: number }) {
if (!count) return null;
const variants: Record<MissingPrecedentStatus, string> = {
open: "bg-gold-wash text-gold-deep border-gold/40",
uploaded: "bg-rule-soft text-ink-muted border-rule",
closed: "bg-emerald-50 text-emerald-800 border-emerald-300/60",
irrelevant: "bg-rule-soft text-ink-muted border-rule",
};
return (
<Badge
variant="outline"
className={`ms-1 text-[0.65rem] ${variants[status]}`}
>
{count}
</Badge>
);
}
export default function MissingPrecedentsPage() {
const [caseNumber, setCaseNumber] = useState("");
const [legalTopic, setLegalTopic] = useState("");
const counts = useMissingPrecedents({ limit: 1 });
const byStatus = counts.data?.by_status ?? {};
return (
<AppShell>
<section className="space-y-6">
<header>
<nav className="text-[0.78rem] text-ink-muted mb-1">
<Link href="/" className="hover:text-gold-deep">בית</Link>
<span aria-hidden> · </span>
<span className="text-navy">פסיקה חסרה בקורפוס</span>
</nav>
<h1 className="text-navy mb-0">פסיקה חסרה בקורפוס</h1>
<p className="text-ink-muted text-sm mt-1 max-w-3xl">
פסיקות שצוטטו בכתבי הטענות אך אינן עדיין בקורפוס. סוכן המחקר רושם
פערים אוטומטית; היו&quot;ר סוגר אותם על־ידי העלאת המסמך ניתוב
אוטומטי בין הקורפוס הסמכותי (פסקי דין) להחלטות ועדות ערר.
</p>
</header>
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5 space-y-5">
{/* Shared filters */}
<div className="flex items-end gap-3 flex-wrap">
<div className="flex-1 min-w-[200px]">
<label className="text-[0.78rem] text-ink-muted">תיק (מספר ערר)</label>
<Input
value={caseNumber}
onChange={(e) => setCaseNumber(e.target.value)}
placeholder="1017-03-26"
dir="rtl"
/>
</div>
<div className="flex-1 min-w-[200px]">
<label className="text-[0.78rem] text-ink-muted">נושא משפטי</label>
<Input
value={legalTopic}
onChange={(e) => setLegalTopic(e.target.value)}
placeholder="זכות עמידה"
dir="rtl"
/>
</div>
</div>
<Tabs defaultValue="open" dir="rtl">
<TabsList className="bg-rule-soft/60">
<TabsTrigger value="open">
פתוחות
<StatusBadge status="open" count={byStatus.open ?? 0} />
</TabsTrigger>
<TabsTrigger value="uploaded">
הועלו
<StatusBadge status="uploaded" count={byStatus.uploaded ?? 0} />
</TabsTrigger>
<TabsTrigger value="closed">
נסגרו
<StatusBadge status="closed" count={byStatus.closed ?? 0} />
</TabsTrigger>
<TabsTrigger value="irrelevant">
לא רלוונטי
<StatusBadge
status="irrelevant"
count={byStatus.irrelevant ?? 0}
/>
</TabsTrigger>
<TabsTrigger value="all">הכל</TabsTrigger>
</TabsList>
<TabsContent value="open" className="mt-4">
<MissingPrecedentsTable
status="open"
caseNumber={caseNumber.trim() || undefined}
legalTopic={legalTopic.trim() || undefined}
/>
</TabsContent>
<TabsContent value="uploaded" className="mt-4">
<MissingPrecedentsTable
status="uploaded"
caseNumber={caseNumber.trim() || undefined}
legalTopic={legalTopic.trim() || undefined}
/>
</TabsContent>
<TabsContent value="closed" className="mt-4">
<MissingPrecedentsTable
status="closed"
caseNumber={caseNumber.trim() || undefined}
legalTopic={legalTopic.trim() || undefined}
/>
</TabsContent>
<TabsContent value="irrelevant" className="mt-4">
<MissingPrecedentsTable
status="irrelevant"
caseNumber={caseNumber.trim() || undefined}
legalTopic={legalTopic.trim() || undefined}
/>
</TabsContent>
<TabsContent value="all" className="mt-4">
<MissingPrecedentsTable
caseNumber={caseNumber.trim() || undefined}
legalTopic={legalTopic.trim() || undefined}
/>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</section>
</AppShell>
);
}

View File

@@ -2,14 +2,24 @@
import { use, useState } from "react"; import { use, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Pencil } from "lucide-react"; import { Pencil, Check, X } from "lucide-react";
import { toast } from "sonner";
import { AppShell } from "@/components/app-shell"; import { AppShell } from "@/components/app-shell";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { usePrecedent } from "@/lib/api/precedent-library"; import { Textarea } from "@/components/ui/textarea";
import {
usePrecedent,
useUpdatePrecedent,
type Precedent,
} from "@/lib/api/precedent-library";
import { PrecedentEditSheet } from "@/components/precedents/precedent-edit-sheet"; import { PrecedentEditSheet } from "@/components/precedents/precedent-edit-sheet";
import {
FormattedCitation,
CitationCopyButton,
} from "@/components/precedents/formatted-citation";
import { ExtractedHalachotSection } from "@/components/precedents/extracted-halachot"; import { ExtractedHalachotSection } from "@/components/precedents/extracted-halachot";
import { RelatedCasesSection } from "@/components/precedents/link-related-dialog"; import { RelatedCasesSection } from "@/components/precedents/link-related-dialog";
@@ -34,6 +44,9 @@ export default function PrecedentDetailPage({
const { id } = use(params); const { id } = use(params);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const { data, isPending, error } = usePrecedent(id); const { data, isPending, error } = usePrecedent(id);
const update = useUpdatePrecedent();
const [editingCitation, setEditingCitation] = useState(false);
const [citationDraft, setCitationDraft] = useState("");
return ( return (
<AppShell> <AppShell>
@@ -80,6 +93,36 @@ export default function PrecedentDetailPage({
</Button> </Button>
</div> </div>
{/* Citation per Israeli unified citation rules. The LLM
extractor composes this from the document; the chair
can override below. */}
<CitationBlock
precedent={data as Precedent}
editing={editingCitation}
draft={citationDraft}
onStartEdit={() => {
setCitationDraft(data.citation_formatted ?? "");
setEditingCitation(true);
}}
onCancel={() => setEditingCitation(false)}
onChange={setCitationDraft}
onSave={async () => {
try {
await update.mutateAsync({
id,
patch: { citation_formatted: citationDraft.trim() },
});
toast.success("מראה מקום עודכן");
setEditingCitation(false);
} catch (e) {
toast.error(
e instanceof Error ? e.message : "שמירה נכשלה",
);
}
}}
saving={update.isPending}
/>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
{data.practice_area ? ( {data.practice_area ? (
<Badge variant="outline" className="text-[0.7rem]"> <Badge variant="outline" className="text-[0.7rem]">
@@ -178,3 +221,109 @@ export default function PrecedentDetailPage({
</AppShell> </AppShell>
); );
} }
function CitationBlock({
precedent,
editing,
draft,
onStartEdit,
onCancel,
onChange,
onSave,
saving,
}: {
precedent: Precedent;
editing: boolean;
draft: string;
onStartEdit: () => void;
onCancel: () => void;
onChange: (v: string) => void;
onSave: () => void;
saving: boolean;
}) {
const citation = (precedent.citation_formatted ?? "").trim();
if (editing) {
return (
<div className="rounded-md border border-gold/40 bg-gold-wash/30 p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-[0.78rem] font-semibold text-navy">
עריכת מראה מקום
</span>
<span className="text-[0.7rem] text-ink-muted">
הקף את שמות הצדדים בכפול-כוכבית <code className="font-mono">**שם**</code> להדגשה
</span>
</div>
<Textarea
value={draft}
onChange={(e) => onChange(e.target.value)}
rows={3}
dir="rtl"
className="font-mono text-sm"
placeholder='ערר (ועדות ערר ...) 1234/24 **עורר נ&apos; הוועדה המקומית** (נבו 1.2.2025)'
disabled={saving}
/>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={onSave}
disabled={saving || !draft.trim()}
className="bg-navy text-parchment hover:bg-navy-soft"
>
<Check className="w-3.5 h-3.5 me-1" />
שמור
</Button>
<Button
size="sm"
variant="outline"
onClick={onCancel}
disabled={saving}
>
<X className="w-3.5 h-3.5 me-1" />
ביטול
</Button>
</div>
</div>
);
}
if (!citation) {
return (
<div className="rounded-md border border-dashed border-rule bg-rule-soft/30 p-3 flex items-center justify-between gap-2">
<span className="text-[0.78rem] text-ink-muted">
מראה מקום (כללי הציטוט האחיד) טרם חולץ
</span>
<Button size="sm" variant="outline" onClick={onStartEdit}>
<Pencil className="w-3.5 h-3.5 me-1" />
הוסף ידנית
</Button>
</div>
);
}
return (
<div className="rounded-md border border-rule bg-parchment-50 p-3 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<span className="text-[0.7rem] uppercase tracking-wide text-ink-muted">
מראה מקום
</span>
<div className="flex items-center gap-1.5">
<CitationCopyButton citation={citation} size="xs" />
<button
type="button"
onClick={onStartEdit}
title="ערוך מראה מקום"
aria-label="ערוך מראה מקום"
className="inline-flex items-center justify-center rounded-md border border-rule bg-surface hover:bg-rule-soft/50 text-ink-muted hover:text-navy h-7 w-7"
>
<Pencil className="w-3.5 h-3.5" />
</button>
</div>
</div>
<FormattedCitation
citation={citation}
className="block text-navy text-sm leading-relaxed"
/>
</div>
);
}

View File

@@ -1,30 +1,49 @@
"use client"; "use client";
import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Upload } from "lucide-react";
import { AppShell } from "@/components/app-shell"; import { AppShell } from "@/components/app-shell";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { StyleReportPanel } from "@/components/training/style-report-panel"; import { StyleReportPanel } from "@/components/training/style-report-panel";
import { CorpusPanel } from "@/components/training/corpus-panel"; import { CorpusPanel } from "@/components/training/corpus-panel";
import { ComparePanel } from "@/components/training/compare-panel"; import { ComparePanel } from "@/components/training/compare-panel";
import { CuratorPortraitPanel } from "@/components/training/curator-portrait-panel";
import { ChatPanel } from "@/components/training/chat-panel";
import { TrainingUploadDialog } from "@/components/training/upload-dialog";
export default function TrainingPage() { export default function TrainingPage() {
const [uploadOpen, setUploadOpen] = useState(false);
return ( return (
<AppShell> <AppShell>
<section className="space-y-6"> <section className="space-y-6">
<header> <header className="flex items-start justify-between gap-4 flex-wrap">
<nav className="text-[0.78rem] text-ink-muted mb-1"> <div>
<Link href="/" className="hover:text-gold-deep">בית</Link> <nav className="text-[0.78rem] text-ink-muted mb-1">
<span aria-hidden> · </span> <Link href="/" className="hover:text-gold-deep">בית</Link>
<span className="text-navy">אימון סגנון</span> <span aria-hidden> · </span>
</nav> <span className="text-navy">אימון סגנון</span>
<h1 className="text-navy mb-0">הפורטרט הסגנוני של דפנה</h1> </nav>
<p className="text-ink-muted text-sm mt-1 max-w-2xl"> <h1 className="text-navy mb-0">הפורטרט הסגנוני של דפנה</h1>
לוח בקרה של קורפוס האימון סטטיסטיקות, אנטומיית החלטה ממוצעת, <p className="text-ink-muted text-sm mt-1 max-w-2xl">
ביטויי חתימה, וכלי השוואה בין שתי החלטות. לוח בקרה של קורפוס האימון סטטיסטיקות, אנטומיית החלטה ממוצעת,
</p> ביטויי חתימה, וכלי השוואה בין שתי החלטות.
</p>
</div>
<Button
onClick={() => setUploadOpen(true)}
className="bg-navy text-parchment hover:bg-navy-soft shrink-0"
>
<Upload className="w-4 h-4 me-1" />
העלה החלטה
</Button>
</header> </header>
<TrainingUploadDialog open={uploadOpen} onOpenChange={setUploadOpen} />
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" /> <div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
<Card className="bg-surface border-rule shadow-sm"> <Card className="bg-surface border-rule shadow-sm">
@@ -34,6 +53,8 @@ export default function TrainingPage() {
<TabsTrigger value="report">פורטרט סגנון</TabsTrigger> <TabsTrigger value="report">פורטרט סגנון</TabsTrigger>
<TabsTrigger value="corpus">קורפוס</TabsTrigger> <TabsTrigger value="corpus">קורפוס</TabsTrigger>
<TabsTrigger value="compare">השוואה</TabsTrigger> <TabsTrigger value="compare">השוואה</TabsTrigger>
<TabsTrigger value="curator">הסוכן</TabsTrigger>
<TabsTrigger value="chat">שיחה</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="report" className="mt-5"> <TabsContent value="report" className="mt-5">
@@ -47,6 +68,14 @@ export default function TrainingPage() {
<TabsContent value="compare" className="mt-5"> <TabsContent value="compare" className="mt-5">
<ComparePanel /> <ComparePanel />
</TabsContent> </TabsContent>
<TabsContent value="curator" className="mt-5">
<CuratorPortraitPanel />
</TabsContent>
<TabsContent value="chat" className="mt-5">
<ChatPanel />
</TabsContent>
</Tabs> </Tabs>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -15,6 +15,7 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { GlobalSearch } from "@/components/global-search"; import { GlobalSearch } from "@/components/global-search";
import { headerSubtitle } from "@/components/header-context"; import { headerSubtitle } from "@/components/header-context";
import { useMissingPrecedentsOpenCount } from "@/lib/api/missing-precedents";
/** /**
* Ezer Mishpati navigation shell — two-row header. * Ezer Mishpati navigation shell — two-row header.
@@ -45,9 +46,10 @@ const NAV_GROUPS: NavGroup[] = [
{ {
id: "knowledge", id: "knowledge",
items: [ items: [
{ href: "/precedents", label: "ספריית פסיקה" }, { href: "/precedents", label: "ספריית פסיקה" },
{ href: "/training", label: "אימון סגנון" }, { href: "/missing-precedents", label: "פסיקה חסרה" },
{ href: "/methodology", label: "מתודולוגיה" }, { href: "/training", label: "אימון סגנון" },
{ href: "/methodology", label: "מתודולוגיה" },
], ],
}, },
]; ];
@@ -240,7 +242,8 @@ function NavLink({ item, active }: { item: NavItem; active: boolean }) {
: "text-parchment/80 hover:text-parchment hover:bg-navy-soft/60"} : "text-parchment/80 hover:text-parchment hover:bg-navy-soft/60"}
`} `}
> >
{item.label} <span>{item.label}</span>
{item.href === "/missing-precedents" ? <MissingPrecedentsBadge /> : null}
{active && ( {active && (
<span <span
className="absolute -bottom-[19px] inset-x-2 h-[2px] bg-gold" className="absolute -bottom-[19px] inset-x-2 h-[2px] bg-gold"
@@ -250,3 +253,18 @@ function NavLink({ item, active }: { item: NavItem; active: boolean }) {
</Link> </Link>
); );
} }
/* Small open-count badge next to "פסיקה חסרה" — only renders when >0
* so the nav stays quiet in normal operation. */
function MissingPrecedentsBadge() {
const { data: openCount } = useMissingPrecedentsOpenCount();
if (!openCount) return null;
return (
<span
className="ms-1 inline-flex items-center justify-center min-w-[1.25rem] h-4 px-1 rounded-full bg-gold text-navy text-[0.65rem] font-semibold"
aria-label={`${openCount} פסיקות חסרות פתוחות`}
>
{openCount}
</span>
);
}

View File

@@ -12,17 +12,35 @@ const BUCKETS: Bucket[] = [
{ key: "compensation_197", label: "פיצויים (ס׳ 197)", color: "var(--color-warn)" }, { key: "compensation_197", label: "פיצויים (ס׳ 197)", color: "var(--color-warn)" },
]; ];
/* For chart aggregation, collapse בל"מ variants back to their parent
* domain — building_permit / betterment_levy / compensation_197. The
* dedicated בל"מ filter in the cases table handles the cross-cutting view. */
function collapseBlam(s: AppealSubtype): AppealSubtype {
if (s === "extension_request_building_permit") return "building_permit";
if (s === "extension_request_betterment_levy") return "betterment_levy";
if (s === "extension_request_compensation") return "compensation_197";
return s;
}
export function subtypeOf(c: Case): AppealSubtype { export function subtypeOf(c: Case): AppealSubtype {
return c.appeal_subtype && c.appeal_subtype !== "unknown" const raw = c.appeal_subtype && c.appeal_subtype !== "unknown"
? c.appeal_subtype ? c.appeal_subtype
: deriveSubtype(c.case_number); : deriveSubtype(c.case_number);
return collapseBlam(raw);
} }
export function AppealTypeBars({ cases }: { cases?: Case[] }) { export function AppealTypeBars({ cases }: { cases?: Case[] }) {
/* All seven subtypes initialized to 0 — subtypeOf() collapses בל"מ
* variants back to their parent domain, so the extension_request_*
* counters will remain 0 in practice; they exist here to satisfy the
* Record<AppealSubtype, number> type. */
const counts: Record<AppealSubtype, number> = { const counts: Record<AppealSubtype, number> = {
building_permit: 0, building_permit: 0,
betterment_levy: 0, betterment_levy: 0,
compensation_197: 0, compensation_197: 0,
extension_request_building_permit: 0,
extension_request_betterment_levy: 0,
extension_request_compensation: 0,
unknown: 0, unknown: 0,
}; };
(cases ?? []).forEach((c) => { (cases ?? []).forEach((c) => {

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,18 @@ 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, proceedingTypes,
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 +46,11 @@ 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 ?? "",
proceeding_type: data.proceeding_type ?? "ערר",
}, },
}); });
@@ -54,6 +63,11 @@ 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 ?? "",
proceeding_type: data.proceeding_type ?? "ערר",
}); });
}, [open, data, form]); }, [open, data, form]);
@@ -74,11 +88,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 +109,86 @@ export function CaseEditDialog({ data }: { data: CaseDetail }) {
<FieldError message={form.formState.errors.subject?.message} /> <FieldError message={form.formState.errors.subject?.message} />
</div> </div>
<div>
<Label className="text-navy">סוג תיק</Label>
<Controller
control={form.control}
name="proceeding_type"
render={({ field }) => (
<Select
value={field.value ?? "ערר"}
onValueChange={(v) =>
field.onChange(v as CaseUpdateInput["proceeding_type"])
}
dir="rtl"
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{proceedingTypes.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<p className="text-[0.7rem] text-ink-muted mt-1">
ערר = הליך עיקרי; בל&quot;מ = בקשה להארכת מועד להגשת ערר
</p>
</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

@@ -8,6 +8,7 @@ import { CreateRepoButton } from "@/components/cases/create-repo-button";
import { import {
PRACTICE_AREA_LABELS, PRACTICE_AREA_LABELS,
APPEAL_SUBTYPE_LABELS, APPEAL_SUBTYPE_LABELS,
isBlamSubtype,
} from "@/lib/practice-area"; } from "@/lib/practice-area";
import type { CaseDetail } from "@/lib/api/cases"; import type { CaseDetail } from "@/lib/api/cases";
@@ -40,7 +41,7 @@ export function CaseHeader({ data }: { data?: CaseDetail }) {
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<span className="font-display text-[2rem] font-black text-navy leading-none tabular-nums"> <span className="font-display text-[2rem] font-black text-navy leading-none tabular-nums">
ערר {data?.case_number ?? "—"} {data?.proceeding_type ?? "ערר"} {data?.case_number ?? "—"}
</span> </span>
{data?.status && <StatusBadge status={data.status} />} {data?.status && <StatusBadge status={data.status} />}
{data?.archived_at && ( {data?.archived_at && (
@@ -62,6 +63,15 @@ export function CaseHeader({ data }: { data?: CaseDetail }) {
)} )}
</Badge> </Badge>
)} )}
{(data?.proceeding_type === 'בל"מ' || isBlamSubtype(data?.appeal_subtype)) && (
<Badge
variant="outline"
className="rounded-full px-2.5 py-0.5 text-[0.72rem] font-bold bg-warn/10 text-warn-deep border-warn/40"
title="בקשה להארכת מועד להגשת ערר"
>
בל&quot;מ
</Badge>
)}
{data?.case_number && ( {data?.case_number && (
<CaseArchiveAction <CaseArchiveAction
caseNumber={data.case_number} caseNumber={data.case_number}

View File

@@ -16,7 +16,12 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { StatusBadge } from "@/components/cases/status-badge"; import { StatusBadge } from "@/components/cases/status-badge";
import { isBlamSubtype } from "@/lib/practice-area";
import type { Case } from "@/lib/api/cases"; import type { Case } from "@/lib/api/cases";
function formatDate(iso?: string) { function formatDate(iso?: string) {
@@ -49,8 +54,17 @@ const columns: ColumnDef<Case>[] = [
accessorKey: "title", accessorKey: "title",
header: "כותרת", header: "כותרת",
cell: ({ row }) => ( cell: ({ row }) => (
<div className="text-ink max-w-[420px] truncate" title={row.original.title}> <div className="text-ink max-w-[420px] truncate flex items-center gap-2" title={row.original.title}>
{row.original.title} {(row.original.proceeding_type === 'בל"מ' || isBlamSubtype(row.original.appeal_subtype)) && (
<Badge
variant="outline"
className="rounded-full px-1.5 py-0 text-[0.65rem] font-bold bg-warn/10 text-warn-deep border-warn/40 shrink-0"
title="בקשה להארכת מועד להגשת ערר"
>
בל&quot;מ
</Badge>
)}
<span className="truncate">{row.original.title}</span>
</div> </div>
), ),
}, },
@@ -94,8 +108,17 @@ export function CasesTable({
{ id: "updated_at", desc: true }, { id: "updated_at", desc: true },
]); ]);
const [globalFilter, setGlobalFilter] = useState(""); const [globalFilter, setGlobalFilter] = useState("");
/* "all" = all cases; "blam" = only בל"מ; "regular" = exclude בל"מ */
const [blamFilter, setBlamFilter] = useState<"all" | "blam" | "regular">("all");
const data = useMemo(() => cases ?? [], [cases]); const data = useMemo(() => {
const all = cases ?? [];
const isBlam = (c: Case) =>
c.proceeding_type === 'בל"מ' || isBlamSubtype(c.appeal_subtype);
if (blamFilter === "blam") return all.filter(isBlam);
if (blamFilter === "regular") return all.filter((c) => !isBlam(c));
return all;
}, [cases, blamFilter]);
const table = useReactTable({ const table = useReactTable({
data, data,
@@ -126,6 +149,20 @@ export function CasesTable({
className="max-w-sm bg-surface" className="max-w-sm bg-surface"
dir="rtl" dir="rtl"
/> />
<Select
value={blamFilter}
onValueChange={(v) => setBlamFilter(v as "all" | "blam" | "regular")}
dir="rtl"
>
<SelectTrigger className="w-40 bg-surface">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">כל התיקים</SelectItem>
<SelectItem value="blam">בל&quot;מ בלבד</SelectItem>
<SelectItem value="regular">ערר רגיל בלבד</SelectItem>
</SelectContent>
</Select>
<span className="text-sm text-ink-muted me-auto"> <span className="text-sm text-ink-muted me-auto">
{table.getFilteredRowModel().rows.length} תיקים {table.getFilteredRowModel().rows.length} תיקים
</span> </span>

View File

@@ -269,6 +269,26 @@ function PostSaveView({
</div> </div>
)} )}
{extractResult?.status === "queued" && (
<div className="rounded-md border border-info/30 bg-info-bg px-2.5 py-2 text-[0.72rem] text-ink space-y-0.5">
<p>
<strong>נשלח לאנליטיקאי.</strong> ה-issue נפתח ב-Paperclip והחילוץ
ירוץ ברקע. תראה comment בעברית עם התוצאה כשהוא יסיים לרוב כמה
דקות.
</p>
</div>
)}
{extractResult?.status === "skipped" && (
<div className="rounded-md border border-warn/40 bg-warn-bg px-2.5 py-2 text-[0.72rem] text-ink space-y-0.5">
<p>
<strong>לא ניתן להפעיל אוטומטית</strong> ({extractResult.reason}).
הפעל ידנית מ-Claude Code:
<code className="ms-1 select-all">mcp__legal-ai__extract_appraiser_facts</code>
</p>
</div>
)}
{extractResult?.status === "no_appraisals" && ( {extractResult?.status === "no_appraisals" && (
<p className="text-[0.72rem] text-ink-muted"> <p className="text-[0.72rem] text-ink-muted">
אין בתיק מסמכים מתויגים כ-שומה. אין בתיק מסמכים מתויגים כ-שומה.
@@ -320,8 +340,8 @@ function PostSaveView({
{pending && ( {pending && (
<p className="text-[0.68rem] text-ink-muted leading-tight"> <p className="text-[0.68rem] text-ink-muted leading-tight">
החילוץ יכול להימשך כמה דקות שומות ארוכות עוברות ניתוח פסקה אחר שולח לאנליטיקאי דרך Paperclip לוקח שנייה. החילוץ עצמו ירוץ אצל
פסקה ע"י המודל. האנליטיקאי וייתן comment כשיסיים.
</p> </p>
)} )}
</div> </div>

View File

@@ -6,6 +6,7 @@ import { Progress } from "@/components/ui/progress";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogFooter, DialogFooter,
@@ -127,6 +128,7 @@ function DocumentPreviewDialog({
<DialogContent className="sm:max-w-2xl max-h-[80vh] flex flex-col" dir="rtl"> <DialogContent className="sm:max-w-2xl max-h-[80vh] flex flex-col" dir="rtl">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-right">{displayName}</DialogTitle> <DialogTitle className="text-right">{displayName}</DialogTitle>
<DialogDescription className="sr-only">תצוגה מקדימה של תוכן המסמך</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
{loading && ( {loading && (
@@ -184,6 +186,7 @@ function DeleteConfirmDialog({
<DialogContent dir="rtl"> <DialogContent dir="rtl">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-right">מחיקת מסמך</DialogTitle> <DialogTitle className="text-right">מחיקת מסמך</DialogTitle>
<DialogDescription className="sr-only">אישור מחיקת המסמך מהתיק</DialogDescription>
</DialogHeader> </DialogHeader>
<p className="text-sm text-ink-muted text-right"> <p className="text-sm text-ink-muted text-right">
האם למחוק את המסמך <strong>&ldquo;{displayName}&rdquo;</strong>? האם למחוק את המסמך <strong>&ldquo;{displayName}&rdquo;</strong>?

View File

@@ -8,6 +8,7 @@ import { Textarea } from "@/components/ui/textarea";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
@@ -323,6 +324,7 @@ export function DraftsPanel({
<DialogContent className="sm:max-w-sm" dir="rtl"> <DialogContent className="sm:max-w-sm" dir="rtl">
<DialogHeader> <DialogHeader>
<DialogTitle>מחיקת טיוטה</DialogTitle> <DialogTitle>מחיקת טיוטה</DialogTitle>
<DialogDescription className="sr-only">אישור מחיקת קובץ הטיוטה</DialogDescription>
</DialogHeader> </DialogHeader>
<p className="text-sm text-ink-muted"> <p className="text-sm text-ink-muted">
למחוק את הקובץ{" "} למחוק את הקובץ{" "}
@@ -493,6 +495,7 @@ function NewCaseFeedbackDialog({ caseNumber }: { caseNumber: string }) {
<DialogContent className="sm:max-w-lg" dir="rtl"> <DialogContent className="sm:max-w-lg" dir="rtl">
<DialogHeader> <DialogHeader>
<DialogTitle>הערת יו״ר תיק {caseNumber}</DialogTitle> <DialogTitle>הערת יו״ר תיק {caseNumber}</DialogTitle>
<DialogDescription className="sr-only">הוספת הערת יו״ר על בלוק בהחלטה</DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-2"> <form onSubmit={handleSubmit} className="space-y-4 mt-2">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">

View File

@@ -0,0 +1,222 @@
"use client";
import { useMemo } from "react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
PARTY_LABELS_HE,
PRIORITY_LABELS_HE,
PRIORITY_ORDER,
useAggregateArguments,
useLegalArguments,
type LegalArgument,
type LegalArgumentParty,
type LegalArgumentPriority,
} from "@/lib/api/legal-arguments";
import { toast } from "sonner";
import { Loader2, RefreshCw, Sparkles } from "lucide-react";
const PRIORITY_BADGE_TONE: Record<LegalArgumentPriority, string> = {
threshold: "bg-danger-bg/60 text-danger-strong border-danger/40",
substantive: "bg-gold-soft/50 text-navy border-gold/40",
procedural: "bg-rule-soft text-ink border-rule",
relief: "bg-emerald-50 text-emerald-900 border-emerald-200",
};
function groupByPriority(
args: LegalArgument[],
): Record<LegalArgumentPriority, LegalArgument[]> {
const out: Record<LegalArgumentPriority, LegalArgument[]> = {
threshold: [],
substantive: [],
procedural: [],
relief: [],
};
for (const a of args) {
(out[a.priority] ?? out.substantive).push(a);
}
for (const key of PRIORITY_ORDER) {
out[key].sort((x, y) => x.argument_index - y.argument_index);
}
return out;
}
type PartySectionProps = {
party: LegalArgumentParty;
args: LegalArgument[];
};
function PartySection({ party, args }: PartySectionProps) {
const grouped = useMemo(() => groupByPriority(args), [args]);
return (
<div className="space-y-3">
<div className="flex items-baseline justify-between border-b border-rule pb-2">
<h3 className="text-navy text-base font-semibold">
{PARTY_LABELS_HE[party] ?? party}
</h3>
<span className="text-ink-muted text-xs">
{args.length} טיעונים
</span>
</div>
{PRIORITY_ORDER.map((priority) => {
const list = grouped[priority];
if (!list?.length) return null;
return (
<div key={priority} className="space-y-1">
<div className="flex items-center gap-2">
<Badge
variant="outline"
className={`${PRIORITY_BADGE_TONE[priority]} text-xs`}
>
{PRIORITY_LABELS_HE[priority]}
</Badge>
<span className="text-ink-muted text-xs">
{list.length} טיעונים
</span>
</div>
<Accordion type="multiple" className="rounded-md border border-rule bg-surface">
{list.map((arg) => (
<AccordionItem key={arg.id} value={arg.id} className="px-3">
<AccordionTrigger className="text-start">
<div className="flex flex-1 flex-col items-start gap-1">
<span className="text-navy text-sm font-medium leading-tight">
{arg.argument_index}. {arg.argument_title}
</span>
{arg.legal_topic && (
<span className="text-ink-muted text-xs">
{arg.legal_topic}
</span>
)}
</div>
</AccordionTrigger>
<AccordionContent>
<div className="space-y-2 px-1">
<p className="text-ink leading-relaxed whitespace-pre-line">
{arg.argument_body}
</p>
{arg.supporting_claims.length > 0 && (
<p className="text-ink-muted text-xs">
מסתמך על {arg.supporting_claims.length} פרופוזיציות
גולמיות.
</p>
)}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
);
})}
</div>
);
}
type LegalArgumentsPanelProps = {
caseNumber: string;
};
export function LegalArgumentsPanel({ caseNumber }: LegalArgumentsPanelProps) {
const { data, isPending, isError, error } = useLegalArguments(caseNumber);
const aggregate = useAggregateArguments(caseNumber);
const parties = useMemo<LegalArgumentParty[]>(() => {
if (!data?.by_party) return [];
const order: LegalArgumentParty[] = [
"appellant",
"respondent",
"committee",
"permit_applicant",
"unknown",
];
return order.filter((p) => (data.by_party[p]?.length ?? 0) > 0);
}, [data]);
const handleAggregate = (force: boolean) => {
aggregate.mutate(force, {
onSuccess: () => {
toast.success(
force
? "הופעלה חזרה חישוב טיעונים (force). יסתיים תוך דקה."
: "הופעל חישוב טיעונים. רענן בעוד דקה.",
);
},
onError: (e) => toast.error(`שגיאה: ${(e as Error).message}`),
});
};
return (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5 space-y-4">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h2 className="text-navy text-base font-semibold">
טיעונים משפטיים
</h2>
<p className="text-ink-muted text-xs mt-0.5">
טיעונים מאוגדים מתוך הפרופוזיציות הגולמיות, מקובצים לפי צד וקדימות.
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={aggregate.isPending}
onClick={() => handleAggregate(false)}
>
{aggregate.isPending ? (
<Loader2 className="w-3.5 h-3.5 animate-spin me-1.5" />
) : (
<Sparkles className="w-3.5 h-3.5 me-1.5" />
)}
חשב טיעונים
</Button>
<Button
variant="ghost"
size="sm"
disabled={aggregate.isPending || !data?.total}
onClick={() => handleAggregate(true)}
title="חישוב מחדש (מוחק טיעונים קיימים)"
>
<RefreshCw className="w-3.5 h-3.5" />
</Button>
</div>
</div>
{isPending ? (
<div className="space-y-2">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
) : isError ? (
<p className="text-danger text-sm">
שגיאה בטעינת טיעונים: {(error as Error).message}
</p>
) : !data?.total ? (
<p className="text-ink-muted text-sm">
אין טיעונים מאוגדים עדיין. לחץ "חשב טיעונים" כדי להריץ את ה-aggregator.
</p>
) : (
<div className="space-y-6">
{parties.map((party) => (
<PartySection
key={party}
party={party}
args={data.by_party[party] ?? []}
/>
))}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,414 @@
"use client";
import { useEffect, useState } from "react";
import { Upload, Save, Loader2, CheckCircle2 } from "lucide-react";
import { toast } from "sonner";
import {
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
useMissingPrecedent,
useUpdateMissingPrecedent,
useUploadMissingPrecedent,
STATUS_LABELS,
type MissingPrecedentPatch,
} from "@/lib/api/missing-precedents";
import {
PRACTICE_AREAS, PRECEDENT_LEVELS, DISTRICTS,
} from "@/components/precedents/practice-area";
type Props = {
id: string | null;
onOpenChange: (open: boolean) => void;
};
const ACCEPT = ".pdf,.docx,.doc,.rtf,.txt,.md";
function isCommitteeCitation(citation: string): boolean {
const norm = citation.trim();
return /^(ערר[\s(]|בל"מ[\s(]|ARAR )/.test(norm);
}
export function MissingPrecedentDetailDrawer({ id, onOpenChange }: Props) {
const open = id !== null;
const { data: mp, isPending } = useMissingPrecedent(id);
const update = useUpdateMissingPrecedent();
const upload = useUploadMissingPrecedent();
// The only chair-editable field on the missing-precedent is `notes` —
// free-text. Everything else (citation, who-cited-whom, status) is set
// when the row was detected, and updates automatically when the file
// is uploaded. The metadata of the *uploaded* precedent (case_name,
// chair, district, …) is auto-extracted by the LLM and lives on the
// case_law row, not here.
const [notes, setNotes] = useState("");
// Upload form fields.
const [file, setFile] = useState<File | null>(null);
const [decisionDate, setDecisionDate] = useState("");
const [court, setCourt] = useState("");
const [practiceArea, setPracticeArea] = useState<string>("");
const [appealSubtype, setAppealSubtype] = useState("");
const [precedentLevel, setPrecedentLevel] = useState("");
const [chairName, setChairName] = useState("");
const [district, setDistrict] = useState("");
const [committeeCaseNumber, setCommitteeCaseNumber] = useState("");
const [summary, setSummary] = useState("");
// Sync form from record when it loads or id changes.
const [syncedId, setSyncedId] = useState<string | null>(null);
if (mp && mp.id !== syncedId) {
setSyncedId(mp.id);
setNotes(mp.notes ?? "");
}
// Reset on close. The cascading-render warning is the intended side
// effect here — wiping the form when the drawer closes.
useEffect(() => {
if (open) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setFile(null);
setSyncedId(null);
setDecisionDate(""); setCourt(""); setPracticeArea("");
setAppealSubtype(""); setPrecedentLevel(""); setChairName("");
setDistrict(""); setCommitteeCaseNumber(""); setSummary("");
}, [open]);
const handleSaveNotes = async () => {
if (!mp) return;
const patch: MissingPrecedentPatch = { notes };
try {
await update.mutateAsync({ id: mp.id, patch });
toast.success("הערות נשמרו");
} catch (e) {
toast.error("שמירה נכשלה");
console.error(e);
}
};
const isCommittee = mp ? isCommitteeCitation(mp.citation) : false;
const handleUpload = async (e: React.FormEvent) => {
e.preventDefault();
if (!mp || !file) {
toast.error("בחר קובץ");
return;
}
try {
await upload.mutateAsync({
id: mp.id,
file,
case_number: isCommittee ? committeeCaseNumber || undefined : undefined,
chair_name: isCommittee ? chairName || undefined : undefined,
district: isCommittee ? district || undefined : undefined,
court: court || undefined,
decision_date: decisionDate || undefined,
practice_area: practiceArea || undefined,
appeal_subtype: appealSubtype || undefined,
precedent_level: precedentLevel || undefined,
source_type: isCommittee ? "appeals_committee" : "court_ruling",
summary: summary || undefined,
});
toast.success(
"הקובץ הועלה. חילוץ המטא־דאטה (שם, ערכאה, תאריך, יו״ר, מחוז…) מתבצע ברקע ויסתיים בתוך כדקה.",
);
onOpenChange(false);
} catch (e: unknown) {
const msg =
e instanceof Error
? e.message
: typeof e === "string"
? e
: "כשל העלאה";
toast.error(msg);
console.error(e);
}
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="left"
className="w-full sm:max-w-2xl overflow-y-auto"
>
<SheetHeader className="space-y-1">
<SheetTitle className="text-navy">
פסיקה חסרה
{mp ? (
<Badge
variant="outline"
className="ms-2 align-middle"
>
{STATUS_LABELS[mp.status]}
</Badge>
) : null}
</SheetTitle>
<SheetDescription>
פרטים מלאים והעלאת הפסיקה לקורפוס.
</SheetDescription>
</SheetHeader>
{isPending || !mp ? (
<div className="space-y-3 px-6 py-4">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-4 w-1/2" />
</div>
) : (
<div className="space-y-6 px-6 py-4">
{/* ── Citation block (read-only) ── */}
<section className="space-y-2">
<div className="text-[0.78rem] text-ink-muted">מראה מקום</div>
<div className="text-sm text-navy font-medium bg-rule-soft/40 rounded-md px-3 py-2 leading-relaxed">
{mp.citation}
</div>
{mp.claim_quote ? (
<>
<div className="text-[0.78rem] text-ink-muted mt-3">ציטוט מכתב הטענות</div>
<div className="text-xs text-ink bg-gold-wash/30 border-s-2 border-gold rounded-md px-3 py-2 leading-relaxed">
{mp.claim_quote}
</div>
</>
) : null}
</section>
{/* ── Linked record (if closed) ── */}
{mp.linked_case_law_id ? (
<section className="space-y-1 bg-emerald-50 border border-emerald-200 rounded-lg p-3">
<div className="flex items-center gap-2 text-emerald-800 font-medium text-sm">
<CheckCircle2 className="w-4 h-4" />
מקושר ל
</div>
<div className="text-sm text-emerald-900 truncate">
{mp.linked_case_law_name || "—"}
</div>
<div className="text-[0.72rem] text-emerald-700 truncate">
{mp.linked_case_law_number}
</div>
</section>
) : null}
{/* ── Notes (only chair-editable field; everything else is
auto-detected or auto-extracted from the file). ── */}
<section className="space-y-2">
<Label htmlFor="notes" className="text-sm font-semibold text-navy">
הערות
</Label>
<p className="text-[0.72rem] text-ink-muted leading-relaxed">
שדה חופשי לדוגמה: מי מצטט (הוועדה / העורר / המשיב) ובאיזה הקשר.
שאר השדות (שם, ערכאה, יו״ר, מחוז, תאריך, תת־סוג, תקציר) יחולצו
אוטומטית מהקובץ בעת ההעלאה.
</p>
<Textarea
id="notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
dir="rtl"
/>
<Button
onClick={handleSaveNotes}
disabled={update.isPending}
variant="outline"
size="sm"
className="border-rule"
>
{update.isPending ? (
<Loader2 className="w-4 h-4 me-1 animate-spin" />
) : (
<Save className="w-4 h-4 me-1" />
)}
שמור הערות
</Button>
</section>
{/* ── Upload section ── */}
{!mp.linked_case_law_id ? (
<section className="space-y-3 border-t border-rule pt-5">
<h3 className="text-sm font-semibold text-navy">
העלאת הפסיקה לקורפוס
</h3>
<div className="text-[0.78rem] text-ink-muted leading-relaxed">
ניתוב אוטומטי לפי הציטוט:&nbsp;
<strong className="text-navy">
{isCommittee ? "החלטת ועדת ערר (internal)" : "פסק דין (library)"}
</strong>
<br />
שדות נוספים (שם, ערכאה, תאריך, יו״ר, מחוז, תת־סוג) יחולצו אוטומטית
מהקובץ ברקע.
</div>
<form onSubmit={handleUpload} className="space-y-3">
<div>
<Label htmlFor="file">קובץ (PDF / DOCX / RTF / TXT / MD)</Label>
<Input
id="file"
type="file"
accept={ACCEPT}
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
required
/>
</div>
<details className="group rounded-md border border-rule bg-rule-soft/30">
<summary className="cursor-pointer select-none px-3 py-2 text-[0.78rem] text-ink-muted hover:text-navy">
אופציונלי דריסה ידנית של שדות שיחולצו אוטומטית
</summary>
<div className="space-y-3 border-t border-rule px-3 py-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="court">ערכאה</Label>
<Input
id="court"
value={court}
onChange={(e) => setCourt(e.target.value)}
placeholder="בית המשפט העליון"
dir="rtl"
/>
</div>
<div>
<Label htmlFor="decision_date">תאריך</Label>
<Input
id="decision_date"
type="date"
value={decisionDate}
onChange={(e) => setDecisionDate(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="practice_area">תחום</Label>
<Select value={practiceArea} onValueChange={setPracticeArea}>
<SelectTrigger>
<SelectValue placeholder="ללא" />
</SelectTrigger>
<SelectContent>
{PRACTICE_AREAS.map((a) => (
<SelectItem key={a.value} value={a.value}>
{a.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="appeal_subtype">תת־סוג</Label>
<Input
id="appeal_subtype"
value={appealSubtype}
onChange={(e) => setAppealSubtype(e.target.value)}
placeholder="זכות עמידה"
dir="rtl"
/>
</div>
</div>
{isCommittee ? (
<>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="chair_name">יו״ר</Label>
<Input
id="chair_name"
value={chairName}
onChange={(e) => setChairName(e.target.value)}
placeholder="דפנה תמיר"
dir="rtl"
/>
</div>
<div>
<Label htmlFor="district">מחוז</Label>
<Select value={district} onValueChange={setDistrict}>
<SelectTrigger>
<SelectValue placeholder="בחר" />
</SelectTrigger>
<SelectContent>
{DISTRICTS.map((d) => (
<SelectItem key={d.value} value={d.value}>
{d.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label htmlFor="committee_case_number">
מספר ערר (לציטוט הקטן)
</Label>
<Input
id="committee_case_number"
value={committeeCaseNumber}
onChange={(e) => setCommitteeCaseNumber(e.target.value)}
placeholder="ערר 1112/22 ..."
dir="rtl"
/>
</div>
</>
) : (
<div>
<Label htmlFor="precedent_level">רמת תקדים</Label>
<Select
value={precedentLevel}
onValueChange={setPrecedentLevel}
>
<SelectTrigger>
<SelectValue placeholder="ללא" />
</SelectTrigger>
<SelectContent>
{PRECEDENT_LEVELS.map((l) => (
<SelectItem key={l.value} value={l.value}>
{l.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div>
<Label htmlFor="summary">תקציר</Label>
<Textarea
id="summary"
value={summary}
onChange={(e) => setSummary(e.target.value)}
rows={2}
dir="rtl"
/>
</div>
</div>
</details>
<Button
type="submit"
disabled={!file || upload.isPending}
className="bg-navy text-parchment hover:bg-navy-soft"
>
{upload.isPending ? (
<Loader2 className="w-4 h-4 me-1 animate-spin" />
) : (
<Upload className="w-4 h-4 me-1" />
)}
העלאה וסגירה
</Button>
</form>
</section>
) : null}
</div>
)}
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,223 @@
"use client";
import { useState } from "react";
import { Trash2, Upload, Pencil, ExternalLink } from "lucide-react";
import { toast } from "sonner";
import Link from "next/link";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
useMissingPrecedents,
useDeleteMissingPrecedent,
CITED_BY_PARTY_LABELS,
STATUS_LABELS,
type MissingPrecedent,
type MissingPrecedentStatus,
} from "@/lib/api/missing-precedents";
import { MissingPrecedentDetailDrawer } from "./missing-precedent-detail-drawer";
function formatDate(iso: string | null) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString("he-IL");
} catch {
return iso;
}
}
function StatusBadge({ status }: { status: MissingPrecedentStatus }) {
const variants: Record<MissingPrecedentStatus, string> = {
open: "bg-gold-wash text-gold-deep border-gold/40",
uploaded: "bg-rule-soft text-ink-muted border-rule",
closed: "bg-emerald-50 text-emerald-800 border-emerald-300/60",
irrelevant: "bg-rule-soft text-ink-muted border-rule line-through",
};
return (
<Badge variant="outline" className={variants[status]}>
{STATUS_LABELS[status]}
</Badge>
);
}
function TableSkeleton({ cols }: { cols: number }) {
return (
<>
{Array.from({ length: 4 }).map((_, i) => (
<TableRow key={i} className="border-rule">
{Array.from({ length: cols }).map((__, j) => (
<TableCell key={j}>
<Skeleton className="h-4 w-full" />
</TableCell>
))}
</TableRow>
))}
</>
);
}
type Props = {
status?: MissingPrecedentStatus | "";
caseNumber?: string;
legalTopic?: string;
};
export function MissingPrecedentsTable({ status, caseNumber, legalTopic }: Props) {
const [openId, setOpenId] = useState<string | null>(null);
const { data, isPending, error } = useMissingPrecedents({
status: status === "" ? undefined : status,
caseNumber,
legalTopic,
limit: 200,
});
const del = useDeleteMissingPrecedent();
const handleDelete = async (mp: MissingPrecedent) => {
if (!confirm(`למחוק את הרשומה? ${mp.case_name || mp.citation.slice(0, 60)}...`)) {
return;
}
try {
await del.mutateAsync(mp.id);
toast.success("הרשומה נמחקה");
} catch (e) {
toast.error("מחיקה נכשלה");
console.error(e);
}
};
if (error) {
return (
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-4 text-danger text-center text-sm">
{error.message}
</div>
);
}
return (
<>
<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" />
</TableRow>
</TableHeader>
<TableBody>
{isPending ? (
<TableSkeleton cols={7} />
) : !data?.items.length ? (
<TableRow className="border-rule">
<TableCell colSpan={7} className="text-center text-ink-muted py-8">
אין פסיקות חסרות בקריטריונים הנוכחיים.
</TableCell>
</TableRow>
) : (
data.items.map((mp) => (
<TableRow
key={mp.id}
className="border-rule hover:bg-rule-soft/30 cursor-pointer"
onClick={() => setOpenId(mp.id)}
>
<TableCell className="max-w-[440px]">
<div className="text-sm text-navy font-medium truncate">
{mp.case_name || mp.citation.split(" ").slice(0, 6).join(" ")}
</div>
<div className="text-[0.72rem] text-ink-muted truncate" dir="rtl">
{mp.citation}
</div>
</TableCell>
<TableCell>
<span className="text-sm text-ink">{mp.legal_topic || "—"}</span>
</TableCell>
<TableCell>
{mp.cited_in_case_number ? (
<Link
href={`/cases/${encodeURIComponent(mp.cited_in_case_number)}`}
onClick={(e) => e.stopPropagation()}
className="text-sm text-navy hover:text-gold-deep inline-flex items-center gap-1"
>
{mp.cited_in_case_number}
<ExternalLink className="w-3 h-3" />
</Link>
) : (
<span className="text-ink-muted text-sm"></span>
)}
</TableCell>
<TableCell className="text-sm text-ink">
{mp.cited_by_party
? CITED_BY_PARTY_LABELS[mp.cited_by_party]
: "—"}
{mp.cited_by_party_name ? (
<div className="text-[0.7rem] text-ink-muted truncate max-w-[160px]">
{mp.cited_by_party_name}
</div>
) : null}
</TableCell>
<TableCell>
<StatusBadge status={mp.status} />
{mp.linked_case_law_number ? (
<div className="text-[0.7rem] text-emerald-700 mt-1">
{mp.linked_case_law_name || mp.linked_case_law_number}
</div>
) : null}
</TableCell>
<TableCell className="text-[0.78rem] text-ink-muted">
{formatDate(mp.created_at)}
</TableCell>
<TableCell className="text-end">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
setOpenId(mp.id);
}}
title={mp.status === "open" ? "העלאה" : "פרטים"}
>
{mp.status === "open" ? (
<Upload className="w-4 h-4" />
) : (
<Pencil className="w-4 h-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleDelete(mp);
}}
disabled={del.isPending}
className="text-danger hover:text-danger"
title="מחיקה"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<MissingPrecedentDetailDrawer
id={openId}
onOpenChange={(open) => {
if (!open) setOpenId(null);
}}
/>
</>
);
}

View File

@@ -0,0 +1,116 @@
"use client";
// Rendering helpers for the formal Israeli citation ("כללי הציטוט האחיד").
//
// Backend stores the citation as Markdown: parties' names wrapped in
// **double asterisks**, everything else regular. These helpers:
// 1. Render the citation with <strong> for the bold ranges.
// 2. Copy it to the clipboard as BOTH text/html (so Word/Docs paste
// with bold preserved) and text/plain (which keeps the markers
// so the markdown survives a plain-text paste).
import { useState } from "react";
import { Check, Copy } from "lucide-react";
import { toast } from "sonner";
function parseSegments(md: string): Array<{ bold: boolean; text: string }> {
const out: Array<{ bold: boolean; text: string }> = [];
const re = /\*\*([^*]+)\*\*/g;
let last = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(md)) !== null) {
if (m.index > last) out.push({ bold: false, text: md.slice(last, m.index) });
out.push({ bold: true, text: m[1] });
last = re.lastIndex;
}
if (last < md.length) out.push({ bold: false, text: md.slice(last) });
return out;
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
export function FormattedCitation({
citation,
className,
}: {
citation: string;
className?: string;
}) {
const segments = parseSegments(citation);
return (
<span className={className} dir="rtl">
{segments.map((s, i) =>
s.bold ? (
<strong key={i} className="font-semibold">
{s.text}
</strong>
) : (
<span key={i}>{s.text}</span>
),
)}
</span>
);
}
export function CitationCopyButton({
citation,
size = "sm",
}: {
citation: string;
size?: "sm" | "xs";
}) {
const [copied, setCopied] = useState(false);
async function handleCopy() {
const segments = parseSegments(citation);
const html = segments
.map((s) =>
s.bold
? `<strong>${escapeHtml(s.text)}</strong>`
: escapeHtml(s.text),
)
.join("");
const wrappedHtml = `<span dir="rtl">${html}</span>`;
try {
const cb = navigator.clipboard;
if (typeof ClipboardItem !== "undefined" && cb && "write" in cb) {
const item = new ClipboardItem({
"text/html": new Blob([wrappedHtml], { type: "text/html" }),
"text/plain": new Blob([citation], { type: "text/plain" }),
});
await cb.write([item]);
} else {
await cb.writeText(citation);
}
setCopied(true);
toast.success("המראה מקום הועתק (עם הדגשה לצדדים)");
window.setTimeout(() => setCopied(false), 1800);
} catch (err) {
console.error("citation copy failed", err);
toast.error("העתקה נכשלה");
}
}
const dims = size === "xs" ? "h-7 w-7" : "h-8 w-8";
return (
<button
type="button"
onClick={handleCopy}
title={copied ? "הועתק" : "העתק לפי כללי הציטוט (עם הדגשה לצדדים)"}
aria-label={copied ? "הועתק" : "העתק מראה מקום"}
className={`inline-flex items-center justify-center rounded-md border border-rule bg-surface hover:bg-rule-soft/50 text-ink-muted hover:text-navy ${dims}`}
>
{copied ? (
<Check className="w-3.5 h-3.5 text-emerald-600" />
) : (
<Copy className="w-3.5 h-3.5" />
)}
</button>
);
}

View File

@@ -25,6 +25,10 @@ import {
import { PRACTICE_AREAS, 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";
import {
FormattedCitation,
CitationCopyButton,
} from "./formatted-citation";
function formatDate(iso: string | null) { function formatDate(iso: string | null) {
if (!iso) return "—"; if (!iso) return "—";
@@ -152,11 +156,28 @@ function CourtRow({ p, onEdit }: { p: Precedent; onEdit: (id: string) => void })
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"
> >
<Link href={`/precedents/${p.id}`} className="hover:underline hover:text-gold-deep" dir="auto"> <div className="flex items-start justify-between gap-2">
{cleanCitation(p.case_number)} <Link
</Link> href={`/precedents/${p.id}`}
className="hover:underline hover:text-gold-deep block min-w-0"
dir="auto"
>
{p.citation_formatted ? (
<FormattedCitation
citation={p.citation_formatted}
className="block leading-snug"
/>
) : (
cleanCitation(p.case_number)
)}
</Link>
{p.citation_formatted ? (
<CitationCopyButton citation={p.citation_formatted} size="xs" />
) : null}
</div>
</TableCell> </TableCell>
<TableCell className="text-ink whitespace-normal break-words max-w-[260px] py-3"> {/* Column "שם / ערכאה" hidden by request (case_name often equals case_number prefix). Keep field in DB; restore by un-hiding. */}
<TableCell className="hidden 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 ? <div className="text-[0.72rem] text-ink-muted">{p.court}</div> : null} {p.court ? <div className="text-[0.72rem] text-ink-muted">{p.court}</div> : null}
</TableCell> </TableCell>
@@ -236,11 +257,28 @@ function CommitteeRow({ p, onEdit }: { p: Precedent; onEdit: (id: string) => voi
className="font-semibold text-navy text-right whitespace-normal break-words min-w-[200px] max-w-[320px] py-3" className="font-semibold text-navy text-right whitespace-normal break-words min-w-[200px] max-w-[320px] py-3"
dir="rtl" dir="rtl"
> >
<Link href={`/precedents/${p.id}`} className="hover:underline hover:text-gold-deep" dir="auto"> <div className="flex items-start justify-between gap-2">
{cleanCitation(p.case_number)} <Link
</Link> href={`/precedents/${p.id}`}
className="hover:underline hover:text-gold-deep block min-w-0"
dir="auto"
>
{p.citation_formatted ? (
<FormattedCitation
citation={p.citation_formatted}
className="block leading-snug"
/>
) : (
cleanCitation(p.case_number)
)}
</Link>
{p.citation_formatted ? (
<CitationCopyButton citation={p.citation_formatted} size="xs" />
) : null}
</div>
</TableCell> </TableCell>
<TableCell className="text-ink whitespace-normal break-words max-w-[220px] py-3"> {/* Column "שם" hidden by request (case_name often equals case_number prefix). Keep field in DB; restore by un-hiding. */}
<TableCell className="hidden text-ink whitespace-normal break-words max-w-[220px] py-3">
<div className="font-medium">{cleanCitation(p.case_name)}</div> <div className="font-medium">{cleanCitation(p.case_name)}</div>
</TableCell> </TableCell>
<TableCell className="text-ink-muted text-[0.78rem]"> <TableCell className="text-ink-muted text-[0.78rem]">
@@ -367,7 +405,8 @@ export function LibraryListPanel() {
<TableHeader className="bg-rule-soft/60"> <TableHeader className="bg-rule-soft/60">
<TableRow className="border-rule"> <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> {/* "שם / ערכאה" hidden by request — see CourtRow */}
<TableHead className="hidden 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>
@@ -415,7 +454,8 @@ export function LibraryListPanel() {
<TableHeader className="bg-rule-soft/60"> <TableHeader className="bg-rule-soft/60">
<TableRow className="border-rule"> <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> {/* "שם" hidden by request — see CommitteeRow */}
<TableHead className="hidden 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>

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