From 0593fe9b011a3eb16c049bec6f96fd8a7c89ac13 Mon Sep 17 00:00:00 2001 From: Chaim Date: Sat, 4 Apr 2026 10:17:24 +0000 Subject: [PATCH] Add interactive case creation wizard + document upload with auto-rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New SPA UI with 4 views: - Case list (#/) with status cards and document counts - New case wizard (#/new) with 4-step form: details, parties, schedule, review+create - Case view (#/case/:id) with grouped documents and drag-drop upload with tagging - Legacy upload (#/upload) for backwards compatibility Auto-creation pipeline in wizard step 4: 1. Creates case in legal-ai DB with local git repo 2. Creates Gitea repo in 'cases' org and pushes initial commit 3. Creates Paperclip project via direct DB insert Document upload with smart rename: - scan_001.pdf -> כתב-ערר-קובר-1130-25.pdf - Based on doc_type + party_name + case_number New files: - web/gitea_client.py: Gitea REST API client - web/paperclip_client.py: Paperclip embedded DB client Co-Authored-By: Claude Opus 4.6 (1M context) --- web/app.py | 226 +++++- web/gitea_client.py | 98 +++ web/paperclip_client.py | 77 ++ web/static/index.html | 1524 +++++++++++++++++++++++---------------- 4 files changed, 1276 insertions(+), 649 deletions(-) create mode 100644 web/gitea_client.py create mode 100644 web/paperclip_client.py diff --git a/web/app.py b/web/app.py index 80bb096..51200dc 100644 --- a/web/app.py +++ b/web/app.py @@ -17,7 +17,7 @@ from uuid import UUID, uuid4 # Allow importing legal_mcp from the MCP server source sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "mcp-server" / "src")) -from fastapi import FastAPI, File, HTTPException, UploadFile +from fastapi import FastAPI, File, Form, HTTPException, UploadFile from fastapi.responses import FileResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel @@ -26,6 +26,12 @@ from legal_mcp import config from legal_mcp.services import chunker, db, embeddings, extractor, processor from legal_mcp.tools import cases as cases_tools, search as search_tools, workflow as workflow_tools, drafting as drafting_tools +# Import integration clients (same directory) +_web_dir = Path(__file__).resolve().parent +sys.path.insert(0, str(_web_dir.parent)) +from web.gitea_client import create_repo, setup_remote_and_push +from web.paperclip_client import create_project as pc_create_project, get_project_url + logger = logging.getLogger(__name__) @@ -178,17 +184,34 @@ async def health(): @app.get("/api/cases") -async def list_cases(): - """List existing cases for the dropdown.""" +async def list_cases(detail: bool = False): + """List existing cases. With detail=true, includes doc counts and integration URLs.""" cases = await db.list_cases() - return [ - { - "case_number": c["case_number"], - "title": c["title"], - "status": c["status"], - } - for c in cases - ] + if not detail: + return [ + {"case_number": c["case_number"], "title": c["title"], "status": c["status"]} + for c in cases + ] + # Enhanced listing with document counts + pool = await db.get_pool() + result = [] + async with pool.acquire() as conn: + for c in cases: + case_id = UUID(c["id"]) + doc_count = await conn.fetchval( + "SELECT count(*) FROM documents WHERE case_id = $1", case_id + ) + result.append({ + "case_number": c["case_number"], + "title": c["title"], + "status": c["status"], + "expected_outcome": c.get("expected_outcome", ""), + "committee_type": c.get("committee_type", ""), + "hearing_date": str(c["hearing_date"]) if c.get("hearing_date") else "", + "document_count": doc_count, + "gitea_url": f"https://gitea.nautilus.marcusgroup.org/cases/{c['case_number']}", + }) + return result # ── Paperclip Integration API ───────────────────────────────────── @@ -546,6 +569,187 @@ async def api_document_text(doc_id: str): return {"doc_id": doc_id, "text": text} +# ── Integration Endpoints — Gitea & Paperclip ──────────────────── + + +DOC_TYPE_NAMES = { + "appeal": "כתב-ערר", + "response": "תשובת", + "protocol": "פרוטוקול-דיון", + "plan": "תכנית", + "decision": "החלטה", + "court_decision": "פסק-דין", + "permit": "היתר", + "appraisal": "שומה", + "exhibit": "נספח", + "objection": "התנגדות", + "reference": "מסמך-עזר", +} + + +def generate_doc_filename(doc_type: str, case_number: str, party_name: str = "", ext: str = ".pdf") -> str: + """Generate a clear Hebrew filename for a document.""" + base = DOC_TYPE_NAMES.get(doc_type, doc_type) + parts = [base] + if party_name: + safe_party = re.sub(r"[^\w\u0590-\u05FF\s]", "", party_name).strip().replace(" ", "-") + parts.append(safe_party) + parts.append(case_number) + return "-".join(parts) + ext + + +class GiteaRepoRequest(BaseModel): + case_number: str + title: str + description: str = "" + + +@app.post("/api/integrations/gitea/create-repo") +async def api_gitea_create_repo(req: GiteaRepoRequest): + """Create a Gitea repo in the 'cases' org and link it to the local case directory.""" + try: + repo = await create_repo(req.case_number, req.title, req.description) + except Exception as e: + raise HTTPException(502, f"Gitea error: {e}") + + clone_url = repo.get("clone_url") or repo.get("html_url", "") + case_dir = config.CASES_DIR / req.case_number + + pushed = False + if case_dir.exists(): + pushed = setup_remote_and_push(case_dir, clone_url) + + return { + "repo_url": repo.get("html_url", ""), + "clone_url": clone_url, + "pushed": pushed, + } + + +class PaperclipProjectRequest(BaseModel): + case_number: str + title: str + description: str = "" + appeal_type: str = "רישוי" + + +@app.post("/api/integrations/paperclip/create-project") +async def api_paperclip_create_project(req: PaperclipProjectRequest): + """Create a project in Paperclip's embedded DB.""" + try: + project = await pc_create_project( + case_number=req.case_number, + title=req.title, + description=req.description, + appeal_type=req.appeal_type, + ) + except Exception as e: + raise HTTPException(502, f"Paperclip error: {e}") + return project + + +@app.post("/api/cases/{case_number}/documents/upload-tagged") +async def api_upload_tagged_document( + case_number: str, + file: UploadFile = File(...), + doc_type: str = Form("auto"), + party_name: str = Form(""), + title: str = Form(""), +): + """Upload a document to a case with tagging and auto-rename.""" + case = await db.get_case_by_number(case_number) + if not case: + raise HTTPException(404, f"תיק {case_number} לא נמצא") + + if not file.filename: + raise HTTPException(400, "No filename provided") + + ext = Path(file.filename).suffix.lower() + if ext not in ALLOWED_EXTENSIONS: + raise HTTPException(400, f"סוג קובץ לא נתמך: {ext}") + + content = await file.read() + if len(content) > MAX_FILE_SIZE: + raise HTTPException(400, f"קובץ גדול מדי. מקסימום: {MAX_FILE_SIZE // (1024*1024)}MB") + + # Generate smart filename + new_filename = generate_doc_filename(doc_type, case_number, party_name, ext) + + # Save to case directory + case_dir = config.CASES_DIR / case_number / "documents" + case_dir.mkdir(parents=True, exist_ok=True) + dest = case_dir / new_filename + + # Handle duplicates + counter = 1 + while dest.exists(): + stem = new_filename.rsplit(".", 1)[0] + dest = case_dir / f"{stem}-{counter}{ext}" + counter += 1 + + dest.write_bytes(content) + + # Create document record + case_id = UUID(case["id"]) + doc_title = title or new_filename.rsplit(".", 1)[0].replace("-", " ") + doc = await db.create_document( + case_id=case_id, + doc_type=doc_type if doc_type != "auto" else "reference", + title=doc_title, + file_path=str(dest), + ) + + # Process in background + task_id = str(uuid4()) + _progress[task_id] = {"status": "queued", "filename": new_filename} + asyncio.create_task(_process_tagged_document(task_id, dest, case_number, case_id, UUID(doc["id"]), doc_type, new_filename)) + + return { + "task_id": task_id, + "filename": new_filename, + "original_name": file.filename, + "doc_type": doc_type, + } + + +async def _process_tagged_document(task_id: str, dest: Path, case_number: str, case_id: UUID, doc_id: UUID, doc_type: str, display_name: str): + """Process an uploaded tagged document in the background.""" + try: + _progress[task_id] = {"status": "processing", "filename": display_name, "step": "extracting"} + result = await processor.process_document(doc_id, case_id) + + # Git commit + push + repo_dir = config.CASES_DIR / case_number + if repo_dir.exists(): + env = { + "GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local", + "GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local", + "PATH": "/usr/bin:/bin", + } + doc_type_hebrew = DOC_TYPE_NAMES.get(doc_type, doc_type) + subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True) + subprocess.run( + ["git", "commit", "-m", f"הוספת {doc_type_hebrew}: {display_name}"], + cwd=repo_dir, capture_output=True, env=env, + ) + # Try to push to Gitea (non-blocking) + subprocess.run(["git", "push"], cwd=repo_dir, capture_output=True, env={ + **env, + "GIT_TERMINAL_PROMPT": "0", + }) + + _progress[task_id] = { + "status": "completed", + "filename": display_name, + "result": result, + "case_number": case_number, + "doc_type": doc_type, + } + except Exception as e: + logger.exception("Processing failed for %s", display_name) + _progress[task_id] = {"status": "failed", "error": str(e), "filename": display_name} + + # ── Background Processing ───────────────────────────────────────── diff --git a/web/gitea_client.py b/web/gitea_client.py new file mode 100644 index 0000000..580b438 --- /dev/null +++ b/web/gitea_client.py @@ -0,0 +1,98 @@ +"""Gitea REST API client — create repos in the 'cases' org and push.""" + +from __future__ import annotations + +import logging +import os +import subprocess +from pathlib import Path + +import httpx + +logger = logging.getLogger(__name__) + +GITEA_ORG = "cases" + + +def _host() -> str: + return os.environ.get("GITEA_HOST", "https://gitea.nautilus.marcusgroup.org") + + +def _token() -> str: + return os.environ.get("GITEA_ACCESS_TOKEN", "") + + +async def create_repo(case_number: str, title: str, description: str = "") -> dict: + """Create a private repo in the 'cases' org on Gitea.""" + repo_name = case_number # e.g. "1130-25" + async with httpx.AsyncClient(verify=False, timeout=30) as client: + resp = await client.post( + f"{_host()}/api/v1/orgs/{GITEA_ORG}/repos", + headers={"Authorization": f"token {_token()}"}, + json={ + "name": repo_name, + "description": f"ערר {case_number} — {title}"[:255], + "private": True, + "auto_init": False, + }, + ) + if resp.status_code == 409: + # Repo already exists — fetch it + resp2 = await client.get( + f"{_host()}/api/v1/repos/{GITEA_ORG}/{repo_name}", + headers={"Authorization": f"token {_token()}"}, + ) + resp2.raise_for_status() + return resp2.json() + resp.raise_for_status() + return resp.json() + + +def setup_remote_and_push(case_dir: str | Path, repo_clone_url: str) -> bool: + """Add Gitea as remote 'origin' (or update it) and push.""" + case_dir = Path(case_dir) + if not (case_dir / ".git").exists(): + return False + + env = { + "GIT_AUTHOR_NAME": "Ezer Mishpati", + "GIT_AUTHOR_EMAIL": "legal@local", + "GIT_COMMITTER_NAME": "Ezer Mishpati", + "GIT_COMMITTER_EMAIL": "legal@local", + "PATH": os.environ.get("PATH", "/usr/bin:/bin"), + } + + # Inject token into clone URL for auth + auth_url = repo_clone_url.replace("https://", f"https://chaim:{_token()}@") + + # Check if remote exists + result = subprocess.run( + ["git", "remote", "get-url", "origin"], + cwd=case_dir, capture_output=True, text=True, + ) + if result.returncode == 0: + subprocess.run( + ["git", "remote", "set-url", "origin", auth_url], + cwd=case_dir, capture_output=True, env=env, + ) + else: + subprocess.run( + ["git", "remote", "add", "origin", auth_url], + cwd=case_dir, capture_output=True, env=env, + ) + + # Push + push_result = subprocess.run( + ["git", "push", "-u", "origin", "main"], + cwd=case_dir, capture_output=True, text=True, env=env, + ) + if push_result.returncode != 0: + # Try master branch + push_result = subprocess.run( + ["git", "push", "-u", "origin", "master"], + cwd=case_dir, capture_output=True, text=True, env=env, + ) + if push_result.returncode != 0: + logger.warning("Git push failed: %s", push_result.stderr) + return False + return True diff --git a/web/paperclip_client.py b/web/paperclip_client.py new file mode 100644 index 0000000..7d45ee3 --- /dev/null +++ b/web/paperclip_client.py @@ -0,0 +1,77 @@ +"""Paperclip project creation via direct DB access (embedded PostgreSQL).""" + +from __future__ import annotations + +import logging +import uuid + +import asyncpg + +logger = logging.getLogger(__name__) + +PAPERCLIP_DB_URL = "postgresql://paperclip:paperclip@127.0.0.1:54329/paperclip" + +# Company IDs from Paperclip DB +COMPANIES = { + "licensing": "42a7acd0-30c5-4cbd-ac97-7424f65df294", # CMP — רישוי ובניה + "betterment": "8639e837-4c9d-47fa-a76b-95788d651896", # CMPA — היטלי השבחה +} + +APPEAL_TYPE_TO_COMPANY = { + "רישוי": "licensing", + "licensing": "licensing", + "היטל השבחה": "betterment", + "betterment_levy": "betterment", + "פיצויים": "betterment", + "compensation": "betterment", +} + + +def _get_company_id(appeal_type: str) -> str: + key = APPEAL_TYPE_TO_COMPANY.get(appeal_type, "licensing") + return COMPANIES[key] + + +async def create_project( + case_number: str, + title: str, + description: str = "", + appeal_type: str = "רישוי", + color: str = "#6366f1", +) -> dict: + """Create a project in the Paperclip embedded DB.""" + company_id = _get_company_id(appeal_type) + project_id = str(uuid.uuid4()) + + conn = await asyncpg.connect(PAPERCLIP_DB_URL) + try: + await conn.execute( + """INSERT INTO projects (id, company_id, name, description, status, color) + VALUES ($1, $2::uuid, $3, $4, 'backlog', $5) + ON CONFLICT DO NOTHING""", + project_id, company_id, f"ערר {case_number} — {title}"[:200], description[:500] if description else "", color, + ) + return { + "id": project_id, + "company_id": company_id, + "name": f"ערר {case_number} — {title}", + "url": f"https://pc.nautilus.marcusgroup.org/{'CMP' if 'licensing' in appeal_type or appeal_type == 'רישוי' else 'CMPA'}/projects/{project_id}/issues", + } + finally: + await conn.close() + + +async def get_project_url(case_number: str) -> str | None: + """Find existing Paperclip project for a case number.""" + conn = await asyncpg.connect(PAPERCLIP_DB_URL) + try: + row = await conn.fetchrow( + "SELECT id, company_id FROM projects WHERE name LIKE $1", + f"%{case_number}%", + ) + if row: + prefix = "CMP" if row["company_id"] == uuid.UUID(COMPANIES["licensing"]) else "CMPA" + return f"https://pc.nautilus.marcusgroup.org/{prefix}/projects/{row['id']}/issues" + return None + finally: + await conn.close() diff --git a/web/static/index.html b/web/static/index.html index 3f056e9..6bb1f24 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -3,7 +3,7 @@ -עוזר משפטי — העלאת מסמכים +עוזר משפטי — ניהול תיקים
-

עוזר משפטי

+

עוזר משפטי

- העלאת מסמכים + ניהול תיקים +
- -
-
-
-
-
📄
-

גרור קבצים לכאן או לחץ לבחירה

-

PDF, DOCX, RTF, TXT — עד 50MB

- -
-
-
-
-
+ +
+ - - - - -
-