3 Commits

Author SHA1 Message Date
3288624349 Add methodology settings page with golden ratios, discussion rules, and checklists
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m29s
New /methodology page with 3 tabs for viewing and editing decision
writing methodology. Uses DB override pattern: hardcoded Python
constants serve as defaults, edits saved to appeal_type_rules table,
delete restores default.

Backend: 3 generic endpoints (GET/PUT/DELETE /api/methodology/{category}/{key})
with validation per category type.

Frontend: methodology.ts hooks, GoldenRatiosPanel (number inputs per
outcome/section), DiscussionRulesPanel (accordion with textarea per
rule), ContentChecklistsPanel (markdown editor with preview toggle).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:30:39 +00:00
5dd24729e2 Auto-strip Nevo preambles and separate style analysis per appeal subtype
- Add strip_nevo_preamble() to extractor.py — auto-removes Nevo database
  headers (bibliography, legislation, mini-ratio) during training upload
- Add appeal_subtype column to style_patterns table — patterns are now
  stored per subtype instead of globally mixed
- Update clear_style_patterns() to support subtype-scoped deletion
- Pass appeal_subtype through analyze_corpus → store → upsert pipeline

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:03:06 +00:00
ba39707c70 Add CMPA (betterment levy) training support and update methodology
Support ingestion of betterment levy (היטל השבחה) decisions into a
separate training corpus (CMPA). Key changes:

- Add .doc file extraction via LibreOffice conversion in extractor
- Add practice_area/appeal_subtype columns to style_corpus table
- Route training files to cmp/ or cmpa/ subdirs based on appeal subtype
- Fix derive_subtype to handle ARAR-YY-NNNN format (was matching year digit)
- Expose practice_area/appeal_subtype params in MCP upload_training tool
- Add appeal_subtype filter to analyze_style for per-type style analysis
- Update betterment levy methodology in lessons.py: checklist (from generic
  to corpus-based), opening/closing strategies, and discussion rules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:00:35 +00:00
15 changed files with 976 additions and 69 deletions

View File

