feat(mcp): FU-14 פרוסה 1 — get_appraiser_facts (GAP-44) + limit-caps (GAP-53)

תוספתי בלבד, אפס שבירת-תאימות. שני invariants מחוזה-כלי-ה-MCP (X9):

GAP-44 (INV-TOOL4, סימטריית extract/get): נוסף get_appraiser_facts — ה-get
המקביל ל-extract_appraiser_facts. קורא list_appraiser_facts + detect_appraiser_conflicts
מה-DB ללא חילוץ-LLM יקר ולא-דטרמיניסטי. מחזיר count=0 (לא שגיאה) אם טרם חולץ.

GAP-53 (INV-TOOL5, limit-caps / OWASP API4:2023): נוסף _clamp_limit (תקרה 200,
non-positive→max) על ~13 כלי list/search ב-server.py (case_list, search_*,
precedent_library_list, halachot_pending, missing_precedent_list, list_*_citations…).
list_chair_feedback קיבל param limit חדש (server→workflow→db עם LIMIT) — היה ללא תקרה כלל.

לא הוסף get_appraiser_facts ל-frontmatter של סוכנים (INV-AG3 "לא עודף" — ההוראות
עוד לא מפנות אליו; חיווט = follow-up). נותר ב-FU-14: GAP-45/48/49/50/51/52.

עודכנו docs/spec/X9 (INV-TOOL4/5) ו-gap-audit (סטטוס פרוסה 1).

אומת: py_compile על 4 קבצי הקוד. אימות runtime (restart MCP server) נדחה עד
שהחילוץ הפעיל של היו"ר יסתיים.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 14:37:30 +00:00
parent 67a3d9a9b0
commit ebfe7f6a1d
6 changed files with 78 additions and 20 deletions

View File

@@ -63,15 +63,15 @@ Kleppmann *DDIA* (idempotence) · IETF — *Idempotency-Key header* draft (https
**כלל:** לכל כלי-חילוץ שכותב ל-DB יש **כלי-קריאה (get) מקביל**, והפלט **נשמר durably** (לא מוחזר-ונאבד). **כלל:** לכל כלי-חילוץ שכותב ל-DB יש **כלי-קריאה (get) מקביל**, והפלט **נשמר durably** (לא מוחזר-ונאבד).
מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) (מקור-אמת נגיש). **פרויקטלי-תפעולי.** מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) (מקור-אמת נגיש). **פרויקטלי-תפעולי.**
**מקור-סמכות:** דפוס `extract_claims``get_claims`, `aggregate``get_legal_arguments` ב-[server.py](../../mcp-server/src/legal_mcp/server.py). **מקור-סמכות:** דפוס `extract_claims``get_claims`, `aggregate``get_legal_arguments` ב-[server.py](../../mcp-server/src/legal_mcp/server.py).
**אכיפה:** לכל extract — get מקביל. **כיום מופר:** `extract_appraiser_facts` כותב, **אין `get_appraiser_facts`** → חילוץ-חוזר יקר ולא-דטרמיניסטי. **אכיפה:** לכל extract — get מקביל. **GAP-44 ✅ נסגר (2026-06-06):** נוסף `get_appraiser_facts` (קורא `list_appraiser_facts`+`detect_appraiser_conflicts`, ללא חילוץ-מחדש). נותר: תור-חילוץ סמוי (GAP-45).
**הפרה ידועה:** [gap-audit GAP-44](gap-audit.md); תור-חילוץ סמוי ([gap-audit GAP-45](gap-audit.md)). **הפרה ידועה:** תור-חילוץ סמוי ([gap-audit GAP-45](gap-audit.md)).
### INV-TOOL5: limit-caps על כל כלי-רשימה/חיפוש ### INV-TOOL5: limit-caps על כל כלי-רשימה/חיפוש
**כלל:** לכל כלי שמחזיר רשימה יש **תקרת-limit נאכפת** (הגנה מפני עומס/DoS); pagination היכן שרלוונטי. **הנדסי.** **כלל:** לכל כלי שמחזיר רשימה יש **תקרת-limit נאכפת** (הגנה מפני עומס/DoS); pagination היכן שרלוונטי. **הנדסי.**
**מקורות:** OWASP API Security Top 10 — *API4:2023 Unrestricted Resource Consumption* (https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/) · **מקורות:** OWASP API Security Top 10 — *API4:2023 Unrestricted Resource Consumption* (https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/) ·
Microsoft *REST API Guidelines* (pagination) · Stripe API (limit caps) | סטטוס: verified Microsoft *REST API Guidelines* (pagination) · Stripe API (limit caps) | סטטוס: verified
**אכיפה:** clamp ל-max בכל כלי-רשימה. **כיום אין** `precedent_library_list`/`search_*`/`missing_precedent_list` ללא תקרה; `list_chair_feedback` ללא limit כלל ([gap-audit GAP-53](gap-audit.md)). **אכיפה:** clamp ל-max בכל כלי-רשימה. **GAP-53 ✅ נסגר (2026-06-06):** `_clamp_limit` (תקרה 200) על ~13 כלי list/search ב-[server.py](../../mcp-server/src/legal_mcp/server.py); `list_chair_feedback` קיבל param `limit` (server→workflow→db עם `LIMIT`).
**הפרה ידועה:** [gap-audit GAP-53](gap-audit.md). **הפרה ידועה:**
### INV-TOOL6: שלמות-הרשאות — כל כלי שהוראות-הסוכן דורשות מוענק ### INV-TOOL6: שלמות-הרשאות — כל כלי שהוראות-הסוכן דורשות מוענק
**כלל:** מפת-ההרשאות (אילו כלים מוענקים לכל סוכן) **תואמת** את מה שהוראות-הסוכן מצריכות — לא חסר ולא עודף. **כלל:** מפת-ההרשאות (אילו כלים מוענקים לכל סוכן) **תואמת** את מה שהוראות-הסוכן מצריכות — לא חסר ולא עודף.

