diff --git a/pgz_sport_api.py b/pgz_sport_api.py
index f90c4bb..a0221ec 100644
--- a/pgz_sport_api.py
+++ b/pgz_sport_api.py
@@ -1376,6 +1376,15 @@ if HAS_S3_ROUTERS:
app.include_router(img_proxy_router, prefix='/api/v2')
app.include_router(audit_coverage_router, prefix='/api/v2')
+# Round-2 enrichment endpoint
+try:
+ from enrich_router import router as enrich_router
+ app.include_router(enrich_router, prefix='/api/v2')
+ print('[ENRICH] router loaded')
+except Exception as e:
+ print(f'[ENRICH] router fail: {e}')
+
+
diff --git a/routers/enrich_router.py b/routers/enrich_router.py
new file mode 100644
index 0000000..8da932b
--- /dev/null
+++ b/routers/enrich_router.py
@@ -0,0 +1,134 @@
+"""
+enrich_router.py — Round-2 enrichment endpoint
+Author: dradulic@outlook.com Date: 2026-05-04
+
+Surfaces "Obogati podatke" buttons for klubovi, savezi, sportasi.
+
+Strategy:
+ 1) Read what's already in DB and surface fields the frontend may not have shown.
+ 2) Build curated research URLs (Google, Wikipedia HR, Sportilus, sport-pgz.hr,
+ HNS Semafor) so the operator can verify or expand by hand.
+ 3) If the entity has a `web` URL set, quickly fetch the page and extract
+
+ to return as a "live snippet". 5s timeout, fail-soft.
+"""
+import os, re, json, time, urllib.parse, urllib.request, html
+import psycopg2, psycopg2.extras
+from fastapi import APIRouter, HTTPException
+
+router = APIRouter()
+
+DB = dict(host=os.environ.get('PG_HOST','10.10.0.2'),
+ port=int(os.environ.get('PG_PORT','6432')),
+ dbname=os.environ.get('PG_DB','rinet_v3'),
+ user=os.environ.get('PG_USER','rinet'),
+ password=os.environ.get('PG_PASS',''))
+
+UA = 'pgz-sport-enrich/2.0'
+
+def _db():
+ c = psycopg2.connect(**DB); c.autocommit = True; return c
+
+def _fetch_one(sql, p):
+ with _db() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
+ cur.execute(sql, p)
+ r = cur.fetchone()
+ return dict(r) if r else None
+
+def _fetch_title(url, timeout=5):
+ if not url: return None
+ try:
+ if not url.startswith('http'):
+ return None
+ req = urllib.request.Request(url, headers={'User-Agent': UA})
+ with urllib.request.urlopen(req, timeout=timeout) as r:
+ data = r.read(40000).decode('utf-8','ignore')
+ title_m = re.search(r']*>([^<]+)', data, re.I)
+ desc_m = re.search(r'
@@ -242,6 +262,7 @@ const NAV_ITEMS = [
{id:'financije', ic:'€', label:'Financije'},
{id:'objekti', ic:'\u{1F4CD}', label:'Objekti'},
{id:'manifestacije', ic:'\u{1F4C5}', label:'Manifestacije'},
+ {id:'mreza', ic:'\u{1F578}', label:'Mreža'},
{id:'forenzika', ic:'⚠', label:'Forenzika'}
];
const SECTION_TITLES = {
@@ -252,6 +273,7 @@ const SECTION_TITLES = {
financije: ['Financije', 'Sufinanciranje sporta'],
objekti: ['Sportski objekti', 'Geocodirana infrastruktura'],
manifestacije: ['Manifestacije', 'Sportski događaji'],
+ mreza: ['Mreža', 'Force-directed graf entiteta i veza'],
forenzika: ['Forenzika', 'Kritični nalazi i alarmi']
};
@@ -307,6 +329,68 @@ async function api(path){
return null;
}
}
+async function apiPost(path, body){
+ try{
+ const r = await fetch(API+path, {method:'POST', headers:{'Content-Type':'application/json'}, body: body?JSON.stringify(body):'{}'});
+ if(!r.ok) throw new Error('HTTP '+r.status);
+ return await r.json();
+ }catch(e){
+ console.error('API POST error', path, e);
+ return null;
+ }
+}
+
+async function enrichEntity(kind, id){
+ const targetId = 'enrich-out-'+kind+'-'+id;
+ const target = document.getElementById(targetId);
+ if(target) target.innerHTML = '⏳ Obogaćivanje u tijeku — pretraživanje izvora…
';
+ const r = await apiPost('/v2/enrich/'+kind+'/'+id);
+ if(!r){ if(target) target.innerHTML = 'Greška pri obogaćivanju
'; return; }
+ const cov = r.coverage||0;
+ const covCls = cov>=70?'high':(cov>=40?'mid':'low');
+ const html = `
+
+ 🟢 OBOGAĆENO
+ Coverage ${cov}%
+ ${r.filled_fields}/${r.total_fields} polja popunjeno
+
+ ${r.live_snippet && r.live_snippet.title ? `
+
+
📡 Live snippet
+
${esc(r.live_snippet.title)}
+ ${r.live_snippet.description ? '
'+esc(r.live_snippet.description)+'
' : ''}
+
+
+ ` : ''}
+ ${r.missing_fields && r.missing_fields.length ? `
+
+
Nedostaje:
+
${r.missing_fields.map(f=>''+esc(f)+'').join('')}
+
+ ` : ''}
+
+ `;
+ if(target) target.innerHTML = html;
+}
+
+function enrichBlock(kind, id){
+ return `
+
+
+
✨ Obogati podatke
+
+
+
+
Klikom na "Pokreni" platforma će pretražiti vanjske izvore (Google, Wikipedia, službene web stranice) i prikazati dopune za ovu entitetsku karticu.
+
+
+ `;
+}
function sortRows(rows, key, dir){
if(!key) return rows;
const sorted = rows.slice();
@@ -376,7 +460,26 @@ document.addEventListener('keydown', e => { if(e.key==='Escape') closePanel(); }
//=========== NAVIGATION ===========
function buildNav(){
const nav = $('#nav');
- nav.innerHTML = NAV_ITEMS.map(n => ''+n.ic+''+n.label+'
').join('');
+ nav.innerHTML = NAV_ITEMS.map(n => ''+n.ic+''+n.label+'
').join('');
+}
+function toggleSidebar(){
+ const sb = document.getElementById('sb');
+ const tg = document.getElementById('sb-toggle');
+ if(!sb) return;
+ const isCollapsed = sb.classList.toggle('collapsed');
+ if(tg) tg.textContent = isCollapsed ? '⮞' : '⮜';
+ try { localStorage.setItem('sidebar-state', isCollapsed ? 'collapsed' : 'expanded'); } catch(e){}
+}
+function restoreSidebar(){
+ try {
+ const s = localStorage.getItem('sidebar-state');
+ if(s === 'collapsed'){
+ const sb = document.getElementById('sb');
+ const tg = document.getElementById('sb-toggle');
+ if(sb) sb.classList.add('collapsed');
+ if(tg) tg.textContent = '⮞';
+ }
+ } catch(e){}
}
function navTo(id){
_state.section = id;
@@ -398,6 +501,7 @@ function loadSection(id){
case 'financije': return loadFinancije();
case 'objekti': return loadObjekti();
case 'manifestacije': return loadManifestacije();
+ case 'mreza': return loadMreza();
case 'forenzika': return loadForenzika();
}
}
@@ -734,6 +838,8 @@ async function openSavez(id){
` : 'Nema podataka o klubovima
'}
+
+ ${enrichBlock('savez', s.id)}
`;
openPanel('Savez · '+s.naziv, html);
}
@@ -917,6 +1023,8 @@ async function openKlub(id){
` : 'Nema zabilježenih potpora
'}
+
+ ${enrichBlock('klub', k.id)}
`;
openPanel('Klub · '+(k.naziv||''), html);
}
@@ -1161,6 +1269,8 @@ async function openSportas(id){
` : 'Nema zabilježenih nagrada
'}
+
+ ${enrichBlock('sportas', d.id)}
`;
openPanel('Sportaš · '+(d.ime||'')+' '+(d.prezime||''), html);
}
@@ -1544,6 +1654,285 @@ function openManif(id){
openPanel('Manifestacija · '+m.naziv, html);
}
+//=========== MREŽA (Network Graph) ===========
+const _mreza = {data:null, sim:null, allNodes:null, allEdges:null, filter:{osoba:'', klub:'', tvrtka:'', tip:''}};
+
+async function loadMreza(){
+ const root = $('#pg-mreza');
+ if(!_mreza.data){
+ root.innerHTML = 'Učitavanje grafa entiteta…
';
+ let resp;
+ try{
+ const r = await fetch('https://api.rinet.one/api/v1/presenter/graph-real');
+ resp = await r.json();
+ }catch(e){ console.error('graph fetch error', e); }
+ if(!resp || !resp.data){ root.innerHTML='Greška pri dohvatu graf-podataka
'; return; }
+ _mreza.data = resp.data;
+ _mreza.allNodes = (resp.data.nodes||[]).slice();
+ _mreza.allEdges = (resp.data.edges||[]).slice();
+ }
+ renderMrezaShell();
+ renderMrezaGraph();
+}
+
+function renderMrezaShell(){
+ const root = $('#pg-mreza');
+ const types = Array.from(new Set((_mreza.allNodes||[]).map(n=>n.type))).sort();
+ const totalN = (_mreza.allNodes||[]).length;
+ const totalE = (_mreza.allEdges||[]).length;
+ root.innerHTML = `
+
+
+
+
Osoba
${(_mreza.allNodes||[]).filter(n=>n.type==='person').length}
+
Tvrtki / entiteta
${(_mreza.allNodes||[]).filter(n=>n.type==='entity'||n.type==='supplier').length}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Osoba
+
Entitet (high risk)
+
Dobavljač
+
Veličina = risk / promet · Klikni čvor za detalje
+
+
+ `;
+ $('#mr-osoba').addEventListener('input', debounce(applyMrezaFilter, 200));
+ $('#mr-klub').addEventListener('input', debounce(applyMrezaFilter, 200));
+ $('#mr-tvrtka').addEventListener('input', debounce(applyMrezaFilter, 200));
+ $('#mr-tip').addEventListener('change', applyMrezaFilter);
+}
+
+function applyMrezaFilter(){
+ const osoba = ($('#mr-osoba').value||'').toLowerCase().trim();
+ const klub = ($('#mr-klub').value||'').toLowerCase().trim();
+ const tvrtka = ($('#mr-tvrtka').value||'').toLowerCase().trim();
+ const tip = $('#mr-tip').value;
+
+ let nodes = (_mreza.allNodes||[]).slice();
+ if(osoba) nodes = nodes.filter(n => n.type==='person' && (n.label||'').toLowerCase().includes(osoba) || n.type!=='person');
+ if(klub){
+ // filter entity/supplier by label match (savezi/klubovi appear as entities)
+ nodes = nodes.filter(n => {
+ if(n.type==='person') return true;
+ return (n.label||'').toLowerCase().includes(klub);
+ });
+ }
+ if(tvrtka){
+ nodes = nodes.filter(n => {
+ if(n.type==='person') return true;
+ return (n.label||'').toLowerCase().includes(tvrtka);
+ });
+ }
+ if(tip) nodes = nodes.filter(n => n.type===tip);
+ // Also filter to stronger: if osoba is set, drop persons not matching
+ if(osoba) nodes = nodes.filter(n => n.type!=='person' || (n.label||'').toLowerCase().includes(osoba));
+ if(klub) nodes = nodes.filter(n => n.type==='person' || (n.label||'').toLowerCase().includes(klub));
+ if(tvrtka) nodes = nodes.filter(n => n.type==='person' || (n.label||'').toLowerCase().includes(tvrtka));
+
+ const ids = new Set(nodes.map(n=>n.id));
+ let edges = (_mreza.allEdges||[]).filter(e => ids.has(e.source.id||e.source) && ids.has(e.target.id||e.target));
+ // After edge filter, keep only nodes that have at least one edge OR were direct matches
+ const used = new Set();
+ for(const e of edges){
+ used.add(e.source.id||e.source);
+ used.add(e.target.id||e.target);
+ }
+ // If we have any text filter, restrict to nodes that have edges; otherwise keep all
+ if(osoba||klub||tvrtka){
+ nodes = nodes.filter(n => used.has(n.id));
+ }
+
+ $('#mr-cnt').textContent = nodes.length+' čvorova · '+edges.length+' veza';
+ renderMrezaGraph(nodes, edges);
+}
+
+function resetMreza(){
+ $('#mr-osoba').value = '';
+ $('#mr-klub').value = '';
+ $('#mr-tvrtka').value = '';
+ $('#mr-tip').value = '';
+ applyMrezaFilter();
+}
+
+function renderMrezaGraph(nodes, edges){
+ if(!nodes) nodes = (_mreza.allNodes||[]).slice();
+ if(!edges) edges = (_mreza.allEdges||[]).slice();
+ const svgEl = document.getElementById('mr-svg');
+ if(!svgEl) return;
+ const container = document.getElementById('mr-graph');
+ const W = container.clientWidth || 800;
+ const H = container.clientHeight || 640;
+
+ if(_mreza.sim){ try{_mreza.sim.stop();}catch(e){} _mreza.sim = null; }
+
+ const svg = d3.select(svgEl);
+ svg.selectAll('*').remove();
+ svg.attr('viewBox', '0 0 '+W+' '+H);
+
+ // Deep-copy so D3 sim doesn't mutate originals
+ const N = nodes.map(n => Object.assign({}, n));
+ const Nmap = new Map(N.map(n=>[n.id, n]));
+ const E = edges.map(e => ({
+ source: Nmap.get(e.source.id||e.source) || (e.source.id||e.source),
+ target: Nmap.get(e.target.id||e.target) || (e.target.id||e.target),
+ color: e.color, size: e.size
+ })).filter(e => typeof e.source === 'object' && typeof e.target === 'object');
+
+ // Zoom/pan
+ const g = svg.append('g');
+ svg.call(d3.zoom().scaleExtent([0.2, 5]).on('zoom', (ev) => g.attr('transform', ev.transform)));
+
+ const sim = d3.forceSimulation(N)
+ .force('link', d3.forceLink(E).id(d => d.id).distance(d => 60 + 20/(d.size||1)))
+ .force('charge', d3.forceManyBody().strength(d => -50 - (d.size||5)*4))
+ .force('center', d3.forceCenter(W/2, H/2))
+ .force('collide', d3.forceCollide().radius(d => Math.max(6, (d.size||5)*0.7 + 4)));
+ _mreza.sim = sim;
+
+ const link = g.append('g')
+ .attr('stroke-opacity', 0.5)
+ .selectAll('line').data(E).join('line')
+ .attr('stroke', d => d.color || '#283560')
+ .attr('stroke-width', d => Math.max(0.4, (d.size||0.4)));
+
+ const node = g.append('g')
+ .selectAll('g').data(N).join('g')
+ .style('cursor','pointer')
+ .call(d3.drag()
+ .on('start', (ev,d) => { if(!ev.active) sim.alphaTarget(0.3).restart(); d.fx=d.x; d.fy=d.y; })
+ .on('drag', (ev,d) => { d.fx=ev.x; d.fy=ev.y; })
+ .on('end', (ev,d) => { if(!ev.active) sim.alphaTarget(0); d.fx=null; d.fy=null; }))
+ .on('click', (ev,d) => openMrezaNode(d));
+
+ node.append('circle')
+ .attr('r', d => Math.max(5, (d.size||5)*0.7))
+ .attr('fill', d => d.color || '#004CC4')
+ .attr('stroke', '#0d1021')
+ .attr('stroke-width', 1.5);
+
+ node.append('text')
+ .text(d => (d.label||'').slice(0,28))
+ .attr('x', d => Math.max(6, (d.size||5)*0.7) + 4)
+ .attr('y', 4)
+ .attr('fill', '#e2e6f0')
+ .attr('font-size', '10px')
+ .attr('font-family', 'Inter, sans-serif')
+ .style('pointer-events','none');
+
+ node.append('title').text(d => (d.label||'')+' ['+d.type+']');
+
+ sim.on('tick', () => {
+ link.attr('x1', d=>d.source.x).attr('y1', d=>d.source.y)
+ .attr('x2', d=>d.target.x).attr('y2', d=>d.target.y);
+ node.attr('transform', d => 'translate('+d.x+','+d.y+')');
+ });
+
+ if(!$('#mr-cnt').textContent){
+ $('#mr-cnt').textContent = N.length+' čvorova · '+E.length+' veza';
+ }
+}
+
+function openMrezaNode(n){
+ const m = n.meta || {};
+ // Find connected nodes
+ const id = n.id;
+ const edges = (_mreza.allEdges||[]).filter(e => (e.source.id||e.source)===id || (e.target.id||e.target)===id);
+ const connectedIds = new Set();
+ for(const e of edges){
+ const s = e.source.id||e.source;
+ const t = e.target.id||e.target;
+ if(s===id) connectedIds.add(t); else connectedIds.add(s);
+ }
+ const connected = (_mreza.allNodes||[]).filter(x => connectedIds.has(x.id));
+
+ let html = `
+
+
+
${esc(n.label)}
+
+ ${esc(n.type)}
+ ${m.risk?'Risk '+m.risk+'':''}
+ ${m.forensic?'Forenzika '+m.forensic+'':''}
+
+
+
+
+ ${(m.risk!=null || m.forensic!=null) ? `
+
+ ${m.risk!=null ? '
' : ''}
+ ${m.forensic!=null ? '
Forenzički flag
'+m.forensic+'
' : ''}
+ ${m.winner_contracts!=null ? '
Ugovori (W)
'+m.winner_contracts+'
' : ''}
+
` : ''}
+
+
+
+
+
ID
${esc(n.id)}
+
Tip
${esc(n.type)}
+
Naziv
${esc(n.label)}
+ ${m.oib?'
OIB
'+esc(m.oib)+'
':''}
+ ${m.city?'
Grad
'+esc(m.city)+'
':''}
+ ${m.buyer_contracts!=null?'
Ugovori kao kupac
'+m.buyer_contracts+'
':''}
+ ${m.buyer_value!=null?'
Vrijednost (kupac)
'+fmtEurFull(m.buyer_value)+'
':''}
+ ${m.winner_contracts!=null?'
Ugovori kao dobavljač
'+m.winner_contracts+'
':''}
+ ${m.total!=null?'
Ukupan promet
'+fmtEurFull(m.total)+'
':''}
+ ${m.contracts!=null?'
Broj ugovora
'+m.contracts+'
':''}
+
+
+
+
+
🔗 Veze (${connected.length})
+ ${connected.length ? '
'+
+ '| Tip | Naziv | Risk |
'+
+ ''+connected.slice(0,80).map(c => `
+
+ | ${esc(c.type)} |
+ ${esc(c.label)} |
+ ${(c.meta&&c.meta.risk)||'—'} |
+
`).join('')+
+ '
' : '
Nema povezanih entiteta
'}
+
+
+ ${(m.forensic && m.forensic > 0 && n.type==='entity') ? `
+
+
+
+
${m.forensic} forenzičkih flagova
+
Ovaj entitet ima zabilježene forenzičke nalaze. Provjeri detalje u sekciji Forenzika.
+
+
` : ''}
+
+ ${(m.buyer_contracts && m.buyer_contracts > 0) ? `
+
+
+
+
Kao kupac
${m.buyer_contracts} ugovora · ${fmtEurFull(m.buyer_value||0)}
+
+
` : ''}
+ `;
+ openPanel(n.label, html);
+}
+
//=========== FORENZIKA ===========
const _forenzika = {alerts:null, custom:null, filter:{severity:'', tip:'', q:''}};
@@ -1858,6 +2247,7 @@ async function runForensicScan(){
//=========== INIT ===========
function init(){
+ restoreSidebar();
buildNav();
navTo('dashboard');
}