@@ -165,10 +165,13 @@ async def document_upload_training(
decision_date: str = "", decision_date: str = "",
subject_categories: list[str] | None = None, subject_categories: list[str] | None = None,
title: str = "", title: str = "",
practice_area: str = "appeals_committee",
appeal_subtype: str = "",
) -> str: ) -> str:
"""העלאת החלטה קודמת של דפנה לקורפוס הסגנון. קטגוריות: בנייה, שימוש חורג, תכנית, היתר, הקלה, חלוקה, תמ"א 38, היטל השבחה, פיצויים 197.""" """העלאת החלטה קודמת של דפנה לקורפוס הסגנון. קטגוריות: בנייה, שימוש חורג, תכנית, היתר, הקלה, חלוקה, תמ"א 38, היטל השבחה, פיצויים 197. סוג ערר: building_permit / betterment_levy / compensation_197 (ריק = אוטומטי ממספר ההחלטה)."""
return await documents.document_upload_training( return await documents.document_upload_training(
file_path, decision_number, decision_date, subject_categories, title, 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() @mcp.tool()
async def analyze_style() -> str: async def analyze_style(appeal_subtype: str = "") -> str:
"""ניתוח סגנון על קורפוס ההחלטות של דפנה. מחלץ ושומר דפוסי כתיבה.""" """ניתוח סגנון על קורפוס ההחלטות של דפנה. מחלץ ושומר דפוסי כתיבה. סוג ערר: building_permit / betterment_levy / compensation_197 (ריק = הכל)."""
return await drafting.analyze_style() return await drafting.analyze_style(appeal_subtype)
@mcp.tool() @mcp.tool()

View File

@@ -104,6 +104,8 @@ CREATE TABLE IF NOT EXISTS style_corpus (
summary TEXT DEFAULT '', summary TEXT DEFAULT '',
outcome TEXT DEFAULT '', outcome TEXT DEFAULT '',
key_principles JSONB DEFAULT '[]', key_principles JSONB DEFAULT '[]',
practice_area TEXT DEFAULT 'appeals_committee',
appeal_subtype TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT now() created_at TIMESTAMPTZ DEFAULT now()
); );
@@ -114,6 +116,7 @@ CREATE TABLE IF NOT EXISTS style_patterns (
frequency INTEGER DEFAULT 1, frequency INTEGER DEFAULT 1,
context TEXT DEFAULT '', context TEXT DEFAULT '',
examples JSONB DEFAULT '[]', examples JSONB DEFAULT '[]',
appeal_subtype TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT now() 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 practice_area TEXT DEFAULT 'appeals_committee';
ALTER TABLE cases ADD COLUMN IF NOT EXISTS appeal_subtype TEXT DEFAULT ''; 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 -- טבלת qa_results
CREATE TABLE IF NOT EXISTS qa_results ( CREATE TABLE IF NOT EXISTS qa_results (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
@@ -882,6 +892,8 @@ async def add_to_style_corpus(
summary: str = "", summary: str = "",
outcome: str = "", outcome: str = "",
key_principles: list[str] | None = None, key_principles: list[str] | None = None,
practice_area: str = "appeals_committee",
appeal_subtype: str = "",
) -> UUID: ) -> UUID:
pool = await get_pool() pool = await get_pool()
corpus_id = uuid4() corpus_id = uuid4()
@@ -889,11 +901,13 @@ async def add_to_style_corpus(
await conn.execute( await conn.execute(
"""INSERT INTO style_corpus """INSERT INTO style_corpus
(id, document_id, decision_number, decision_date, (id, document_id, decision_number, decision_date,
subject_categories, full_text, summary, outcome, key_principles) subject_categories, full_text, summary, outcome, key_principles,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)""", practice_area, appeal_subtype)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)""",
corpus_id, document_id, decision_number, decision_date, corpus_id, document_id, decision_number, decision_date,
json.dumps(subject_categories), full_text, summary, outcome, json.dumps(subject_categories), full_text, summary, outcome,
json.dumps(key_principles or []), json.dumps(key_principles or []),
practice_area, appeal_subtype,
) )
return corpus_id return corpus_id
@@ -963,12 +977,14 @@ async def upsert_style_pattern(
pattern_text: str, pattern_text: str,
context: str = "", context: str = "",
examples: list[str] | None = None, examples: list[str] | None = None,
appeal_subtype: str = "",
) -> None: ) -> None:
pool = await get_pool() pool = await get_pool()
async with pool.acquire() as conn: async with pool.acquire() as conn:
existing = await conn.fetchrow( existing = await conn.fetchrow(
"SELECT id, frequency FROM style_patterns WHERE pattern_type = $1 AND pattern_text = $2", "SELECT id, frequency FROM style_patterns "
pattern_type, pattern_text, "WHERE pattern_type = $1 AND pattern_text = $2 AND appeal_subtype = $3",
pattern_type, pattern_text, appeal_subtype,
) )
if existing: if existing:
await conn.execute( await conn.execute(
@@ -977,18 +993,27 @@ async def upsert_style_pattern(
) )
else: else:
await conn.execute( await conn.execute(
"""INSERT INTO style_patterns (pattern_type, pattern_text, context, examples) """INSERT INTO style_patterns (pattern_type, pattern_text, context, examples, appeal_subtype)
VALUES ($1, $2, $3, $4)""", VALUES ($1, $2, $3, $4, $5)""",
pattern_type, pattern_text, context, pattern_type, pattern_text, context,
json.dumps(examples or []), json.dumps(examples or []),
appeal_subtype,
) )
async def clear_style_patterns() -> None: async def clear_style_patterns(appeal_subtype: str = "") -> None:
"""Delete all existing style patterns (used before re-analysis).""" """Delete style patterns, optionally filtered by appeal_subtype.
Empty appeal_subtype = delete ALL patterns.
"""
pool = await get_pool() pool = await get_pool()
async with pool.acquire() as conn: 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) ───────────── # ── Semantic Search (V2 — decision blocks & case law) ─────────────

View File

@@ -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). Primary PDF extraction: PyMuPDF direct text (for born-digital PDFs).
Fallback: Google Cloud Vision OCR (for scanned documents). Fallback: Google Cloud Vision OCR (for scanned documents).
DOC files: converted to DOCX via LibreOffice before extraction.
Post-processing: Hebrew abbreviation quote fixer. Post-processing: Hebrew abbreviation quote fixer.
""" """
@@ -10,6 +11,8 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import re import re
import subprocess
import tempfile
from pathlib import Path from pathlib import Path
import fitz # PyMuPDF import fitz # PyMuPDF
@@ -129,6 +132,8 @@ async def extract_text(file_path: str) -> tuple[str, int]:
return await _extract_pdf(path) return await _extract_pdf(path)
elif suffix == ".docx": elif suffix == ".docx":
return _extract_docx(path), 0 return _extract_docx(path), 0
elif suffix == ".doc":
return _extract_doc(path), 0
elif suffix == ".rtf": elif suffix == ".rtf":
return _extract_rtf(path), 0 return _extract_rtf(path), 0
elif suffix in (".txt", ".md"): 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) 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: def _extract_docx(path: Path) -> str:
"""Extract text from DOCX file.""" """Extract text from DOCX file."""
doc = DocxDocument(str(path)) doc = DocxDocument(str(path))
@@ -198,3 +218,30 @@ def _extract_rtf(path: Path) -> str:
"""Extract text from RTF file.""" """Extract text from RTF file."""
rtf_content = path.read_text(encoding="utf-8", errors="replace") rtf_content = path.read_text(encoding="utf-8", errors="replace")
return rtf_to_text(rtf_content) 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

View File

@@ -72,9 +72,14 @@ OPENING_STRATEGIES = {
), ),
}, },
"betterment_levy": { "betterment_levy": {
"style": "direct_with_disclaimer", "style": "direct_factual",
"paragraphs": (1, 3), "paragraphs": (1, 3),
"description": "פתיחה ישירה עם מסקנה + 'על מנת לא לצאת בחסר'", "description": (
"פתיחה ישירה ועובדתית: 'בפנינו ערר על דרישת תשלום היטל השבחה מיום [תאריך] "
"בסך של [סכום] ₪' → רקע קצר (נכס, תכנית משביחה, מימוש) → "
"תמצית טענות הצדדים (עוררים + משיבה בנפרד). "
"אין הקשר תכנוני רחב. הפתיחה = עובדות בלבד."
),
}, },
} }
@@ -101,9 +106,16 @@ SUMMARY_STRATEGIES = {
), ),
}, },
"betterment_levy": { "betterment_levy": {
"heading": "סיכום", "heading": "various",
"format": "numbered_hebrew_dry", "format": "dry_operative",
"description": "אותיות עבריות, סיום יבש ללא פסקה חמה", "description": (
"סיום יבש ואופרטיבי. כותרת משתנה: 'סוף דבר' / 'לאור כל האמור לעיל' / ללא כותרת. "
"תוכן: 'הערר נדחה/מתקבל' + הוצאות ('כל צד ישא בהוצאותיו' / חיוב בסכום). "
"אם מתקבל: הוראות אופרטיביות (החזר, שומה מתוקנת, תנאים). "
"חתימה: 'ניתנה פה אחד היום, [תאריך עברי], [תאריך לועזי].' "
"לעיתים: 'התיק ייסגר.' / 'עומדת זכות ערר כדין.' "
"אין פסקה חמה. אין חזרה על נימוקים."
),
}, },
} }
@@ -129,7 +141,12 @@ DISCUSSION_RULES: dict[str, list[str]] = {
"מבנה ישיר: נקודות עיקריות → ניתוח → מסקנה.", "מבנה ישיר: נקודות עיקריות → ניתוח → מסקנה.",
], ],
"betterment_levy": [ "betterment_levy": [
"מבנה ישיר עם מסקנה מוקדמת + 'על מנת לא לצאת בחסר' לנקודות נוספות.", "פתיחת דיון: מסקנה מוקדמת ('לאחר שבחנו... מצאנו כי דין הערר להידחות/להתקבל').",
"תקן ביקורת: ציון רף ההתערבות בשומה מכרעת (בר\"ם 3644/13 גלר) — אבחנה בין שמאי למשפטי.",
"הצגת הלכה פסוקה: ציטוט ארוך מפס\"ד מרכזי → 'ברוח הדברים לעיל נבחן את טענות הצדדים'.",
"טיפול שיטתי: כל טענה/סוגיה בנפרד → ניתוח → מסקנת ביניים.",
"ביטויים: 'אין בידינו לקבל', 'לא מצאנו מקום להתערב', 'קביעה נכונה שאין מקום להתערב בה'.",
"'על מנת לא לצאת בחסר' — לנקודות obiter dicta בסוף הדיון.",
], ],
} }
@@ -448,26 +465,41 @@ CONTENT_CHECKLISTS: dict[str, str] = {
""", """,
"betterment_levy": """## צ'קליסט תוכן — ערר היטל השבחה "betterment_levy": """## צ'קליסט תוכן — ערר היטל השבחה
⚠️ שים לב: אין עדיין החלטות היטל השבחה בקורפוס האימון. מבוסס על ניתוח 26 החלטות של דפנה תמיר (קורפוס CMPA, אפריל 2026).
הצ'קליסט הזה מבוסס על ידע כללי — לא על ניתוח ספציפי של סגנון דפנה.
### א. המסגרת הנורמטיבית ### א. תקן ביקורת (חובה בפתיחת הדיון)
- ציין את רף ההתערבות: "ועדת הערר תיטה לאמץ את חוות דעתו של השמאי..."
- אבחנה: התערבות מצומצמת בעניינים שמאיים-מקצועיים, התערבות רחבה בעניינים משפטיים
- הפניה ל-בר"ם 3644/13 גלר או פסיקה דומה
### ב. המסגרת הנורמטיבית
- התוספת השלישית לחוק התכנון והבנייה - התוספת השלישית לחוק התכנון והבנייה
- אירוע מס — מה יצר את ההשבחה? - סעיפי הפטור הרלוונטיים (ס' 19(ג), ס' 19(ב) וכו')
- אירוע מס — מה יצר את ההשבחה? (תכנית, היתר, מכר)
- מועד המימוש ומועד הקובע
### ב. שומה ### ג. שומה ומתודולוגיה שמאית
- שיטת השומה (שומה מכרעת / שמאי מייעץ) - שיטת השומה (שומה מכרעת / שומה מוסכמת / שמאי מייעץ)
- מועד הקובע - מבחן השימוש הטוב והיעיל (highest and best use) — מצב קודם ומצב חדש
- זכויות בנייה — לפני ואחרי - זכויות בנייה — לפני ואחרי (אחוזי בנייה, שטחים עיקריים, תמהיל שימושים)
- שווי מקרקעין — מצב קודם ומצב חדש (שיטת השוואה / יחידות תועלת)
- עלויות עודפות (חניה, מטלות ציבוריות, תשתיות)
- מקדמי זמינות, שיעורי הפקעה
### ג. שאלות משפטיות ### ד. שאלות משפטיות (לפי רלוונטיות)
- פטורים (ס' 19) - פטורים — דירת מגורים (ס' 19(ג)(1)), שטח עד 140 מ"ר, תא משפחתי
- מועדי תשלום - מועד מימוש — זיכרון דברים vs הסכם מכר, העברת זכויות
- שיערוך - זהות החייב — בעלים, חוכר, יזם, חברה בבעלות יזם
- מקרקעי ישראל — הסדרים מיוחדים (ס' 21 לתוספת השלישית)
- שומות מוסכמות — תוקף, משמעות, "בלתי נצפה מראש"
- פרשנות תכניות — ייעוד, שימושים מותרים, מדיניות ועדה מקומית
### ד. ניתוח שמאי ### ה. ניתוח שמאי (כשיש שומה מכרעת)
- האם השומה תקינה? - האם השומה מבוססת על מסד עובדתי הולם?
- פערים בין השומות - האם השיטה השמאית מקובלת?
- האם ההנחות סבירות והגיוניות?
- טעות מהותית / דופי חמור?
- פגם מינהלי (ניגוד עניינים, משוא פנים)?
""", """,
} }

View File

@@ -43,14 +43,17 @@ SUBTYPES_BY_AREA: dict[str, set[str]] = {
# ── Derivation ───────────────────────────────────────────────────── # ── Derivation ─────────────────────────────────────────────────────
_FIRST_DIGIT = re.compile(r"^\s*(\d)")
_APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE = { _APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE = {
"1": "building_permit", "1": "building_permit",
"8": "betterment_levy", "8": "betterment_levy",
"9": "compensation_197", "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: 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.
@@ -58,15 +61,20 @@ def derive_subtype(case_number: str, practice_area: str = DEFAULT_PRACTICE_AREA)
For appeals_committee, the convention is: For appeals_committee, the convention is:
1xxx → building_permit, 8xxx → betterment_levy, 9xxx → compensation_197. 1xxx → building_permit, 8xxx → betterment_levy, 9xxx → compensation_197.
For other practice areas there is no public numbering convention yet, Handles multiple formats: ARAR-25-8126, 8126/25, 1170, ערר 1024-25.
so we return 'unknown' until a real rule is defined.
""" """
if practice_area != "appeals_committee": if practice_area != "appeals_committee":
return "unknown" 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: if not m:
return "unknown" 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 ───────────────────────────────────────────────────── # ── Validation ─────────────────────────────────────────────────────

View File

@@ -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. """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. Returns summary of patterns found.
""" """
pool = await db.get_pool() pool = await db.get_pool()
async with pool.acquire() as conn: async with pool.acquire() as conn:
rows = await conn.fetch( if appeal_subtype:
"SELECT full_text, decision_number FROM style_corpus ORDER BY decision_date DESC LIMIT 20" 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: if not rows:
return {"error": "אין החלטות בקורפוס. העלה החלטות קודמות תחילה."} return {"error": "אין החלטות בקורפוס. העלה החלטות קודמות תחילה."}
# Clear old patterns before re-analysis # Clear old patterns for this subtype (or all if unfiltered)
await db.clear_style_patterns() await db.clear_style_patterns(appeal_subtype)
# Calculate token budget # Calculate token budget
total_chars = sum(len(row["full_text"]) for row in rows) 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: if estimated_tokens < MAX_INPUT_TOKENS:
return await _analyze_single_pass(rows) return await _analyze_single_pass(rows, appeal_subtype)
else: 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.""" """Send all decisions in a single API call."""
decisions_text = "" decisions_text = ""
for row in rows: for row in rows:
@@ -153,10 +164,10 @@ async def _analyze_single_pass(rows) -> dict:
timeout=claude_session.LONG_TIMEOUT, 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.""" """Analyze each decision individually, then synthesize patterns."""
all_patterns = [] all_patterns = []
@@ -186,7 +197,7 @@ async def _analyze_multi_pass(rows) -> dict:
timeout=claude_session.LONG_TIMEOUT, 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: def _extract_json(response_text: str) -> list | None:
@@ -237,14 +248,16 @@ def _extract_json(response_text: str) -> list | None:
return 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.""" """Parse Claude's response and store patterns in the database."""
patterns = _extract_json(response_text) patterns = _extract_json(response_text)
if patterns is None: if patterns is None:
return {"error": "Could not parse analysis results", "raw": response_text} return {"error": "Could not parse analysis results", "raw": response_text}
# Store patterns # Store patterns tagged by appeal_subtype
count = 0 count = 0
for pattern in patterns: for pattern in patterns:
await db.upsert_style_pattern( 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", ""), pattern_text=pattern.get("text", ""),
context=pattern.get("context", ""), context=pattern.get("context", ""),
examples=[pattern.get("example", "")], examples=[pattern.get("example", "")],
appeal_subtype=appeal_subtype,
) )
count += 1 count += 1
return { return {
"patterns_found": count, "patterns_found": count,
"decisions_analyzed": num_decisions, "decisions_analyzed": num_decisions,
"appeal_subtype": appeal_subtype or "all",
"pattern_types": list({p.get("type") for p in patterns}), "pattern_types": list({p.get("type") for p in patterns}),
} }

View File

@@ -139,14 +139,22 @@ async def document_upload_training(
appeal_subtype = pa.derive_subtype(decision_number, practice_area) appeal_subtype = pa.derive_subtype(decision_number, practice_area)
pa.validate(practice_area, appeal_subtype) pa.validate(practice_area, appeal_subtype)
# Copy to training directory (skip if already there) # Copy to training directory, organized by subtype
config.TRAINING_DIR.mkdir(parents=True, exist_ok=True) _SUBTYPE_DIRS = {
dest = config.TRAINING_DIR / source.name "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(): if source.resolve() != dest.resolve():
shutil.copy2(str(source), str(dest)) shutil.copy2(str(source), str(dest))
# Extract text # Extract text and strip Nevo preamble
text, page_count = await extractor.extract_text(str(dest)) text, page_count = await extractor.extract_text(str(dest))
text = extractor.strip_nevo_preamble(text)
# Parse date # Parse date
d_date = None d_date = None
@@ -174,11 +182,12 @@ async def document_upload_training(
title=f"[קורפוס] {title}", title=f"[קורפוס] {title}",
file_path=str(dest), file_path=str(dest),
page_count=page_count, page_count=page_count,
practice_area=practice_area,
appeal_subtype=appeal_subtype,
) )
doc_id = UUID(doc["id"]) 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 # Generate embeddings and store chunks
texts = [c.content for c in chunks] texts = [c.content for c in chunks]
@@ -193,10 +202,7 @@ async def document_upload_training(
} }
for c, emb in zip(chunks, embs) for c, emb in zip(chunks, embs)
] ]
await db.store_chunks( await db.store_chunks(doc_id, None, chunk_dicts)
doc_id, None, chunk_dicts,
practice_area=practice_area, appeal_subtype=appeal_subtype,
)
return json.dumps({ return json.dumps({
"corpus_id": str(corpus_id), "corpus_id": str(corpus_id),

View File

@@ -454,11 +454,16 @@ async def save_block_content(case_number: str, block_id: str, content: str) -> s
return str(e) 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 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) return json.dumps(result, ensure_ascii=False, indent=2)

View 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>
);
}

View File

@@ -25,6 +25,7 @@ type NavItem = {
const NAV_ITEMS: NavItem[] = [ const NAV_ITEMS: NavItem[] = [
{ href: "/", label: "בית" }, { href: "/", label: "בית" },
{ href: "/training", label: "אימון סגנון" }, { href: "/training", label: "אימון סגנון" },
{ href: "/methodology", label: "מתודולוגיה" },
{ href: "/skills", label: "מיומנויות" }, { href: "/skills", label: "מיומנויות" },
{ href: "/diagnostics", label: "אבחון" }, { href: "/diagnostics", label: "אבחון" },
{ href: "/settings", label: "הגדרות" }, { href: "/settings", label: "הגדרות" },

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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) });
},
});
}

View File

@@ -22,6 +22,7 @@ import zipfile
from fastapi import FastAPI, File, Form, HTTPException, UploadFile from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from typing import Any
from pydantic import BaseModel from pydantic import BaseModel
import asyncpg import asyncpg
@@ -2332,6 +2333,106 @@ async def api_delete_tag_mapping(mapping_id: str):
return {"ok": True} 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 ─────────────────────────────────────────── # ── Skill Management API ───────────────────────────────────────────