Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
After refactoring to blueprints, templates still used bare endpoint names
(e.g., url_for('admin_zopk')) instead of prefixed names (e.g.,
url_for('admin.admin_zopk')). While most worked via backward-compat aliases,
api_zopk_search_news was missing from the alias list causing 500 on /admin/zopk.
Fixed 19 template files and added missing alias for api_zopk_search_news.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
218 lines
10 KiB
HTML
218 lines
10 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block title %}Deduplikacja Faktów - ZOPK{% 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; }
|
||
.controls { display: flex; gap: var(--spacing-md); margin-bottom: var(--spacing-lg); align-items: center; flex-wrap: wrap; }
|
||
.control-group { display: flex; align-items: center; gap: var(--spacing-xs); }
|
||
.control-group label { font-size: var(--font-size-sm); color: var(--text-secondary); }
|
||
.control-group input, .control-group select { padding: 6px 12px; border: 1px solid var(--border); border-radius: var(--radius); }
|
||
.duplicate-card { background: var(--surface); border-radius: var(--radius-lg); box-shadow: var(--shadow); margin-bottom: var(--spacing-md); overflow: hidden; }
|
||
.duplicate-header { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: white; padding: var(--spacing-sm) var(--spacing-md); display: flex; justify-content: space-between; align-items: center; }
|
||
.similarity-badge { background: rgba(255,255,255,0.2); padding: 4px 12px; border-radius: 20px; font-weight: 600; }
|
||
.duplicate-content { display: grid; grid-template-columns: 1fr auto 1fr; gap: var(--spacing-md); padding: var(--spacing-md); }
|
||
.fact-box { background: var(--background); padding: var(--spacing-md); border-radius: var(--radius); }
|
||
.fact-box.primary { border-left: 4px solid var(--success); }
|
||
.fact-box.duplicate { border-left: 4px solid var(--error); }
|
||
.fact-text { font-size: var(--font-size-sm); line-height: 1.6; margin-bottom: var(--spacing-sm); }
|
||
.fact-meta { font-size: var(--font-size-xs); color: var(--text-secondary); }
|
||
.merge-arrow { display: flex; align-items: center; justify-content: center; font-size: 2rem; color: var(--primary); }
|
||
.duplicate-actions { padding: var(--spacing-sm) var(--spacing-md); background: var(--background); display: flex; justify-content: flex-end; gap: var(--spacing-sm); }
|
||
.btn { padding: 8px 16px; border-radius: var(--radius); font-weight: 500; cursor: pointer; border: none; transition: var(--transition); }
|
||
.btn-primary { background: var(--primary); color: white; }
|
||
.btn-primary:hover { background: var(--primary-dark); }
|
||
.btn-secondary { background: var(--surface); color: var(--text-primary); border: 1px solid var(--border); }
|
||
.stats-bar { display: flex; gap: var(--spacing-lg); margin-bottom: var(--spacing-lg); }
|
||
.stat-item { background: var(--surface); padding: var(--spacing-md); border-radius: var(--radius); text-align: center; }
|
||
.stat-value { font-size: var(--font-size-2xl); font-weight: 700; color: var(--primary); }
|
||
.stat-label { font-size: var(--font-size-sm); color: var(--text-secondary); }
|
||
.loading { text-align: center; padding: var(--spacing-xl); color: var(--text-secondary); }
|
||
.empty-state { text-align: center; padding: var(--spacing-xl); color: var(--text-secondary); }
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="container">
|
||
<div class="breadcrumb">
|
||
<a href="{{ url_for('admin.admin_zopk') }}">Panel ZOPK</a>
|
||
<span>›</span>
|
||
<a href="{{ url_for('admin.admin_zopk_knowledge_dashboard') }}">Baza Wiedzy</a>
|
||
<span>›</span>
|
||
<span>Deduplikacja Faktów</span>
|
||
</div>
|
||
|
||
<div class="page-header">
|
||
<h1>🔀 Deduplikacja Faktów</h1>
|
||
</div>
|
||
|
||
<div class="controls">
|
||
<div class="control-group">
|
||
<label>Min. podobieństwo:</label>
|
||
<input type="range" id="minSimilarity" min="0.5" max="0.95" step="0.05" value="0.7" oninput="document.getElementById('simValue').textContent = this.value">
|
||
<span id="simValue">0.7</span>
|
||
</div>
|
||
<div class="control-group">
|
||
<label>Typ faktu:</label>
|
||
<select id="factType">
|
||
<option value="">Wszystkie</option>
|
||
<option value="investment">Inwestycja</option>
|
||
<option value="decision">Decyzja</option>
|
||
<option value="milestone">Kamień milowy</option>
|
||
<option value="statistic">Statystyka</option>
|
||
</select>
|
||
</div>
|
||
<button class="btn btn-primary" onclick="loadDuplicates()">🔍 Szukaj duplikatów</button>
|
||
<button class="btn btn-secondary" onclick="mergeAllHigh()">⚡ Połącz wszystkie >90%</button>
|
||
</div>
|
||
|
||
<div class="stats-bar">
|
||
<div class="stat-item">
|
||
<div class="stat-value" id="totalPairs">-</div>
|
||
<div class="stat-label">Par duplikatów</div>
|
||
</div>
|
||
<div class="stat-item">
|
||
<div class="stat-value" id="avgSimilarity">-</div>
|
||
<div class="stat-label">Śr. podobieństwo</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="duplicatesList">
|
||
<div class="loading">Kliknij "Szukaj duplikatów" aby rozpocząć...</div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
let duplicatesData = [];
|
||
|
||
async function loadDuplicates() {
|
||
const minSim = document.getElementById('minSimilarity').value;
|
||
const factType = document.getElementById('factType').value;
|
||
|
||
document.getElementById('duplicatesList').innerHTML = '<div class="loading">Ładowanie...</div>';
|
||
|
||
try {
|
||
const url = `/api/zopk/knowledge/fact-duplicates?min_similarity=${minSim}&fact_type=${factType}&limit=100`;
|
||
const response = await fetch(url);
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
duplicatesData = data.duplicates;
|
||
document.getElementById('totalPairs').textContent = data.count;
|
||
|
||
if (data.count > 0) {
|
||
const avgSim = duplicatesData.reduce((sum, d) => sum + d.similarity, 0) / data.count;
|
||
document.getElementById('avgSimilarity').textContent = (avgSim * 100).toFixed(0) + '%';
|
||
}
|
||
|
||
renderDuplicates();
|
||
}
|
||
} catch (error) {
|
||
document.getElementById('duplicatesList').innerHTML = '<div class="empty-state">Błąd ładowania: ' + error + '</div>';
|
||
}
|
||
}
|
||
|
||
function renderDuplicates() {
|
||
if (duplicatesData.length === 0) {
|
||
document.getElementById('duplicatesList').innerHTML = '<div class="empty-state">Brak duplikatów do pokazania</div>';
|
||
return;
|
||
}
|
||
|
||
const html = duplicatesData.map((d, idx) => `
|
||
<div class="duplicate-card" id="dup-${idx}">
|
||
<div class="duplicate-header">
|
||
<span>${d.fact1.fact_type || 'fakt'}</span>
|
||
<span class="similarity-badge">${(d.similarity * 100).toFixed(0)}% podobieństwa</span>
|
||
</div>
|
||
<div class="duplicate-content">
|
||
<div class="fact-box primary">
|
||
<div class="fact-text">${escapeHtml(d.fact1.text)}</div>
|
||
<div class="fact-meta">
|
||
ID: ${d.fact1.id}${d.fact1.confidence_score ? ' | Pewność: ' + (d.fact1.confidence_score * 100).toFixed(0) + '%' : ''}
|
||
${d.fact1.is_verified ? ' | ✅ Zweryfikowany' : ''}
|
||
</div>
|
||
</div>
|
||
<div class="merge-arrow">→</div>
|
||
<div class="fact-box duplicate">
|
||
<div class="fact-text">${escapeHtml(d.fact2.text)}</div>
|
||
<div class="fact-meta">
|
||
ID: ${d.fact2.id}${d.fact2.confidence_score ? ' | Pewność: ' + (d.fact2.confidence_score * 100).toFixed(0) + '%' : ''}
|
||
${d.fact2.is_verified ? ' | ✅ Zweryfikowany' : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="duplicate-actions">
|
||
<button class="btn btn-secondary" onclick="skipDuplicate(${idx})">⏭️ Pomiń</button>
|
||
<button class="btn btn-primary" onclick="mergeFacts(${d.fact1.id}, ${d.fact2.id}, ${idx})">🔀 Połącz</button>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
|
||
document.getElementById('duplicatesList').innerHTML = html;
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text || '';
|
||
return div.innerHTML;
|
||
}
|
||
|
||
async function mergeFacts(primaryId, duplicateId, idx) {
|
||
try {
|
||
const response = await fetch('/api/zopk/knowledge/fact-duplicates/merge', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': '{{ csrf_token() }}'},
|
||
body: JSON.stringify({primary_id: primaryId, duplicate_id: duplicateId})
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
document.getElementById('dup-' + idx).remove();
|
||
duplicatesData.splice(idx, 1);
|
||
document.getElementById('totalPairs').textContent = duplicatesData.length;
|
||
} else {
|
||
alert('Błąd: ' + data.error);
|
||
}
|
||
} catch (error) {
|
||
alert('Błąd: ' + error);
|
||
}
|
||
}
|
||
|
||
function skipDuplicate(idx) {
|
||
document.getElementById('dup-' + idx).remove();
|
||
duplicatesData.splice(idx, 1);
|
||
document.getElementById('totalPairs').textContent = duplicatesData.length;
|
||
}
|
||
|
||
async function mergeAllHigh() {
|
||
const highSim = duplicatesData.filter(d => d.similarity >= 0.9);
|
||
if (highSim.length === 0) {
|
||
alert('Brak duplikatów z podobieństwem >= 90%');
|
||
return;
|
||
}
|
||
|
||
if (!confirm(`Połączyć ${highSim.length} par z podobieństwem >= 90%?`)) return;
|
||
|
||
let merged = 0;
|
||
for (const d of highSim) {
|
||
try {
|
||
const response = await fetch('/api/zopk/knowledge/fact-duplicates/merge', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': '{{ csrf_token() }}'},
|
||
body: JSON.stringify({primary_id: d.fact1.id, duplicate_id: d.fact2.id})
|
||
});
|
||
const data = await response.json();
|
||
if (data.success) merged++;
|
||
} catch (e) {}
|
||
}
|
||
|
||
alert(`Połączono ${merged}/${highSim.length} par`);
|
||
loadDuplicates();
|
||
}
|
||
{% endblock %}
|