Compare commits
3 Commits
684a4cfd3b
...
3288624349
| Author | SHA1 | Date | |
|---|---|---|---|
| 3288624349 | |||
| 5dd24729e2 | |||
| ba39707c70 |
@@ -165,10 +165,13 @@ async def document_upload_training(
|
||||
decision_date: str = "",
|
||||
subject_categories: list[str] | None = None,
|
||||
title: str = "",
|
||||
practice_area: str = "appeals_committee",
|
||||
appeal_subtype: str = "",
|
||||
) -> str:
|
||||
"""העלאת החלטה קודמת של דפנה לקורפוס הסגנון. קטגוריות: בנייה, שימוש חורג, תכנית, היתר, הקלה, חלוקה, תמ"א 38, היטל השבחה, פיצויים 197."""
|
||||
"""העלאת החלטה קודמת של דפנה לקורפוס הסגנון. קטגוריות: בנייה, שימוש חורג, תכנית, היתר, הקלה, חלוקה, תמ"א 38, היטל השבחה, פיצויים 197. סוג ערר: building_permit / betterment_levy / compensation_197 (ריק = אוטומטי ממספר ההחלטה)."""
|
||||
return await documents.document_upload_training(
|
||||
file_path, decision_number, decision_date, subject_categories, title,
|
||||
practice_area, appeal_subtype,
|
||||
)
|
||||
|
||||
|
||||
@@ -319,9 +322,9 @@ async def export_docx(case_number: str, output_path: str = "") -> str:
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def analyze_style() -> str:
|
||||
"""ניתוח סגנון על קורפוס ההחלטות של דפנה. מחלץ ושומר דפוסי כתיבה."""
|
||||
return await drafting.analyze_style()
|
||||
async def analyze_style(appeal_subtype: str = "") -> str:
|
||||
"""ניתוח סגנון על קורפוס ההחלטות של דפנה. מחלץ ושומר דפוסי כתיבה. סוג ערר: building_permit / betterment_levy / compensation_197 (ריק = הכל)."""
|
||||
return await drafting.analyze_style(appeal_subtype)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
|
||||
@@ -104,6 +104,8 @@ CREATE TABLE IF NOT EXISTS style_corpus (
|
||||
summary TEXT DEFAULT '',
|
||||
outcome TEXT DEFAULT '',
|
||||
key_principles JSONB DEFAULT '[]',
|
||||
practice_area TEXT DEFAULT 'appeals_committee',
|
||||
appeal_subtype TEXT DEFAULT '',
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
@@ -114,6 +116,7 @@ CREATE TABLE IF NOT EXISTS style_patterns (
|
||||
frequency INTEGER DEFAULT 1,
|
||||
context TEXT DEFAULT '',
|
||||
examples JSONB DEFAULT '[]',
|
||||
appeal_subtype TEXT DEFAULT '',
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
@@ -159,6 +162,13 @@ ALTER TABLE cases ADD COLUMN IF NOT EXISTS appeal_type TEXT DEFAULT '';
|
||||
ALTER TABLE cases ADD COLUMN IF NOT EXISTS practice_area TEXT DEFAULT 'appeals_committee';
|
||||
ALTER TABLE cases ADD COLUMN IF NOT EXISTS appeal_subtype TEXT DEFAULT '';
|
||||
|
||||
-- הרחבת style_corpus עם practice_area / appeal_subtype
|
||||
ALTER TABLE style_corpus ADD COLUMN IF NOT EXISTS practice_area TEXT DEFAULT 'appeals_committee';
|
||||
ALTER TABLE style_corpus ADD COLUMN IF NOT EXISTS appeal_subtype TEXT DEFAULT '';
|
||||
|
||||
-- הרחבת style_patterns עם appeal_subtype לניתוח סגנון נפרד לכל סוג ערר
|
||||
ALTER TABLE style_patterns ADD COLUMN IF NOT EXISTS appeal_subtype TEXT DEFAULT '';
|
||||
|
||||
-- טבלת qa_results
|
||||
CREATE TABLE IF NOT EXISTS qa_results (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
@@ -882,6 +892,8 @@ async def add_to_style_corpus(
|
||||
summary: str = "",
|
||||
outcome: str = "",
|
||||
key_principles: list[str] | None = None,
|
||||
practice_area: str = "appeals_committee",
|
||||
appeal_subtype: str = "",
|
||||
) -> UUID:
|
||||
pool = await get_pool()
|
||||
corpus_id = uuid4()
|
||||
@@ -889,11 +901,13 @@ async def add_to_style_corpus(
|
||||
await conn.execute(
|
||||
"""INSERT INTO style_corpus
|
||||
(id, document_id, decision_number, decision_date,
|
||||
subject_categories, full_text, summary, outcome, key_principles)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)""",
|
||||
subject_categories, full_text, summary, outcome, key_principles,
|
||||
practice_area, appeal_subtype)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)""",
|
||||
corpus_id, document_id, decision_number, decision_date,
|
||||
json.dumps(subject_categories), full_text, summary, outcome,
|
||||
json.dumps(key_principles or []),
|
||||
practice_area, appeal_subtype,
|
||||
)
|
||||
return corpus_id
|
||||
|
||||
@@ -963,12 +977,14 @@ async def upsert_style_pattern(
|
||||
pattern_text: str,
|
||||
context: str = "",
|
||||
examples: list[str] | None = None,
|
||||
appeal_subtype: str = "",
|
||||
) -> None:
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
existing = await conn.fetchrow(
|
||||
"SELECT id, frequency FROM style_patterns WHERE pattern_type = $1 AND pattern_text = $2",
|
||||
pattern_type, pattern_text,
|
||||
"SELECT id, frequency FROM style_patterns "
|
||||
"WHERE pattern_type = $1 AND pattern_text = $2 AND appeal_subtype = $3",
|
||||
pattern_type, pattern_text, appeal_subtype,
|
||||
)
|
||||
if existing:
|
||||
await conn.execute(
|
||||
@@ -977,18 +993,27 @@ async def upsert_style_pattern(
|
||||
)
|
||||
else:
|
||||
await conn.execute(
|
||||
"""INSERT INTO style_patterns (pattern_type, pattern_text, context, examples)
|
||||
VALUES ($1, $2, $3, $4)""",
|
||||
"""INSERT INTO style_patterns (pattern_type, pattern_text, context, examples, appeal_subtype)
|
||||
VALUES ($1, $2, $3, $4, $5)""",
|
||||
pattern_type, pattern_text, context,
|
||||
json.dumps(examples or []),
|
||||
appeal_subtype,
|
||||
)
|
||||
|
||||
|
||||
async def clear_style_patterns() -> None:
|
||||
"""Delete all existing style patterns (used before re-analysis)."""
|
||||
async def clear_style_patterns(appeal_subtype: str = "") -> None:
|
||||
"""Delete style patterns, optionally filtered by appeal_subtype.
|
||||
|
||||
Empty appeal_subtype = delete ALL patterns.
|
||||
"""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute("DELETE FROM style_patterns")
|
||||
if appeal_subtype:
|
||||
await conn.execute(
|
||||
"DELETE FROM style_patterns WHERE appeal_subtype = $1", appeal_subtype
|
||||
)
|
||||
else:
|
||||
await conn.execute("DELETE FROM style_patterns")
|
||||
|
||||
|
||||
# ── Semantic Search (V2 — decision blocks & case law) ─────────────
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Text extraction from PDF, DOCX, and RTF files.
|
||||
"""Text extraction from PDF, DOCX, DOC, and RTF files.
|
||||
|
||||
Primary PDF extraction: PyMuPDF direct text (for born-digital PDFs).
|
||||
Fallback: Google Cloud Vision OCR (for scanned documents).
|
||||
DOC files: converted to DOCX via LibreOffice before extraction.
|
||||
Post-processing: Hebrew abbreviation quote fixer.
|
||||
"""
|
||||
|
||||
@@ -10,6 +11,8 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import fitz # PyMuPDF
|
||||
@@ -129,6 +132,8 @@ async def extract_text(file_path: str) -> tuple[str, int]:
|
||||
return await _extract_pdf(path)
|
||||
elif suffix == ".docx":
|
||||
return _extract_docx(path), 0
|
||||
elif suffix == ".doc":
|
||||
return _extract_doc(path), 0
|
||||
elif suffix == ".rtf":
|
||||
return _extract_rtf(path), 0
|
||||
elif suffix in (".txt", ".md"):
|
||||
@@ -187,6 +192,21 @@ def _ocr_with_google_vision(image_bytes: bytes, page_num: int) -> str:
|
||||
return _fix_hebrew_quotes(text)
|
||||
|
||||
|
||||
def _extract_doc(path: Path) -> str:
|
||||
"""Extract text from legacy .doc file by converting to .docx via LibreOffice."""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
result = subprocess.run(
|
||||
["libreoffice", "--headless", "--convert-to", "docx", str(path), "--outdir", tmp_dir],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"LibreOffice conversion failed: {result.stderr}")
|
||||
docx_path = Path(tmp_dir) / f"{path.stem}.docx"
|
||||
if not docx_path.exists():
|
||||
raise FileNotFoundError(f"Converted file not found: {docx_path}")
|
||||
return _extract_docx(docx_path)
|
||||
|
||||
|
||||
def _extract_docx(path: Path) -> str:
|
||||
"""Extract text from DOCX file."""
|
||||
doc = DocxDocument(str(path))
|
||||
@@ -198,3 +218,30 @@ def _extract_rtf(path: Path) -> str:
|
||||
"""Extract text from RTF file."""
|
||||
rtf_content = path.read_text(encoding="utf-8", errors="replace")
|
||||
return rtf_to_text(rtf_content)
|
||||
|
||||
|
||||
# ── Nevo preamble stripping ──────────────────────────────────────
|
||||
|
||||
_NEVO_MARKERS = ("ספרות:", "חקיקה שאוזכרה:", "מיני-רציו:", "פסקי דין שאוזכרו:",
|
||||
"כתבי עת:", "הועתק מנבו")
|
||||
|
||||
_DECISION_START = re.compile(
|
||||
r"^(בפנינו|לפנינו|הערר שבנדון|ועדת הערר לתכנון|רקע עובדתי|עסקינן)",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def strip_nevo_preamble(text: str) -> str:
|
||||
"""Remove Nevo database preamble (bibliography, legislation, mini-ratio) from decision text.
|
||||
|
||||
Returns the original text unchanged if no preamble is detected.
|
||||
"""
|
||||
head = text[:400]
|
||||
if not any(marker in head for marker in _NEVO_MARKERS):
|
||||
return text
|
||||
m = _DECISION_START.search(text)
|
||||
if m and m.start() > 50:
|
||||
stripped = text[m.start():]
|
||||
logger.debug("Stripped %d chars of Nevo preamble", m.start())
|
||||
return stripped
|
||||
return text
|
||||
|
||||
@@ -72,9 +72,14 @@ OPENING_STRATEGIES = {
|
||||
),
|
||||
},
|
||||
"betterment_levy": {
|
||||
"style": "direct_with_disclaimer",
|
||||
"style": "direct_factual",
|
||||
"paragraphs": (1, 3),
|
||||
"description": "פתיחה ישירה עם מסקנה + 'על מנת לא לצאת בחסר'",
|
||||
"description": (
|
||||
"פתיחה ישירה ועובדתית: 'בפנינו ערר על דרישת תשלום היטל השבחה מיום [תאריך] "
|
||||
"בסך של [סכום] ₪' → רקע קצר (נכס, תכנית משביחה, מימוש) → "
|
||||
"תמצית טענות הצדדים (עוררים + משיבה בנפרד). "
|
||||
"אין הקשר תכנוני רחב. הפתיחה = עובדות בלבד."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -101,9 +106,16 @@ SUMMARY_STRATEGIES = {
|
||||
),
|
||||
},
|
||||
"betterment_levy": {
|
||||
"heading": "סיכום",
|
||||
"format": "numbered_hebrew_dry",
|
||||
"description": "אותיות עבריות, סיום יבש ללא פסקה חמה",
|
||||
"heading": "various",
|
||||
"format": "dry_operative",
|
||||
"description": (
|
||||
"סיום יבש ואופרטיבי. כותרת משתנה: 'סוף דבר' / 'לאור כל האמור לעיל' / ללא כותרת. "
|
||||
"תוכן: 'הערר נדחה/מתקבל' + הוצאות ('כל צד ישא בהוצאותיו' / חיוב בסכום). "
|
||||
"אם מתקבל: הוראות אופרטיביות (החזר, שומה מתוקנת, תנאים). "
|
||||
"חתימה: 'ניתנה פה אחד היום, [תאריך עברי], [תאריך לועזי].' "
|
||||
"לעיתים: 'התיק ייסגר.' / 'עומדת זכות ערר כדין.' "
|
||||
"אין פסקה חמה. אין חזרה על נימוקים."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -129,7 +141,12 @@ DISCUSSION_RULES: dict[str, list[str]] = {
|
||||
"מבנה ישיר: נקודות עיקריות → ניתוח → מסקנה.",
|
||||
],
|
||||
"betterment_levy": [
|
||||
"מבנה ישיר עם מסקנה מוקדמת + 'על מנת לא לצאת בחסר' לנקודות נוספות.",
|
||||
"פתיחת דיון: מסקנה מוקדמת ('לאחר שבחנו... מצאנו כי דין הערר להידחות/להתקבל').",
|
||||
"תקן ביקורת: ציון רף ההתערבות בשומה מכרעת (בר\"ם 3644/13 גלר) — אבחנה בין שמאי למשפטי.",
|
||||
"הצגת הלכה פסוקה: ציטוט ארוך מפס\"ד מרכזי → 'ברוח הדברים לעיל נבחן את טענות הצדדים'.",
|
||||
"טיפול שיטתי: כל טענה/סוגיה בנפרד → ניתוח → מסקנת ביניים.",
|
||||
"ביטויים: 'אין בידינו לקבל', 'לא מצאנו מקום להתערב', 'קביעה נכונה שאין מקום להתערב בה'.",
|
||||
"'על מנת לא לצאת בחסר' — לנקודות obiter dicta בסוף הדיון.",
|
||||
],
|
||||
}
|
||||
|
||||
@@ -448,26 +465,41 @@ CONTENT_CHECKLISTS: dict[str, str] = {
|
||||
""",
|
||||
|
||||
"betterment_levy": """## צ'קליסט תוכן — ערר היטל השבחה
|
||||
⚠️ שים לב: אין עדיין החלטות היטל השבחה בקורפוס האימון.
|
||||
הצ'קליסט הזה מבוסס על ידע כללי — לא על ניתוח ספציפי של סגנון דפנה.
|
||||
מבוסס על ניתוח 26 החלטות של דפנה תמיר (קורפוס CMPA, אפריל 2026).
|
||||
|
||||
### א. המסגרת הנורמטיבית
|
||||
### א. תקן ביקורת (חובה בפתיחת הדיון)
|
||||
- ציין את רף ההתערבות: "ועדת הערר תיטה לאמץ את חוות דעתו של השמאי..."
|
||||
- אבחנה: התערבות מצומצמת בעניינים שמאיים-מקצועיים, התערבות רחבה בעניינים משפטיים
|
||||
- הפניה ל-בר"ם 3644/13 גלר או פסיקה דומה
|
||||
|
||||
### ב. המסגרת הנורמטיבית
|
||||
- התוספת השלישית לחוק התכנון והבנייה
|
||||
- אירוע מס — מה יצר את ההשבחה?
|
||||
- סעיפי הפטור הרלוונטיים (ס' 19(ג), ס' 19(ב) וכו')
|
||||
- אירוע מס — מה יצר את ההשבחה? (תכנית, היתר, מכר)
|
||||
- מועד המימוש ומועד הקובע
|
||||
|
||||
### ב. שומה
|
||||
- שיטת השומה (שומה מכרעת / שמאי מייעץ)
|
||||
- מועד הקובע
|
||||
- זכויות בנייה — לפני ואחרי
|
||||
### ג. שומה ומתודולוגיה שמאית
|
||||
- שיטת השומה (שומה מכרעת / שומה מוסכמת / שמאי מייעץ)
|
||||
- מבחן השימוש הטוב והיעיל (highest and best use) — מצב קודם ומצב חדש
|
||||
- זכויות בנייה — לפני ואחרי (אחוזי בנייה, שטחים עיקריים, תמהיל שימושים)
|
||||
- שווי מקרקעין — מצב קודם ומצב חדש (שיטת השוואה / יחידות תועלת)
|
||||
- עלויות עודפות (חניה, מטלות ציבוריות, תשתיות)
|
||||
- מקדמי זמינות, שיעורי הפקעה
|
||||
|
||||
### ג. שאלות משפטיות
|
||||
- פטורים (ס' 19)
|
||||
- מועדי תשלום
|
||||
- שיערוך
|
||||
### ד. שאלות משפטיות (לפי רלוונטיות)
|
||||
- פטורים — דירת מגורים (ס' 19(ג)(1)), שטח עד 140 מ"ר, תא משפחתי
|
||||
- מועד מימוש — זיכרון דברים vs הסכם מכר, העברת זכויות
|
||||
- זהות החייב — בעלים, חוכר, יזם, חברה בבעלות יזם
|
||||
- מקרקעי ישראל — הסדרים מיוחדים (ס' 21 לתוספת השלישית)
|
||||
- שומות מוסכמות — תוקף, משמעות, "בלתי נצפה מראש"
|
||||
- פרשנות תכניות — ייעוד, שימושים מותרים, מדיניות ועדה מקומית
|
||||
|
||||
### ד. ניתוח שמאי
|
||||
- האם השומה תקינה?
|
||||
- פערים בין השומות
|
||||
### ה. ניתוח שמאי (כשיש שומה מכרעת)
|
||||
- האם השומה מבוססת על מסד עובדתי הולם?
|
||||
- האם השיטה השמאית מקובלת?
|
||||
- האם ההנחות סבירות והגיוניות?
|
||||
- טעות מהותית / דופי חמור?
|
||||
- פגם מינהלי (ניגוד עניינים, משוא פנים)?
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
@@ -43,14 +43,17 @@ SUBTYPES_BY_AREA: dict[str, set[str]] = {
|
||||
|
||||
# ── Derivation ─────────────────────────────────────────────────────
|
||||
|
||||
_FIRST_DIGIT = re.compile(r"^\s*(\d)")
|
||||
|
||||
_APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE = {
|
||||
"1": "building_permit",
|
||||
"8": "betterment_levy",
|
||||
"9": "compensation_197",
|
||||
}
|
||||
|
||||
# Match the case number (last numeric group) in formats like:
|
||||
# ARAR-25-8126, ARAR-24-01-8007-33, 8126/25, 1170, ערר 1024-25
|
||||
_CASE_NUM = re.compile(r"(?:ARAR[-\s]*\d{2}[-\s]*(?:\d{2}[-\s]*)?)(\d{4})", re.IGNORECASE)
|
||||
_PLAIN_NUM = re.compile(r"(\d{4})")
|
||||
|
||||
|
||||
def derive_subtype(case_number: str, practice_area: str = DEFAULT_PRACTICE_AREA) -> str:
|
||||
"""Infer the appeal_subtype from case_number.
|
||||
@@ -58,15 +61,20 @@ def derive_subtype(case_number: str, practice_area: str = DEFAULT_PRACTICE_AREA)
|
||||
For appeals_committee, the convention is:
|
||||
1xxx → building_permit, 8xxx → betterment_levy, 9xxx → compensation_197.
|
||||
|
||||
For other practice areas there is no public numbering convention yet,
|
||||
so we return 'unknown' until a real rule is defined.
|
||||
Handles multiple formats: ARAR-25-8126, 8126/25, 1170, ערר 1024-25.
|
||||
"""
|
||||
if practice_area != "appeals_committee":
|
||||
return "unknown"
|
||||
m = _FIRST_DIGIT.match(case_number or "")
|
||||
cn = case_number or ""
|
||||
# Try ARAR format first (extracts the 4-digit case number after year prefix)
|
||||
m = _CASE_NUM.search(cn)
|
||||
if not m:
|
||||
# Fallback: first 4-digit number in the string
|
||||
m = _PLAIN_NUM.search(cn)
|
||||
if not m:
|
||||
return "unknown"
|
||||
return _APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE.get(m.group(1), "unknown")
|
||||
first_digit = m.group(1)[0]
|
||||
return _APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE.get(first_digit, "unknown")
|
||||
|
||||
|
||||
# ── Validation ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -109,22 +109,33 @@ SYNTHESIS_PROMPT = """\
|
||||
"""
|
||||
|
||||
|
||||
async def analyze_corpus() -> dict:
|
||||
async def analyze_corpus(appeal_subtype: str = "") -> dict:
|
||||
"""Analyze the style corpus and extract/update patterns.
|
||||
|
||||
Args:
|
||||
appeal_subtype: filter by appeal subtype (e.g. 'betterment_levy', 'building_permit').
|
||||
Empty string = all decisions.
|
||||
|
||||
Returns summary of patterns found.
|
||||
"""
|
||||
pool = await db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT full_text, decision_number FROM style_corpus ORDER BY decision_date DESC LIMIT 20"
|
||||
)
|
||||
if appeal_subtype:
|
||||
rows = await conn.fetch(
|
||||
"SELECT full_text, decision_number FROM style_corpus "
|
||||
"WHERE appeal_subtype = $1 ORDER BY decision_date DESC LIMIT 20",
|
||||
appeal_subtype,
|
||||
)
|
||||
else:
|
||||
rows = await conn.fetch(
|
||||
"SELECT full_text, decision_number FROM style_corpus ORDER BY decision_date DESC LIMIT 20"
|
||||
)
|
||||
|
||||
if not rows:
|
||||
return {"error": "אין החלטות בקורפוס. העלה החלטות קודמות תחילה."}
|
||||
|
||||
# Clear old patterns before re-analysis
|
||||
await db.clear_style_patterns()
|
||||
# Clear old patterns for this subtype (or all if unfiltered)
|
||||
await db.clear_style_patterns(appeal_subtype)
|
||||
|
||||
# Calculate token budget
|
||||
total_chars = sum(len(row["full_text"]) for row in rows)
|
||||
@@ -136,12 +147,12 @@ async def analyze_corpus() -> dict:
|
||||
)
|
||||
|
||||
if estimated_tokens < MAX_INPUT_TOKENS:
|
||||
return await _analyze_single_pass(rows)
|
||||
return await _analyze_single_pass(rows, appeal_subtype)
|
||||
else:
|
||||
return await _analyze_multi_pass(rows)
|
||||
return await _analyze_multi_pass(rows, appeal_subtype)
|
||||
|
||||
|
||||
async def _analyze_single_pass(rows) -> dict:
|
||||
async def _analyze_single_pass(rows, appeal_subtype: str = "") -> dict:
|
||||
"""Send all decisions in a single API call."""
|
||||
decisions_text = ""
|
||||
for row in rows:
|
||||
@@ -153,10 +164,10 @@ async def _analyze_single_pass(rows) -> dict:
|
||||
timeout=claude_session.LONG_TIMEOUT,
|
||||
)
|
||||
|
||||
return await _parse_and_store_patterns(raw, len(rows))
|
||||
return await _parse_and_store_patterns(raw, len(rows), appeal_subtype)
|
||||
|
||||
|
||||
async def _analyze_multi_pass(rows) -> dict:
|
||||
async def _analyze_multi_pass(rows, appeal_subtype: str = "") -> dict:
|
||||
"""Analyze each decision individually, then synthesize patterns."""
|
||||
all_patterns = []
|
||||
|
||||
@@ -186,7 +197,7 @@ async def _analyze_multi_pass(rows) -> dict:
|
||||
timeout=claude_session.LONG_TIMEOUT,
|
||||
)
|
||||
|
||||
return await _parse_and_store_patterns(raw, len(rows))
|
||||
return await _parse_and_store_patterns(raw, len(rows), appeal_subtype)
|
||||
|
||||
|
||||
def _extract_json(response_text: str) -> list | None:
|
||||
@@ -237,14 +248,16 @@ def _extract_json(response_text: str) -> list | None:
|
||||
return None
|
||||
|
||||
|
||||
async def _parse_and_store_patterns(response_text: str, num_decisions: int) -> dict:
|
||||
async def _parse_and_store_patterns(
|
||||
response_text: str, num_decisions: int, appeal_subtype: str = "",
|
||||
) -> dict:
|
||||
"""Parse Claude's response and store patterns in the database."""
|
||||
patterns = _extract_json(response_text)
|
||||
|
||||
if patterns is None:
|
||||
return {"error": "Could not parse analysis results", "raw": response_text}
|
||||
|
||||
# Store patterns
|
||||
# Store patterns tagged by appeal_subtype
|
||||
count = 0
|
||||
for pattern in patterns:
|
||||
await db.upsert_style_pattern(
|
||||
@@ -252,11 +265,13 @@ async def _parse_and_store_patterns(response_text: str, num_decisions: int) -> d
|
||||
pattern_text=pattern.get("text", ""),
|
||||
context=pattern.get("context", ""),
|
||||
examples=[pattern.get("example", "")],
|
||||
appeal_subtype=appeal_subtype,
|
||||
)
|
||||
count += 1
|
||||
|
||||
return {
|
||||
"patterns_found": count,
|
||||
"decisions_analyzed": num_decisions,
|
||||
"appeal_subtype": appeal_subtype or "all",
|
||||
"pattern_types": list({p.get("type") for p in patterns}),
|
||||
}
|
||||
|
||||
@@ -139,14 +139,22 @@ async def document_upload_training(
|
||||
appeal_subtype = pa.derive_subtype(decision_number, practice_area)
|
||||
pa.validate(practice_area, appeal_subtype)
|
||||
|
||||
# Copy to training directory (skip if already there)
|
||||
config.TRAINING_DIR.mkdir(parents=True, exist_ok=True)
|
||||
dest = config.TRAINING_DIR / source.name
|
||||
# Copy to training directory, organized by subtype
|
||||
_SUBTYPE_DIRS = {
|
||||
"betterment_levy": "cmpa",
|
||||
"compensation_197": "cmpa",
|
||||
"building_permit": "cmp",
|
||||
}
|
||||
subdir = _SUBTYPE_DIRS.get(appeal_subtype, "")
|
||||
training_dest = config.TRAINING_DIR / subdir if subdir else config.TRAINING_DIR
|
||||
training_dest.mkdir(parents=True, exist_ok=True)
|
||||
dest = training_dest / source.name
|
||||
if source.resolve() != dest.resolve():
|
||||
shutil.copy2(str(source), str(dest))
|
||||
|
||||
# Extract text
|
||||
# Extract text and strip Nevo preamble
|
||||
text, page_count = await extractor.extract_text(str(dest))
|
||||
text = extractor.strip_nevo_preamble(text)
|
||||
|
||||
# Parse date
|
||||
d_date = None
|
||||
@@ -174,11 +182,12 @@ async def document_upload_training(
|
||||
title=f"[קורפוס] {title}",
|
||||
file_path=str(dest),
|
||||
page_count=page_count,
|
||||
practice_area=practice_area,
|
||||
appeal_subtype=appeal_subtype,
|
||||
)
|
||||
doc_id = UUID(doc["id"])
|
||||
await db.update_document(doc_id, extracted_text=text, extraction_status="completed")
|
||||
await db.update_document(
|
||||
doc_id, extracted_text=text, extraction_status="completed",
|
||||
metadata={"practice_area": practice_area, "appeal_subtype": appeal_subtype},
|
||||
)
|
||||
|
||||
# Generate embeddings and store chunks
|
||||
texts = [c.content for c in chunks]
|
||||
@@ -193,10 +202,7 @@ async def document_upload_training(
|
||||
}
|
||||
for c, emb in zip(chunks, embs)
|
||||
]
|
||||
await db.store_chunks(
|
||||
doc_id, None, chunk_dicts,
|
||||
practice_area=practice_area, appeal_subtype=appeal_subtype,
|
||||
)
|
||||
await db.store_chunks(doc_id, None, chunk_dicts)
|
||||
|
||||
return json.dumps({
|
||||
"corpus_id": str(corpus_id),
|
||||
|
||||
@@ -454,11 +454,16 @@ async def save_block_content(case_number: str, block_id: str, content: str) -> s
|
||||
return str(e)
|
||||
|
||||
|
||||
async def analyze_style() -> str:
|
||||
"""הרצת ניתוח סגנון על קורפוס ההחלטות של דפנה. מחלץ דפוסי כתיבה ושומר אותם."""
|
||||
async def analyze_style(appeal_subtype: str = "") -> str:
|
||||
"""הרצת ניתוח סגנון על קורפוס ההחלטות של דפנה. מחלץ דפוסי כתיבה ושומר אותם.
|
||||
|
||||
Args:
|
||||
appeal_subtype: סינון לפי סוג ערר (building_permit / betterment_levy / compensation_197).
|
||||
ריק = כל ההחלטות.
|
||||
"""
|
||||
from legal_mcp.services.style_analyzer import analyze_corpus
|
||||
|
||||
result = await analyze_corpus()
|
||||
result = await analyze_corpus(appeal_subtype)
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
|
||||
47
web-ui/src/app/methodology/page.tsx
Normal file
47
web-ui/src/app/methodology/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { GoldenRatiosPanel } from "@/components/methodology/golden-ratios-panel";
|
||||
import { DiscussionRulesPanel } from "@/components/methodology/discussion-rules-panel";
|
||||
import { ContentChecklistsPanel } from "@/components/methodology/content-checklists-panel";
|
||||
|
||||
export default function MethodologyPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-navy">מתודולוגיה</h1>
|
||||
<p className="text-sm text-ink-muted mt-1">
|
||||
הגדרות ניסוח — יחסי אורך, כללי דיון, וצ׳קליסטים לפי סוג ערר
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<Tabs defaultValue="ratios" dir="rtl">
|
||||
<TabsList className="bg-rule-soft/60">
|
||||
<TabsTrigger value="ratios">יחסי זהב</TabsTrigger>
|
||||
<TabsTrigger value="rules">כללי דיון</TabsTrigger>
|
||||
<TabsTrigger value="checklists">צ׳קליסטים</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="ratios" className="mt-5">
|
||||
<GoldenRatiosPanel />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="rules" className="mt-5">
|
||||
<DiscussionRulesPanel />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="checklists" className="mt-5">
|
||||
<ContentChecklistsPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ type NavItem = {
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ href: "/", label: "בית" },
|
||||
{ href: "/training", label: "אימון סגנון" },
|
||||
{ href: "/methodology", label: "מתודולוגיה" },
|
||||
{ href: "/skills", label: "מיומנויות" },
|
||||
{ href: "/diagnostics", label: "אבחון" },
|
||||
{ href: "/settings", label: "הגדרות" },
|
||||
|
||||
181
web-ui/src/components/methodology/content-checklists-panel.tsx
Normal file
181
web-ui/src/components/methodology/content-checklists-panel.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import {
|
||||
useMethodology,
|
||||
useUpdateMethodology,
|
||||
useResetMethodology,
|
||||
} from "@/lib/api/methodology";
|
||||
import { toast } from "sonner";
|
||||
import { Save, RotateCcw, Eye, EyeOff, Loader2 } from "lucide-react";
|
||||
|
||||
const CHECKLIST_LABELS: Record<string, string> = {
|
||||
licensing_substantive: "ערר רישוי מהותי",
|
||||
licensing_threshold: "ערר רישוי סף/סמכות",
|
||||
licensing_property: "ערר רישוי קנייני",
|
||||
tama38: "תמ\"א 38",
|
||||
betterment_levy: "היטל השבחה",
|
||||
};
|
||||
|
||||
const CHECKLIST_ORDER = [
|
||||
"licensing_substantive",
|
||||
"licensing_threshold",
|
||||
"licensing_property",
|
||||
"tama38",
|
||||
"betterment_levy",
|
||||
];
|
||||
|
||||
type ChecklistItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
original: string;
|
||||
draft: string;
|
||||
isOverride: boolean;
|
||||
dirty: boolean;
|
||||
};
|
||||
|
||||
export function ContentChecklistsPanel() {
|
||||
const { data, isLoading } = useMethodology<string>("content_checklists");
|
||||
const update = useUpdateMethodology("content_checklists");
|
||||
const reset = useResetMethodology("content_checklists");
|
||||
const [items, setItems] = useState<ChecklistItem[]>([]);
|
||||
const [active, setActive] = useState(CHECKLIST_ORDER[0]);
|
||||
const [preview, setPreview] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.items) return;
|
||||
setItems(
|
||||
CHECKLIST_ORDER
|
||||
.filter((k) => k in data.items)
|
||||
.map((key) => ({
|
||||
key,
|
||||
label: CHECKLIST_LABELS[key] ?? key,
|
||||
original: data.items[key].value,
|
||||
draft: data.items[key].value,
|
||||
isOverride: data.items[key].is_override,
|
||||
dirty: false,
|
||||
})),
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const current = items.find((i) => i.key === active);
|
||||
|
||||
const updateDraft = (text: string) => {
|
||||
setItems((prev) =>
|
||||
prev.map((i) =>
|
||||
i.key === active
|
||||
? { ...i, draft: text, dirty: text !== i.original }
|
||||
: i,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!current) return;
|
||||
update.mutate(
|
||||
{ key: current.key, value: current.draft },
|
||||
{
|
||||
onSuccess: () => toast.success(`${current.label} נשמר`),
|
||||
onError: () => toast.error("שגיאה בשמירה"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (!current) return;
|
||||
reset.mutate(current.key, {
|
||||
onSuccess: () => toast.success(`${current.label} אופס`),
|
||||
onError: () => toast.error("שגיאה באיפוס"),
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-ink-faint">
|
||||
<Loader2 className="w-5 h-5 animate-spin ml-2" />
|
||||
טוען...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Tab selector */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{items.map((item) => (
|
||||
<Button
|
||||
key={item.key}
|
||||
size="sm"
|
||||
variant={active === item.key ? "default" : "outline"}
|
||||
onClick={() => { setActive(item.key); setPreview(false); }}
|
||||
className="text-xs"
|
||||
>
|
||||
{item.label}
|
||||
{item.isOverride && (
|
||||
<Badge variant="secondary" className="text-[9px] mr-1.5 px-1">
|
||||
מותאם
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Editor / Preview */}
|
||||
{current && (
|
||||
<Card className="border-rule">
|
||||
<CardContent className="px-5 py-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-navy">{current.label}</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setPreview(!preview)}
|
||||
className="text-xs"
|
||||
>
|
||||
{preview ? <EyeOff className="w-3.5 h-3.5 ml-1" /> : <Eye className="w-3.5 h-3.5 ml-1" />}
|
||||
{preview ? "עריכה" : "תצוגה מקדימה"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{preview ? (
|
||||
<div className="border border-rule rounded-md p-4 bg-sand-soft/30 max-h-[500px] overflow-y-auto">
|
||||
<Markdown content={current.draft} />
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
value={current.draft}
|
||||
onChange={(e) => updateDraft(e.target.value)}
|
||||
className="min-h-[400px] font-mono text-sm leading-relaxed"
|
||||
dir="rtl"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" disabled={!current.dirty || update.isPending} onClick={handleSave}>
|
||||
{update.isPending ? <Loader2 className="w-3 h-3 animate-spin ml-1" /> : <Save className="w-3 h-3 ml-1" />}
|
||||
שמור
|
||||
</Button>
|
||||
{current.isOverride && (
|
||||
<Button size="sm" variant="outline" disabled={reset.isPending} onClick={handleReset}>
|
||||
<RotateCcw className="w-3 h-3 ml-1" />
|
||||
איפוס לברירת מחדל
|
||||
</Button>
|
||||
)}
|
||||
<Badge
|
||||
variant={current.isOverride ? "default" : "secondary"}
|
||||
className="text-[10px] mr-auto"
|
||||
>
|
||||
{current.isOverride ? "מותאם" : "ברירת מחדל"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
188
web-ui/src/components/methodology/discussion-rules-panel.tsx
Normal file
188
web-ui/src/components/methodology/discussion-rules-panel.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
useMethodology,
|
||||
useUpdateMethodology,
|
||||
useResetMethodology,
|
||||
} from "@/lib/api/methodology";
|
||||
import { toast } from "sonner";
|
||||
import { Save, RotateCcw, Plus, Trash2, Loader2, ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
const OUTCOME_LABELS: Record<string, string> = {
|
||||
universal: "כללי (לכל סוגי התוצאות)",
|
||||
rejection: "דחייה",
|
||||
full_acceptance: "קבלה מלאה",
|
||||
partial_acceptance: "קבלה חלקית",
|
||||
betterment_levy: "היטל השבחה",
|
||||
};
|
||||
|
||||
type RulesSection = {
|
||||
key: string;
|
||||
label: string;
|
||||
original: string[];
|
||||
draft: string[];
|
||||
isOverride: boolean;
|
||||
dirty: boolean;
|
||||
expanded: boolean;
|
||||
};
|
||||
|
||||
export function DiscussionRulesPanel() {
|
||||
const { data, isLoading } = useMethodology<string[]>("discussion_rules");
|
||||
const update = useUpdateMethodology("discussion_rules");
|
||||
const reset = useResetMethodology("discussion_rules");
|
||||
const [sections, setSections] = useState<RulesSection[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.items) return;
|
||||
const order = ["universal", "rejection", "partial_acceptance", "full_acceptance", "betterment_levy"];
|
||||
setSections(
|
||||
order
|
||||
.filter((k) => k in data.items)
|
||||
.map((key) => ({
|
||||
key,
|
||||
label: OUTCOME_LABELS[key] ?? key,
|
||||
original: data.items[key].value,
|
||||
draft: [...data.items[key].value],
|
||||
isOverride: data.items[key].is_override,
|
||||
dirty: false,
|
||||
expanded: key === "universal",
|
||||
})),
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const toggle = (idx: number) => {
|
||||
setSections((prev) =>
|
||||
prev.map((s, i) => (i === idx ? { ...s, expanded: !s.expanded } : s)),
|
||||
);
|
||||
};
|
||||
|
||||
const updateRule = (sIdx: number, rIdx: number, text: string) => {
|
||||
setSections((prev) =>
|
||||
prev.map((s, i) => {
|
||||
if (i !== sIdx) return s;
|
||||
const draft = [...s.draft];
|
||||
draft[rIdx] = text;
|
||||
return { ...s, draft, dirty: JSON.stringify(draft) !== JSON.stringify(s.original) };
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const addRule = (sIdx: number) => {
|
||||
setSections((prev) =>
|
||||
prev.map((s, i) => {
|
||||
if (i !== sIdx) return s;
|
||||
const draft = [...s.draft, ""];
|
||||
return { ...s, draft, dirty: true };
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const removeRule = (sIdx: number, rIdx: number) => {
|
||||
setSections((prev) =>
|
||||
prev.map((s, i) => {
|
||||
if (i !== sIdx) return s;
|
||||
const draft = s.draft.filter((_, j) => j !== rIdx);
|
||||
return { ...s, draft, dirty: JSON.stringify(draft) !== JSON.stringify(s.original) };
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = (sec: RulesSection) => {
|
||||
const cleaned = sec.draft.filter((s) => s.trim());
|
||||
update.mutate(
|
||||
{ key: sec.key, value: cleaned },
|
||||
{
|
||||
onSuccess: () => toast.success(`${sec.label} נשמר`),
|
||||
onError: () => toast.error("שגיאה בשמירה"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleReset = (sec: RulesSection) => {
|
||||
reset.mutate(sec.key, {
|
||||
onSuccess: () => toast.success(`${sec.label} אופס`),
|
||||
onError: () => toast.error("שגיאה באיפוס"),
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-ink-faint">
|
||||
<Loader2 className="w-5 h-5 animate-spin ml-2" />
|
||||
טוען...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{sections.map((sec, si) => (
|
||||
<Card key={sec.key} className="border-rule">
|
||||
<CardContent className="px-5 py-0">
|
||||
{/* Accordion header */}
|
||||
<button
|
||||
className="flex items-center justify-between w-full py-3 text-right"
|
||||
onClick={() => toggle(si)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-navy">{sec.label}</h3>
|
||||
<Badge variant={sec.isOverride ? "default" : "secondary"} className="text-[10px]">
|
||||
{sec.isOverride ? "מותאם" : "ברירת מחדל"}
|
||||
</Badge>
|
||||
<span className="text-[11px] text-ink-faint">{sec.draft.length} כללים</span>
|
||||
</div>
|
||||
{sec.expanded ? <ChevronUp className="w-4 h-4 text-ink-faint" /> : <ChevronDown className="w-4 h-4 text-ink-faint" />}
|
||||
</button>
|
||||
|
||||
{/* Expanded content */}
|
||||
{sec.expanded && (
|
||||
<div className="pb-4 space-y-2">
|
||||
{sec.draft.map((rule, ri) => (
|
||||
<div key={ri} className="flex gap-2">
|
||||
<Textarea
|
||||
value={rule}
|
||||
onChange={(e) => updateRule(si, ri, e.target.value)}
|
||||
className="min-h-[60px] text-sm flex-1"
|
||||
dir="rtl"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-red-400 hover:text-red-600 flex-shrink-0 mt-1"
|
||||
onClick={() => removeRule(si, ri)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button size="sm" variant="outline" onClick={() => addRule(si)}>
|
||||
<Plus className="w-3 h-3 ml-1" />
|
||||
הוסף כלל
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-rule/50">
|
||||
<Button size="sm" disabled={!sec.dirty || update.isPending} onClick={() => handleSave(sec)}>
|
||||
{update.isPending ? <Loader2 className="w-3 h-3 animate-spin ml-1" /> : <Save className="w-3 h-3 ml-1" />}
|
||||
שמור
|
||||
</Button>
|
||||
{sec.isOverride && (
|
||||
<Button size="sm" variant="outline" disabled={reset.isPending} onClick={() => handleReset(sec)}>
|
||||
<RotateCcw className="w-3 h-3 ml-1" />
|
||||
איפוס
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
177
web-ui/src/components/methodology/golden-ratios-panel.tsx
Normal file
177
web-ui/src/components/methodology/golden-ratios-panel.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
useMethodology,
|
||||
useUpdateMethodology,
|
||||
useResetMethodology,
|
||||
type GoldenRatios,
|
||||
} from "@/lib/api/methodology";
|
||||
import { toast } from "sonner";
|
||||
import { Save, RotateCcw, Loader2 } from "lucide-react";
|
||||
|
||||
const OUTCOME_LABELS: Record<string, string> = {
|
||||
rejection: "דחייה",
|
||||
full_acceptance: "קבלה מלאה",
|
||||
partial_acceptance: "קבלה חלקית",
|
||||
betterment_levy: "היטל השבחה",
|
||||
};
|
||||
|
||||
const SECTION_LABELS: Record<string, string> = {
|
||||
background: "רקע",
|
||||
claims: "טענות",
|
||||
discussion: "דיון",
|
||||
summary: "סיכום",
|
||||
};
|
||||
|
||||
type RatioCard = {
|
||||
key: string;
|
||||
label: string;
|
||||
original: GoldenRatios;
|
||||
draft: GoldenRatios;
|
||||
isOverride: boolean;
|
||||
dirty: boolean;
|
||||
};
|
||||
|
||||
export function GoldenRatiosPanel() {
|
||||
const { data, isLoading } = useMethodology<GoldenRatios>("golden_ratios");
|
||||
const update = useUpdateMethodology("golden_ratios");
|
||||
const reset = useResetMethodology("golden_ratios");
|
||||
const [cards, setCards] = useState<RatioCard[]>([]);
|
||||
|
||||
// Sync from server
|
||||
useEffect(() => {
|
||||
if (!data?.items) return;
|
||||
setCards(
|
||||
Object.entries(data.items).map(([key, item]) => ({
|
||||
key,
|
||||
label: OUTCOME_LABELS[key] ?? key,
|
||||
original: item.value,
|
||||
draft: structuredClone(item.value),
|
||||
isOverride: item.is_override,
|
||||
dirty: false,
|
||||
})),
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const setRange = (cardIdx: number, section: string, idx: 0 | 1, val: number) => {
|
||||
setCards((prev) =>
|
||||
prev.map((c, i) => {
|
||||
if (i !== cardIdx) return c;
|
||||
const next = { ...c, draft: { ...c.draft, [section]: [...c.draft[section]] as [number, number] } };
|
||||
next.draft[section][idx] = val;
|
||||
next.dirty = JSON.stringify(next.draft) !== JSON.stringify(next.original);
|
||||
return next;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = (card: RatioCard) => {
|
||||
update.mutate(
|
||||
{ key: card.key, value: card.draft },
|
||||
{
|
||||
onSuccess: () => toast.success(`${card.label} נשמר`),
|
||||
onError: () => toast.error("שגיאה בשמירה"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleReset = (card: RatioCard) => {
|
||||
reset.mutate(card.key, {
|
||||
onSuccess: () => toast.success(`${card.label} אופס לברירת מחדל`),
|
||||
onError: () => toast.error("שגיאה באיפוס"),
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-ink-faint">
|
||||
<Loader2 className="w-5 h-5 animate-spin ml-2" />
|
||||
טוען...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{cards.map((card, ci) => (
|
||||
<Card key={card.key} className="border-rule">
|
||||
<CardContent className="px-5 py-4 space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-navy">{card.label}</h3>
|
||||
<Badge variant={card.isOverride ? "default" : "secondary"} className="text-[10px]">
|
||||
{card.isOverride ? "מותאם" : "ברירת מחדל"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-ink-faint text-[11px]">
|
||||
<th className="text-right py-1">Section</th>
|
||||
<th className="text-center py-1">Min %</th>
|
||||
<th className="text-center py-1">Max %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(SECTION_LABELS).map(([sec, label]) => (
|
||||
<tr key={sec} className="border-t border-rule/50">
|
||||
<td className="py-1.5 text-ink">{label}</td>
|
||||
<td className="py-1.5 px-1">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={card.draft[sec]?.[0] ?? 0}
|
||||
onChange={(e) => setRange(ci, sec, 0, Number(e.target.value))}
|
||||
className="h-7 w-16 text-center text-xs mx-auto"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1.5 px-1">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={card.draft[sec]?.[1] ?? 0}
|
||||
onChange={(e) => setRange(ci, sec, 1, Number(e.target.value))}
|
||||
className="h-7 w-16 text-center text-xs mx-auto"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!card.dirty || update.isPending}
|
||||
onClick={() => handleSave(card)}
|
||||
>
|
||||
{update.isPending ? <Loader2 className="w-3 h-3 animate-spin ml-1" /> : <Save className="w-3 h-3 ml-1" />}
|
||||
שמור
|
||||
</Button>
|
||||
{card.isOverride && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={reset.isPending}
|
||||
onClick={() => handleReset(card)}
|
||||
>
|
||||
<RotateCcw className="w-3 h-3 ml-1" />
|
||||
איפוס
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
web-ui/src/lib/api/methodology.ts
Normal file
71
web-ui/src/lib/api/methodology.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Methodology settings hooks — view and edit golden ratios,
|
||||
* discussion rules, and content checklists.
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "./client";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────
|
||||
|
||||
export type MethodologyItem<T = unknown> = {
|
||||
value: T;
|
||||
is_override: boolean;
|
||||
updated_at: string | null;
|
||||
};
|
||||
|
||||
export type MethodologyResponse<T = unknown> = {
|
||||
items: Record<string, MethodologyItem<T>>;
|
||||
};
|
||||
|
||||
/** Golden ratio per section: [min%, max%] */
|
||||
export type GoldenRatios = Record<string, [number, number]>;
|
||||
|
||||
// ── Query Keys ───────────────────────────────────────────────────
|
||||
|
||||
export const methodologyKeys = {
|
||||
all: ["methodology"] as const,
|
||||
category: (cat: string) => [...methodologyKeys.all, cat] as const,
|
||||
};
|
||||
|
||||
// ── Hooks ────────────────────────────────────────────────────────
|
||||
|
||||
export function useMethodology<T = unknown>(category: string) {
|
||||
return useQuery({
|
||||
queryKey: methodologyKeys.category(category),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<MethodologyResponse<T>>(
|
||||
`/api/methodology/${category}`,
|
||||
{ signal },
|
||||
),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateMethodology(category: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ key, value }: { key: string; value: unknown }) =>
|
||||
apiRequest<{ key: string; value: unknown; is_override: boolean }>(
|
||||
`/api/methodology/${category}/${key}`,
|
||||
{ method: "PUT", body: { value } },
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: methodologyKeys.category(category) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useResetMethodology(category: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (key: string) =>
|
||||
apiRequest<{ key: string; value: unknown; is_override: boolean }>(
|
||||
`/api/methodology/${category}/${key}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: methodologyKeys.category(category) });
|
||||
},
|
||||
});
|
||||
}
|
||||
101
web/app.py
101
web/app.py
@@ -22,6 +22,7 @@ import zipfile
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from typing import Any
|
||||
from pydantic import BaseModel
|
||||
|
||||
import asyncpg
|
||||
@@ -2332,6 +2333,106 @@ async def api_delete_tag_mapping(mapping_id: str):
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Methodology Settings ───────────────────────────────────────────
|
||||
|
||||
from legal_mcp.services.lessons import (
|
||||
GOLDEN_RATIOS,
|
||||
DISCUSSION_RULES,
|
||||
CONTENT_CHECKLISTS,
|
||||
)
|
||||
|
||||
_METHODOLOGY_DEFAULTS: dict[str, dict] = {
|
||||
"golden_ratios": {k: {s: list(v) for s, v in sec.items()} for k, sec in GOLDEN_RATIOS.items()},
|
||||
"discussion_rules": dict(DISCUSSION_RULES),
|
||||
"content_checklists": dict(CONTENT_CHECKLISTS),
|
||||
}
|
||||
|
||||
_VALID_CATEGORIES = set(_METHODOLOGY_DEFAULTS.keys())
|
||||
|
||||
|
||||
@app.get("/api/methodology/{category}")
|
||||
async def api_get_methodology(category: str):
|
||||
"""Get methodology settings with DB overrides merged over defaults."""
|
||||
if category not in _VALID_CATEGORIES:
|
||||
raise HTTPException(400, f"Unknown category: {category}. Valid: {sorted(_VALID_CATEGORIES)}")
|
||||
|
||||
defaults = _METHODOLOGY_DEFAULTS[category]
|
||||
pool = await db.get_pool()
|
||||
rows = await pool.fetch(
|
||||
"SELECT rule_key, rule_value, created_at FROM appeal_type_rules "
|
||||
"WHERE appeal_type = '_global' AND rule_category = $1",
|
||||
category,
|
||||
)
|
||||
overrides = {r["rule_key"]: r for r in rows}
|
||||
|
||||
items = {}
|
||||
for key, default_val in defaults.items():
|
||||
if key in overrides:
|
||||
items[key] = {
|
||||
"value": overrides[key]["rule_value"],
|
||||
"is_override": True,
|
||||
"updated_at": overrides[key]["created_at"].isoformat() if overrides[key]["created_at"] else None,
|
||||
}
|
||||
else:
|
||||
items[key] = {"value": default_val, "is_override": False, "updated_at": None}
|
||||
|
||||
return {"items": items}
|
||||
|
||||
|
||||
class MethodologyUpdateRequest(BaseModel):
|
||||
value: Any
|
||||
|
||||
|
||||
@app.put("/api/methodology/{category}/{key}")
|
||||
async def api_update_methodology(category: str, key: str, req: MethodologyUpdateRequest):
|
||||
"""Upsert a methodology override. Validates value shape per category."""
|
||||
if category not in _VALID_CATEGORIES:
|
||||
raise HTTPException(400, f"Unknown category: {category}")
|
||||
if key not in _METHODOLOGY_DEFAULTS[category]:
|
||||
raise HTTPException(400, f"Unknown key '{key}' for category '{category}'")
|
||||
|
||||
# Validate value shape
|
||||
if category == "golden_ratios":
|
||||
if not isinstance(req.value, dict):
|
||||
raise HTTPException(422, "golden_ratios value must be a dict of section → [min, max]")
|
||||
for sec, rng in req.value.items():
|
||||
if not (isinstance(rng, list) and len(rng) == 2 and all(isinstance(x, (int, float)) for x in rng)):
|
||||
raise HTTPException(422, f"Section '{sec}' must be [min, max] (integers 0-100)")
|
||||
elif category == "discussion_rules":
|
||||
if not isinstance(req.value, list) or not all(isinstance(s, str) and s.strip() for s in req.value):
|
||||
raise HTTPException(422, "discussion_rules value must be a list of non-empty strings")
|
||||
elif category == "content_checklists":
|
||||
if not isinstance(req.value, str) or not req.value.strip():
|
||||
raise HTTPException(422, "content_checklists value must be a non-empty string")
|
||||
|
||||
pool = await db.get_pool()
|
||||
await pool.execute(
|
||||
"INSERT INTO appeal_type_rules (id, appeal_type, rule_category, rule_key, rule_value) "
|
||||
"VALUES (gen_random_uuid(), '_global', $1, $2, $3::jsonb) "
|
||||
"ON CONFLICT (appeal_type, rule_category, rule_key) DO UPDATE SET rule_value = $3::jsonb",
|
||||
category, key, json.dumps(req.value, ensure_ascii=False),
|
||||
)
|
||||
|
||||
return {"key": key, "value": req.value, "is_override": True}
|
||||
|
||||
|
||||
@app.delete("/api/methodology/{category}/{key}")
|
||||
async def api_reset_methodology(category: str, key: str):
|
||||
"""Delete methodology override, restoring the hardcoded default."""
|
||||
if category not in _VALID_CATEGORIES:
|
||||
raise HTTPException(400, f"Unknown category: {category}")
|
||||
if key not in _METHODOLOGY_DEFAULTS[category]:
|
||||
raise HTTPException(400, f"Unknown key '{key}' for category '{category}'")
|
||||
|
||||
pool = await db.get_pool()
|
||||
await pool.execute(
|
||||
"DELETE FROM appeal_type_rules WHERE appeal_type = '_global' AND rule_category = $1 AND rule_key = $2",
|
||||
category, key,
|
||||
)
|
||||
|
||||
return {"key": key, "value": _METHODOLOGY_DEFAULTS[category][key], "is_override": False}
|
||||
|
||||
|
||||
# ── Skill Management API ───────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user