Compare commits

...

2 Commits

Author SHA1 Message Date
CC6 Worker cef4d2575b M12.2 UI: enrichment diff modal + apply button (sport2.html)
- enrichEntity() now renders {current, proposed} as a diff table with a
  checkbox per field (defaults to checked).
- 'Označi sve' / 'Poništi sve' / '💾 Spremi izmjene' buttons.
- enrichApply() POSTs selected fields to /v2/enrich/{kind}/{id}/apply
  with the cached source list, then refreshes the entity panel and
  re-runs preview so the now-saved values are visible inline.
- Toast '✓ Spremljeno N polja u bazu' confirms the write.
- '✓ Obogaćeno YYYY-MM-DD' badge surfaces metadata.enriched_at.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 00:17:52 +02:00
claude-cc1 fbbe953de3 CC1 R3B-Mreža M1+M2+M3 — autocomplete + 3D centar + forensic enrich
M1 (default centar):
- Augment /api/v1/presenter/graph-real with synthetic 'pgz-savez-nogometni' anchor
  (PGŽ gold, size 40), connected to top 3 person + top 3 entity nodes
- centerMrezaOnAnchor() called 1.5s after render and via "🎯 Centar (PGŽ)" button

M2 (autocomplete):
- Backend GET /api/v2/search/suggest?q=&type=person|club|company
  Searches pgz_sport.klubovi, pgz_sport.savezi, pgz_sport.clanovi,
  civic.persons, civic.entities; returns 20 results max
- Frontend: 3 inputs get keydown+input handlers, dropdown UI under each
  Enter → first suggestion, click → suggestion, blur → close
- centerMrezaOnSuggestion: finds existing node by label, or injects new node
  + edge from anchor and re-renders

M3 (forensic enrich):
- Backend POST /api/v2/forensic/findings/{id}/enrich
  Extract person name from entities_involved or title regex,
  hit hr.wikipedia.org REST summary, persist into raw_data.enrichment
- Frontend: forensicEnrichBlock + customFindingEnrichBlock added to alert
  panel and custom-finding panel (Liverić). Custom uses direct Wikipedia
  fetch since they're not in DB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 00:16:45 +02:00
