feat(zopk): Graf relacji encji (Priorytet 5)

- Dodano endpoint /admin/zopk/knowledge/graph z wizualizacją D3.js
- Dodano API endpoint /api/zopk/knowledge/graph/data
- Graf współwystępowania encji z kolorami według typu
- Rozmiar węzłów proporcjonalny do liczby wzmianek
- Filtry: typ encji, minimalna liczba współwystąpień
- Tooltips z informacjami o encjach
- Zoom i drag-and-drop interakcje

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-17 09:14:30 +01:00
parent 143f5c674a
commit 85c3f75e9b
3 changed files with 635 additions and 0 deletions

119
app.py
View File

@ -11906,6 +11906,125 @@ def api_zopk_duplicates_merge():
db.close()
# ============================================================
# ZOPK ENTITY RELATIONS GRAPH
# ============================================================
@app.route('/admin/zopk/knowledge/graph')
@login_required
def admin_zopk_knowledge_graph():
"""Admin page for entity relations graph visualization."""
if not current_user.is_admin:
flash('Brak uprawnień do tej strony.', 'error')
return redirect(url_for('dashboard'))
return render_template('admin/zopk_knowledge_graph.html')
@app.route('/api/zopk/knowledge/graph/data')
@login_required
def api_zopk_knowledge_graph_data():
"""Get graph data for entity co-occurrence visualization."""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
from sqlalchemy import text, func
from database import ZOPKKnowledgeEntity, ZOPKKnowledgeEntityMention
db = SessionLocal()
try:
# Filter parameters
entity_type = request.args.get('entity_type', '')
min_cooccurrence = int(request.args.get('min_cooccurrence', 3))
limit = min(int(request.args.get('limit', 100)), 500)
# Get top entities by mentions
entities_query = db.query(ZOPKKnowledgeEntity).filter(
ZOPKKnowledgeEntity.mentions_count >= 5
)
if entity_type:
entities_query = entities_query.filter(
ZOPKKnowledgeEntity.entity_type == entity_type
)
entities_query = entities_query.order_by(
ZOPKKnowledgeEntity.mentions_count.desc()
).limit(100)
entities = entities_query.all()
entity_ids = [e.id for e in entities]
if not entity_ids:
return jsonify({'success': True, 'nodes': [], 'links': []})
# Get co-occurrences (entities appearing in same chunk)
cooccur_query = text("""
SELECT
m1.entity_id as source,
m2.entity_id as target,
COUNT(*) as value
FROM zopk_knowledge_entity_mentions m1
JOIN zopk_knowledge_entity_mentions m2
ON m1.chunk_id = m2.chunk_id
AND m1.entity_id < m2.entity_id
WHERE m1.entity_id = ANY(:entity_ids)
AND m2.entity_id = ANY(:entity_ids)
GROUP BY m1.entity_id, m2.entity_id
HAVING COUNT(*) >= :min_cooccurrence
ORDER BY COUNT(*) DESC
LIMIT :limit
""")
result = db.execute(cooccur_query, {
'entity_ids': entity_ids,
'min_cooccurrence': min_cooccurrence,
'limit': limit
})
# Build nodes and links
used_entity_ids = set()
links = []
for row in result:
links.append({
'source': row.source,
'target': row.target,
'value': row.value
})
used_entity_ids.add(row.source)
used_entity_ids.add(row.target)
# Build nodes only for entities that have links
entity_map = {e.id: e for e in entities}
nodes = []
for eid in used_entity_ids:
if eid in entity_map:
e = entity_map[eid]
nodes.append({
'id': e.id,
'name': e.name,
'type': e.entity_type,
'mentions': e.mentions_count,
'verified': e.is_verified
})
return jsonify({
'success': True,
'nodes': nodes,
'links': links,
'stats': {
'total_nodes': len(nodes),
'total_links': len(links)
}
})
except Exception as e:
logger.error(f"Error getting graph data: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
# ============================================================
# KRS AUDIT (Krajowy Rejestr Sądowy)
# ============================================================

View File

@ -331,6 +331,13 @@
<div class="quick-link-desc">Łączenie podobnych encji</div>
</div>
</a>
<a href="{{ url_for('admin_zopk_knowledge_graph') }}" class="quick-link" style="border-color: #8b5cf6;">
<div class="quick-link-icon">🕸️</div>
<div class="quick-link-text">
<div class="quick-link-title">Graf relacji</div>
<div class="quick-link-desc">Wizualizacja powiązań</div>
</div>
</a>
<a href="{{ url_for('admin_zopk_news') }}" class="quick-link">
<div class="quick-link-icon">📰</div>
<div class="quick-link-text">

View File

@ -0,0 +1,509 @@
{% extends "base.html" %}
{% block title %}Graf Relacji Encji - ZOPK Baza Wiedzy{% endblock %}
{% block extra_css %}
<style>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-lg);
}
.page-header h1 {
font-size: var(--font-size-2xl);
color: var(--text-primary);
}
.breadcrumb {
display: flex;
gap: var(--spacing-xs);
color: var(--text-secondary);
font-size: var(--font-size-sm);
margin-bottom: var(--spacing-lg);
}
.breadcrumb a {
color: var(--primary);
text-decoration: none;
}
.breadcrumb a:hover {
text-decoration: underline;
}
.graph-container {
position: relative;
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
overflow: hidden;
}
.graph-controls {
position: absolute;
top: var(--spacing-md);
left: var(--spacing-md);
z-index: 10;
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.control-group {
background: rgba(255,255,255,0.95);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.control-group label {
display: block;
font-size: var(--font-size-xs);
color: var(--text-secondary);
margin-bottom: 4px;
}
.control-group select,
.control-group input {
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
}
.graph-stats {
position: absolute;
top: var(--spacing-md);
right: var(--spacing-md);
z-index: 10;
background: rgba(255,255,255,0.95);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
box-shadow: var(--shadow);
font-size: var(--font-size-sm);
}
.graph-stats strong {
color: var(--primary);
}
#graph-svg {
width: 100%;
height: 700px;
cursor: grab;
}
#graph-svg:active {
cursor: grabbing;
}
.node {
cursor: pointer;
}
.node circle {
stroke: #fff;
stroke-width: 2px;
transition: all 0.2s ease;
}
.node:hover circle {
stroke-width: 4px;
filter: brightness(1.1);
}
.node text {
font-size: 10px;
fill: var(--text-primary);
pointer-events: none;
text-anchor: middle;
dominant-baseline: middle;
}
.link {
stroke: #999;
stroke-opacity: 0.4;
}
.link:hover {
stroke-opacity: 0.8;
}
/* Node colors by type */
.node-company { fill: #3b82f6; }
.node-person { fill: #ec4899; }
.node-place { fill: #10b981; }
.node-organization { fill: #f59e0b; }
.node-project { fill: #8b5cf6; }
.node-event { fill: #ef4444; }
.node-other { fill: #6b7280; }
/* Tooltip */
.tooltip {
position: absolute;
background: rgba(0,0,0,0.8);
color: white;
padding: 8px 12px;
border-radius: var(--radius);
font-size: var(--font-size-sm);
pointer-events: none;
z-index: 100;
max-width: 250px;
}
.tooltip h4 {
margin: 0 0 4px 0;
font-size: var(--font-size-base);
}
.tooltip p {
margin: 0;
font-size: var(--font-size-xs);
opacity: 0.8;
}
/* Legend */
.legend {
position: absolute;
bottom: var(--spacing-md);
left: var(--spacing-md);
background: rgba(255,255,255,0.95);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
box-shadow: var(--shadow);
display: flex;
flex-wrap: wrap;
gap: var(--spacing-md);
font-size: var(--font-size-xs);
}
.legend-item {
display: flex;
align-items: center;
gap: 4px;
}
.legend-dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255,255,255,0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 20;
}
.loading-spinner {
width: 48px;
height: 48px;
border: 4px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.btn {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: 6px 12px;
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 500;
cursor: pointer;
transition: var(--transition);
text-decoration: none;
border: none;
background: var(--primary);
color: white;
}
.btn:hover {
background: var(--primary-dark);
}
</style>
{% endblock %}
{% block content %}
<div class="container">
<div class="breadcrumb">
<a href="{{ url_for('admin_zopk') }}">Panel ZOPK</a>
<span></span>
<a href="{{ url_for('admin_zopk_knowledge_dashboard') }}">Baza Wiedzy</a>
<span></span>
<span>Graf Relacji</span>
</div>
<div class="page-header">
<h1>🕸️ Graf Współwystępowania Encji</h1>
</div>
<div class="graph-container">
<div class="loading-overlay" id="loadingOverlay">
<div class="loading-spinner"></div>
</div>
<div class="graph-controls">
<div class="control-group">
<label>Typ encji:</label>
<select id="entityType" onchange="loadGraph()">
<option value="">Wszystkie</option>
<option value="company">Firmy</option>
<option value="person">Osoby</option>
<option value="place">Miejsca</option>
<option value="organization">Organizacje</option>
<option value="project">Projekty</option>
</select>
</div>
<div class="control-group">
<label>Min. współwystąpień:</label>
<input type="range" id="minCooccurrence" min="2" max="10" value="3"
oninput="document.getElementById('cooccurValue').textContent = this.value"
onchange="loadGraph()">
<span id="cooccurValue">3</span>
</div>
<div class="control-group">
<button class="btn" onclick="resetZoom()">🔄 Reset widoku</button>
</div>
</div>
<div class="graph-stats" id="graphStats">
Ładowanie...
</div>
<svg id="graph-svg"></svg>
<div class="legend">
<div class="legend-item">
<div class="legend-dot node-company"></div>
<span>Firmy</span>
</div>
<div class="legend-item">
<div class="legend-dot node-person"></div>
<span>Osoby</span>
</div>
<div class="legend-item">
<div class="legend-dot node-place"></div>
<span>Miejsca</span>
</div>
<div class="legend-item">
<div class="legend-dot node-organization"></div>
<span>Organizacje</span>
</div>
<div class="legend-item">
<div class="legend-dot node-project"></div>
<span>Projekty</span>
</div>
</div>
</div>
</div>
<!-- Tooltip -->
<div class="tooltip" id="tooltip" style="display: none;"></div>
{% endblock %}
{% block extra_js %}
// Wait for D3 to load
if (typeof d3 === 'undefined') {
const script = document.createElement('script');
script.src = 'https://d3js.org/d3.v7.min.js';
script.onload = initGraph;
document.head.appendChild(script);
} else {
initGraph();
}
let simulation, svg, g, link, node, zoom;
let currentNodes = [];
let currentLinks = [];
function initGraph() {
svg = d3.select('#graph-svg');
const width = svg.node().getBoundingClientRect().width;
const height = 700;
// Create zoom behavior
zoom = d3.zoom()
.scaleExtent([0.1, 4])
.on('zoom', (event) => {
g.attr('transform', event.transform);
});
svg.call(zoom);
// Create container group
g = svg.append('g');
// Create link and node groups
g.append('g').attr('class', 'links');
g.append('g').attr('class', 'nodes');
loadGraph();
}
async function loadGraph() {
const overlay = document.getElementById('loadingOverlay');
overlay.style.display = 'flex';
const entityType = document.getElementById('entityType').value;
const minCooccurrence = document.getElementById('minCooccurrence').value;
try {
const url = `/api/zopk/knowledge/graph/data?entity_type=${entityType}&min_cooccurrence=${minCooccurrence}&limit=200`;
const response = await fetch(url);
const data = await response.json();
if (data.success) {
currentNodes = data.nodes;
currentLinks = data.links;
updateGraph();
document.getElementById('graphStats').innerHTML =
`<strong>${data.stats.total_nodes}</strong> encji • <strong>${data.stats.total_links}</strong> połączeń`;
} else {
console.error('Error loading graph:', data.error);
}
} catch (error) {
console.error('Error:', error);
} finally {
overlay.style.display = 'none';
}
}
function updateGraph() {
const width = svg.node().getBoundingClientRect().width;
const height = 700;
// Stop existing simulation
if (simulation) simulation.stop();
// Clear existing elements
g.select('.links').selectAll('*').remove();
g.select('.nodes').selectAll('*').remove();
// Create links
link = g.select('.links')
.selectAll('line')
.data(currentLinks)
.enter()
.append('line')
.attr('class', 'link')
.attr('stroke-width', d => Math.sqrt(d.value));
// Create nodes
node = g.select('.nodes')
.selectAll('g')
.data(currentNodes)
.enter()
.append('g')
.attr('class', 'node')
.call(d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended))
.on('mouseover', showTooltip)
.on('mouseout', hideTooltip);
// Add circles
node.append('circle')
.attr('r', d => Math.max(8, Math.min(30, Math.sqrt(d.mentions) * 2)))
.attr('class', d => `node-${getNodeType(d.type)}`);
// Add labels for larger nodes
node.filter(d => d.mentions >= 10)
.append('text')
.attr('dy', d => Math.max(8, Math.min(30, Math.sqrt(d.mentions) * 2)) + 12)
.text(d => d.name.length > 15 ? d.name.slice(0, 15) + '...' : d.name);
// Create simulation
simulation = d3.forceSimulation(currentNodes)
.force('link', d3.forceLink(currentLinks)
.id(d => d.id)
.distance(100)
.strength(d => Math.min(1, d.value / 10)))
.force('charge', d3.forceManyBody().strength(-200))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(d => Math.max(8, Math.sqrt(d.mentions) * 2) + 5))
.on('tick', ticked);
}
function ticked() {
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})`);
}
function dragstarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragended(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
function showTooltip(event, d) {
const tooltip = document.getElementById('tooltip');
tooltip.innerHTML = `
<h4>${d.name}</h4>
<p>Typ: ${d.type}</p>
<p>Wzmianki: ${d.mentions}</p>
${d.verified ? '<p>✅ Zweryfikowano</p>' : ''}
`;
tooltip.style.display = 'block';
tooltip.style.left = (event.pageX + 10) + 'px';
tooltip.style.top = (event.pageY + 10) + 'px';
}
function hideTooltip() {
document.getElementById('tooltip').style.display = 'none';
}
function getNodeType(type) {
const typeMap = {
'company': 'company',
'person': 'person',
'place': 'place',
'Lokalizacja': 'place',
'organization': 'organization',
'Organizacja': 'organization',
'project': 'project',
'Projekt': 'project',
'event': 'event'
};
return typeMap[type] || 'other';
}
function resetZoom() {
svg.transition().duration(500).call(
zoom.transform,
d3.zoomIdentity
);
}
{% endblock %}