View File

@@ -198,6 +198,7 @@
### FU-14 — חוזה כלי-ה-MCP ### FU-14 — חוזה כלי-ה-MCP
- **מכסה:** GAP-44,45,47..54 · **invariants:** INV-TOOL1TOOL5 · **effort:** L · **תלויות:** FU-1 - **מכסה:** GAP-44,45,47..54 · **invariants:** INV-TOOL1TOOL5 · **effort:** L · **תלויות:** FU-1
- **סוג:** code — envelope אחיד, מיזוג חיפוש/בלוקים, idempotency, limit-caps, get-symmetry, set_outcome SSoT - **סוג:** code — envelope אחיד, מיזוג חיפוש/בלוקים, idempotency, limit-caps, get-symmetry, set_outcome SSoT
- **סטטוס חלקי (פרוסה 1, 2026-06-06):** ✅ **GAP-44** — נוסף `get_appraiser_facts` (ה-get המקביל ל-extract, INV-TOOL4); ✅ **GAP-53** — נוסף `_clamp_limit` (תקרה 200, INV-TOOL5) על ~13 כלי list/search + הוספת limit ל-`list_chair_feedback` (שהיה ללא תקרה). נותר: GAP-45 (status-tool), GAP-48 (envelope), GAP-49/50 (מיזוג+rename — שובר), GAP-51 (set_outcome enum SSoT), GAP-52 (idempotency).
### FU-15 — deploy/env/secrets ### FU-15 — deploy/env/secrets
- **מכסה:** GAP-55..62 · **invariants:** INV-ENV1ENV5 · **effort:** M · **תלויות:** - **מכסה:** GAP-55..62 · **invariants:** INV-ENV1ENV5 · **effort:** M · **תלויות:**

View File

