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 # Allow importing legal_mcp from the MCP server source
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "mcp-server" / "src")) 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.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel 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.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 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__) logger = logging.getLogger(__name__)
@@ -178,17 +184,34 @@ async def health():
@app.get("/api/cases") @app.get("/api/cases")
async def list_cases(): async def list_cases(detail: bool = False):
"""List existing cases for the dropdown.""" """List existing cases. With detail=true, includes doc counts and integration URLs."""
cases = await db.list_cases() cases = await db.list_cases()
return [ if not detail:
{ return [
"case_number": c["case_number"], {"case_number": c["case_number"], "title": c["title"], "status": c["status"]}
"title": c["title"], for c in cases
"status": c["status"], ]
} # Enhanced listing with document counts
for c in cases 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 ───────────────────────────────────── # ── Paperclip Integration API ─────────────────────────────────────
@@ -546,6 +569,187 @@ async def api_document_text(doc_id: str):
return {"doc_id": doc_id, "text": text} 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 ───────────────────────────────────────── # ── 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