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 + + <meta description> 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'<title[^>]*>([^<]+)', 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)+'
' : ''} +
↗ ${esc(r.live_snippet.url.slice(0,80))}
+
+ ` : ''} + ${r.missing_fields && r.missing_fields.length ? ` +
+
Nedostaje:
+
${r.missing_fields.map(f=>''+esc(f)+'').join('')}
+
+ ` : ''} +
+
🔍 Istraži dalje:
+
+ ${(r.research_links||[]).map(l => ''+l.icon+' '+esc(l.label)+'').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 => '').join(''); + nav.innerHTML = NAV_ITEMS.map(n => '').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 = ` +
+
Čvorova
${totalN}
+
Veza
${totalE}
+
Osoba
${(_mreza.allNodes||[]).filter(n=>n.type==='person').length}
+
Tvrtki / entiteta
${(_mreza.allNodes||[]).filter(n=>n.type==='entity'||n.type==='supplier').length}
+
+ +
+ + + + + + +
+ +
+
+ +
+
+ +
+
🎨 Legenda
+
+
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 ? '
Risk score
'+m.risk+'
' : ''} + ${m.forensic!=null ? '
Forenzički flag
'+m.forensic+'
' : ''} + ${m.winner_contracts!=null ? '
Ugovori (W)
'+m.winner_contracts+'
' : ''} +
` : ''} + +
+
📋 Detalji
+
+
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 ? '
'+ + ''+ + ''+connected.slice(0,80).map(c => ` + + + + + `).join('')+ + '
TipNazivRisk
${esc(c.type)}${esc(c.label)}${(c.meta&&c.meta.risk)||'—'}
' : '
Nema povezanih entiteta
'} +
+ + ${(m.forensic && m.forensic > 0 && n.type==='entity') ? ` +
+
⚠ Forenzika
+
+
${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) ? ` +
+
💼 Procurement
+
+
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'); }