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:
226
web/app.py
226
web/app.py
@@ -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
98
web/gitea_client.py
Normal 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
77
web/paperclip_client.py
Normal 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
Reference in New Issue
Block a user