diff --git a/web/app.py b/web/app.py index 2c90fd6..0e3c098 100644 --- a/web/app.py +++ b/web/app.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio import json import logging +import os import re import shutil import subprocess @@ -17,11 +18,15 @@ 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")) +import zipfile + from fastapi import FastAPI, File, Form, HTTPException, UploadFile from fastapi.responses import FileResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel +import asyncpg + 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 @@ -774,6 +779,249 @@ async def api_paperclip_create_project(req: PaperclipProjectRequest): return project +# ── Skill Management API ─────────────────────────────────────────── + + +PAPERCLIP_DB_URL = os.environ.get( + "PAPERCLIP_DB_URL", "postgresql://paperclip:paperclip@127.0.0.1:54329/paperclip" +) +# In Docker: mounted at /paperclip-skills; locally: ~/.paperclip/instances/default/skills +_docker_skills = Path("/paperclip-skills") +_local_skills = Path.home() / ".paperclip" / "instances" / "default" / "skills" +PAPERCLIP_SKILLS_DIR = _docker_skills if _docker_skills.exists() else _local_skills +# Default company ID for skills +SKILLS_COMPANY_ID = os.environ.get("PAPERCLIP_COMPANY_ID", "42a7acd0-30c5-4cbd-ac97-7424f65df294") + + +@app.get("/api/admin/skills") +async def api_list_skills(): + """List installed Paperclip skills with DB sync status.""" + conn = await asyncpg.connect(PAPERCLIP_DB_URL) + try: + rows = await conn.fetch( + "SELECT slug, name, length(markdown) as md_chars, file_inventory, updated_at " + "FROM company_skills WHERE company_id = $1::uuid ORDER BY slug", + SKILLS_COMPANY_ID, + ) + finally: + await conn.close() + + skills = [] + for r in rows: + slug = r["slug"] + skill_dir = PAPERCLIP_SKILLS_DIR / SKILLS_COMPANY_ID / slug + disk_exists = skill_dir.exists() + disk_skill_md = None + if disk_exists: + skill_md = skill_dir / "SKILL.md" + if skill_md.exists(): + disk_skill_md = skill_md.stat().st_size + + skills.append({ + "slug": slug, + "name": r["name"], + "db_markdown_chars": r["md_chars"], + "file_inventory": json.loads(r["file_inventory"]) if isinstance(r["file_inventory"], str) else r["file_inventory"], + "updated_at": r["updated_at"].isoformat() if r["updated_at"] else None, + "disk_exists": disk_exists, + "disk_skill_md_bytes": disk_skill_md, + }) + + # Also check for skills on disk that aren't in DB + company_dir = PAPERCLIP_SKILLS_DIR / SKILLS_COMPANY_ID + if company_dir.exists(): + db_slugs = {s["slug"] for s in skills} + for d in sorted(company_dir.iterdir()): + if d.is_dir() and d.name not in db_slugs: + skill_md = d / "SKILL.md" + skills.append({ + "slug": d.name, + "name": d.name, + "db_markdown_chars": 0, + "file_inventory": [], + "updated_at": None, + "disk_exists": True, + "disk_skill_md_bytes": skill_md.stat().st_size if skill_md.exists() else None, + "not_in_db": True, + }) + + return skills + + +@app.post("/api/admin/skills/install") +async def api_install_skill(file: UploadFile = File(...)): + """Install or update a Paperclip skill from a ZIP file. + + The ZIP should contain a SKILL.md at root (or in a single subdirectory). + The skill slug is derived from the directory name or ZIP filename. + """ + if not file.filename: + raise HTTPException(400, "No filename provided") + + if not file.filename.lower().endswith(".zip"): + raise HTTPException(400, "Only ZIP files are supported") + + content = await file.read() + if len(content) > 100 * 1024 * 1024: # 100MB limit + raise HTTPException(400, "File too large (max 100MB)") + + import io + try: + zf = zipfile.ZipFile(io.BytesIO(content)) + except zipfile.BadZipFile: + raise HTTPException(400, "Invalid ZIP file") + + # Find SKILL.md and determine the skill root + skill_md_path = None + skill_root = "" + names = zf.namelist() + + for name in names: + basename = name.split("/")[-1] + if basename == "SKILL.md": + skill_md_path = name + # Root is everything before SKILL.md + skill_root = name[: -len("SKILL.md")] + break + + if not skill_md_path: + zf.close() + raise HTTPException(400, "ZIP must contain a SKILL.md file") + + # Determine slug: from directory name in ZIP, or from ZIP filename + if skill_root and skill_root.strip("/"): + slug = skill_root.strip("/").split("/")[0] + else: + slug = Path(file.filename).stem.lower() + slug = re.sub(r"[^\w\-]", "-", slug).strip("-") + + # Extract to skill directory + skill_dir = PAPERCLIP_SKILLS_DIR / SKILLS_COMPANY_ID / slug + skill_dir.mkdir(parents=True, exist_ok=True) + + # Clear existing contents + for item in skill_dir.rglob("*"): + if item.is_file(): + item.unlink() + # Remove empty subdirs + for item in sorted(skill_dir.rglob("*"), reverse=True): + if item.is_dir(): + try: + item.rmdir() + except OSError: + pass + + # Extract files, stripping the skill_root prefix + extracted_files = [] + for name in names: + if name.endswith("/"): + continue # skip directories + if not name.startswith(skill_root): + continue # skip files outside skill root + + rel_path = name[len(skill_root):] + if not rel_path: + continue + # Skip macOS metadata + if "/__MACOSX/" in name or rel_path.startswith("__MACOSX/") or rel_path.startswith("."): + continue + + dest = skill_dir / rel_path + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(zf.read(name)) + extracted_files.append(rel_path) + + zf.close() + + # Read SKILL.md content + skill_md_file = skill_dir / "SKILL.md" + if not skill_md_file.exists(): + raise HTTPException(500, "SKILL.md was not extracted properly") + + markdown_content = skill_md_file.read_text(encoding="utf-8") + + # Build file_inventory + file_inventory = [] + for rel in sorted(extracted_files): + if rel == "SKILL.md": + kind = "skill" + elif rel.startswith("scripts/"): + kind = "script" + elif rel.startswith("references/"): + kind = "reference" + elif rel.endswith(".zip"): + kind = "archive" + else: + kind = "resource" + file_inventory.append({"kind": kind, "path": rel}) + + # Update DB + conn = await asyncpg.connect(PAPERCLIP_DB_URL) + try: + existing = await conn.fetchval( + "SELECT id FROM company_skills WHERE company_id = $1::uuid AND slug = $2", + SKILLS_COMPANY_ID, slug, + ) + if existing: + await conn.execute( + """UPDATE company_skills + SET markdown = $1, file_inventory = $2::jsonb, updated_at = now() + WHERE id = $3""", + markdown_content, + json.dumps(file_inventory, ensure_ascii=False), + existing, + ) + action = "updated" + else: + await conn.execute( + """INSERT INTO company_skills + (company_id, key, slug, name, markdown, source_type, file_inventory) + VALUES ($1::uuid, $2, $3, $4, $5, 'local_path', $6::jsonb)""", + SKILLS_COMPANY_ID, slug, slug, slug, + markdown_content, + json.dumps(file_inventory, ensure_ascii=False), + ) + action = "installed" + finally: + await conn.close() + + return { + "slug": slug, + "action": action, + "files_extracted": len(extracted_files), + "file_inventory": file_inventory, + "markdown_chars": len(markdown_content), + } + + +@app.post("/api/admin/paperclip/restart") +async def api_restart_paperclip(): + """Restart the Paperclip PM2 process. + + Tries pm2 directly (works when running locally on the host). + In Docker, writes a restart flag file that the host watcher picks up. + """ + # Try pm2 directly (works when running outside Docker) + result = subprocess.run( + ["pm2", "restart", "paperclip"], + capture_output=True, text=True, timeout=15, + ) + if result.returncode == 0: + return {"status": "restarted", "method": "pm2", "output": result.stdout.strip()} + + # Fallback: write a flag file that host-side watcher picks up + flag_file = PAPERCLIP_SKILLS_DIR / ".restart-requested" + try: + flag_file.write_text(str(time.time())) + return { + "status": "restart_requested", + "method": "flag_file", + "message": "Restart requested — the host watcher will restart Paperclip shortly.", + } + except Exception: + raise HTTPException(500, "Cannot restart Paperclip from Docker. Run manually: pm2 restart paperclip") + + @app.post("/api/cases/{case_number}/documents/upload-tagged") async def api_upload_tagged_document( case_number: str, diff --git a/web/static/index.html b/web/static/index.html index 03d4f22..a3e5008 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -241,6 +241,27 @@ header nav a:hover, header nav a.active { color: #fff; background: rgba(255,255, .empty { text-align: center; color: #bbb; padding: 40px 20px; font-size: 0.88em; line-height: 1.6; } +/* ── Skills Management ───────────────────────────────── */ +.skill-list { display: flex; flex-direction: column; gap: 10px; } +.skill-item { + display: flex; align-items: center; gap: 14px; padding: 14px 18px; + border: 1px solid #eee; border-radius: 8px; background: #fafafa; + transition: background 0.15s; +} +.skill-item:hover { background: #f0f0f0; } +.skill-item .skill-icon { font-size: 1.5em; flex-shrink: 0; } +.skill-item .skill-info { flex: 1; min-width: 0; } +.skill-item .skill-name { font-size: 0.95em; font-weight: 600; } +.skill-item .skill-meta { font-size: 0.75em; color: #999; margin-top: 3px; display: flex; gap: 14px; flex-wrap: wrap; } +.skill-item .skill-badges { display: flex; gap: 6px; flex-shrink: 0; } +.skill-item .badge-ok { background: #e8f5e9; color: #388e3c; padding: 2px 8px; border-radius: 4px; font-size: 0.72em; font-weight: 600; } +.skill-item .badge-warn { background: #fff3e0; color: #f57c00; padding: 2px 8px; border-radius: 4px; font-size: 0.72em; font-weight: 600; } +.skill-install-result { + margin-top: 16px; padding: 16px; border-radius: 8px; + background: #e8f5e9; border: 1px solid #c8e6c9; font-size: 0.88em; +} +.skill-install-result.error { background: #ffebee; border-color: #ffcdd2; } + @media (max-width: 800px) { .main { padding: 16px; } header { padding: 14px 16px; } @@ -260,6 +281,7 @@ header nav a:hover, header nav a.active { color: #fff; background: rgba(255,255, תיקים + תיק חדש העלאה + Skills @@ -442,6 +464,46 @@ header nav a:hover, header nav a.active { color: #fff; background: rgba(255,255, + +
ZIP עם SKILL.md, scripts/, references/ — לפי מבנה Anthropic
+ +