@@ -84,10 +84,24 @@ async def case_create(
) )
# INV-TOOL5 / GAP-53: hard cap on list/search result sizes (OWASP API4:2023 —
# Unrestricted Resource Consumption). Non-positive is treated as "max", not "all".
_MAX_LIMIT = 200
def _clamp_limit(limit: int, hard_max: int = _MAX_LIMIT) -> int:
"""Clamp a caller-supplied result limit to [1, hard_max]."""
try:
n = int(limit)
except (TypeError, ValueError):
return hard_max
return hard_max if n <= 0 else min(n, hard_max)
@mcp.tool() @mcp.tool()
async def case_list(status: str = "", limit: int = 50) -> str: async def case_list(status: str = "", limit: int = 50) -> str:
"""רשימת תיקי ערר. סינון אופציונלי לפי סטטוס (new/in_progress/drafted/reviewed/final).""" """רשימת תיקי ערר. סינון אופציונלי לפי סטטוס (new/in_progress/drafted/reviewed/final)."""
return await cases.case_list(status, limit) return await cases.case_list(status, _clamp_limit(limit))
@mcp.tool() @mcp.tool()
@@ -162,7 +176,7 @@ async def precedent_search_library(
) -> str: ) -> str:
"""חיפוש בציטוטים שדפנה צירפה ידנית לתיקים בעבר (case_precedents). """חיפוש בציטוטים שדפנה צירפה ידנית לתיקים בעבר (case_precedents).
שונה מ-search_precedent_library שמחפש בקורפוס הפסיקה הסמכותית.""" שונה מ-search_precedent_library שמחפש בקורפוס הפסיקה הסמכותית."""
return await precedents.precedent_search_library(query, practice_area, limit) return await precedents.precedent_search_library(query, practice_area, _clamp_limit(limit))
# ── External Precedent Library — authoritative case-law corpus ───── # ── External Precedent Library — authoritative case-law corpus ─────
@@ -214,7 +228,7 @@ async def precedent_library_list(
""" """
return await plib.precedent_library_list( return await plib.precedent_library_list(
practice_area, court, precedent_level, source_type, search, practice_area, court, precedent_level, source_type, search,
source_kind, limit, source_kind, _clamp_limit(limit),
) )
@@ -273,7 +287,7 @@ async def style_corpus_enrich(corpus_id: str, overwrite: bool = False) -> str:
@mcp.tool() @mcp.tool()
async def style_corpus_pending_enrichment(limit: int = 50) -> str: async def style_corpus_pending_enrichment(limit: int = 50) -> str:
"""רשימת החלטות בקורפוס הסגנון שעדיין חסרות summary/outcome/key_principles — מועמדות לחילוץ.""" """רשימת החלטות בקורפוס הסגנון שעדיין חסרות summary/outcome/key_principles — מועמדות לחילוץ."""
return await train_tools.list_corpus_pending_enrichment(limit) return await train_tools.list_corpus_pending_enrichment(_clamp_limit(limit))
@mcp.tool() @mcp.tool()
@@ -296,7 +310,7 @@ async def search_precedent_library(
"""חיפוש סמנטי בקורפוס הפסיקה הסמכותית. מחזיר הלכות (מאושרות בלבד) + קטעי טקסט. השתמש כש-legal-writer צריך לצטט פסיקה מחייבת בבלוק י (CREAC: rule + explanation).""" """חיפוש סמנטי בקורפוס הפסיקה הסמכותית. מחזיר הלכות (מאושרות בלבד) + קטעי טקסט. השתמש כש-legal-writer צריך לצטט פסיקה מחייבת בבלוק י (CREAC: rule + explanation)."""
return await plib.search_precedent_library( return await plib.search_precedent_library(
query, practice_area, court, precedent_level, appeal_subtype, query, practice_area, court, precedent_level, appeal_subtype,
None, subject_tag, limit, include_halachot, None, subject_tag, _clamp_limit(limit), include_halachot,
) )
@@ -320,7 +334,7 @@ async def halacha_review(
@mcp.tool() @mcp.tool()
async def halachot_pending(limit: int = 100) -> str: async def halachot_pending(limit: int = 100) -> str:
"""תור ההלכות הממתינות לאישור.""" """תור ההלכות הממתינות לאישור."""
return await plib.halachot_pending(limit) return await plib.halachot_pending(_clamp_limit(limit))
# Documents # Documents
@@ -439,7 +453,7 @@ async def search_decisions(
) -> str: ) -> str:
"""חיפוש סמנטי בהחלטות קודמות ובמסמכים — מסונן לפי תחום משפטי.""" """חיפוש סמנטי בהחלטות קודמות ובמסמכים — מסונן לפי תחום משפטי."""
return await search.search_decisions( return await search.search_decisions(
query, limit, section_type, practice_area, appeal_subtype, case_number, query, _clamp_limit(limit), section_type, practice_area, appeal_subtype, case_number,
) )
@@ -450,7 +464,7 @@ async def search_case_documents(
limit: int = 10, limit: int = 10,
) -> str: ) -> str:
"""חיפוש סמנטי בתוך מסמכי תיק ספציפי.""" """חיפוש סמנטי בתוך מסמכי תיק ספציפי."""
return await search.search_case_documents(case_number, query, limit) return await search.search_case_documents(case_number, query, _clamp_limit(limit))
@mcp.tool() @mcp.tool()
@@ -463,7 +477,7 @@ async def find_similar_cases(
) -> str: ) -> str:
"""מציאת תיקים דומים על בסיס תיאור — מסונן לפי תחום משפטי.""" """מציאת תיקים דומים על בסיס תיאור — מסונן לפי תחום משפטי."""
return await search.find_similar_cases( return await search.find_similar_cases(
description, limit, practice_area, appeal_subtype, case_number, description, _clamp_limit(limit), practice_area, appeal_subtype, case_number,
) )
@@ -496,7 +510,7 @@ async def search_internal_decisions(
כשרוצים להרחיב מעבר לטקסט המקורי. default False. כשרוצים להרחיב מעבר לטקסט המקורי. 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, _clamp_limit(limit), include_halachot,
include_cited_by=include_cited_by, include_cited_by=include_cited_by,
) )
@@ -571,6 +585,12 @@ async def extract_appraiser_facts(case_number: str) -> str:
return await drafting.extract_appraiser_facts(case_number) return await drafting.extract_appraiser_facts(case_number)
@mcp.tool()
async def get_appraiser_facts(case_number: str) -> str:
"""קריאת עובדות-השמאי שכבר חולצו (facts + סתירות) — ללא חילוץ-מחדש יקר. ה-get המקביל ל-extract_appraiser_facts."""
return await drafting.get_appraiser_facts(case_number)
@mcp.tool() @mcp.tool()
async def write_interim_draft(case_number: str, instructions: str = "") -> str: async def write_interim_draft(case_number: str, instructions: str = "") -> str:
"""כתיבת ארבעת הבלוקים לטיוטת ביניים (רקע, תכניות+היתרים, טענות, הליכים) — אותו skill וטמפלט.""" """כתיבת ארבעת הבלוקים לטיוטת ביניים (רקע, תכניות+היתרים, טענות, הליכים) — אותו skill וטמפלט."""
@@ -813,7 +833,7 @@ async def missing_precedent_list(
case_number=case_number, case_number=case_number,
status=status, status=status,
legal_topic=legal_topic, legal_topic=legal_topic,
limit=limit, limit=_clamp_limit(limit),
) )
@@ -878,7 +898,7 @@ async def list_internal_citations(
return await cit_tools.list_internal_citations( return await cit_tools.list_internal_citations(
case_law_id=case_law_id, case_law_id=case_law_id,
linked_only=linked_only, linked_only=linked_only,
limit=limit, limit=_clamp_limit(limit),
) )
@@ -894,7 +914,7 @@ async def list_incoming_citations(
""" """
return await cit_tools.list_incoming_citations( return await cit_tools.list_incoming_citations(
case_law_id=case_law_id, case_law_id=case_law_id,
limit=limit, limit=_clamp_limit(limit),
) )
@@ -917,9 +937,10 @@ async def list_chair_feedback(
case_number: str = "", case_number: str = "",
category: str = "", category: str = "",
unresolved_only: bool = True, unresolved_only: bool = True,
limit: int = 100,
) -> str: ) -> str:
"""הצגת הערות יו"ר שתועדו — אפשר לסנן לפי תיק, קטגוריה, מטופלות.""" """הצגת הערות יו"ר שתועדו — אפשר לסנן לפי תיק, קטגוריה, מטופלות."""
return await workflow.list_chair_feedback(case_number, category, unresolved_only) return await workflow.list_chair_feedback(case_number, category, unresolved_only, _clamp_limit(limit))
@mcp.tool() @mcp.tool()

View File

@@ -2428,8 +2428,9 @@ async def list_chair_feedback(
case_id: UUID | None = None, case_id: UUID | None = None,
category: str | None = None, category: str | None = None,
unresolved_only: bool = False, unresolved_only: bool = False,
limit: int = 100,
) -> list[dict]: ) -> list[dict]:
"""List chair feedback, optionally filtered.""" """List chair feedback, optionally filtered. Capped by limit (INV-TOOL5 / GAP-53)."""
pool = await get_pool() pool = await get_pool()
conditions = [] conditions = []
params: list = [] params: list = []
@@ -2447,9 +2448,10 @@ async def list_chair_feedback(
conditions.append("resolved = FALSE") conditions.append("resolved = FALSE")
where = f"WHERE {' AND '.join(conditions)}" if conditions else "" where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
params.append(max(1, int(limit)))
async with pool.acquire() as conn: async with pool.acquire() as conn:
rows = await conn.fetch( rows = await conn.fetch(
f"SELECT * FROM chair_feedback {where} ORDER BY created_at DESC", f"SELECT * FROM chair_feedback {where} ORDER BY created_at DESC LIMIT ${idx}",
*params, *params,
) )
return [dict(r) for r in rows] return [dict(r) for r in rows]

View File

@@ -477,6 +477,37 @@ async def extract_appraiser_facts(case_number: str) -> str:
ensure_ascii=False, indent=2) ensure_ascii=False, indent=2)
async def get_appraiser_facts(case_number: str) -> str:
"""קריאת עובדות-השמאי שכבר חולצו לתיק — ללא הרצת חילוץ מחדש (INV-TOOL4 / GAP-44).
ה-get המקביל ל-extract_appraiser_facts: מחזיר את העובדות השמורות בטבלת
appraiser_facts + סתירות מזוהות בין שמאים, בלי קריאת-LLM יקרה ולא-דטרמיניסטית.
מחזיר facts ריק אם החילוץ טרם רץ (status=ok, count=0) — לא שגיאה.
Args:
case_number: מספר תיק הערר
"""
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"])
try:
facts = await db.list_appraiser_facts(case_id)
conflicts = await db.detect_appraiser_conflicts(case_id)
return json.dumps({
"status": "ok",
"case_number": case_number,
"count": len(facts),
"facts": facts,
"conflicts": conflicts,
}, default=str, ensure_ascii=False, indent=2)
except Exception as e:
return json.dumps({"status": "error", "message": str(e)},
ensure_ascii=False, indent=2)
async def write_interim_draft(case_number: str, instructions: str = "") -> str: async def write_interim_draft(case_number: str, instructions: str = "") -> str:
"""כתיבת ארבעת הבלוקים לטיוטת ביניים: רקע (ו), תכניות+היתרים (ט), """כתיבת ארבעת הבלוקים לטיוטת ביניים: רקע (ו), תכניות+היתרים (ט),
טענות הצדדים (ז), הליכים (ח). אם לא חולצו עובדות שמאיות עדיין — טענות הצדדים (ז), הליכים (ח). אם לא חולצו עובדות שמאיות עדיין —

