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 ? `
${escapeHtml(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)') + '

    ' + + '
    ' + + (atLabel ? '' + esc(atLabel) + '' : '') + + (year ? '' + esc(year) + '' : '') + + (src.jurisdiction ? '' + esc(src.jurisdiction) + '' : '') + + 'ID ' + esc(String(src.doc_id)) + '' + + (src.source_url ? 'Source ↗' : '') + + '
    '; + + 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 = '
    ' + + '

    ' + esc(actionLabels[action] || action) + '

    ' + + '' + esc(String(count)) + ' document' + (count !== 1 ? 's' : '') + '' + + '
    '; + + 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

    +
    +
      +
    1. + +
      + Nothing explored yet +

      Search for a document to start.

      +
      +
    2. +
    + + + + +
    + + +
    + + +
    + + +
    + + + + +
    + + + + + +
    + + + + 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) {