From 2faae002e7e13844518ea2ae0eafd371ede4ffef Mon Sep 17 00:00:00 2001 From: Chaim Date: Tue, 14 Apr 2026 06:24:23 +0000 Subject: [PATCH] Add settings page for tag-to-company mappings and auto-create Paperclip projects When a case is created, a Paperclip project is now automatically created in the correct company based on the appeal_subtype tag. Tag-to-company mappings are managed via a new Settings page that pulls companies from Paperclip DB. Co-Authored-By: Claude Opus 4.6 (1M context) --- mcp-server/src/legal_mcp/services/db.py | 10 ++ web/app.py | 65 ++++++++ web/paperclip_client.py | 56 +++++-- web/static/index.html | 202 ++++++++++++++++++++++++ 4 files changed, 320 insertions(+), 13 deletions(-) diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index a72b4eb..2def531 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -376,6 +376,16 @@ CREATE TABLE IF NOT EXISTS chair_feedback ( created_at TIMESTAMPTZ DEFAULT now() ); +CREATE TABLE IF NOT EXISTS tag_company_mappings ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tag TEXT NOT NULL, -- appeal_subtype value (e.g. building_permit) + tag_label TEXT NOT NULL DEFAULT '', -- Hebrew display label + company_id TEXT NOT NULL, -- Paperclip company UUID + company_name TEXT NOT NULL DEFAULT '', -- cached company name for display + created_at TIMESTAMPTZ DEFAULT now(), + UNIQUE(tag, company_id) +); + -- ═══════════════════════════════════════════════════════════════════ -- Indexes -- ═══════════════════════════════════════════════════════════════════ diff --git a/web/app.py b/web/app.py index 867eac2..d006975 100644 --- a/web/app.py +++ b/web/app.py @@ -2090,6 +2090,71 @@ async def api_paperclip_create_project(req: PaperclipProjectRequest): return project +# ── Settings: Tag → Company Mappings ────────────────────────────── + +@app.get("/api/settings/paperclip-companies") +async def api_paperclip_companies(): + """List all companies from Paperclip's DB.""" + pc_url = os.environ.get( + "PAPERCLIP_DB_URL", "postgresql://paperclip:paperclip@127.0.0.1:54329/paperclip" + ) + try: + conn = await asyncpg.connect(pc_url) + try: + rows = await conn.fetch( + "SELECT id, name, identifier FROM companies ORDER BY name" + ) + return [{"id": str(r["id"]), "name": r["name"], "identifier": r.get("identifier", "")} for r in rows] + finally: + await conn.close() + except Exception as e: + raise HTTPException(502, f"Cannot reach Paperclip DB: {e}") + + +@app.get("/api/settings/tag-mappings") +async def api_get_tag_mappings(): + """Get all tag → company mappings.""" + pool = await db.get_pool() + rows = await pool.fetch( + "SELECT id, tag, tag_label, company_id, company_name, created_at FROM tag_company_mappings ORDER BY tag" + ) + return [dict(r) for r in rows] + + +class TagMappingRequest(BaseModel): + tag: str + tag_label: str = "" + company_id: str + company_name: str = "" + + +@app.post("/api/settings/tag-mappings") +async def api_add_tag_mapping(req: TagMappingRequest): + """Add a tag → company mapping.""" + pool = await db.get_pool() + try: + row = await pool.fetchrow( + """INSERT INTO tag_company_mappings (tag, tag_label, company_id, company_name) + VALUES ($1, $2, $3, $4) + ON CONFLICT (tag, company_id) DO UPDATE SET tag_label = $2, company_name = $4 + RETURNING id, tag, tag_label, company_id, company_name""", + req.tag, req.tag_label, req.company_id, req.company_name, + ) + return dict(row) + except Exception as e: + raise HTTPException(400, str(e)) + + +@app.delete("/api/settings/tag-mappings/{mapping_id}") +async def api_delete_tag_mapping(mapping_id: str): + """Delete a tag → company mapping.""" + pool = await db.get_pool() + result = await pool.execute("DELETE FROM tag_company_mappings WHERE id = $1::uuid", mapping_id) + if result == "DELETE 0": + raise HTTPException(404, "Mapping not found") + return {"ok": True} + + # ── Skill Management API ─────────────────────────────────────────── diff --git a/web/paperclip_client.py b/web/paperclip_client.py index 8def13e..1654f7e 100644 --- a/web/paperclip_client.py +++ b/web/paperclip_client.py @@ -27,19 +27,41 @@ COMPANIES = { "betterment": "8639e837-4c9d-47fa-a76b-95788d651896", # CMPA — היטלי השבחה } -APPEAL_TYPE_TO_COMPANY = { - "רישוי": "licensing", - "licensing": "licensing", - "היטל השבחה": "betterment", - "betterment_levy": "betterment", - "פיצויים": "betterment", - "compensation": "betterment", +# Fallback mapping — used only when DB lookup returns no results +_FALLBACK_APPEAL_TYPE_TO_COMPANY = { + "רישוי": COMPANIES["licensing"], + "היטל השבחה": COMPANIES["betterment"], + "פיצויים": COMPANIES["betterment"], + "building_permit": COMPANIES["licensing"], + "betterment_levy": COMPANIES["betterment"], + "compensation_197": COMPANIES["betterment"], + "compensation": COMPANIES["betterment"], + "licensing": COMPANIES["licensing"], } +# Legal-AI DB URL for reading tag_company_mappings +_LEGAL_DB_URL = os.environ.get( + "DATABASE_URL", "postgresql://legal:legal@127.0.0.1:5432/legal_ai" +) -def _get_company_id(appeal_type: str) -> str: - key = APPEAL_TYPE_TO_COMPANY.get(appeal_type, "licensing") - return COMPANIES[key] + +async def _get_company_id(appeal_type: str) -> str: + """Resolve appeal_type tag to a Paperclip company ID via DB mappings, with fallback.""" + try: + conn = await asyncpg.connect(_LEGAL_DB_URL) + try: + row = await conn.fetchrow( + "SELECT company_id FROM tag_company_mappings WHERE tag = $1 LIMIT 1", + appeal_type, + ) + if row: + return row["company_id"] + finally: + await conn.close() + except Exception: + logger.debug("DB lookup for tag mapping failed, using fallback for '%s'", appeal_type) + + return _FALLBACK_APPEAL_TYPE_TO_COMPANY.get(appeal_type, COMPANIES["licensing"]) async def create_project( @@ -50,11 +72,16 @@ async def create_project( color: str = "#6366f1", ) -> dict: """Create a project in the Paperclip embedded DB, or return existing one.""" - company_id = _get_company_id(appeal_type) - prefix = "CMP" if _get_company_id(appeal_type) == COMPANIES["licensing"] else "CMPA" + company_id = await _get_company_id(appeal_type) conn = await asyncpg.connect(PAPERCLIP_DB_URL) try: + # Resolve prefix from company identifier in Paperclip DB + comp_row = await conn.fetchrow( + "SELECT identifier FROM companies WHERE id = $1::uuid", company_id, + ) + prefix = comp_row["identifier"] if comp_row and comp_row["identifier"] else "CMP" + # Check for existing project with this case number existing = await conn.fetchrow( "SELECT id, name FROM projects WHERE name LIKE $1 AND company_id = $2::uuid", @@ -216,7 +243,10 @@ async def get_project_url(case_number: str) -> str | None: f"%{case_number}%", ) if row: - prefix = "CMP" if row["company_id"] == uuid.UUID(COMPANIES["licensing"]) else "CMPA" + comp_row = await conn.fetchrow( + "SELECT identifier FROM companies WHERE id = $1::uuid", str(row["company_id"]), + ) + prefix = comp_row["identifier"] if comp_row and comp_row["identifier"] else "CMP" return f"https://pc.nautilus.marcusgroup.org/{prefix}/projects/{row['id']}/issues" return None finally: diff --git a/web/static/index.html b/web/static/index.html index 7624c24..676caaa 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -1881,6 +1881,7 @@ kbd { כתיבה Skills מצב מערכת + הגדרות @@ -2405,6 +2406,45 @@ kbd {
טוען...
+ + +
+ + + +
+
שיוך תגי תיקים לחברות Paperclip
+
+

