Add settings page for tag-to-company mappings and auto-create Paperclip projects
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m22s

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 06:24:23 +00:00
parent 140a2e442d
commit 2faae002e7
4 changed files with 320 additions and 13 deletions

View File

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

View File

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

View File

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

View File

@@ -1881,6 +1881,7 @@ kbd {
<a href="#/compose" id="navCompose">כתיבה</a>
<a href="#/skills" id="navSkills">Skills</a>
<a href="#/diagnostics" id="navDiagnostics">מצב מערכת</a>
<a href="#/settings" id="navSettings">הגדרות</a>
<button class="theme-toggle" id="themeToggle" onclick="toggleTheme()" title="החלף ערכת צבעים (Shift+D)">
<span id="themeIcon">🌙</span>
</button>
@@ -2405,6 +2406,45 @@ kbd {
<div class="empty">טוען...</div>
</div>
</div>
<!-- ══ Page: Settings ══ -->
<div class="page" id="page-settings">
<div class="page-header">
<h2>הגדרות</h2>
</div>
<!-- Tag → Company Mappings -->
<div class="card">
<div class="card-header">שיוך תגי תיקים לחברות Paperclip</div>
<div class="card-body">
<p style="color:#888;font-size:0.85em;margin-bottom:16px">כל תג ערר (סוג תיק) משויך לחברה ב-Paperclip. כשנפתח תיק חדש, הפרויקט נוצר אוטומטית בחברה המתאימה לפי התג.</p>
<!-- Add new mapping -->
<div style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;margin-bottom:24px;padding:16px;background:var(--bg-secondary,#f8f9fa);border-radius:8px">
<div style="flex:1;min-width:160px">
<label style="display:block;font-size:0.8em;color:#888;margin-bottom:4px">תג ערר</label>
<input type="text" id="settingsNewTag" placeholder="לדוגמה: building_permit" style="width:100%;padding:8px;border:1px solid var(--border,#ddd);border-radius:6px;background:var(--bg-primary,#fff);color:var(--text-primary)">
</div>
<div style="flex:1;min-width:160px">
<label style="display:block;font-size:0.8em;color:#888;margin-bottom:4px">תיאור בעברית</label>
<input type="text" id="settingsNewTagLabel" placeholder="לדוגמה: רישוי ובנייה" style="width:100%;padding:8px;border:1px solid var(--border,#ddd);border-radius:6px;background:var(--bg-primary,#fff);color:var(--text-primary)">
</div>
<div style="flex:1.5;min-width:200px">
<label style="display:block;font-size:0.8em;color:#888;margin-bottom:4px">חברה ב-Paperclip</label>
<select id="settingsCompanySelect" style="width:100%;padding:8px;border:1px solid var(--border,#ddd);border-radius:6px;background:var(--bg-primary,#fff);color:var(--text-primary)">
<option value="">טוען חברות...</option>
</select>
</div>
<button class="btn btn-primary" onclick="addTagMapping()" style="white-space:nowrap">הוסף שיוך</button>
</div>
<!-- Existing mappings table -->
<div id="tagMappingsTable">
<div class="empty">טוען...</div>
</div>
</div>
</div>
</div>
</div>
<!-- Modal for pattern examples -->
@@ -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 = `<div class="empty">שגיאה בטעינה: ${esc(e.message)}</div>`;
}
}
// ── 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');
}
}
</script>
</body>
</html>