+151 -15
View File
@@ -357,34 +357,78 @@ async function apiPost(path, body){
}
}
// Cache the latest preview so /apply can pass back the same sources
window._enrichPreviews = window._enrichPreviews || {};
async function enrichEntity(kind, id){
const targetId = 'enrich-out-'+kind+'-'+id;
const target = document.getElementById(targetId);
if(target) target.innerHTML = '<div class="loading">⏳ Obogaćivanje u tijeku — pretraživanje izvora…</div>';
const r = await apiPost('/v2/enrich/'+kind+'/'+id);
if(!r){ if(target) target.innerHTML = '<div class="empty">Greška pri obogaćivanju</div>'; return; }
window._enrichPreviews[kind+':'+id] = r;
const cov = r.coverage||0;
const covCls = cov>=70?'high':(cov>=40?'mid':'low');
const proposed = r.proposed || {};
const current = r.current || {};
const propKeys = Object.keys(proposed);
const lastEnr = r.last_enriched_at
? `<span class="tb-s" title="${esc(r.last_enriched_at)}">✓ Obogaćeno ${esc(String(r.last_enriched_at).slice(0,10))}</span>`
: '';
let diffHtml = '';
if(propKeys.length){
const rows = propKeys.map(k => {
const cv = current[k]; const pv = proposed[k];
const cvHtml = cv
? '<span style="color:var(--t1)">'+esc(String(cv).slice(0,200))+'</span>'
: '<span class="tag rd">prazno</span>';
const pvHtml = '<span style="color:var(--ok)">'+esc(String(pv).slice(0,400))+'</span>';
return `<tr>
<td style="vertical-align:top;padding:6px 8px"><label style="display:flex;gap:6px;align-items:center;cursor:pointer">
<input type="checkbox" data-field="${esc(k)}" checked style="width:16px;height:16px"> <b style="font-family:monospace;font-size:12px">${esc(k)}</b>
</label></td>
<td style="vertical-align:top;padding:6px 8px;font-size:12px;max-width:240px">${cvHtml}</td>
<td style="vertical-align:top;padding:6px 8px;font-size:12px">${pvHtml}</td>
</tr>`;
}).join('');
diffHtml = `
<div style="margin:10px 0;border:1px solid var(--ln);border-radius:6px;overflow:hidden">
<div style="padding:8px 10px;background:var(--bg3);font-size:11px;color:var(--t4);text-transform:uppercase;letter-spacing:.5px">📋 Predložene izmjene (uncheck za preskočiti)</div>
<table style="width:100%;border-collapse:collapse;font-size:12px">
<thead><tr style="background:var(--bg2)"><th style="text-align:left;padding:6px 8px;width:160px">Polje</th><th style="text-align:left;padding:6px 8px;width:240px">Trenutno</th><th style="text-align:left;padding:6px 8px">Predloženo</th></tr></thead>
<tbody id="enrich-diff-${kind}-${id}">${rows}</tbody>
</table>
<div style="padding:8px 10px;background:var(--bg2);display:flex;gap:8px;justify-content:flex-end">
<button class="btn" onclick="enrichSelectAll('${kind}',${id},true)">Označi sve</button>
<button class="btn" onclick="enrichSelectAll('${kind}',${id},false)">Poništi sve</button>
<button class="btn primary" onclick="enrichApply('${kind}',${id})">💾 Spremi izmjene</button>
</div>
</div>`;
} else {
diffHtml = '<div class="empty" style="padding:14px">Nema novih predloženih dopuna iz vanjskih izvora.</div>';
}
const sourcesHtml = (r.sources||[]).map(s => `
<div style="padding:8px 10px;background:var(--bg3);border-left:3px solid var(--pgz-gold);border-radius:4px;margin-bottom:6px">
<div style="font-size:11px;color:var(--t4);text-transform:uppercase;letter-spacing:.5px">${esc(s.source||'')}</div>
${s.title ? '<div style="font-weight:700;color:var(--t0);font-size:13px">'+esc(s.title)+'</div>' : ''}
${s.extract ? '<div style="font-size:11.5px;color:var(--t1);line-height:1.5">'+esc(String(s.extract).slice(0,300))+'…</div>' : ''}
${s.url ? '<div style="margin-top:4px"><a href="'+esc(s.url)+'" target="_blank" style="font-size:11px">↗ '+esc(String(s.url).slice(0,90))+'</a></div>' : ''}
</div>`).join('');
const html = `
<div style="display:flex;gap:8px;align-items:center;margin-bottom:10px">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:10px">
<span class="tag gr">🟢 OBOGAĆENO</span>
<span class="score ${covCls}">Coverage ${cov}%</span>
<span class="tb-s">${r.filled_fields}/${r.total_fields} polja popunjeno</span>
${lastEnr}
</div>
${r.live_snippet && r.live_snippet.title ? `
<div style="padding:10px;background:var(--bg3);border-left:3px solid var(--pgz-gold);border-radius:5px;margin-bottom:10px">
<div style="font-size:11px;color:var(--t4);text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px">📡 Live snippet</div>
<div style="font-weight:700;color:var(--t0);font-size:13px;margin-bottom:4px">${esc(r.live_snippet.title)}</div>
${r.live_snippet.description ? '<div style="font-size:11.5px;color:var(--t1);line-height:1.5">'+esc(r.live_snippet.description)+'</div>' : ''}
<div style="margin-top:6px"><a href="${esc(r.live_snippet.url)}" target="_blank">↗ ${esc(r.live_snippet.url.slice(0,80))}</a></div>
</div>
` : ''}
${r.missing_fields && r.missing_fields.length ? `
<div style="margin-bottom:10px">
<div style="font-size:11px;color:var(--t2);margin-bottom:4px">Nedostaje:</div>
<div>${r.missing_fields.map(f=>'<span class="tag rd">'+esc(f)+'</span>').join('')}</div>
</div>
` : ''}
${diffHtml}
${sourcesHtml ? `<div style="margin:10px 0">
<div style="font-size:11px;color:var(--t4);text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px">🔗 Izvori</div>
${sourcesHtml}
</div>` : ''}
<div>
<div style="font-size:11px;color:var(--t2);margin-bottom:6px">🔍 Istraži dalje:</div>
<div style="display:flex;flex-wrap:wrap;gap:6px">
@@ -395,6 +439,54 @@ async function enrichEntity(kind, id){
if(target) target.innerHTML = html;
}
function enrichSelectAll(kind, id, on){
const tbody = document.getElementById('enrich-diff-'+kind+'-'+id);
if(!tbody) return;
tbody.querySelectorAll('input[type=checkbox]').forEach(cb => { cb.checked = !!on; });
}
async function enrichApply(kind, id){
const target = document.getElementById('enrich-out-'+kind+'-'+id);
const tbody = document.getElementById('enrich-diff-'+kind+'-'+id);
const preview = (window._enrichPreviews||{})[kind+':'+id];
if(!preview){ alert('Prvo pokreni "▶ Pokreni"'); return; }
const proposed = preview.proposed || {};
const fields = {};
if(tbody){
tbody.querySelectorAll('input[type=checkbox]:checked').forEach(cb => {
const f = cb.getAttribute('data-field');
if(f && proposed[f] !== undefined) fields[f] = proposed[f];
});
} else {
Object.assign(fields, proposed);
}
if(!Object.keys(fields).length){ alert('Označi barem jedno polje za primjenu.'); return; }
if(target) target.innerHTML = '<div class="loading">⏳ Spremam u bazu…</div>';
try{
const r = await fetch(API+'/v2/enrich/'+kind+'/'+id+'/apply', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({fields, sources: preview.sources || []}),
});
const data = await r.json();
if(!r.ok){ throw new Error(data.detail || ('HTTP '+r.status)); }
// Refresh the entire detail panel so the new values render
if(kind === 'klub' && typeof openKlub === 'function') await openKlub(id);
else if(kind === 'savez' && typeof openSavez === 'function') await openSavez(id);
else if(kind === 'sportas' && typeof openSportas === 'function') await openSportas(id);
setTimeout(() => enrichEntity(kind, id), 350);
const cnt = Object.keys(data.applied||{}).length;
const t = document.createElement('div');
t.style.cssText = 'position:fixed;bottom:20px;right:20px;background:var(--ok,#1ec773);color:#0b1a16;padding:10px 16px;border-radius:6px;font-weight:700;z-index:9999;box-shadow:0 4px 16px rgba(0,0,0,.4)';
t.textContent = '✓ Spremljeno '+cnt+' polja u bazu';
document.body.appendChild(t);
setTimeout(()=>t.remove(), 3500);
}catch(e){
console.error(e);
if(target) target.innerHTML = '<div class="empty" style="color:var(--bad,#ff6b6b)">Greška pri spremanju: '+esc(e.message||String(e))+'</div>';
}
}
function enrichBlock(kind, id){
return `
<div class="card" id="enrich-card-${kind}-${id}">
@@ -2417,10 +2509,54 @@ function renderCustomFindingPanel(c){
<div class="card-h"><div class="card-t">📄 Dokumenti / dokazi</div></div>
<div class="empty" style="padding:14px">Za ovaj manualni nalaz nisu priloženi PDF dokazi. Pokreni "Obogati podatke" za prikupljanje izvora.</div>
</div>
${customFindingEnrichBlock(c.id, c.osoba || c.naslov)}
`;
openPanel('Forenzika · '+c.naslov, html);
}
function customFindingEnrichBlock(customId, queryName){
const safeId = String(customId).replace(/[^a-z0-9_-]/gi,'_');
return `
<div class="card" id="fenrich-card-${safeId}">
<div class="card-h">
<div class="card-t">✨ Obogati podatke (Wikipedia)</div>
<button class="btn primary" onclick="enrichCustomFinding('${safeId}', '${esc(queryName).replace(/'/g,'\\\\&#39;')}')">▶ Pokreni</button>
</div>
<div id="fenrich-out-${safeId}">
<div class="empty" style="padding:14px">Lookup Wikipedia HR za "${esc(queryName)}" i prikaži dopune.</div>
</div>
</div>
`;
}
async function enrichCustomFinding(safeId, queryName){
const out = document.getElementById('fenrich-out-'+safeId);
if(out) out.innerHTML = '<div class="loading">Lookup Wikipedia HR…</div>';
// Custom findings aren't in DB — call wiki lookup via a synthesised forensic_findings.id of -1 won't work.
// Instead, use the existing /v2/enrich/sportas pattern to query Wikipedia by name.
// We re-use the wiki summary via a mini fetch helper.
try{
const wiki = await fetch('https://hr.wikipedia.org/api/rest_v1/page/summary/'+encodeURIComponent(queryName.replace(/ /g,'_')))
.then(r => r.ok ? r.json() : null).catch(()=>null);
if(!wiki || wiki.type==='disambiguation' || !wiki.extract){
if(out) out.innerHTML = '<div class="empty" style="padding:14px">Nije pronađen Wikipedia HR članak za <b>'+esc(queryName)+'</b>.</div>';
return;
}
const w = {title: wiki.title, extract: wiki.extract, description: wiki.description, url: (wiki.content_urls||{}).desktop?.page};
if(out) out.innerHTML = `
<div style="margin-bottom:8px"><span class="tag gr">🟢 Wikipedia HR</span></div>
<div style="padding:10px;background:var(--bg3);border-left:3px solid var(--pgz-gold);border-radius:5px">
<div style="font-weight:700;color:var(--t0);font-size:14px;margin-bottom:6px">${esc(w.title||'')}</div>
${w.description?'<div style="font-size:11px;color:var(--t2);margin-bottom:6px;font-style:italic">'+esc(w.description)+'</div>':''}
${w.extract?'<div style="font-size:12px;line-height:1.6;color:var(--t1)">'+esc(w.extract)+'</div>':''}
${w.url?'<div style="margin-top:8px"><a href="'+esc(w.url)+'" target="_blank">↗ Otvori članak</a></div>':''}
</div>`;
}catch(e){
if(out) out.innerHTML = '<div class="empty" style="color:var(--red);padding:14px">Greška: '+esc(String(e))+'</div>';
}
}
function renderAlertPanel(a){
const sevColor = a.razina==='CRITICAL'?'rd':(a.razina==='HIGH'?'am':'b');
const html = `