diff --git a/assets/css/tools.css b/assets/css/tools.css
index 17e0e7f..07f9439 100644
--- a/assets/css/tools.css
+++ b/assets/css/tools.css
@@ -2672,7 +2672,8 @@ p {
}
.dr-source-tag--upload { background: #fff0e8; color: #8a4524; }
-.dr-source-tag--score { background: #eef3fb; color: #314158; }
+.dr-source-tag--score { background: #eef3fb; color: #314158; }
+.dr-source-tag--graph { background: #f0fdf4; color: #166534; }
.dr-source-excerpt {
color: var(--muted);
diff --git a/assets/js/advocate.js b/assets/js/advocate.js
index 6282594..4e8c9d2 100644
--- a/assets/js/advocate.js
+++ b/assets/js/advocate.js
@@ -833,6 +833,7 @@
const score = s.reranker_score != null ? s.reranker_score : s.similarity;
const originTagClass = s.source_origin === 'upload' ? 'dr-source-tag dr-source-tag--upload' : 'dr-source-tag';
const originLabel = s.source_origin === 'upload' ? 'upload' : 'corpus';
+ const graphExpanded = s.graph_expanded === true;
const link = s.deep_link || s.source_url;
const titleHtml = link
? `${escapeHtml(s.title || 'Untitled')} ↗`
@@ -844,6 +845,7 @@
${s.section ? `
${originLabel}
+ ${graphExpanded ? `
via citation graph` : ''}
${s.authority_label ? `
${escapeHtml(s.authority_label)}` : ''}
${escapeHtml(s.package_or_corpus || '—')}
${(s.matched_sub_questions || []).map((q) => `
${escapeHtml(q)}`).join('')}
diff --git a/assets/js/citations.js b/assets/js/citations.js
new file mode 100644
index 0000000..20f69d5
--- /dev/null
+++ b/assets/js/citations.js
@@ -0,0 +1,287 @@
+(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, '&').replace(//g, '>').replace(/"/g, '"');
+ }
+
+ 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 `
`
+ + esc(d.title || '(Untitled)')
+ + (al ? `${esc(al)}` : '')
+ + ``;
+ }).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 = '
Document '
+ + esc(String(docId))
+ + ' is not in the citation graph yet. The graph grows as new documents are scraped and resolved.
';
+ }
+
+ function renderResults(data, action) {
+ const src = data.source;
+ const atLabel = authorityLabel(src.atype);
+ const year = yearFrom(src.valid_from);
+
+ sourceCard.innerHTML =
+ '
' + esc(src.title || '(Untitled)') + '
'
+ + '
';
+
+ 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 = '';
+
+ if (results.length === 0) {
+ html += '
No connected documents found for this action.
';
+ } else {
+ html += '
';
+ 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 += '
'
+ + '
' + esc(chipLbl) + ''
+ + (cardTitle ? '
' + esc(cardTitle) + '' : '')
+ + (!cardTitle && r.ref ? '
' + esc(r.ref) + '' : '')
+ + '
'
+ + (atl ? '' + esc(atl) + '' : '')
+ + (yr ? '' + esc(yr) + '' : '')
+ + '
'
+ + (!unresolved ? '
' : '')
+ + '
';
+ }
+ html += '
';
+ }
+
+ 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 =
+ '
'
+ + '
'
+ + '
' + esc(entry.title) + ''
+ + '
' + esc(authorityLabel(entry.atype) || 'Document') + ' · ID ' + esc(String(entry.doc_id)) + '
'
+ + '
';
+ 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 }));
+ }
+
+})();
diff --git a/citations.php b/citations.php
new file mode 100644
index 0000000..35b17de
--- /dev/null
+++ b/citations.php
@@ -0,0 +1,138 @@
+
+
+
Exploration trail
+
Navigation history
+
+
+ -
+
+
+
Nothing explored yet
+
Search for a document to start.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/includes/DeepResearchAgent.php b/includes/DeepResearchAgent.php
index 24966c2..27714d1 100644
--- a/includes/DeepResearchAgent.php
+++ b/includes/DeepResearchAgent.php
@@ -374,6 +374,7 @@ final class DbnDeepResearchAgent
'source_url' => $s['source_url'] ?? null,
'source_origin' => $s['source_origin'] ?? 'corpus',
'authority_label'=> $s['authority_label'] ?? null,
+ 'graph_expanded' => $s['graph_expanded'] ?? false,
'excerpt' => $s['excerpt'] ?? '',
], $topSources),
];
@@ -733,6 +734,7 @@ PROMPT;
'similarity' => $similarity,
'reranker_score' => $rerankerScore,
'document_id' => isset($chunk['document_id']) ? (int)$chunk['document_id'] : null,
+ 'graph_expanded' => !empty($chunk['_graph_expanded']),
'source_origin' => 'corpus',
'authority_type' => $chunk['authority_type'] ?? null,
'jurisdiction' => $chunk['jurisdiction'] ?? null,
diff --git a/includes/i18n.php b/includes/i18n.php
index fb03937..9dda3b0 100644
--- a/includes/i18n.php
+++ b/includes/i18n.php
@@ -446,6 +446,7 @@ function dbnToolsLaunchedTools(?string $language = null): array
'deep-research' => ['Deep Research', 'Agent + RAG', 'Expand a question into research angles, search legal slices, and synthesize a cited brief.', 'Family-legal'],
'discrepancy' => ['Discrepancy Finder', 'Document comparison', 'Upload two versions of a Barnevernet document and find contradictions, deleted facts, and new allegations.', 'Cross-document AI'],
'corpus' => ['Corpus', 'Legal knowledge base', 'Inspect indexed sources, corpus health, legal categories, and retrieval behavior.', '~220 K passages'],
+ 'citations' => ['Citations', 'Citation graph', 'Browse the legal citation graph — what a statute cites, what cites it, and what implements or amends it.', 'Graph topology'],
],
'no' => [
'transcribe' => ['Transkriber', 'Lyd og møter', 'Gjør lyd eller video om til tekst med talerinndeling og juridisk ordforråd.', 'Whisper / GPU'],
@@ -456,6 +457,7 @@ function dbnToolsLaunchedTools(?string $language = null): array
'deep-research' => ['Dyp research', 'Agent + RAG', 'Utvid et spørsmål til forskningsvinkler, søk juridiske kilder og lag et kildebelagt notat.', 'Familierett'],
'discrepancy' => ['Avviksfinner', 'Dokumentsammenligning', 'Last opp to versjoner av et barneverndokument og finn motsigelser, slettede fakta og nye påstander.', 'Kryssdokument AI'],
'corpus' => ['Korpus', 'Juridisk kunnskapsbase', 'Se indekserte kilder, korpushelse, juridiske kategorier og søkeoppsett.', '~220 K utdrag'],
+ 'citations' => ['Siteringer', 'Siteringsgraf', 'Utforsk siteringsgrafen — hva et dokument siterer, hva som siterer det, og hva som implementerer det.', 'Grafstruktur'],
],
'uk' => [
'transcribe' => ['Транскрипція', 'Аудіо та зустрічі', 'Перетворюйте аудіо або відео на текст із розділенням мовців і юридичною лексикою.', 'Whisper / GPU'],
@@ -466,6 +468,7 @@ function dbnToolsLaunchedTools(?string $language = null): array
'deep-research' => ['Глибоке дослідження', 'Agent + RAG', 'Розгортає питання в дослідницькі напрями, шукає юридичні джерела та створює бриф.', 'Сімейне право'],
'discrepancy' => ['Пошук розбіжностей', 'Порівняння документів', 'Завантажте дві версії документа Barnevernet і знайдіть суперечності, видалені факти та нові твердження.', 'Міждокументний AI'],
'corpus' => ['Корпус', 'Юридична база знань', 'Переглядайте індексовані джерела, стан корпусу, категорії та поведінку пошуку.', '~220 тис. уривків'],
+ 'citations' => ['Граф цитувань', 'Мережа посилань', 'Граф правових посилань — що цитує документ, хто цитує його, що його реалізує.', 'Граф-топологія'],
],
'pl' => [
'transcribe' => ['Transkrypcja', 'Audio i spotkania', 'Zamień audio lub wideo na tekst z rozdzieleniem mówców i słownictwem prawnym.', 'Whisper / GPU'],
@@ -476,11 +479,12 @@ function dbnToolsLaunchedTools(?string $language = null): array
'deep-research' => ['Głębokie badanie', 'Agent + RAG', 'Rozwija pytanie w kierunki badawcze, przeszukuje źródła prawne i tworzy brief z cytatami.', 'Prawo rodzinne'],
'discrepancy' => ['Wyszukiwacz rozbieżności', 'Porównanie dokumentów', 'Prześlij dwie wersje dokumentu Barnevernet i znajdź sprzeczności, usunięte fakty i nowe zarzuty.', 'AI Między-dokumentowe'],
'corpus' => ['Korpus', 'Prawna baza wiedzy', 'Sprawdzaj indeksowane źródła, stan korpusu, kategorie prawne i działanie wyszukiwania.', '~220 tys. fragmentów'],
+ 'citations' => ['Graf cytowań', 'Sieć cytowań', 'Przeglądaj sieć cytowań — co cytuje dokument, kto go cytuje i co go implementuje.', 'Topologia grafu'],
],
];
$selected = $copy[$language] ?? $copy['en'];
- $order = ['transcribe', 'timeline', 'redact', 'barnevernet', 'advocate', 'deep-research', 'discrepancy', 'corpus'];
+ $order = ['transcribe', 'timeline', 'redact', 'barnevernet', 'advocate', 'deep-research', 'discrepancy', 'corpus', 'citations'];
$icons = [
'transcribe' => 'TR',
'timeline' => 'TL',
@@ -490,6 +494,7 @@ function dbnToolsLaunchedTools(?string $language = null): array
'deep-research' => 'DR',
'discrepancy' => 'DC',
'corpus' => 'KB',
+ 'citations' => 'CIT',
];
$out = [];
foreach ($order as $slug) {