Compare commits
3 Commits
684a4cfd3b
...
3288624349
| Author | SHA1 | Date | |
|---|---|---|---|
| 3288624349 | |||
| 5dd24729e2 | |||
| ba39707c70 |
@@ -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()
|
||||||
|
|||||||
@@ -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) ─────────────
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 לתוספת השלישית)
|
||||||
|
- שומות מוסכמות — תוקף, משמעות, "בלתי נצפה מראש"
|
||||||
|
- פרשנות תכניות — ייעוד, שימושים מותרים, מדיניות ועדה מקומית
|
||||||
|
|
||||||
### ד. ניתוח שמאי
|
### ה. ניתוח שמאי (כשיש שומה מכרעת)
|
||||||
- האם השומה תקינה?
|
- האם השומה מבוססת על מסד עובדתי הולם?
|
||||||
- פערים בין השומות
|
- האם השיטה השמאית מקובלת?
|
||||||
|
- האם ההנחות סבירות והגיוניות?
|
||||||
|
- טעות מהותית / דופי חמור?
|
||||||
|
- פגם מינהלי (ניגוד עניינים, משוא פנים)?
|
||||||
""",
|
""",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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[] = [
|
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: "הגדרות" },
|
||||||
|
|||||||
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 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 ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user