כל תג ערר (סוג תיק) משויך לחברה ב-Paperclip. כשנפתח תיק חדש, הפרויקט נוצר אוטומטית בחברה המתאימה לפי התג.

+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
טוען...
+
+
+
+
@@ -2514,6 +2554,11 @@ function handleRoute() { document.getElementById('navCompose').classList.add('active'); subtitle = 'כתיבת החלטה'; initComposePage(); + } else if (hash === '#/settings') { + document.getElementById('page-settings').classList.add('active'); + document.getElementById('navSettings').classList.add('active'); + subtitle = 'הגדרות'; + loadSettingsPage(); } document.getElementById('pageSubtitle').textContent = subtitle; @@ -4974,6 +5019,163 @@ async function loadCorpusList() { container.innerHTML = `
שגיאה בטעינה: ${esc(e.message)}
`; } } +// ── Settings Page ───────────────────────────────────────────────── +let _settingsCompanies = []; + +async function loadSettingsPage() { + await Promise.all([loadPaperclipCompanies(), loadTagMappings()]); +} + +async function loadPaperclipCompanies() { + const sel = document.getElementById('settingsCompanySelect'); + try { + const res = await fetch(`${API}/settings/paperclip-companies`); + if (!res.ok) throw new Error(await res.text()); + _settingsCompanies = await res.json(); + // Build options safely via DOM + sel.textContent = ''; + const defaultOpt = document.createElement('option'); + defaultOpt.value = ''; + defaultOpt.textContent = '— בחר חברה —'; + sel.appendChild(defaultOpt); + for (const c of _settingsCompanies) { + const opt = document.createElement('option'); + opt.value = c.id; + opt.dataset.name = c.name; + opt.textContent = c.name + (c.identifier ? ` (${c.identifier})` : ''); + sel.appendChild(opt); + } + } catch (e) { + sel.textContent = ''; + const opt = document.createElement('option'); + opt.value = ''; + opt.textContent = 'שגיאה: ' + e.message; + sel.appendChild(opt); + } +} + +async function loadTagMappings() { + const container = document.getElementById('tagMappingsTable'); + try { + const res = await fetch(`${API}/settings/tag-mappings`); + if (!res.ok) throw new Error(await res.text()); + const mappings = await res.json(); + if (!mappings.length) { + container.textContent = ''; + const empty = document.createElement('div'); + empty.className = 'empty'; + empty.textContent = 'אין שיוכים מוגדרים עדיין'; + container.appendChild(empty); + return; + } + + // Group by company + const byCompany = {}; + for (const m of mappings) { + const key = m.company_id; + if (!byCompany[key]) byCompany[key] = { company_name: m.company_name || m.company_id, tags: [] }; + byCompany[key].tags.push(m); + } + + const table = document.createElement('table'); + table.style.cssText = 'width:100%;border-collapse:collapse;font-size:0.9em'; + + const thead = table.createTHead(); + const headerRow = thead.insertRow(); + headerRow.style.borderBottom = '2px solid var(--border,#ddd)'; + for (const label of ['Company', 'Tag', 'Label', '']) { + const th = document.createElement('th'); + th.style.cssText = 'text-align:right;padding:8px'; + if (label === '') th.style.width = '60px'; + th.textContent = label; + headerRow.appendChild(th); + } + + const tbody = table.createTBody(); + for (const [companyId, group] of Object.entries(byCompany)) { + for (let i = 0; i < group.tags.length; i++) { + const m = group.tags[i]; + const tr = tbody.insertRow(); + tr.style.borderBottom = '1px solid var(--border,#eee)'; + + if (i === 0) { + const tdCompany = tr.insertCell(); + tdCompany.style.cssText = 'padding:8px;font-weight:600;vertical-align:top'; + tdCompany.rowSpan = group.tags.length; + tdCompany.textContent = group.company_name; + } + + const tdTag = tr.insertCell(); + tdTag.style.padding = '8px'; + const code = document.createElement('code'); + code.style.cssText = 'background:var(--bg-secondary,#f0f0f0);padding:2px 6px;border-radius:4px'; + code.textContent = m.tag; + tdTag.appendChild(code); + + const tdLabel = tr.insertCell(); + tdLabel.style.padding = '8px'; + tdLabel.textContent = m.tag_label || '—'; + + const tdAction = tr.insertCell(); + tdAction.style.padding = '8px'; + const btn = document.createElement('button'); + btn.className = 'btn-icon btn-icon-danger'; + btn.title = 'הסר שיוך'; + btn.textContent = '✕'; + btn.addEventListener('click', () => deleteTagMapping(m.id)); + tdAction.appendChild(btn); + } + } + + container.textContent = ''; + container.appendChild(table); + } catch (e) { + container.textContent = ''; + const empty = document.createElement('div'); + empty.className = 'empty'; + empty.textContent = 'שגיאה: ' + e.message; + container.appendChild(empty); + } +} + +async function addTagMapping() { + const tag = document.getElementById('settingsNewTag').value.trim(); + const tagLabel = document.getElementById('settingsNewTagLabel').value.trim(); + const sel = document.getElementById('settingsCompanySelect'); + const companyId = sel.value; + const companyName = sel.selectedOptions[0]?.dataset?.name || ''; + + if (!tag) { showToast('יש להזין תג', 'error'); return; } + if (!companyId) { showToast('יש לבחור חברה', 'error'); return; } + + try { + const res = await fetch(`${API}/settings/tag-mappings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tag, tag_label: tagLabel, company_id: companyId, company_name: companyName }), + }); + if (!res.ok) throw new Error(await res.text()); + showToast('שיוך נוסף בהצלחה'); + document.getElementById('settingsNewTag').value = ''; + document.getElementById('settingsNewTagLabel').value = ''; + sel.value = ''; + await loadTagMappings(); + } catch (e) { + showToast('שגיאה: ' + e.message, 'error'); + } +} + +async function deleteTagMapping(id) { + if (!confirm('להסיר שיוך זה?')) return; + try { + const res = await fetch(`${API}/settings/tag-mappings/${id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error(await res.text()); + showToast('שיוך הוסר'); + await loadTagMappings(); + } catch (e) { + showToast('שגיאה: ' + e.message, 'error'); + } +}