Files
dobetternorge-tools/assets/js/citations.js
T
daveadmin 04555a96b1 Add Citation Explorer tool and graph-expansion badges to Advocate results
- citations.php + assets/js/citations.js: new tool page for browsing the
  FalkorDB citation graph by title/ID, with autocomplete, action pills
  (cites/cited_by/implements/chain), hop-by-hop navigation, and exploration trail
- advocate.js: tag graph-expanded source cards with 'via citation graph' badge
- DeepResearchAgent: propagate _graph_expanded flag through normalizeCorpusChunk
  and top_sources serialization so it reaches the frontend
- tools.css: add .dr-source-tag--graph variant (green pill)
- i18n.php: register 'citations' tool in all 4 languages with CIT icon

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:30:04 +02:00

288 lines
12 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
'use strict';
if (document.body.dataset.activeTool !== 'citations') return;
const form = document.getElementById('citationsForm');
const titleInput = document.getElementById('titleInput');
const docIdInput = document.getElementById('docIdHidden');
const acList = document.getElementById('citAutocomplete');
const depthRow = document.getElementById('depthRow');
const depthRange = document.getElementById('depthRange');
const depthValue = document.getElementById('depthValue');
const runBtn = document.getElementById('citeRunBtn');
const statusEl = document.getElementById('citStatus');
const resultsDiv = document.getElementById('citationResults');
const sourceCard = document.getElementById('citSourceCard');
const resultsArea = document.getElementById('citResultsArea');
const trailList = document.getElementById('trailList');
const trail = [];
// ── Utilities ─────────────────────────────────────────────────────────────
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function setStatus(msg, kind) {
statusEl.textContent = msg;
statusEl.style.color = kind === 'error' ? '#b41e1e' : kind === 'ok' ? 'var(--teal-dark)' : 'var(--muted)';
}
function debounce(fn, ms) {
let t;
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
}
function authorityLabel(atype) {
const map = {
case_law: 'Case law', guidance: 'Guidance', report: 'Report',
law: 'Statute', treaty: 'Treaty', ombudsman: 'Ombudsman',
tribunal: 'Tribunal', regulatory: 'Regulatory',
};
return map[atype] || atype || '';
}
function relChipClass(action, relType) {
if (action === 'chain') return 'rel-chip--reachable';
if (action === 'cited_by') return 'rel-chip--cited-by';
if (action === 'cites') return 'rel-chip--cites';
const rt = (relType || '').toUpperCase();
if (rt === 'REPEALS') return 'rel-chip--repeals';
return 'rel-chip--implements';
}
function relChipLabel(action, relType) {
if (action === 'chain') return 'Reachable';
if (action === 'cited_by') return 'Cited by';
if (action === 'cites') return 'Cites';
if (relType) return relType.replace(/_/g, ' ');
return 'Implements';
}
function yearFrom(dateStr) {
if (!dateStr) return '';
return String(dateStr).slice(0, 4);
}
// ── Autocomplete ──────────────────────────────────────────────────────────
function hideAc() { acList.hidden = true; }
function renderAc(docs) {
if (!docs.length) { hideAc(); return; }
acList.innerHTML = docs.map(d => {
const al = authorityLabel(d.authority_type);
return `<li role="option" data-id="${esc(String(d.id))}" data-title="${esc(d.title || '')}">`
+ esc(d.title || '(Untitled)')
+ (al ? `<span class="cit-ac-atype">${esc(al)}</span>` : '')
+ `</li>`;
}).join('');
acList.hidden = false;
}
const fetchAc = debounce(async (q) => {
if (!q || q.length < 2) { hideAc(); return; }
try {
const res = await fetch('api/corpus-documents.php?' + new URLSearchParams({ title: q, limit: 15 }), { credentials: 'same-origin' });
if (!res.ok) { hideAc(); return; }
const data = await res.json();
renderAc(data.documents || []);
} catch { hideAc(); }
}, 300);
titleInput.addEventListener('input', (e) => {
const val = e.target.value.trim();
if (/^\d+$/.test(val)) {
docIdInput.value = val;
hideAc();
} else {
docIdInput.value = '';
fetchAc(val);
}
});
acList.addEventListener('mousedown', (e) => {
const li = e.target.closest('li[data-id]');
if (!li) return;
e.preventDefault();
titleInput.value = li.dataset.title;
docIdInput.value = li.dataset.id;
hideAc();
});
titleInput.addEventListener('blur', () => setTimeout(hideAc, 150));
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideAc(); });
// ── Action radios + depth slider ──────────────────────────────────────────
document.querySelectorAll('input[name="citAction"]').forEach(r => {
r.addEventListener('change', () => { depthRow.hidden = r.value !== 'chain'; });
});
depthRange.addEventListener('input', () => { depthValue.textContent = depthRange.value; });
// ── Form submit ───────────────────────────────────────────────────────────
form.addEventListener('submit', async (e) => {
e.preventDefault();
let docId = parseInt(docIdInput.value, 10) || parseInt(titleInput.value.trim(), 10);
if (!docId || docId <= 0) {
setStatus('Enter a document title or numeric ID.', 'error');
titleInput.focus();
return;
}
const action = document.querySelector('input[name="citAction"]:checked').value;
const params = { action, doc_id: docId, limit: 50 };
if (action === 'chain') params.depth = parseInt(depthRange.value, 10);
setStatus('Querying citation graph…', 'busy');
runBtn.disabled = true;
try {
const url = 'https://ai.bluenotelogic.com/api/graph-search.php?' + new URLSearchParams(params);
const res = await fetch(url, { credentials: 'omit' });
if (res.status === 404) {
setStatus('', '');
renderNotFound(docId);
return;
}
if (!res.ok) {
setStatus('Graph error (' + res.status + ').', 'error');
return;
}
const data = await res.json();
if (data.error) { setStatus(data.error, 'error'); return; }
setStatus('', '');
renderResults(data, action);
addToTrail(data.source);
} catch (err) {
setStatus('Network error: ' + err.message, 'error');
} finally {
runBtn.disabled = false;
}
});
// ── Render ────────────────────────────────────────────────────────────────
function renderNotFound(docId) {
resultsDiv.hidden = false;
sourceCard.innerHTML = '';
resultsArea.innerHTML = '<p class="cit-no-results">Document '
+ esc(String(docId))
+ ' is not in the citation graph yet. The graph grows as new documents are scraped and resolved.</p>';
}
function renderResults(data, action) {
const src = data.source;
const atLabel = authorityLabel(src.atype);
const year = yearFrom(src.valid_from);
sourceCard.innerHTML =
'<p class="cit-source-card__title">' + esc(src.title || '(Untitled)') + '</p>'
+ '<div class="cit-source-card__meta">'
+ (atLabel ? '<span class="source-badge badge--teal">' + esc(atLabel) + '</span>' : '')
+ (year ? '<span>' + esc(year) + '</span>' : '')
+ (src.jurisdiction ? '<span>' + esc(src.jurisdiction) + '</span>' : '')
+ '<span style="color:var(--muted)">ID ' + esc(String(src.doc_id)) + '</span>'
+ (src.source_url ? '<a href="' + esc(src.source_url) + '" target="_blank" rel="noopener" style="color:var(--teal)">Source ↗</a>' : '')
+ '</div>';
const results = data.results || [];
const count = data.count ?? results.length;
const actionLabels = {
cites: 'Documents cited',
cited_by: 'Documents citing this',
implements: 'Documents implementing / amending',
chain: 'Reachable documents (citation chain)',
};
let html = '<div class="cit-results-header">'
+ '<p class="eyebrow">' + esc(actionLabels[action] || action) + '</p>'
+ '<span class="cit-results-count">' + esc(String(count)) + ' document' + (count !== 1 ? 's' : '') + '</span>'
+ '</div>';
if (results.length === 0) {
html += '<p class="cit-no-results">No connected documents found for this action.</p>';
} else {
html += '<div class="cit-grid">';
for (const r of results) {
const chipCls = relChipClass(action, r.rel_type);
const chipLbl = relChipLabel(action, r.rel_type);
const unresolved = r.doc_id === null || r.doc_id === undefined;
const cardTitle = r.title || '';
const atl = authorityLabel(r.atype);
const yr = yearFrom(r.valid_from);
html += '<div class="cit-card' + (unresolved ? ' cit-card--unresolved' : '') + '">'
+ '<span class="rel-chip ' + esc(chipCls) + '">' + esc(chipLbl) + '</span>'
+ (cardTitle ? '<span class="cit-card__title">' + esc(cardTitle) + '</span>' : '')
+ (!cardTitle && r.ref ? '<span class="cit-card__ref">' + esc(r.ref) + '</span>' : '')
+ '<div class="cit-card__meta">'
+ (atl ? '<span class="source-badge badge--muted">' + esc(atl) + '</span>' : '')
+ (yr ? '<span>' + esc(yr) + '</span>' : '')
+ '</div>'
+ (!unresolved ? '<button class="cit-explore-btn" type="button" data-explore-id="' + esc(String(r.doc_id)) + '" data-explore-title="' + esc(cardTitle) + '">Explore →</button>' : '')
+ '</div>';
}
html += '</div>';
}
resultsArea.innerHTML = html;
resultsDiv.hidden = false;
resultsArea.querySelectorAll('.cit-explore-btn').forEach(btn => {
btn.addEventListener('click', () => exploreDoc(parseInt(btn.dataset.exploreId, 10), btn.dataset.exploreTitle));
});
}
// ── Trail ─────────────────────────────────────────────────────────────────
function addToTrail(source) {
if (trail.length && trail[trail.length - 1].doc_id === source.doc_id) return;
trail.push({ doc_id: source.doc_id, title: source.title || '(Untitled)', atype: source.atype });
renderTrail();
}
function renderTrail() {
const placeholder = document.getElementById('trailPlaceholder');
if (placeholder) placeholder.remove();
trailList.innerHTML = '';
for (let i = trail.length - 1; i >= 0; i--) {
const entry = trail[i];
const isCurrent = i === trail.length - 1;
const li = document.createElement('li');
li.className = 'cit-trail-item' + (isCurrent ? ' is-active' : '');
li.innerHTML =
'<span class="trace-status ' + (isCurrent ? 'running' : 'complete') + '"></span>'
+ '<div>'
+ '<strong>' + esc(entry.title) + '</strong>'
+ '<p>' + esc(authorityLabel(entry.atype) || 'Document') + ' · ID ' + esc(String(entry.doc_id)) + '</p>'
+ '</div>';
if (!isCurrent) {
li.addEventListener('click', () => exploreDoc(entry.doc_id, entry.title));
}
trailList.appendChild(li);
}
}
// ── Navigation ────────────────────────────────────────────────────────────
function exploreDoc(docId, title) {
titleInput.value = title || String(docId);
docIdInput.value = String(docId);
hideAc();
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
}
})();