mirror of
https://github.com/zvx-echo6/recon.git
synced 2026-05-20 06:34:40 +02:00
Phase 7: manual review dashboard for tied items
Adds /peertube/review page showing only tied_manual items for human domain assignment. Each row displays video title, channel, concept domain counts, and a dropdown to select the correct domain. Routes: GET /peertube/review (page), GET /api/peertube/review/items (JSON), GET /api/peertube/review/stats (counts), POST /api/peertube/review/assign (assign + push to PeerTube). Review subnav entry added to PEERTUBE_SUBNAV. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6a17df8078
commit
d1270be64d
2 changed files with 246 additions and 0 deletions
98
lib/api.py
98
lib/api.py
|
|
@ -88,6 +88,7 @@ KNOWLEDGE_SUBNAV = [
|
|||
PEERTUBE_SUBNAV = [
|
||||
{'href': '/peertube', 'label': 'Dashboard'},
|
||||
{'href': '/peertube/channels', 'label': 'Channels'},
|
||||
{'href': '/peertube/review', 'label': 'Review'},
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -376,6 +377,103 @@ def peertube_channels():
|
|||
domain='peertube', subnav=PEERTUBE_SUBNAV, active_page='/peertube/channels')
|
||||
|
||||
|
||||
@app.route('/peertube/review')
|
||||
def peertube_review():
|
||||
from .recon_domains import VALID_DOMAINS
|
||||
return render_template('peertube/review.html',
|
||||
domain='peertube', subnav=PEERTUBE_SUBNAV,
|
||||
active_page='/peertube/review',
|
||||
valid_domains=sorted(VALID_DOMAINS))
|
||||
|
||||
|
||||
@app.route('/api/peertube/review/stats')
|
||||
def api_peertube_review_stats():
|
||||
db = StatusDB()
|
||||
counts = db.get_domain_status_counts()
|
||||
return jsonify(counts)
|
||||
|
||||
|
||||
@app.route('/api/peertube/review/items')
|
||||
def api_peertube_review_items():
|
||||
import json as _json
|
||||
from .recon_domains import VALID_DOMAINS
|
||||
db = StatusDB()
|
||||
config = get_config()
|
||||
items = db.get_items_by_domain_status('tied_manual', limit=200)
|
||||
|
||||
result = []
|
||||
concepts_dir = config['paths']['concepts']
|
||||
for item in items:
|
||||
file_hash = item['hash']
|
||||
# Count domains from concept files
|
||||
top_domains = []
|
||||
doc_concepts_dir = os.path.join(concepts_dir, file_hash)
|
||||
if os.path.isdir(doc_concepts_dir):
|
||||
from collections import Counter
|
||||
domain_counter = Counter()
|
||||
for fname in os.listdir(doc_concepts_dir):
|
||||
if not fname.startswith('window_') or not fname.endswith('.json'):
|
||||
continue
|
||||
try:
|
||||
with open(os.path.join(doc_concepts_dir, fname)) as f:
|
||||
concepts = _json.load(f)
|
||||
for c in concepts:
|
||||
if isinstance(c, dict):
|
||||
dom = c.get('domain')
|
||||
if isinstance(dom, str) and dom in VALID_DOMAINS:
|
||||
domain_counter[dom] += 1
|
||||
except Exception:
|
||||
continue
|
||||
top_domains = [{'domain': d, 'count': cnt}
|
||||
for d, cnt in domain_counter.most_common(5)]
|
||||
|
||||
result.append({
|
||||
'hash': file_hash,
|
||||
'filename': item.get('filename', ''),
|
||||
'category': item.get('category', ''),
|
||||
'recon_domain': item.get('recon_domain'),
|
||||
'recon_domain_status': item.get('recon_domain_status'),
|
||||
'top_domains': top_domains,
|
||||
})
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@app.route('/api/peertube/review/assign', methods=['POST'])
|
||||
def api_peertube_review_assign():
|
||||
from .recon_domains import VALID_DOMAINS, DOMAIN_CATEGORY_MAP
|
||||
from .peertube_writer import push_category, extract_uuid
|
||||
data = request.get_json()
|
||||
file_hash = data.get('hash')
|
||||
domain = data.get('domain')
|
||||
|
||||
if not file_hash or not domain:
|
||||
return jsonify({'ok': False, 'error': 'Missing hash or domain'}), 400
|
||||
if domain not in VALID_DOMAINS:
|
||||
return jsonify({'ok': False, 'error': f'Invalid domain: {domain}'}), 400
|
||||
|
||||
db = StatusDB()
|
||||
config = get_config()
|
||||
|
||||
db.set_domain_assignment(file_hash, domain, 'manual_assigned')
|
||||
|
||||
# Push to PeerTube
|
||||
conn = db._get_conn()
|
||||
cat_row = conn.execute(
|
||||
"SELECT path FROM catalogue WHERE hash = ?", (file_hash,)
|
||||
).fetchone()
|
||||
if cat_row:
|
||||
uuid = extract_uuid(dict(cat_row)['path'])
|
||||
if uuid:
|
||||
cat_id = DOMAIN_CATEGORY_MAP[domain]
|
||||
try:
|
||||
push_category(uuid, cat_id, config)
|
||||
db.set_peertube_pushed(file_hash)
|
||||
except Exception as e:
|
||||
return jsonify({'ok': True, 'warning': f'Assigned but PeerTube push failed: {e}'})
|
||||
|
||||
return jsonify({'ok': True, 'domain': domain})
|
||||
|
||||
|
||||
@app.route('/settings/keys')
|
||||
def settings_keys():
|
||||
from lib.key_manager import get_key_manager
|
||||
|
|
|
|||
148
templates/peertube/review.html
Normal file
148
templates/peertube/review.html
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div id="pt-review">
|
||||
<div class="stat-grid" style="grid-template-columns:repeat(4, 1fr);">
|
||||
<div class="stat-card"><div class="label">Manual Review</div><div class="value" id="rv-manual">—</div></div>
|
||||
<div class="stat-card"><div class="label">Assigned</div><div class="value" id="rv-assigned">—</div></div>
|
||||
<div class="stat-card"><div class="label">Tied (Pass 1)</div><div class="value" id="rv-tied1">—</div></div>
|
||||
<div class="stat-card"><div class="label">Needs Reprocess</div><div class="value" id="rv-reprocess">—</div></div>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="margin-top:16px;">
|
||||
<h3 class="section-title" style="margin-bottom:12px;">Manual Review Queue</h3>
|
||||
<div id="rv-items-container">
|
||||
<table class="data-table" id="rv-table" style="display:none;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Video</th>
|
||||
<th>Channel</th>
|
||||
<th>Current Domain</th>
|
||||
<th>Top Domains</th>
|
||||
<th>Assign</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="rv-tbody"></tbody>
|
||||
</table>
|
||||
<div id="rv-empty" class="text-muted" style="padding:24px;text-align:center;">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const VALID_DOMAINS = {{ valid_domains | tojson }};
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const resp = await fetch('/api/peertube/review/stats');
|
||||
const data = await resp.json();
|
||||
document.getElementById('rv-manual').textContent = data.tied_manual || 0;
|
||||
document.getElementById('rv-assigned').textContent =
|
||||
(data.assigned || 0) + (data.tied_pass_2 || 0) + (data.manual_assigned || 0);
|
||||
document.getElementById('rv-tied1').textContent = data.tied_pass_1 || 0;
|
||||
document.getElementById('rv-reprocess').textContent = data.needs_reprocess || 0;
|
||||
} catch (e) {
|
||||
console.error('Failed to load stats:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadItems() {
|
||||
try {
|
||||
const resp = await fetch('/api/peertube/review/items');
|
||||
const items = await resp.json();
|
||||
const tbody = document.getElementById('rv-tbody');
|
||||
const table = document.getElementById('rv-table');
|
||||
const empty = document.getElementById('rv-empty');
|
||||
|
||||
if (!items.length) {
|
||||
empty.textContent = 'No items pending manual review.';
|
||||
table.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = '';
|
||||
table.style.display = '';
|
||||
empty.style.display = 'none';
|
||||
|
||||
items.forEach(item => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.id = 'row-' + item.hash;
|
||||
|
||||
// Video title/filename
|
||||
const tdVideo = document.createElement('td');
|
||||
tdVideo.textContent = item.filename || item.hash.slice(0, 12);
|
||||
tdVideo.title = item.hash;
|
||||
tr.appendChild(tdVideo);
|
||||
|
||||
// Channel
|
||||
const tdChannel = document.createElement('td');
|
||||
tdChannel.textContent = item.category || '—';
|
||||
tr.appendChild(tdChannel);
|
||||
|
||||
// Current domain
|
||||
const tdCurrent = document.createElement('td');
|
||||
tdCurrent.textContent = item.recon_domain || '—';
|
||||
tr.appendChild(tdCurrent);
|
||||
|
||||
// Top domains (from concept counts)
|
||||
const tdTop = document.createElement('td');
|
||||
if (item.top_domains) {
|
||||
tdTop.innerHTML = item.top_domains.map(d =>
|
||||
'<span class="badge">' + d.domain + ' (' + d.count + ')</span>'
|
||||
).join(' ');
|
||||
}
|
||||
tr.appendChild(tdTop);
|
||||
|
||||
// Assign dropdown + button
|
||||
const tdAssign = document.createElement('td');
|
||||
const sel = document.createElement('select');
|
||||
sel.className = 'input-sm';
|
||||
sel.innerHTML = '<option value="">Select...</option>' +
|
||||
VALID_DOMAINS.map(d => '<option value="' + d + '">' + d + '</option>').join('');
|
||||
if (item.recon_domain) {
|
||||
sel.value = item.recon_domain;
|
||||
}
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn btn-sm btn-primary';
|
||||
btn.textContent = 'Assign';
|
||||
btn.onclick = () => assignDomain(item.hash, sel.value, tr);
|
||||
tdAssign.appendChild(sel);
|
||||
tdAssign.appendChild(btn);
|
||||
tr.appendChild(tdAssign);
|
||||
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
} catch (e) {
|
||||
document.getElementById('rv-empty').textContent = 'Error loading items: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function assignDomain(hash, domain, row) {
|
||||
if (!domain) {
|
||||
alert('Select a domain first');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch('/api/peertube/review/assign', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({hash: hash, domain: domain})
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (result.ok) {
|
||||
row.style.opacity = '0.4';
|
||||
row.querySelector('button').disabled = true;
|
||||
row.querySelector('button').textContent = 'Done';
|
||||
loadStats();
|
||||
} else {
|
||||
alert('Assignment failed: ' + (result.error || 'unknown error'));
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
loadStats();
|
||||
loadItems();
|
||||
</script>
|
||||
{% endblock %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue