Add chair feedback system and content checklists for block-yod
Backend changes cherry-picked from ui-rewrite branch to enable feedback API endpoints for the Next.js staging UI. - chair_feedback DB table + API endpoints (GET/POST/PATCH) - Content checklists by appeal subtype injected into block-yod prompt - MCP tools for recording and listing chair feedback - Corpus analysis documentation (24 decisions) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -390,6 +390,29 @@ async def ingest_final_version(
|
||||
return await workflow.ingest_final_version(case_number, file_path, final_text)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def record_chair_feedback(
|
||||
case_number: str,
|
||||
feedback_text: str,
|
||||
block_id: str = "block-yod",
|
||||
category: str = "missing_content",
|
||||
lesson_extracted: str = "",
|
||||
) -> str:
|
||||
"""תיעוד הערת יו"ר (דפנה) על טיוטת החלטה — חסר, שגיאה, סגנון."""
|
||||
return await workflow.record_chair_feedback(
|
||||
case_number, feedback_text, block_id, category, lesson_extracted,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def list_chair_feedback(
|
||||
case_number: str = "",
|
||||
category: str = "",
|
||||
unresolved_only: bool = True,
|
||||
) -> str:
|
||||
"""הצגת הערות יו"ר שתועדו — אפשר לסנן לפי תיק, קטגוריה, מטופלות."""
|
||||
return await workflow.list_chair_feedback(case_number, category, unresolved_only)
|
||||
|
||||
|
||||
def main():
|
||||
mcp.run(transport="stdio")
|
||||
|
||||
@@ -20,6 +20,7 @@ from uuid import UUID
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import db, embeddings, claude_session
|
||||
from legal_mcp.services.lessons import get_content_checklist
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -215,6 +216,8 @@ BLOCK_PROMPTS = {
|
||||
- **ללא כותרות משנה** (חריג: נושאים נפרדים לחלוטין)
|
||||
- מספור רציף
|
||||
|
||||
{content_checklist}
|
||||
|
||||
## כיוון מאושר (חובה):
|
||||
{direction_context}
|
||||
|
||||
@@ -310,6 +313,15 @@ async def write_block(
|
||||
outcome = (decision or {}).get("outcome", "rejected")
|
||||
structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "")
|
||||
|
||||
# Content checklist — tells block-yod WHAT topics to cover
|
||||
content_checklist = ""
|
||||
if block_id == "block-yod":
|
||||
content_checklist = get_content_checklist(
|
||||
appeal_type=case.get("appeal_type", ""),
|
||||
subject=case.get("subject", ""),
|
||||
subject_categories=case.get("subject_categories", []),
|
||||
)
|
||||
|
||||
# Format prompt — per Anthropic long-context best practices:
|
||||
# Place source documents FIRST (top of prompt), instructions LAST.
|
||||
# "Queries at the end can improve response quality by up to 30%"
|
||||
@@ -323,6 +335,7 @@ async def write_block(
|
||||
style_context=style_context,
|
||||
discussion_context=discussion_context,
|
||||
structure_guidance=structure_guidance,
|
||||
content_checklist=content_checklist,
|
||||
)
|
||||
|
||||
# Restructure: sources first, then instructions
|
||||
|
||||
@@ -462,6 +462,22 @@ CREATE TABLE IF NOT EXISTS case_law_embeddings (
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════
|
||||
-- Chair Feedback (הערות דפנה על טיוטות)
|
||||
-- ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chair_feedback (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
case_id UUID REFERENCES cases(id) ON DELETE SET NULL,
|
||||
block_id TEXT DEFAULT '', -- block-yod, block-vav, etc.
|
||||
feedback_text TEXT NOT NULL, -- ההערה של דפנה
|
||||
category TEXT DEFAULT 'other', -- missing_content/wrong_tone/wrong_structure/factual_error/style/other
|
||||
lesson_extracted TEXT DEFAULT '', -- הלקח שהופק
|
||||
applied_to TEXT[] DEFAULT '{}', -- לאילו קבצים/כללים הלקח יושם
|
||||
resolved BOOLEAN DEFAULT FALSE, -- האם הלקח יושם
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════
|
||||
-- Indexes
|
||||
-- ═══════════════════════════════════════════════════════════════════
|
||||
@@ -1321,3 +1337,72 @@ async def search_precedents(
|
||||
|
||||
results.sort(key=lambda x: x["score"], reverse=True)
|
||||
return results[:limit]
|
||||
|
||||
|
||||
# ── Chair feedback ────────────────────────────────────────────────
|
||||
|
||||
async def record_chair_feedback(
|
||||
case_id: UUID | None,
|
||||
block_id: str,
|
||||
feedback_text: str,
|
||||
category: str = "other",
|
||||
lesson_extracted: str = "",
|
||||
) -> UUID:
|
||||
"""Record feedback from the chair (Dafna) on a draft block."""
|
||||
pool = await get_pool()
|
||||
feedback_id = uuid4()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""INSERT INTO chair_feedback
|
||||
(id, case_id, block_id, feedback_text, category, lesson_extracted)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)""",
|
||||
feedback_id, case_id, block_id, feedback_text, category,
|
||||
lesson_extracted,
|
||||
)
|
||||
return feedback_id
|
||||
|
||||
|
||||
async def list_chair_feedback(
|
||||
case_id: UUID | None = None,
|
||||
category: str | None = None,
|
||||
unresolved_only: bool = False,
|
||||
) -> list[dict]:
|
||||
"""List chair feedback, optionally filtered."""
|
||||
pool = await get_pool()
|
||||
conditions = []
|
||||
params: list = []
|
||||
idx = 1
|
||||
|
||||
if case_id:
|
||||
conditions.append(f"case_id = ${idx}")
|
||||
params.append(case_id)
|
||||
idx += 1
|
||||
if category:
|
||||
conditions.append(f"category = ${idx}")
|
||||
params.append(category)
|
||||
idx += 1
|
||||
if unresolved_only:
|
||||
conditions.append("resolved = FALSE")
|
||||
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
f"SELECT * FROM chair_feedback {where} ORDER BY created_at DESC",
|
||||
*params,
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
async def resolve_chair_feedback(
|
||||
feedback_id: UUID,
|
||||
applied_to: list[str],
|
||||
) -> None:
|
||||
"""Mark feedback as resolved and record where it was applied."""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""UPDATE chair_feedback
|
||||
SET resolved = TRUE, applied_to = $2
|
||||
WHERE id = $1""",
|
||||
feedback_id, applied_to,
|
||||
)
|
||||
|
||||
@@ -329,3 +329,193 @@ def format_ratios_comment(outcome: str, section: str) -> str:
|
||||
lo, hi = ratios[section]
|
||||
return f"יעד: {lo}-{hi}% מסך ההחלטה"
|
||||
return ""
|
||||
|
||||
|
||||
# ── Content checklists by appeal subtype ──────────────────────────
|
||||
# Based on systematic analysis of 24 decisions from Dafna's corpus.
|
||||
# See: docs/corpus-analysis.md
|
||||
|
||||
CONTENT_CHECKLISTS: dict[str, str] = {
|
||||
"licensing_substantive": """## צ'קליסט תוכן — ערר רישוי מהותי (חובה)
|
||||
הדיון חייב לכלול את הנושאים הרלוונטיים מהרשימה הבאה.
|
||||
**אל תדלג על נושא שרלוונטי לתיק — בדוק כל סעיף.**
|
||||
|
||||
### א. הקשר תכנוני רחב (חובה בכל ערר מהותי)
|
||||
- תכניות חלות — ציין את התכניות הרלוונטיות ברמה מקומית, מחוזית וארצית (לפי הצורך)
|
||||
- ייעוד הקרקע — מה הייעוד בתכנית? מה השימושים המותרים?
|
||||
- אופי הסביבה — מרקם בנוי, צפיפות, אופי שכונה/ישוב
|
||||
- *דוגמה*: בערר פרומר — 12 סעיפים על MI/200, תמ"א 35, תמ"מ 30/1
|
||||
|
||||
### ב. ניתוח הוראות תכנית (כשיש שאלה של התאמה/סטייה)
|
||||
- ציטוט ישיר מהוראות התכנית הרלוונטיות (200-600 מילים לכל ציטוט)
|
||||
- פרשנות — מה תכלית ההוראה?
|
||||
- יישום — האם הבקשה תואמת או סוטה?
|
||||
- *דוגמה*: בערר לבנון — ניתוח חתכים של נספח בינוי מול הבקשה
|
||||
|
||||
### ג. חניה (כשרלוונטי — מופיע ב-8 מתוך 24 החלטות)
|
||||
- הוראות תכנית + נספח תנועה (ציטוט ישיר)
|
||||
- חישוב מקומות חניה נדרשים vs. מסופקים
|
||||
- חלופות: קרן חניה, חפיפת שימושים, קרבה לתח"צ
|
||||
- *דוגמה*: בערר בית הכרם — 8 סעיפים, 400+ מילים מהוראות תכנית 5166ב
|
||||
|
||||
### ד. קווי בניין ומרווחים (כשרלוונטי)
|
||||
- הוראת תכנית על מרווחים
|
||||
- סטייה ניכרת? — תקנה 2(19) / הלכת בן-יקר-גת
|
||||
- הצדקה + מידתיות — פגיעה בשכנים?
|
||||
|
||||
### ה. גובה וקומות (כשרלוונטי)
|
||||
- הוראת תכנית + נספח בינוי (חתכים)
|
||||
- מטרת ההגבלה — למה יש הגבלת גובה כאן?
|
||||
- סטייה ניכרת — תקנה 2(10) / 2(8)
|
||||
|
||||
### ו. פגיעה בשכנים (כשרלוונטי)
|
||||
- ממצאי סיור באתר
|
||||
- השפעה: צל, פרטיות, רעש, נוף
|
||||
- מידתיות — האם הפגיעה סבירה?
|
||||
|
||||
### ז. שימוש חורג (כשרלוונטי)
|
||||
- מה השימוש המותר בתכנית? מה השימוש המבוקש?
|
||||
- "מבחן ההתאמה" — האם השימוש מתאים למיקום?
|
||||
- תנאים ומגבלות
|
||||
""",
|
||||
|
||||
"licensing_threshold": """## צ'קליסט תוכן — ערר רישוי סף/סמכות
|
||||
הערר עוסק בשאלות סף — אין צורך בדיון תכנוני מקיף.
|
||||
|
||||
### א. שאלת הסמכות
|
||||
- סעיפי חוק רלוונטיים (ס' 12ב, 152, וכו')
|
||||
- פסיקה על גבולות הסמכות
|
||||
|
||||
### ב. זכות ערר
|
||||
- מי רשאי לערור? באיזה מסלול?
|
||||
- הלכת שפר (עע"מ 317/10) — כשרלוונטית
|
||||
|
||||
### ג. שיהוי (אם רלוונטי)
|
||||
""",
|
||||
|
||||
"licensing_property": """## צ'קליסט תוכן — ערר רישוי קנייני
|
||||
הערר עוסק בעיקר בשאלת תימוכין קנייניים — דיון משפטי.
|
||||
|
||||
### א. מסגרת נורמטיבית
|
||||
- הלכת עייזן, בני אליעזר, רוזן — "היתכנות קניינית"
|
||||
- ס' 71ב לחוק המקרקעין
|
||||
|
||||
### ב. בחינת הראיות
|
||||
- הסכמות, רישום, היסטוריית בנייה
|
||||
- חלוקה דה-פקטו ארוכת שנים
|
||||
|
||||
### ג. הפרדה בין קניין לתכנון
|
||||
- גוף תכנוני אינו מכריע בסכסוכי קניין
|
||||
- "היתכנות קניינית" ≠ הוכחת בעלות
|
||||
|
||||
### ד. שאלות תכנוניות (אם רלוונטיות)
|
||||
- אם הערר עולה גם שאלות תכנוניות — דון בהן בנפרד
|
||||
""",
|
||||
|
||||
"tama38": """## צ'קליסט תוכן — ערר תמ"א 38
|
||||
הדיון חייב לאזן בין אינטרס ציבורי לפגיעה בשכנים.
|
||||
|
||||
### א. אינטרס ציבורי — חיזוק/התחדשות
|
||||
- עוצמת האינטרס — בניין גדול vs. בית בודד
|
||||
- "בית בודד" מחליש את אינטרס החיזוק
|
||||
- תרומה לרקמה העירונית
|
||||
|
||||
### ב. תכנית אב / מדיניות אזורית
|
||||
- האם יש תכנית אב? מדיניות 16000?
|
||||
- התאמה לראיה כללית vs. אד-הוק
|
||||
|
||||
### ג. ניתוח השוואתי
|
||||
- זכויות לפי תכנית קיימת vs. מבוקש לפי תמ"א 38
|
||||
- שטחים, קומות, קווי בניין — טבלת השוואה
|
||||
|
||||
### ד. שימור (כשרלוונטי)
|
||||
- חוות דעת אגף שימור
|
||||
- השפעה על מיקום/צורת הבניין
|
||||
|
||||
### ה. חניה (כמעט תמיד רלוונטי)
|
||||
- הוראות תכנית + ס' 17 לתמ"א 38
|
||||
- פטורים — קרבה לתח"צ, קרן חניה, תכנית אב
|
||||
- ניתוח מפורט של חלופות
|
||||
|
||||
### ו. פגיעה בשכנים
|
||||
- ממצאי סיור
|
||||
- צל, פרטיות, קרבה
|
||||
- מידתיות — מה הפגיעה ביחס לתועלת?
|
||||
|
||||
### ז. מטרדי בנייה
|
||||
- "מטרד בנייה אינו עילה לסירוב" — אך תנאים נדרשים
|
||||
- תכנית ארגון אתר
|
||||
""",
|
||||
|
||||
"betterment_levy": """## צ'קליסט תוכן — ערר היטל השבחה
|
||||
⚠️ שים לב: אין עדיין החלטות היטל השבחה בקורפוס האימון.
|
||||
הצ'קליסט הזה מבוסס על ידע כללי — לא על ניתוח ספציפי של סגנון דפנה.
|
||||
|
||||
### א. המסגרת הנורמטיבית
|
||||
- התוספת השלישית לחוק התכנון והבנייה
|
||||
- אירוע מס — מה יצר את ההשבחה?
|
||||
|
||||
### ב. שומה
|
||||
- שיטת השומה (שומה מכרעת / שמאי מייעץ)
|
||||
- מועד הקובע
|
||||
- זכויות בנייה — לפני ואחרי
|
||||
|
||||
### ג. שאלות משפטיות
|
||||
- פטורים (ס' 19)
|
||||
- מועדי תשלום
|
||||
- שיערוך
|
||||
|
||||
### ד. ניתוח שמאי
|
||||
- האם השומה תקינה?
|
||||
- פערים בין השומות
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
def get_content_checklist(
|
||||
appeal_type: str = "",
|
||||
subject: str = "",
|
||||
subject_categories: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Return the appropriate content checklist based on case characteristics.
|
||||
|
||||
Determines the subtype from case metadata:
|
||||
- TAMA 38 cases → tama38 checklist
|
||||
- Betterment levy (8xxx) → betterment_levy checklist
|
||||
- Property-only cases → licensing_property checklist
|
||||
- Threshold/jurisdiction cases → licensing_threshold checklist
|
||||
- All other licensing → licensing_substantive checklist
|
||||
"""
|
||||
cats = subject_categories or []
|
||||
subject_lower = subject.lower() if subject else ""
|
||||
appeal_lower = appeal_type.lower() if appeal_type else ""
|
||||
|
||||
# TAMA 38
|
||||
if any(
|
||||
kw in subject_lower
|
||||
for kw in ["תמ\"א 38", "תמא 38", "תמ\"א38", "חיזוק", "tama"]
|
||||
) or "תמ\"א 38" in cats:
|
||||
return CONTENT_CHECKLISTS["tama38"]
|
||||
|
||||
# Betterment levy
|
||||
if "היטל השבחה" in appeal_lower or "betterment" in appeal_lower or any(
|
||||
"היטל" in c for c in cats
|
||||
):
|
||||
return CONTENT_CHECKLISTS["betterment_levy"]
|
||||
|
||||
# Property-focused (תימוכין קנייניים)
|
||||
if any(
|
||||
kw in subject_lower
|
||||
for kw in ["תימוכין", "קנייני", "בעלות", "הסכמת דיירים"]
|
||||
):
|
||||
return CONTENT_CHECKLISTS["licensing_property"]
|
||||
|
||||
# Threshold/jurisdiction
|
||||
if any(
|
||||
kw in subject_lower
|
||||
for kw in ["סמכות", "סף", "סילוק על הסף", "זכות ערר"]
|
||||
):
|
||||
return CONTENT_CHECKLISTS["licensing_threshold"]
|
||||
|
||||
# Default: substantive licensing
|
||||
return CONTENT_CHECKLISTS["licensing_substantive"]
|
||||
|
||||
@@ -318,3 +318,97 @@ async def ingest_final_version(
|
||||
return json.dumps(result, default=str, ensure_ascii=False, indent=2)
|
||||
except ValueError as e:
|
||||
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
# ── Chair feedback tools ──────────────────────────────────────────
|
||||
|
||||
|
||||
async def record_chair_feedback(
|
||||
case_number: str,
|
||||
feedback_text: str,
|
||||
block_id: str = "block-yod",
|
||||
category: str = "missing_content",
|
||||
lesson_extracted: str = "",
|
||||
) -> str:
|
||||
"""תיעוד הערת יו"ר (דפנה) על טיוטת החלטה.
|
||||
|
||||
Args:
|
||||
case_number: מספר תיק הערר
|
||||
feedback_text: ההערה של דפנה (מה חסר, מה לא נכון, מה צריך לשנות)
|
||||
block_id: הבלוק שההערה מתייחסת אליו (ברירת מחדל: block-yod)
|
||||
category: קטגוריה — missing_content/wrong_tone/wrong_structure/factual_error/style/other
|
||||
lesson_extracted: הלקח שהופק מההערה (אם ברור כבר)
|
||||
"""
|
||||
case = await db.get_case_by_number(case_number)
|
||||
case_id = UUID(case["id"]) if case else None
|
||||
|
||||
valid_categories = [
|
||||
"missing_content", "wrong_tone", "wrong_structure",
|
||||
"factual_error", "style", "other",
|
||||
]
|
||||
if category not in valid_categories:
|
||||
return f"קטגוריה לא חוקית. אפשרויות: {', '.join(valid_categories)}"
|
||||
|
||||
feedback_id = await db.record_chair_feedback(
|
||||
case_id=case_id,
|
||||
block_id=block_id,
|
||||
feedback_text=feedback_text,
|
||||
category=category,
|
||||
lesson_extracted=lesson_extracted,
|
||||
)
|
||||
|
||||
return json.dumps({
|
||||
"status": "ok",
|
||||
"feedback_id": str(feedback_id),
|
||||
"message": f"הערה נרשמה בהצלחה. קטגוריה: {category}.",
|
||||
"next_steps": [
|
||||
"כדי להפיק לקח מההערה, הפעל: analyze_chair_feedback",
|
||||
"כדי לסמן כמטופל: resolve_chair_feedback",
|
||||
],
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
async def list_chair_feedback(
|
||||
case_number: str = "",
|
||||
category: str = "",
|
||||
unresolved_only: bool = True,
|
||||
) -> str:
|
||||
"""הצגת הערות יו"ר שתועדו, עם אפשרות סינון.
|
||||
|
||||
Args:
|
||||
case_number: סינון לפי תיק (אם ריק — כל ההערות)
|
||||
category: סינון לפי קטגוריה
|
||||
unresolved_only: האם להציג רק הערות שלא טופלו (ברירת מחדל: כן)
|
||||
"""
|
||||
case_id = None
|
||||
if case_number:
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if case:
|
||||
case_id = UUID(case["id"])
|
||||
|
||||
feedbacks = await db.list_chair_feedback(
|
||||
case_id=case_id,
|
||||
category=category or None,
|
||||
unresolved_only=unresolved_only,
|
||||
)
|
||||
|
||||
if not feedbacks:
|
||||
return "אין הערות שמתאימות לסינון."
|
||||
|
||||
items = []
|
||||
for fb in feedbacks:
|
||||
items.append({
|
||||
"id": str(fb["id"]),
|
||||
"case_id": str(fb["case_id"]) if fb["case_id"] else None,
|
||||
"block_id": fb["block_id"],
|
||||
"category": fb["category"],
|
||||
"feedback": fb["feedback_text"],
|
||||
"lesson": fb["lesson_extracted"],
|
||||
"resolved": fb["resolved"],
|
||||
"date": fb["created_at"].isoformat() if fb.get("created_at") else None,
|
||||
})
|
||||
|
||||
return json.dumps({
|
||||
"total": len(items),
|
||||
"feedbacks": items,
|
||||
}, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
Reference in New Issue
Block a user