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 = "",
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()

View File

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

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).
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

View File

@@ -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 לתוספת השלישית)
- שומות מוסכמות — תוקף, משמעות, "בלתי נצפה מראש"
- פרשנות תכניות — ייעוד, שימושים מותרים, מדיניות ועדה מקומית
### ד. ניתוח שמאי
- האם השומה תקינה?
- פערים בין השומות
### ה. ניתוח שמאי (כשיש שומה מכרעת)
- האם השומה מבוססת על מסד עובדתי הולם?
- האם השיטה השמאית מקובלת?
- האם ההנחות סבירות והגיוניות?
- טעות מהותית / דופי חמור?
- פגם מינהלי (ניגוד עניינים, משוא פנים)?
""",
}

View File

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

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.
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}),
}

View File

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

View File

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

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[] = [
{ href: "/", label: "בית" },
{ href: "/training", label: "אימון סגנון" },
{ href: "/methodology", label: "מתודולוגיה" },
{ href: "/skills", label: "מיומנויות" },
{ href: "/diagnostics", 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.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 ───────────────────────────────────────────