Edit document doc_type and appraiser side from the case UI
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m26s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m26s
Until now changing a document's doc_type required a manual SQL update.
Adds an inline editor on the document badge so the chair can retag
without leaving the case page, and threads an appraiser_side tag
(committee / appellant / deciding) through the appraisal pipeline so
betterment-levy cases — which usually have 2-3 appraisers — render
conflicts with the deciding appraiser's view marked as governing.
Backend
- New appraiser_facts.appraiser_side column (V5.1) populated from
documents.metadata.appraiser_side at extraction time.
- extract_appraiser_facts now returns status='sides_missing' with the
list of untagged appraisals instead of running with empty side
labels — chair must tag every appraisal first via the UI.
- Conflict detection orders entries committee → appellant → deciding so
the deciding appraiser appears last; block-tet's prompt instructs the
writer to phrase the deciding appraiser's view as the governing
factual finding ("ואולם, השמאי המכריע קבע...").
- New PATCH /api/cases/{n}/documents/{doc_id} (Pydantic model with
whitelist validation) and matching document_update MCP tool. Both
merge appraiser_side into metadata JSONB instead of touching the
schema.
UI
- New shared doc-types module exports the canonical 11 doc_type
options plus the 3 appraiser-side options; both upload-sheet and
the document badge now read from it instead of duplicating Hebrew
labels.
- New DocumentTypeEditor renders a Popover off the doc-type Badge
with two Selects. The save button stays disabled while doc_type is
appraisal but no side has been picked, mirroring the backend
enforcement so the user finds out before triggering extraction.
- usePatchDocument React-Query mutation invalidates the case detail
on success so the badge updates without a manual refresh.
This commit is contained in:
@@ -187,6 +187,17 @@ async def document_list(case_number: str) -> str:
|
|||||||
return await documents.document_list(case_number)
|
return await documents.document_list(case_number)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def document_update(
|
||||||
|
case_number: str,
|
||||||
|
doc_id: str,
|
||||||
|
doc_type: str = "",
|
||||||
|
appraiser_side: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""עדכון תיוג מסמך — doc_type ו/או appraiser_side (committee/appellant/deciding). ריק = ללא שינוי."""
|
||||||
|
return await documents.document_update(case_number, doc_id, doc_type, appraiser_side)
|
||||||
|
|
||||||
|
|
||||||
# Claims extraction
|
# Claims extraction
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def extract_claims(
|
async def extract_claims(
|
||||||
|
|||||||
@@ -4,11 +4,16 @@
|
|||||||
לאפשר זיהוי אוטומטי של סתירות בין שמאים שונים על אותו זיהוי (תכנית או היתר).
|
לאפשר זיהוי אוטומטי של סתירות בין שמאים שונים על אותו זיהוי (תכנית או היתר).
|
||||||
|
|
||||||
שמירה ב-DB: טבלת appraiser_facts (case_id, document_id, appraiser_name,
|
שמירה ב-DB: טבלת appraiser_facts (case_id, document_id, appraiser_name,
|
||||||
fact_type, identifier, details JSONB, page_number).
|
appraiser_side, fact_type, identifier, details JSONB, page_number).
|
||||||
|
|
||||||
|
Precondition: כל מסמך doc_type='appraisal' חייב להיות מתויג עם
|
||||||
|
metadata.appraiser_side מתוך {committee, appellant, deciding}. החילוץ עוצר
|
||||||
|
ומחזיר status='sides_missing' אם יש מסמכים לא מתויגים.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -17,6 +22,13 @@ from legal_mcp.services import claude_session, db
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Allowed sides for an appraiser in an appeals committee case.
|
||||||
|
# committee = שמאי הוועדה המקומית
|
||||||
|
# appellant = שמאי העורר / הצד שכנגד הוועדה
|
||||||
|
# deciding = שמאי מכריע
|
||||||
|
VALID_APPRAISER_SIDES = {"committee", "appellant", "deciding"}
|
||||||
|
|
||||||
|
|
||||||
EXTRACT_FACTS_PROMPT = """אתה מנתח שומות מקרקעין לטובת ועדת ערר לתכנון ובניה.
|
EXTRACT_FACTS_PROMPT = """אתה מנתח שומות מקרקעין לטובת ועדת ערר לתכנון ובניה.
|
||||||
|
|
||||||
תפקידך: לחלץ מתוך השומה שתי קטגוריות של עובדות אובייקטיביות שעליהן השמאי מבסס את חוות דעתו:
|
תפקידך: לחלץ מתוך השומה שתי קטגוריות של עובדות אובייקטיביות שעליהן השמאי מבסס את חוות דעתו:
|
||||||
@@ -77,6 +89,7 @@ async def extract_facts_from_document(
|
|||||||
case_id: UUID,
|
case_id: UUID,
|
||||||
document_id: UUID,
|
document_id: UUID,
|
||||||
appraiser_name: str,
|
appraiser_name: str,
|
||||||
|
appraiser_side: str,
|
||||||
text: str,
|
text: str,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Extract structured facts from a single appraisal document via Claude Code."""
|
"""Extract structured facts from a single appraisal document via Claude Code."""
|
||||||
@@ -107,32 +120,63 @@ async def extract_facts_from_document(
|
|||||||
continue
|
continue
|
||||||
all_facts.append({
|
all_facts.append({
|
||||||
"appraiser_name": appraiser_name,
|
"appraiser_name": appraiser_name,
|
||||||
|
"appraiser_side": appraiser_side,
|
||||||
"fact_type": item["fact_type"],
|
"fact_type": item["fact_type"],
|
||||||
"identifier": _normalize_identifier(ident),
|
"identifier": _normalize_identifier(ident),
|
||||||
"details": item.get("details") or {},
|
"details": item.get("details") or {},
|
||||||
"page_number": item.get("page_number"),
|
"page_number": item.get("page_number"),
|
||||||
})
|
})
|
||||||
|
|
||||||
if all_facts:
|
await db.replace_appraiser_facts(case_id, document_id, all_facts)
|
||||||
await db.replace_appraiser_facts(case_id, document_id, all_facts)
|
|
||||||
else:
|
|
||||||
await db.replace_appraiser_facts(case_id, document_id, [])
|
|
||||||
return all_facts
|
return all_facts
|
||||||
|
|
||||||
|
|
||||||
|
def _doc_metadata(doc: dict) -> dict:
|
||||||
|
metadata = doc.get("metadata") or {}
|
||||||
|
if isinstance(metadata, str):
|
||||||
|
try:
|
||||||
|
metadata = json.loads(metadata)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
metadata = {}
|
||||||
|
return metadata if isinstance(metadata, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
def _infer_appraiser_name(doc: dict) -> str:
|
def _infer_appraiser_name(doc: dict) -> str:
|
||||||
"""Best-effort extraction of the appraiser's name from document title/metadata."""
|
"""Best-effort extraction of the appraiser's name from document title/metadata."""
|
||||||
metadata = doc.get("metadata") or {}
|
meta = _doc_metadata(doc)
|
||||||
name = metadata.get("appraiser_name") if isinstance(metadata, dict) else None
|
name = meta.get("appraiser_name")
|
||||||
if name:
|
if name:
|
||||||
return name
|
return name
|
||||||
title = doc.get("title", "")
|
title = doc.get("title", "")
|
||||||
return title or f"שמאי (מסמך {doc.get('id', '')[:8]})"
|
return title or f"שמאי (מסמך {doc.get('id', '')[:8]})"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_appraiser_side(doc: dict) -> str:
|
||||||
|
"""Return the tagged side, or '' if not tagged."""
|
||||||
|
return _doc_metadata(doc).get("appraiser_side", "") or ""
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_sides_tagged(appraisals: list[dict]) -> list[dict]:
|
||||||
|
"""Return the subset of appraisals missing a valid appraiser_side tag."""
|
||||||
|
missing: list[dict] = []
|
||||||
|
for doc in appraisals:
|
||||||
|
side = _get_appraiser_side(doc)
|
||||||
|
if side not in VALID_APPRAISER_SIDES:
|
||||||
|
missing.append({
|
||||||
|
"document_id": doc["id"],
|
||||||
|
"title": doc.get("title", ""),
|
||||||
|
"current_side": side,
|
||||||
|
})
|
||||||
|
return missing
|
||||||
|
|
||||||
|
|
||||||
async def extract_appraiser_facts(case_id: UUID) -> dict:
|
async def extract_appraiser_facts(case_id: UUID) -> dict:
|
||||||
"""Extract facts from every appraisal document in the case + detect conflicts.
|
"""Extract facts from every appraisal document in the case + detect conflicts.
|
||||||
|
|
||||||
|
Blocks if any appraisal is missing metadata.appraiser_side — the chair must
|
||||||
|
tag each one via the UI before extraction runs, so that conflict rendering
|
||||||
|
in block-tet can identify the deciding appraiser's view as authoritative.
|
||||||
|
|
||||||
Returns a summary dict ready for serialization back to the caller.
|
Returns a summary dict ready for serialization back to the caller.
|
||||||
"""
|
"""
|
||||||
docs = await db.list_documents(case_id)
|
docs = await db.list_documents(case_id)
|
||||||
@@ -146,6 +190,18 @@ async def extract_appraiser_facts(case_id: UUID) -> dict:
|
|||||||
"conflicts": [],
|
"conflicts": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
missing_sides = _validate_sides_tagged(appraisals)
|
||||||
|
if missing_sides:
|
||||||
|
return {
|
||||||
|
"status": "sides_missing",
|
||||||
|
"appraisal_count": len(appraisals),
|
||||||
|
"missing": missing_sides,
|
||||||
|
"message": (
|
||||||
|
"חסר תיוג appraiser_side במסמכי שומה. תייג כל שומה דרך ה-UI "
|
||||||
|
"(ועדה / עורר / מכריע) והרץ שוב."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
by_doc = []
|
by_doc = []
|
||||||
total_facts = 0
|
total_facts = 0
|
||||||
for doc in appraisals:
|
for doc in appraisals:
|
||||||
@@ -160,11 +216,13 @@ async def extract_appraiser_facts(case_id: UUID) -> dict:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
appraiser_name = _infer_appraiser_name(doc)
|
appraiser_name = _infer_appraiser_name(doc)
|
||||||
|
appraiser_side = _get_appraiser_side(doc)
|
||||||
try:
|
try:
|
||||||
facts = await extract_facts_from_document(
|
facts = await extract_facts_from_document(
|
||||||
case_id=case_id,
|
case_id=case_id,
|
||||||
document_id=UUID(doc["id"]),
|
document_id=UUID(doc["id"]),
|
||||||
appraiser_name=appraiser_name,
|
appraiser_name=appraiser_name,
|
||||||
|
appraiser_side=appraiser_side,
|
||||||
text=text,
|
text=text,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -183,6 +241,7 @@ async def extract_appraiser_facts(case_id: UUID) -> dict:
|
|||||||
"document_id": doc["id"],
|
"document_id": doc["id"],
|
||||||
"title": doc.get("title", ""),
|
"title": doc.get("title", ""),
|
||||||
"appraiser_name": appraiser_name,
|
"appraiser_name": appraiser_name,
|
||||||
|
"appraiser_side": appraiser_side,
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"facts_extracted": len(facts),
|
"facts_extracted": len(facts),
|
||||||
"plans": sum(1 for f in facts if f["fact_type"] == "plan"),
|
"plans": sum(1 for f in facts if f["fact_type"] == "plan"),
|
||||||
|
|||||||
@@ -190,8 +190,10 @@ BLOCK_PROMPTS = {
|
|||||||
|
|
||||||
## כללי ציון סתירות בין שמאים (קריטי):
|
## כללי ציון סתירות בין שמאים (קריטי):
|
||||||
- אם שני שמאים או יותר מסרו מידע שונה על אותה תכנית או היתר — חובה לסמן זאת במפורש בנוסח ניטרלי, למשל:
|
- אם שני שמאים או יותר מסרו מידע שונה על אותה תכנית או היתר — חובה לסמן זאת במפורש בנוסח ניטרלי, למשל:
|
||||||
> "יצוין כי השמאי X ציין כי תכנית פלונית חלה על המקרקעין במלואה, בעוד השמאי Y סבר כי חלקה של התכנית בלבד חל"
|
> "יצוין כי שמאי הוועדה ציין כי תכנית פלונית חלה על המקרקעין במלואה, בעוד שמאי העורר סבר כי חלקה של התכנית בלבד חל"
|
||||||
- אין להכריע בסתירה בבלוק זה — ההכרעה (אם נדרשת) תבוא בבלוק י.
|
- **כשקיים שמאי מכריע** — השומה שלו היא הקובעת עובדתית. סמן זאת במפורש בסוף הדיון בסתירה, בנוסח: "ואולם, השמאי המכריע קבע כי..." או "השמאי המכריע, שבחן את עמדות הצדדים, הכריע כי...". הצג את עמדת המכריע **אחרונה** כדי שההקשר יבנה אליה.
|
||||||
|
- השתמש בתוויות הצד המדויקות: "שמאי הוועדה המקומית", "שמאי העורר", "שמאי מכריע" — ולא בשמות פרטיים אלא אם נדרש לבהירות.
|
||||||
|
- אין להכריע בסתירה משפטית או להגיע למסקנה נורמטיבית בבלוק זה — ההכרעה המשפטית (אם נדרשת) תבוא בבלוק י. כאן מציגים רק את הממצא העובדתי כפי שהוא, כולל הכרעת המכריע העובדתית.
|
||||||
- אם אין סתירה — אין להזכיר זאת.
|
- אם אין סתירה — אין להזכיר זאת.
|
||||||
|
|
||||||
## כללים נוספים:
|
## כללים נוספים:
|
||||||
@@ -502,22 +504,43 @@ async def _build_plans_context(case_id: UUID) -> str:
|
|||||||
return "(לא זוהו תכניות)"
|
return "(לא זוהו תכניות)"
|
||||||
|
|
||||||
|
|
||||||
|
APPRAISER_SIDE_LABEL_HE = {
|
||||||
|
"committee": "שמאי הוועדה המקומית",
|
||||||
|
"appellant": "שמאי העורר",
|
||||||
|
"deciding": "שמאי מכריע",
|
||||||
|
"": "שמאי (לא תויג)",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sort key: committee → appellant → deciding → untagged. This matches the order
|
||||||
|
# used by db.detect_appraiser_conflicts so the deciding appraiser is last —
|
||||||
|
# i.e. the conclusion reads most naturally ("...and the deciding appraiser ruled...").
|
||||||
|
_SIDE_ORDER = {"committee": 1, "appellant": 2, "deciding": 3, "": 4}
|
||||||
|
|
||||||
|
|
||||||
|
def _side_label(side: str) -> str:
|
||||||
|
return APPRAISER_SIDE_LABEL_HE.get(side or "", APPRAISER_SIDE_LABEL_HE[""])
|
||||||
|
|
||||||
|
|
||||||
async def _build_appraiser_facts_context(case_id: UUID) -> str:
|
async def _build_appraiser_facts_context(case_id: UUID) -> str:
|
||||||
"""Group appraiser_facts by appraiser, then list each appraiser's plans+permits."""
|
"""Group appraiser_facts by side (then name), list each appraiser's plans+permits."""
|
||||||
facts = await db.list_appraiser_facts(case_id)
|
facts = await db.list_appraiser_facts(case_id)
|
||||||
if not facts:
|
if not facts:
|
||||||
return "(לא חולצו עובדות שמאיות. הרץ extract_appraiser_facts.)"
|
return "(לא חולצו עובדות שמאיות. הרץ extract_appraiser_facts.)"
|
||||||
|
|
||||||
by_appraiser: dict[str, dict[str, list[dict]]] = {}
|
# (side, name) → {plan: [...], permit: [...]}
|
||||||
|
groups: dict[tuple[str, str], dict[str, list[dict]]] = {}
|
||||||
for f in facts:
|
for f in facts:
|
||||||
bucket = by_appraiser.setdefault(f["appraiser_name"], {"plan": [], "permit": []})
|
key = (f.get("appraiser_side", "") or "", f["appraiser_name"])
|
||||||
|
bucket = groups.setdefault(key, {"plan": [], "permit": []})
|
||||||
bucket[f["fact_type"]].append(f)
|
bucket[f["fact_type"]].append(f)
|
||||||
|
|
||||||
|
ordered_keys = sorted(groups.keys(), key=lambda k: (_SIDE_ORDER.get(k[0], 9), k[1]))
|
||||||
|
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
for name in sorted(by_appraiser.keys()):
|
for side, name in ordered_keys:
|
||||||
lines.append(f"\n### {name}")
|
lines.append(f"\n### {_side_label(side)} — {name}")
|
||||||
for label, key in (("תכניות", "plan"), ("היתרים", "permit")):
|
for label, key in (("תכניות", "plan"), ("היתרים", "permit")):
|
||||||
items = by_appraiser[name][key]
|
items = groups[(side, name)][key]
|
||||||
if not items:
|
if not items:
|
||||||
continue
|
continue
|
||||||
lines.append(f"**{label}:**")
|
lines.append(f"**{label}:**")
|
||||||
@@ -543,7 +566,12 @@ async def _build_appraiser_facts_context(case_id: UUID) -> str:
|
|||||||
|
|
||||||
|
|
||||||
async def _build_appraiser_conflicts_context(case_id: UUID) -> str:
|
async def _build_appraiser_conflicts_context(case_id: UUID) -> str:
|
||||||
"""Render conflict groups so the prompt can quote them in the body."""
|
"""Render conflict groups so the prompt can quote them in the body.
|
||||||
|
|
||||||
|
Entries arrive pre-ordered from the DB by side (committee→appellant→deciding).
|
||||||
|
When a deciding appraiser exists, the prompt must treat their view as the
|
||||||
|
governing factual determination.
|
||||||
|
"""
|
||||||
conflicts = await db.detect_appraiser_conflicts(case_id)
|
conflicts = await db.detect_appraiser_conflicts(case_id)
|
||||||
if not conflicts:
|
if not conflicts:
|
||||||
return "(אין סתירות בין שמאים)"
|
return "(אין סתירות בין שמאים)"
|
||||||
@@ -551,15 +579,19 @@ async def _build_appraiser_conflicts_context(case_id: UUID) -> str:
|
|||||||
type_label = {"plan": "תכנית", "permit": "היתר"}
|
type_label = {"plan": "תכנית", "permit": "היתר"}
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
for c in conflicts:
|
for c in conflicts:
|
||||||
lines.append(
|
has_deciding = any(e.get("appraiser_side") == "deciding" for e in c["entries"])
|
||||||
f"\n### סתירה — {type_label.get(c['fact_type'], c['fact_type'])}: {c['identifier']}"
|
header = f"\n### סתירה — {type_label.get(c['fact_type'], c['fact_type'])}: {c['identifier']}"
|
||||||
)
|
if has_deciding:
|
||||||
|
header += " _(יש שמאי מכריע — עמדתו קובעת)_"
|
||||||
|
lines.append(header)
|
||||||
for entry in c["entries"]:
|
for entry in c["entries"]:
|
||||||
|
side = entry.get("appraiser_side", "") or ""
|
||||||
details = entry.get("details") or {}
|
details = entry.get("details") or {}
|
||||||
scope = (details.get("scope") or "").strip()
|
scope = (details.get("scope") or "").strip()
|
||||||
status = (details.get("status") or "").strip()
|
status = (details.get("status") or "").strip()
|
||||||
quote = (details.get("raw_quote") or "").strip()
|
quote = (details.get("raw_quote") or "").strip()
|
||||||
parts = [f"**{entry['appraiser_name']}**"]
|
marker = "★ " if side == "deciding" else ""
|
||||||
|
parts = [f"**{marker}{_side_label(side)} — {entry['appraiser_name']}**"]
|
||||||
if status:
|
if status:
|
||||||
parts.append(f"סטטוס: {status}")
|
parts.append(f"סטטוס: {status}")
|
||||||
if scope:
|
if scope:
|
||||||
|
|||||||
@@ -491,8 +491,17 @@ CREATE TABLE IF NOT EXISTS appraiser_facts (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_appraiser_facts_case ON appraiser_facts(case_id, fact_type);
|
CREATE INDEX IF NOT EXISTS idx_appraiser_facts_case ON appraiser_facts(case_id, fact_type);
|
||||||
CREATE INDEX IF NOT EXISTS idx_appraiser_facts_identifier ON appraiser_facts(case_id, identifier);
|
CREATE INDEX IF NOT EXISTS idx_appraiser_facts_identifier ON appraiser_facts(case_id, identifier);
|
||||||
|
|
||||||
|
-- V5.1: appraiser_side — which party this appraiser represents.
|
||||||
|
-- Values: 'committee' (הוועדה), 'appellant' (העורר), 'deciding' (מכריע).
|
||||||
|
-- Required by extract_appraiser_facts; the chair tags it via the UI before extraction.
|
||||||
|
-- Set via documents.metadata.appraiser_side at upload/edit time, then propagated here
|
||||||
|
-- so that conflict rendering in block-tet can label each entry with its side.
|
||||||
|
ALTER TABLE appraiser_facts ADD COLUMN IF NOT EXISTS appraiser_side TEXT DEFAULT '';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_appraiser_facts_side ON appraiser_facts(case_id, appraiser_side);
|
||||||
|
|
||||||
-- documents.metadata.is_post_hearing: flag for materials submitted after the hearing
|
-- documents.metadata.is_post_hearing: flag for materials submitted after the hearing
|
||||||
-- (השלמות טיעון, הצעות פשרה). Used by block-chet to include them in the proceedings narrative.
|
-- (השלמות טיעון, הצעות פשרה). Used by block-chet to include them in the proceedings narrative.
|
||||||
|
-- documents.metadata.appraiser_side: which side the appraiser represents (see above).
|
||||||
-- No schema change needed — uses existing JSONB metadata column.
|
-- No schema change needed — uses existing JSONB metadata column.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -1333,7 +1342,7 @@ async def replace_appraiser_facts(
|
|||||||
) -> int:
|
) -> int:
|
||||||
"""Replace all appraiser_facts for a given document.
|
"""Replace all appraiser_facts for a given document.
|
||||||
|
|
||||||
Each fact dict: appraiser_name, fact_type ('plan'|'permit'),
|
Each fact dict: appraiser_name, appraiser_side, fact_type ('plan'|'permit'),
|
||||||
identifier, details (dict), page_number (optional).
|
identifier, details (dict), page_number (optional).
|
||||||
"""
|
"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
@@ -1345,11 +1354,12 @@ async def replace_appraiser_facts(
|
|||||||
for f in facts:
|
for f in facts:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""INSERT INTO appraiser_facts
|
"""INSERT INTO appraiser_facts
|
||||||
(case_id, document_id, appraiser_name, fact_type,
|
(case_id, document_id, appraiser_name, appraiser_side,
|
||||||
identifier, details, page_number)
|
fact_type, identifier, details, page_number)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)""",
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)""",
|
||||||
case_id, document_id,
|
case_id, document_id,
|
||||||
f["appraiser_name"],
|
f["appraiser_name"],
|
||||||
|
f.get("appraiser_side", ""),
|
||||||
f["fact_type"],
|
f["fact_type"],
|
||||||
f["identifier"],
|
f["identifier"],
|
||||||
json.dumps(f.get("details", {}), ensure_ascii=False),
|
json.dumps(f.get("details", {}), ensure_ascii=False),
|
||||||
@@ -1396,7 +1406,9 @@ async def detect_appraiser_conflicts(case_id: UUID) -> list[dict]:
|
|||||||
|
|
||||||
A conflict exists when the SAME identifier (e.g., "תמ"א 38") was reported
|
A conflict exists when the SAME identifier (e.g., "תמ"א 38") was reported
|
||||||
differently by two appraisers — different details, or one cited it and the
|
differently by two appraisers — different details, or one cited it and the
|
||||||
other did not. Returns list of conflict groups.
|
other did not. Returns list of conflict groups. Each entry in a group
|
||||||
|
carries the appraiser's side so the caller can label it as committee /
|
||||||
|
appellant / deciding.
|
||||||
"""
|
"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
@@ -1404,10 +1416,19 @@ async def detect_appraiser_conflicts(case_id: UUID) -> list[dict]:
|
|||||||
"""SELECT identifier, fact_type,
|
"""SELECT identifier, fact_type,
|
||||||
json_agg(jsonb_build_object(
|
json_agg(jsonb_build_object(
|
||||||
'appraiser_name', appraiser_name,
|
'appraiser_name', appraiser_name,
|
||||||
|
'appraiser_side', appraiser_side,
|
||||||
'details', details,
|
'details', details,
|
||||||
'page_number', page_number,
|
'page_number', page_number,
|
||||||
'document_id', document_id
|
'document_id', document_id
|
||||||
) ORDER BY appraiser_name) AS entries,
|
) ORDER BY
|
||||||
|
CASE appraiser_side
|
||||||
|
WHEN 'committee' THEN 1
|
||||||
|
WHEN 'appellant' THEN 2
|
||||||
|
WHEN 'deciding' THEN 3
|
||||||
|
ELSE 4
|
||||||
|
END,
|
||||||
|
appraiser_name
|
||||||
|
) AS entries,
|
||||||
COUNT(DISTINCT appraiser_name) AS n_appraisers
|
COUNT(DISTINCT appraiser_name) AS n_appraisers
|
||||||
FROM appraiser_facts
|
FROM appraiser_facts
|
||||||
WHERE case_id = $1
|
WHERE case_id = $1
|
||||||
|
|||||||
@@ -392,3 +392,98 @@ async def get_claims(case_number: str, party_role: str = "") -> str:
|
|||||||
})
|
})
|
||||||
|
|
||||||
return json.dumps(formatted, default=str, ensure_ascii=False, indent=2)
|
return json.dumps(formatted, default=str, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
# Whitelist of doc_type values; mirrors web/app.py:DOC_TYPE_NAMES.
|
||||||
|
ALLOWED_DOC_TYPES = {
|
||||||
|
"appeal", "response", "protocol", "plan", "decision",
|
||||||
|
"court_decision", "permit", "appraisal", "exhibit",
|
||||||
|
"objection", "reference",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Allowed appraiser_side values; '' (empty) clears the tag.
|
||||||
|
ALLOWED_APPRAISER_SIDES = {"committee", "appellant", "deciding", ""}
|
||||||
|
|
||||||
|
|
||||||
|
async def document_update(
|
||||||
|
case_number: str,
|
||||||
|
doc_id: str,
|
||||||
|
doc_type: str = "",
|
||||||
|
appraiser_side: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""עדכון תיוג מסמך — doc_type ו/או appraiser_side. ריק = אין שינוי.
|
||||||
|
|
||||||
|
הולידציה זהה ל-PATCH endpoint ב-web/app.py. appraiser_side נשמר ב-
|
||||||
|
documents.metadata JSONB (מתפרסם משם ע"י extract_appraiser_facts).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
case_number: מספר תיק הערר (לאישור שייכות)
|
||||||
|
doc_id: UUID של המסמך
|
||||||
|
doc_type: ערך חדש (appeal/response/protocol/plan/decision/court_decision/
|
||||||
|
permit/appraisal/exhibit/objection/reference). ריק = אין שינוי.
|
||||||
|
appraiser_side: ערך חדש (committee/appellant/deciding). ריק = אין שינוי;
|
||||||
|
העבר במפורש מחרוזת ריקה לא-default אם רוצים לנקות.
|
||||||
|
"""
|
||||||
|
case = await db.get_case_by_number(case_number)
|
||||||
|
if not case:
|
||||||
|
return json.dumps({"status": "error",
|
||||||
|
"message": f"תיק {case_number} לא נמצא."},
|
||||||
|
ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
try:
|
||||||
|
doc_uuid = UUID(doc_id)
|
||||||
|
except ValueError:
|
||||||
|
return json.dumps({"status": "error",
|
||||||
|
"message": f"doc_id לא תקין: {doc_id}"},
|
||||||
|
ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
doc = await db.get_document(doc_uuid)
|
||||||
|
if not doc:
|
||||||
|
return json.dumps({"status": "error",
|
||||||
|
"message": f"מסמך {doc_id} לא נמצא."},
|
||||||
|
ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
if doc.get("case_id") != case["id"]:
|
||||||
|
return json.dumps({"status": "error",
|
||||||
|
"message": f"מסמך {doc_id} לא שייך לתיק {case_number}."},
|
||||||
|
ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
updates: dict = {}
|
||||||
|
|
||||||
|
if doc_type:
|
||||||
|
if doc_type not in ALLOWED_DOC_TYPES:
|
||||||
|
return json.dumps({
|
||||||
|
"status": "error",
|
||||||
|
"message": f"doc_type לא תקין: {doc_type}",
|
||||||
|
"allowed": sorted(ALLOWED_DOC_TYPES),
|
||||||
|
}, ensure_ascii=False, indent=2)
|
||||||
|
updates["doc_type"] = doc_type
|
||||||
|
|
||||||
|
# appraiser_side is optional. The MCP tool can't distinguish "skip" from
|
||||||
|
# "set to empty string", so we use the convention: only update if non-empty.
|
||||||
|
# To clear, the operator must edit metadata directly (rare).
|
||||||
|
if appraiser_side:
|
||||||
|
if appraiser_side not in ALLOWED_APPRAISER_SIDES:
|
||||||
|
return json.dumps({
|
||||||
|
"status": "error",
|
||||||
|
"message": f"appraiser_side לא תקין: {appraiser_side}",
|
||||||
|
"allowed": sorted(s for s in ALLOWED_APPRAISER_SIDES if s),
|
||||||
|
}, ensure_ascii=False, indent=2)
|
||||||
|
metadata = doc.get("metadata") or {}
|
||||||
|
if isinstance(metadata, str):
|
||||||
|
metadata = json.loads(metadata)
|
||||||
|
metadata["appraiser_side"] = appraiser_side
|
||||||
|
updates["metadata"] = metadata
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return json.dumps({"status": "noop", "message": "אין שינוי לבצע."},
|
||||||
|
ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
await db.update_document(doc_uuid, **updates)
|
||||||
|
fresh = await db.get_document(doc_uuid)
|
||||||
|
return json.dumps({
|
||||||
|
"status": "completed",
|
||||||
|
"doc_id": doc_id,
|
||||||
|
"doc_type": fresh.get("doc_type"),
|
||||||
|
"metadata": fresh.get("metadata"),
|
||||||
|
}, default=str, ensure_ascii=False, indent=2)
|
||||||
|
|||||||
186
web-ui/src/components/cases/document-type-editor.tsx
Normal file
186
web-ui/src/components/cases/document-type-editor.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
APPRAISER_SIDE_LABELS,
|
||||||
|
APPRAISER_SIDE_OPTIONS,
|
||||||
|
DOC_TYPE_OPTIONS,
|
||||||
|
appraiserSideLabel,
|
||||||
|
doctypeLabel,
|
||||||
|
doctypeTone,
|
||||||
|
type AppraiserSide,
|
||||||
|
type DocType,
|
||||||
|
} from "@/lib/doc-types";
|
||||||
|
import { usePatchDocument } from "@/lib/api/documents";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Inline editor for a document's tags. Renders a colored Badge that opens a
|
||||||
|
* Popover with two Selects:
|
||||||
|
* 1. doc_type (always shown)
|
||||||
|
* 2. appraiser_side (only when doc_type === "appraisal")
|
||||||
|
*
|
||||||
|
* The Save button is disabled when doc_type is "appraisal" but no side is
|
||||||
|
* picked — extract_appraiser_facts requires it, so we enforce here too.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function DocumentTypeEditor({
|
||||||
|
caseNumber,
|
||||||
|
docId,
|
||||||
|
docType,
|
||||||
|
appraiserSide,
|
||||||
|
}: {
|
||||||
|
caseNumber: string;
|
||||||
|
docId: string;
|
||||||
|
docType: string;
|
||||||
|
appraiserSide?: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [draftType, setDraftType] = useState<string>(docType || "");
|
||||||
|
const [draftSide, setDraftSide] = useState<string>(appraiserSide || "");
|
||||||
|
const patch = usePatchDocument(caseNumber);
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setDraftType(docType || "");
|
||||||
|
setDraftSide(appraiserSide || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAppraisal = draftType === "appraisal";
|
||||||
|
const sideMissing = isAppraisal && !draftSide;
|
||||||
|
const dirty =
|
||||||
|
draftType !== docType ||
|
||||||
|
(isAppraisal && draftSide !== (appraiserSide || ""));
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (sideMissing || !dirty) return;
|
||||||
|
const body: { doc_type?: string; appraiser_side?: string } = {};
|
||||||
|
if (draftType !== docType) body.doc_type = draftType;
|
||||||
|
if (isAppraisal && draftSide !== (appraiserSide || "")) {
|
||||||
|
body.appraiser_side = draftSide;
|
||||||
|
}
|
||||||
|
// If the new type is NOT appraisal but the doc previously had a side,
|
||||||
|
// clear it so it doesn't dangle confusingly in metadata.
|
||||||
|
if (!isAppraisal && appraiserSide) body.appraiser_side = "";
|
||||||
|
|
||||||
|
await patch.mutateAsync({ docId, patch: body });
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the on-badge label: "שומה · שמאי הוועדה" when both present.
|
||||||
|
const badgeText =
|
||||||
|
docType === "appraisal" && appraiserSide
|
||||||
|
? `${doctypeLabel(docType)} · ${appraiserSideLabel(appraiserSide)}`
|
||||||
|
: doctypeLabel(docType);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
setOpen(next);
|
||||||
|
if (next) reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-gold/50 rounded-full"
|
||||||
|
title="לחץ לשינוי תיוג"
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`rounded-full px-2 py-0.5 text-[0.7rem] ${doctypeTone(docType)} hover:opacity-80`}
|
||||||
|
>
|
||||||
|
{badgeText}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-72 space-y-3" align="end" dir="rtl">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs text-ink-muted">סוג מסמך</label>
|
||||||
|
<Select value={draftType} onValueChange={setDraftType} dir="rtl">
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="בחר סוג" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DOC_TYPE_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAppraisal && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs text-ink-muted">צד השמאי</label>
|
||||||
|
<Select value={draftSide} onValueChange={setDraftSide} dir="rtl">
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="בחר צד" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{APPRAISER_SIDE_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{sideMissing && (
|
||||||
|
<p className="text-[0.7rem] text-warn">
|
||||||
|
נדרש לציין את הצד לפני חילוץ עובדות שמאיות.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-[0.65rem] text-ink-muted leading-tight">
|
||||||
|
ערכים: {Object.values(APPRAISER_SIDE_LABELS).join(" · ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{patch.isError && (
|
||||||
|
<p className="text-[0.7rem] text-danger">
|
||||||
|
שמירה נכשלה. נסה שוב.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
disabled={patch.isPending}
|
||||||
|
>
|
||||||
|
ביטול
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!dirty || sideMissing || patch.isPending}
|
||||||
|
>
|
||||||
|
{patch.isPending && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||||
|
שמור
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export types so callers don't need to dual-import.
|
||||||
|
export type { AppraiserSide, DocType };
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import {
|
import {
|
||||||
@@ -23,40 +22,19 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { apiRequest } from "@/lib/api/client";
|
import { apiRequest } from "@/lib/api/client";
|
||||||
import { casesKeys } from "@/lib/api/cases";
|
import { casesKeys } from "@/lib/api/cases";
|
||||||
import type { CaseDetail, CaseDocument } from "@/lib/api/cases";
|
import type { CaseDetail, CaseDocument } from "@/lib/api/cases";
|
||||||
|
import { DocumentTypeEditor } from "@/components/cases/document-type-editor";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Document list for the case detail "מסמכים" tab. Uses the real document
|
* Document list for the case detail "מסמכים" tab. Uses the real document
|
||||||
* row shape returned by the FastAPI case_get endpoint — see db.list_documents
|
* row shape returned by the FastAPI case_get endpoint — see db.list_documents
|
||||||
* and the `documents` schema in legal_mcp/services/db.py:
|
* and the `documents` schema in legal_mcp/services/db.py:
|
||||||
* id · case_id · doc_type · title · file_path · extraction_status ·
|
* id · case_id · doc_type · title · file_path · extraction_status ·
|
||||||
* page_count · created_at · practice_area · appeal_subtype
|
* page_count · created_at · practice_area · appeal_subtype · metadata
|
||||||
|
*
|
||||||
|
* Doc-type labels and tone classes live in @/lib/doc-types so the upload
|
||||||
|
* sheet, the inline editor, and this panel all stay in sync.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const DOC_TYPE_LABELS: Record<string, string> = {
|
|
||||||
appeal: "כתב ערר",
|
|
||||||
response: "כתב תשובה",
|
|
||||||
protocol: "פרוטוקול",
|
|
||||||
decision: "החלטת ועדה מקומית",
|
|
||||||
plan: "תכנית",
|
|
||||||
appraisal: "שומה",
|
|
||||||
reference: "חומר רקע",
|
|
||||||
auto: "—",
|
|
||||||
};
|
|
||||||
|
|
||||||
function doctypeLabel(t: string): string {
|
|
||||||
return DOC_TYPE_LABELS[t] ?? t;
|
|
||||||
}
|
|
||||||
|
|
||||||
function doctypeTone(t: string): string {
|
|
||||||
switch (t) {
|
|
||||||
case "appeal": return "bg-info-bg text-info border-info/40";
|
|
||||||
case "response": return "bg-gold-wash text-gold-deep border-gold/40";
|
|
||||||
case "decision": return "bg-success-bg text-success border-success/40";
|
|
||||||
case "protocol": return "bg-warn-bg text-warn border-warn/40";
|
|
||||||
default: return "bg-rule-soft text-ink-muted border-rule";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
pending: "בהמתנה",
|
pending: "בהמתנה",
|
||||||
processing: "בעיבוד",
|
processing: "בעיבוד",
|
||||||
@@ -272,12 +250,15 @@ function DocumentRow({
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{doc.doc_type && (
|
{doc.doc_type && (
|
||||||
<Badge
|
<DocumentTypeEditor
|
||||||
variant="outline"
|
caseNumber={caseNumber}
|
||||||
className={`rounded-full px-2 py-0.5 text-[0.7rem] shrink-0 ${doctypeTone(doc.doc_type)}`}
|
docId={doc.id}
|
||||||
>
|
docType={doc.doc_type}
|
||||||
{doctypeLabel(doc.doc_type)}
|
appraiserSide={
|
||||||
</Badge>
|
(doc.metadata as { appraiser_side?: string } | undefined)
|
||||||
|
?.appraiser_side
|
||||||
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -12,23 +12,22 @@ import {
|
|||||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { useUploadDocument, useProgress, type ProgressEvent } from "@/lib/api/documents";
|
import { useUploadDocument, useProgress, type ProgressEvent } from "@/lib/api/documents";
|
||||||
|
import { DOC_TYPE_OPTIONS } from "@/lib/doc-types";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Upload sheet — drag-drop zone + doc-type selector, with live SSE
|
* Upload sheet — drag-drop zone + doc-type selector, with live SSE
|
||||||
* progress for the most-recent upload. Intentionally sequential:
|
* progress for the most-recent upload. Intentionally sequential:
|
||||||
* a single file at a time keeps the SSE subscription simple and
|
* a single file at a time keeps the SSE subscription simple and
|
||||||
* matches how the FastAPI processor handles one task_id per file.
|
* matches how the FastAPI processor handles one task_id per file.
|
||||||
|
*
|
||||||
|
* The "auto" option is upload-only — it triggers backend classification.
|
||||||
|
* After upload, the inline DocumentTypeEditor shows the resolved doc_type
|
||||||
|
* and uses DOC_TYPE_OPTIONS directly (no "auto" entry).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const DOC_TYPES: { value: string; label: string }[] = [
|
const DOC_TYPES: { value: string; label: string }[] = [
|
||||||
{ value: "auto", label: "זיהוי אוטומטי" },
|
{ value: "auto", label: "זיהוי אוטומטי" },
|
||||||
{ value: "appeal", label: "כתב ערר" },
|
...DOC_TYPE_OPTIONS,
|
||||||
{ value: "response", label: "כתב תשובה" },
|
|
||||||
{ value: "protocol", label: "פרוטוקול דיון" },
|
|
||||||
{ value: "decision", label: "החלטת ועדה מקומית" },
|
|
||||||
{ value: "plan", label: "תכנית" },
|
|
||||||
{ value: "appraisal",label: "שומה" },
|
|
||||||
{ value: "reference",label: "חומר רקע" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
type UploadRow = {
|
type UploadRow = {
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ export type CaseDocument = {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
practice_area?: PracticeArea;
|
practice_area?: PracticeArea;
|
||||||
appeal_subtype?: AppealSubtype;
|
appeal_subtype?: AppealSubtype;
|
||||||
|
/** Free-form JSONB. Known keys: appraiser_side, is_post_hearing, references. */
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CaseDetail = Case & {
|
export type CaseDetail = Case & {
|
||||||
|
|||||||
@@ -84,6 +84,58 @@ export function useUploadDocument(caseNumber: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── PATCH document tags ───────────────────────────────────────────
|
||||||
|
|
||||||
|
export type DocumentPatch = {
|
||||||
|
doc_type?: string;
|
||||||
|
appraiser_side?: string; // "" clears; "committee" | "appellant" | "deciding" sets
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PatchDocumentResponse = {
|
||||||
|
status: "completed" | "noop";
|
||||||
|
document: {
|
||||||
|
id: string;
|
||||||
|
doc_type: string;
|
||||||
|
title: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async function patchDocument(
|
||||||
|
caseNumber: string,
|
||||||
|
docId: string,
|
||||||
|
patch: DocumentPatch,
|
||||||
|
): Promise<PatchDocumentResponse> {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/cases/${encodeURIComponent(caseNumber)}/documents/${encodeURIComponent(docId)}`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const contentType = res.headers.get("content-type") ?? "";
|
||||||
|
const parsed = contentType.includes("application/json")
|
||||||
|
? await res.json().catch(() => null)
|
||||||
|
: await res.text().catch(() => null);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiError(`Patch failed with ${res.status}`, res.status, parsed);
|
||||||
|
}
|
||||||
|
return parsed as PatchDocumentResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePatchDocument(caseNumber: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ docId, patch }: { docId: string; patch: DocumentPatch }) =>
|
||||||
|
patchDocument(caseNumber, docId, patch),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: casesKeys.detail(caseNumber) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export function useProgress(taskId: string | null) {
|
export function useProgress(taskId: string | null) {
|
||||||
const [event, setEvent] = useState<ProgressEvent | null>(null);
|
const [event, setEvent] = useState<ProgressEvent | null>(null);
|
||||||
|
|
||||||
|
|||||||
85
web-ui/src/lib/doc-types.ts
Normal file
85
web-ui/src/lib/doc-types.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Canonical document tagging metadata. Mirrors web/app.py:DOC_TYPE_NAMES and
|
||||||
|
* the validation lists in mcp-server/src/legal_mcp/tools/documents.py.
|
||||||
|
*
|
||||||
|
* If you add a doc_type or appraiser_side here, add the matching value on the
|
||||||
|
* backend too — the API rejects anything not on its whitelist.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type DocType =
|
||||||
|
| "appeal"
|
||||||
|
| "response"
|
||||||
|
| "protocol"
|
||||||
|
| "plan"
|
||||||
|
| "decision"
|
||||||
|
| "court_decision"
|
||||||
|
| "permit"
|
||||||
|
| "appraisal"
|
||||||
|
| "exhibit"
|
||||||
|
| "objection"
|
||||||
|
| "reference";
|
||||||
|
|
||||||
|
export const DOC_TYPE_LABELS: Record<DocType, string> = {
|
||||||
|
appeal: "כתב ערר",
|
||||||
|
response: "כתב תשובה",
|
||||||
|
protocol: "פרוטוקול",
|
||||||
|
plan: "תכנית",
|
||||||
|
decision: "החלטת ועדה מקומית",
|
||||||
|
court_decision: "פסק דין",
|
||||||
|
permit: "היתר",
|
||||||
|
appraisal: "שומה",
|
||||||
|
exhibit: "נספח",
|
||||||
|
objection: "התנגדות",
|
||||||
|
reference: "חומר רקע",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Display order for editors and selects. */
|
||||||
|
export const DOC_TYPE_OPTIONS: { value: DocType; label: string }[] = [
|
||||||
|
{ value: "appeal", label: DOC_TYPE_LABELS.appeal },
|
||||||
|
{ value: "response", label: DOC_TYPE_LABELS.response },
|
||||||
|
{ value: "protocol", label: DOC_TYPE_LABELS.protocol },
|
||||||
|
{ value: "plan", label: DOC_TYPE_LABELS.plan },
|
||||||
|
{ value: "decision", label: DOC_TYPE_LABELS.decision },
|
||||||
|
{ value: "court_decision", label: DOC_TYPE_LABELS.court_decision },
|
||||||
|
{ value: "permit", label: DOC_TYPE_LABELS.permit },
|
||||||
|
{ value: "appraisal", label: DOC_TYPE_LABELS.appraisal },
|
||||||
|
{ value: "exhibit", label: DOC_TYPE_LABELS.exhibit },
|
||||||
|
{ value: "objection", label: DOC_TYPE_LABELS.objection },
|
||||||
|
{ value: "reference", label: DOC_TYPE_LABELS.reference },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function doctypeLabel(value: string): string {
|
||||||
|
return (DOC_TYPE_LABELS as Record<string, string>)[value] ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function doctypeTone(value: string): string {
|
||||||
|
switch (value) {
|
||||||
|
case "appeal": return "bg-info-bg text-info border-info/40";
|
||||||
|
case "response": return "bg-gold-wash text-gold-deep border-gold/40";
|
||||||
|
case "decision": return "bg-success-bg text-success border-success/40";
|
||||||
|
case "protocol": return "bg-warn-bg text-warn border-warn/40";
|
||||||
|
case "appraisal": return "bg-info-bg text-info border-info/40";
|
||||||
|
case "court_decision": return "bg-success-bg text-success border-success/40";
|
||||||
|
default: return "bg-rule-soft text-ink-muted border-rule";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Appraiser sides (only relevant when doc_type === "appraisal") ──
|
||||||
|
|
||||||
|
export type AppraiserSide = "committee" | "appellant" | "deciding";
|
||||||
|
|
||||||
|
export const APPRAISER_SIDE_LABELS: Record<AppraiserSide, string> = {
|
||||||
|
committee: "שמאי הוועדה המקומית",
|
||||||
|
appellant: "שמאי העורר",
|
||||||
|
deciding: "שמאי מכריע",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const APPRAISER_SIDE_OPTIONS: { value: AppraiserSide; label: string }[] = [
|
||||||
|
{ value: "committee", label: APPRAISER_SIDE_LABELS.committee },
|
||||||
|
{ value: "appellant", label: APPRAISER_SIDE_LABELS.appellant },
|
||||||
|
{ value: "deciding", label: APPRAISER_SIDE_LABELS.deciding },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function appraiserSideLabel(value: string): string {
|
||||||
|
return (APPRAISER_SIDE_LABELS as Record<string, string>)[value] ?? value;
|
||||||
|
}
|
||||||
65
web/app.py
65
web/app.py
@@ -2999,6 +2999,71 @@ async def api_reprocess_document(case_number: str, doc_id: str):
|
|||||||
return {"status": "reprocessing"}
|
return {"status": "reprocessing"}
|
||||||
|
|
||||||
|
|
||||||
|
_ALLOWED_APPRAISER_SIDES = {"committee", "appellant", "deciding"}
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentPatchRequest(BaseModel):
|
||||||
|
"""Patch payload for a single document. Both fields are optional."""
|
||||||
|
|
||||||
|
doc_type: str | None = None
|
||||||
|
appraiser_side: str | None = None # committee | appellant | deciding | "" to clear
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/cases/{case_number}/documents/{doc_id}")
|
||||||
|
async def api_patch_document(case_number: str, doc_id: str, req: DocumentPatchRequest):
|
||||||
|
"""Update a document's tags. Currently supports doc_type and the
|
||||||
|
metadata.appraiser_side flag (used by extract_appraiser_facts).
|
||||||
|
|
||||||
|
Returns the refreshed document row.
|
||||||
|
"""
|
||||||
|
case = await db.get_case_by_number(case_number)
|
||||||
|
if not case:
|
||||||
|
raise HTTPException(404, f"תיק {case_number} לא נמצא")
|
||||||
|
|
||||||
|
try:
|
||||||
|
document_id = UUID(doc_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, f"doc_id לא תקין: {doc_id}")
|
||||||
|
|
||||||
|
doc = await db.get_document(document_id)
|
||||||
|
if not doc or UUID(doc["case_id"]) != UUID(case["id"]):
|
||||||
|
raise HTTPException(404, "מסמך לא נמצא בתיק")
|
||||||
|
|
||||||
|
updates: dict = {}
|
||||||
|
|
||||||
|
if req.doc_type is not None:
|
||||||
|
if req.doc_type not in DOC_TYPE_NAMES:
|
||||||
|
raise HTTPException(
|
||||||
|
422,
|
||||||
|
f"doc_type לא תקין: {req.doc_type}. ערכים מותרים: "
|
||||||
|
f"{', '.join(sorted(DOC_TYPE_NAMES.keys()))}",
|
||||||
|
)
|
||||||
|
updates["doc_type"] = req.doc_type
|
||||||
|
|
||||||
|
if req.appraiser_side is not None:
|
||||||
|
if req.appraiser_side and req.appraiser_side not in _ALLOWED_APPRAISER_SIDES:
|
||||||
|
raise HTTPException(
|
||||||
|
422,
|
||||||
|
f"appraiser_side לא תקין: {req.appraiser_side}. ערכים מותרים: "
|
||||||
|
f"{', '.join(sorted(_ALLOWED_APPRAISER_SIDES))}",
|
||||||
|
)
|
||||||
|
metadata = doc.get("metadata") or {}
|
||||||
|
if isinstance(metadata, str):
|
||||||
|
metadata = json.loads(metadata)
|
||||||
|
if req.appraiser_side:
|
||||||
|
metadata["appraiser_side"] = req.appraiser_side
|
||||||
|
else:
|
||||||
|
metadata.pop("appraiser_side", None)
|
||||||
|
updates["metadata"] = metadata
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return {"status": "noop", "document": doc}
|
||||||
|
|
||||||
|
await db.update_document(document_id, **updates)
|
||||||
|
fresh = await db.get_document(document_id)
|
||||||
|
return {"status": "completed", "document": fresh}
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/cases/{case_number}/documents/{doc_id}")
|
@app.delete("/api/cases/{case_number}/documents/{doc_id}")
|
||||||
async def api_delete_document(case_number: str, doc_id: str):
|
async def api_delete_document(case_number: str, doc_id: str):
|
||||||
"""Delete a single document from a case (including its chunks and file)."""
|
"""Delete a single document from a case (including its chunks and file)."""
|
||||||
|
|||||||
Reference in New Issue
Block a user