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:
parent
143f5c674a
commit
85c3f75e9b
119
app.py
119
app.py
@ -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)
|
||||
# ============================================================
|
||||
|
||||
@ -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">
|
||||
|
||||
509
templates/admin/zopk_knowledge_graph.html
Normal file
509
templates/admin/zopk_knowledge_graph.html
Normal 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 %}
|
||||
Loading…
Reference in New Issue
Block a user