View File

@@ -394,6 +394,7 @@ async def list_chair_feedback(
case_number: str = "", case_number: str = "",
category: str = "", category: str = "",
unresolved_only: bool = True, unresolved_only: bool = True,
limit: int = 100,
) -> str: ) -> str:
"""הצגת הערות יו"ר שתועדו, עם אפשרות סינון. """הצגת הערות יו"ר שתועדו, עם אפשרות סינון.
@@ -401,6 +402,7 @@ async def list_chair_feedback(
case_number: סינון לפי תיק (אם ריק — כל ההערות) case_number: סינון לפי תיק (אם ריק — כל ההערות)
category: סינון לפי קטגוריה category: סינון לפי קטגוריה
unresolved_only: האם להציג רק הערות שלא טופלו (ברירת מחדל: כן) unresolved_only: האם להציג רק הערות שלא טופלו (ברירת מחדל: כן)
limit: תקרת תוצאות (INV-TOOL5 / GAP-53)
""" """
case_id = None case_id = None
if case_number: if case_number:
@@ -412,6 +414,7 @@ async def list_chair_feedback(
case_id=case_id, case_id=case_id,
category=category or None, category=category or None,
unresolved_only=unresolved_only, unresolved_only=unresolved_only,
limit=limit,
) )
if not feedbacks: if not feedbacks: