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
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:
@@ -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
|
||||
-- ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
65
web/app.py
65
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 ───────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user