feat(corpus): add save-to-corpus + private corpus search scope

- POST /api/save-to-corpus.php — saves tool output text to user's default CaveauAI corpus via ClientRagPipeline
- api/case/upload.php — dual-writes uploaded PDFs to CaveauAI client_documents (best-effort)
- assets/js/corpus-save.js — shared <dialog> handler for .js-save-corpus buttons on all tool pages
- includes/layout_footer.php — injects corpus-save.js + shared save dialog markup
- korrespond/deep-research/barnevernet/discrepancy JS — save-to-corpus buttons on output sections
- api/search.php + LegalTools::search() — corpus_scope param ('shared'|'private'|'both'), merges personal CaveauAI corpus with shared legal library when 'both'
- includes/tool_form.php + assets/js/tools.js — corpus scope radio toggle shown on search tab
- api/user-docs.php — add POST upload method for non-SSO authenticated users

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 17:50:32 +02:00
parent ed329f9d05
commit b014638f39
13 changed files with 465 additions and 33 deletions
+15
View File
@@ -813,6 +813,21 @@
els.results.appendChild(finalContainer.firstChild);
}
// Save-to-corpus button
const briefEl = els.results.querySelector('.dr-brief');
if (briefEl) {
briefEl.id = 'bvjBriefText';
const saveBtn = document.createElement('button');
saveBtn.type = 'button';
saveBtn.className = 'js-save-corpus secondary-button';
saveBtn.dataset.tool = 'barnevernet';
saveBtn.dataset.contentId = 'bvjBriefText';
saveBtn.dataset.suggestedTitle = 'BVJ analyse: ' + (document.getElementById('bvjQuestion')?.value?.slice(0, 80) ?? 'Svar');
saveBtn.textContent = 'Save to corpus';
saveBtn.style.marginTop = '12px';
briefEl.insertAdjacentElement('afterend', saveBtn);
}
// Bind source card clicks
els.results.querySelectorAll('.dr-source-card[data-source-n]').forEach((node) => {
node.addEventListener('click', (e) => {
+106
View File
@@ -0,0 +1,106 @@
/**
* corpus-save.js — "Save to corpus" shared handler for all DBN tool pages.
*
* Buttons that trigger a save must have:
* class="js-save-corpus"
* data-content-id="<id of element containing the text to save>"
* data-tool="<source_tool slug, e.g. korrespond>"
* data-suggested-title="<pre-filled title string>" (optional)
*/
(function () {
'use strict';
const dlg = document.getElementById('save-corpus-dialog');
const form = document.getElementById('save-corpus-form');
const titleIn = document.getElementById('save-corpus-title');
const tagsIn = document.getElementById('save-corpus-tags');
const cancelBtn = document.getElementById('save-corpus-cancel');
if (!dlg || !form) return; // dialog not present (e.g. not logged in)
cancelBtn?.addEventListener('click', () => dlg.close());
let _pendingBtn = null;
let _pendingContent = '';
let _pendingTool = '';
// Delegated click — catches buttons added dynamically by tool JS
document.addEventListener('click', (e) => {
const btn = e.target.closest('.js-save-corpus');
if (!btn) return;
const contentId = btn.dataset.contentId;
const el = contentId ? document.getElementById(contentId) : null;
const content = (el ? (el.value ?? el.textContent) : '').trim();
if (!content || content.length < 30) {
btn.textContent = 'Nothing to save';
setTimeout(() => { btn.textContent = 'Save to corpus'; }, 2000);
return;
}
_pendingBtn = btn;
_pendingContent = content;
_pendingTool = btn.dataset.tool ?? '';
titleIn.value = btn.dataset.suggestedTitle ?? '';
tagsIn.value = '';
dlg.showModal();
titleIn.focus();
titleIn.select();
});
// Form submit inside dialog
form.addEventListener('submit', async (e) => {
e.preventDefault();
dlg.close();
const btn = _pendingBtn;
const content = _pendingContent;
const title = titleIn.value.trim();
const tags = tagsIn.value.trim();
const tool = _pendingTool;
if (!title || !content) return;
if (btn) {
btn.disabled = true;
btn.textContent = 'Saving…';
}
try {
const resp = await fetch('api/save-to-corpus.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content, source_tool: tool, tags }),
});
const data = await resp.json().catch(() => ({}));
if (resp.ok && data.ok) {
if (btn) {
btn.textContent = '✓ Saved to corpus';
btn.classList.add('js-save-corpus--saved');
}
} else {
const msg = data.error ?? `Error ${resp.status}`;
if (btn) {
btn.textContent = 'Save failed';
btn.disabled = false;
btn.title = msg;
}
console.error('[corpus-save] Save failed:', msg);
}
} catch (err) {
if (btn) {
btn.textContent = 'Network error';
btn.disabled = false;
}
console.error('[corpus-save] Network error:', err);
}
_pendingBtn = null;
_pendingContent = '';
});
}());
+15
View File
@@ -563,6 +563,21 @@
${nextHtml}
`;
// Save-to-corpus button (inject after brief block)
const briefEl = els.results.querySelector('.dr-brief');
if (briefEl) {
briefEl.id = 'drBriefText';
const saveBtn = document.createElement('button');
saveBtn.type = 'button';
saveBtn.className = 'js-save-corpus secondary-button';
saveBtn.dataset.tool = 'deep-research';
saveBtn.dataset.contentId = 'drBriefText';
saveBtn.dataset.suggestedTitle = 'Research: ' + (document.getElementById('drQuery')?.value?.slice(0, 80) ?? 'Report');
saveBtn.textContent = 'Save to corpus';
saveBtn.style.marginTop = '12px';
briefEl.insertAdjacentElement('afterend', saveBtn);
}
// Bind source-card click handlers (open modal) — but ignore clicks on inner <a>
els.results.querySelectorAll('.dr-source-card[data-source-n]').forEach((node) => {
node.addEventListener('click', (e) => {
+11
View File
@@ -551,6 +551,17 @@
els.results.appendChild(finalContainer.firstChild);
}
// Save-to-corpus button (appended after final results)
const saveBtn = document.createElement('button');
saveBtn.type = 'button';
saveBtn.className = 'js-save-corpus secondary-button';
saveBtn.dataset.tool = 'discrepancy';
saveBtn.dataset.contentId = 'dcResults';
saveBtn.dataset.suggestedTitle = 'Discrepancy report';
saveBtn.textContent = 'Save to corpus';
saveBtn.style.marginTop = '16px';
els.results.appendChild(saveBtn);
// Bind tabs
els.results.querySelectorAll('.dc-tab').forEach((btn) => {
btn.addEventListener('click', () => {
+12
View File
@@ -543,6 +543,12 @@
</div>
</div>
<pre class="korr-draft-body" id="korrDraftNo">${esc(draftNo)}</pre>
<button type="button" class="js-save-corpus secondary-button"
data-tool="korrespond"
data-content-id="korrDraftNo"
data-suggested-title="${esc((data.output_type || 'Brev') + ' — ' + (data.recipient_body || ''))}">
Save to corpus
</button>
</div>
${isSameLang ? '' : `
<div class="korr-draft-col">
@@ -554,6 +560,12 @@
</div>
</div>
<pre class="korr-draft-body" id="korrDraftUser">${esc(draftUser)}</pre>
<button type="button" class="js-save-corpus secondary-button"
data-tool="korrespond"
data-content-id="korrDraftUser"
data-suggested-title="${esc((data.output_type || 'Brev') + ' — ' + (data.recipient_body || '') + ' (translation)')}">
Save to corpus
</button>
</div>`}
</div>
+7
View File
@@ -910,6 +910,7 @@ document.addEventListener('DOMContentLoaded', () => {
uploadFileList: document.querySelector('#uploadFileList'),
uploadClear: document.querySelector('#uploadClear'),
aliasSection: document.querySelector('#aliasSection'),
corpusScopeControl: document.querySelector('#corpusScopeControl'),
addAliasRow: document.querySelector('#addAliasRow'),
aliasRows: document.querySelector('#aliasRows'),
audioZone: document.querySelector('#audioZone'),
@@ -1013,6 +1014,7 @@ function setTool(toolName) {
els.input.placeholder = tool.placeholder;
}
els.languageControl.classList.toggle('is-hidden', !tool.usesLanguage);
els.corpusScopeControl?.classList.toggle('is-hidden', toolName !== 'search');
els.redactionControl.classList.toggle('is-hidden', toolName !== 'redact');
els.uploadZone.classList.toggle('is-hidden', toolName !== 'redact' && toolName !== 'timeline');
els.aliasSection.classList.toggle('is-hidden', toolName !== 'redact');
@@ -1080,6 +1082,7 @@ async function runTool(event) {
}
if (state.activeTool === 'search') {
payload.limit = 7;
payload.corpus_scope = currentCorpusScope();
}
if (state.activeTool === 'redact') {
lastOriginalText = text;
@@ -1329,6 +1332,10 @@ function currentLanguage() {
return document.querySelector('input[name="language"]:checked')?.value || 'en';
}
function currentCorpusScope() {
return document.querySelector('input[name="corpusScope"]:checked')?.value || 'both';
}
function currentRedactionMode() {
return document.querySelector('input[name="redactionMode"]:checked')?.value || 'standard';
}