Add interactive case creation wizard + document upload with auto-rename

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-04 10:17:24 +00:00
parent cb41867bc9
commit 0593fe9b01
4 changed files with 1276 additions and 649 deletions

View File

@@ -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()
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"],
}
for c in cases
]
"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 ─────────────────────────────────────────

98
web/gitea_client.py Normal file
View File

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

77
web/paperclip_client.py Normal file
View File

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

File diff suppressed because it is too large Load Diff