Add Korrespond tool: drafts replies & new correspondence to NO authorities

Two-pass wizard for drafting to NAV, Barnevernet, schools, Bufdir, kommune,
Statsforvalter, Trygderetten. Pass 1 (gpt-4o-mini) classifies the situation
and emits clarify questions if facts are missing; user answers inline and
resubmits without losing context. Pass 2 retrieves law passages via hard-RAG
(ClientRagPipeline with body-specific slice presets), drafts in Norwegian
bokmål with gpt-4o using [CITE:N] tokens, self-checks that every citation
maps to a real corpus passage, then translates to the working language.
Result is side-by-side Norwegian + EN/PL/UK with copy/download per side
and an expandable Cited Law panel.

Credit deducts only when Pass 2 actually runs, not on a clarify cycle.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 11:27:13 +02:00
parent bc44b0eee2
commit b78a49e060
6 changed files with 1581 additions and 1 deletions
+140
View File
@@ -6996,3 +6996,143 @@ body.lt-landing {
.dr-brief { line-height:1.75; }
summary { display:none; }
}
/* ── Korrespond ──────────────────────────────────────────────────────────── */
.korr-goal-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 6px 0 14px;
}
.korr-chip {
background: rgba(15, 118, 110, 0.08);
border: 1px solid rgba(15, 118, 110, 0.25);
color: var(--ink, #1b2330);
border-radius: 999px;
padding: 4px 12px;
font-size: 0.82rem;
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease;
}
.korr-chip:hover { background: rgba(15, 118, 110, 0.16); }
.korr-chip.is-active {
background: var(--dbn-blue, #00205b);
color: #fff;
border-color: var(--dbn-blue, #00205b);
}
.korr-clarify-panel {
background: rgba(183, 121, 31, 0.08);
border: 1px solid rgba(183, 121, 31, 0.35);
border-radius: 10px;
padding: 18px 20px;
margin: 18px 0;
}
.korr-clarify-panel h3 {
margin: 0 0 6px;
font-size: 1.05rem;
color: var(--ink, #1b2330);
}
.korr-clarify-list {
display: grid;
gap: 10px;
margin: 14px 0;
}
.korr-clarify-item { display: grid; gap: 4px; }
.korr-clarify-item label { font-size: 0.9rem; }
.korr-clarify-item input {
padding: 8px 10px;
border: 1px solid rgba(0, 0, 0, 0.18);
border-radius: 6px;
font: inherit;
background: #fff;
}
.korr-clarify-actions { display: flex; gap: 10px; }
.korr-result-head {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 14px;
}
.korr-flags { display: flex; flex-wrap: wrap; gap: 6px; }
.korr-flag {
font-size: 0.78rem;
padding: 3px 9px;
border-radius: 999px;
border: 1px solid currentColor;
font-weight: 500;
}
.korr-flag.is-ok { color: #0f766e; background: rgba(15, 118, 110, 0.08); }
.korr-flag.is-warn { color: #b7791f; background: rgba(183, 121, 31, 0.10); }
.korr-flag.is-error { color: #ba0c2f; background: rgba(186, 12, 47, 0.08); }
.korr-drafts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.korr-drafts.is-single { grid-template-columns: 1fr; }
@media (max-width: 1100px) {
.korr-drafts { grid-template-columns: 1fr; }
}
.korr-draft-col {
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.10);
border-radius: 10px;
padding: 14px 16px;
}
.korr-draft-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
margin-bottom: 10px;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
padding-bottom: 8px;
}
.korr-draft-head h3 {
margin: 0;
font-size: 0.92rem;
color: var(--dbn-blue, #00205b);
}
.korr-draft-actions { display: flex; gap: 6px; }
.korr-draft-body {
margin: 0;
font-family: 'IBM Plex Mono', 'Menlo', 'Consolas', monospace;
font-size: 0.82rem;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
color: var(--ink, #1b2330);
max-height: 600px;
overflow-y: auto;
}
.korr-cited {
margin-top: 18px;
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.10);
border-radius: 10px;
padding: 12px 16px;
}
.korr-cited summary {
cursor: pointer;
font-size: 0.9rem;
color: var(--dbn-blue, #00205b);
}
.korr-cited-list { display: grid; gap: 12px; margin-top: 10px; }
.korr-cited-item {
border-left: 3px solid var(--dbn-blue, #00205b);
padding: 6px 12px;
background: rgba(0, 32, 91, 0.03);
}
.korr-cited-head { font-size: 0.88rem; margin-bottom: 4px; }
.korr-cited-excerpt { font-size: 0.83rem; line-height: 1.5; margin: 0 0 4px; color: rgba(0, 0, 0, 0.75); }
.korr-cited-item a { font-size: 0.78rem; color: var(--dbn-blue, #00205b); }
/* Required hint */
.required-hint { color: var(--dbn-red, #ba0c2f); font-size: 0.78rem; font-weight: 500; }
+431
View File
@@ -0,0 +1,431 @@
/* korrespond.js — page-scoped UI for /korrespond.php
Two-pass wizard: Pass 1 may return clarify questions; Pass 2 returns Norwegian
+ working-language drafts side by side with verified law citations.
*/
(function () {
'use strict';
const els = {};
let lang = window.DBN_TOOLS_LANG || localStorage.getItem('dbn-ui-lang') || 'en';
let uploadFiles = [];
let lastClassify = null;
let pendingClarifications = {};
const LANG_LABELS = { en: 'English', no: 'Norsk', uk: 'Українська', pl: 'Polski' };
document.addEventListener('DOMContentLoaded', () => {
if (document.body.dataset.activeTool !== 'korrespond') return;
Object.assign(els, {
form: document.getElementById('korrForm'),
status: document.getElementById('korrStatus'),
runButton: document.getElementById('korrRunButton'),
results: document.getElementById('korrResults'),
langButtons: Array.from(document.querySelectorAll('#korrLangSwitcher .lang-btn')),
modeRadios: Array.from(document.querySelectorAll('input[name="korrMode"]')),
bodySelect: document.getElementById('korrBody'),
outputRadios: Array.from(document.querySelectorAll('input[name="korrOutput"]')),
toneRadios: Array.from(document.querySelectorAll('input[name="korrTone"]')),
caseRef: document.getElementById('korrCaseRef'),
where: document.getElementById('korrWhere'),
deadline: document.getElementById('korrDeadline'),
parties: document.getElementById('korrParties'),
narrative: document.getElementById('korrNarrative'),
goal: document.getElementById('korrGoal'),
goalChips: Array.from(document.querySelectorAll('#korrGoalChips .korr-chip')),
uploadZone: document.getElementById('korrUploadZone'),
uploadInput: document.getElementById('korrUploadInput'),
uploadPrompt: document.getElementById('korrUploadPrompt'),
uploadFileInfo: document.getElementById('korrUploadFileInfo'),
uploadFileList: document.getElementById('korrUploadFileList'),
uploadClear: document.getElementById('korrUploadClear'),
clarifyPanel: document.getElementById('korrClarifyPanel'),
clarifyList: document.getElementById('korrClarifyList'),
clarifyContinue:document.getElementById('korrClarifyContinue'),
clarifyForce: document.getElementById('korrClarifyForce'),
});
if (!els.form) return;
bindLang();
bindGoalChips();
bindUpload();
bindClarify();
els.form.addEventListener('submit', (e) => { e.preventDefault(); runRequest(false); });
});
// ── Language switcher ───────────────────────────────────────────────────────
function bindLang() {
els.langButtons.forEach((b) => {
b.classList.toggle('is-active', b.dataset.lang === lang);
b.addEventListener('click', () => {
els.langButtons.forEach((x) => x.classList.remove('is-active'));
b.classList.add('is-active');
lang = b.dataset.lang || 'en';
localStorage.setItem('dbn-ui-lang', lang);
});
});
}
function bindGoalChips() {
els.goalChips.forEach((chip) => {
chip.addEventListener('click', () => {
els.goal.value = chip.dataset.goal || '';
els.goalChips.forEach((c) => c.classList.remove('is-active'));
chip.classList.add('is-active');
});
});
}
// ── File upload ─────────────────────────────────────────────────────────────
function bindUpload() {
if (!els.uploadZone) return;
const onFiles = (fileList) => {
const files = Array.from(fileList || []).slice(0, 4);
if (uploadFiles.length + files.length > 4) {
setStatus('At most 4 files can be uploaded per request.', 'error');
return;
}
files.forEach((f) => {
if (f.size > 8 * 1024 * 1024) {
setStatus(`${f.name} exceeds the 8 MB limit.`, 'error');
return;
}
const ext = (f.name.split('.').pop() || '').toLowerCase();
if (!['pdf', 'docx', 'txt'].includes(ext)) {
setStatus(`${f.name} is not a supported file type (PDF, DOCX, TXT).`, 'error');
return;
}
uploadFiles.push(f);
});
renderUploadList();
};
els.uploadInput.addEventListener('change', (e) => onFiles(e.target.files));
els.uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); els.uploadZone.classList.add('is-drop'); });
els.uploadZone.addEventListener('dragleave', () => els.uploadZone.classList.remove('is-drop'));
els.uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
els.uploadZone.classList.remove('is-drop');
onFiles(e.dataTransfer?.files);
});
els.uploadClear?.addEventListener('click', () => {
uploadFiles = [];
els.uploadInput.value = '';
renderUploadList();
});
}
function renderUploadList() {
if (!uploadFiles.length) {
els.uploadFileInfo.classList.add('is-hidden');
els.uploadPrompt.classList.remove('is-hidden');
return;
}
els.uploadPrompt.classList.add('is-hidden');
els.uploadFileInfo.classList.remove('is-hidden');
els.uploadFileList.innerHTML = uploadFiles.map((f) => {
const kb = (f.size / 1024).toFixed(0);
return `<li><span class="upload-filename">${esc(f.name)}</span><span class="upload-chars">${kb} KB</span></li>`;
}).join('');
}
// ── Clarify panel ───────────────────────────────────────────────────────────
function bindClarify() {
els.clarifyContinue?.addEventListener('click', () => {
pendingClarifications = collectClarifications();
hideClarify();
runRequest(false);
});
els.clarifyForce?.addEventListener('click', () => {
pendingClarifications = collectClarifications();
hideClarify();
runRequest(true);
});
}
function showClarify(questions) {
els.clarifyList.innerHTML = (questions || []).map((q, i) => `
<div class="korr-clarify-item">
<label for="clarify_${i}"><strong>${esc(q.question || '')}</strong></label>
<input type="text" id="clarify_${i}" data-key="${esc(q.key || '')}" placeholder="(optional)">
</div>
`).join('');
els.clarifyPanel.classList.remove('is-hidden');
els.clarifyPanel.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function hideClarify() {
els.clarifyPanel.classList.add('is-hidden');
}
function collectClarifications() {
const inputs = els.clarifyList.querySelectorAll('input[data-key]');
const out = {};
inputs.forEach((inp) => {
const v = (inp.value || '').trim();
if (v) out[inp.dataset.key] = v;
});
return out;
}
// ── Submit ──────────────────────────────────────────────────────────────────
function getRadio(list) {
const checked = list.find((r) => r.checked);
return checked ? checked.value : '';
}
function buildPayload(forceDraft) {
const deadlines = [];
if (els.deadline.value.trim()) deadlines.push(els.deadline.value.trim());
return {
mode: getRadio(els.modeRadios) || 'initiate',
recipient_body: els.bodySelect.value || 'other',
output_type: getRadio(els.outputRadios) || 'email',
tone: getRadio(els.toneRadios) || 'neutral',
language: lang,
case_ref: els.caseRef.value.trim(),
where: els.where.value.trim(),
deadlines,
parties_text: els.parties.value.trim(),
narrative: els.narrative.value.trim(),
goal: els.goal.value.trim(),
clarifications: pendingClarifications,
force_draft: !!forceDraft,
};
}
async function runRequest(forceDraft) {
const payload = buildPayload(forceDraft);
if (!payload.recipient_body) {
setStatus('Pick a recipient body before drafting.', 'error');
return;
}
if (payload.mode === 'initiate' && !payload.narrative) {
setStatus('Describe the situation in "What happened" first.', 'error');
return;
}
if (payload.mode === 'reply' && !uploadFiles.length && !payload.narrative) {
setStatus('Reply mode needs the received letter (upload or paste it).', 'error');
return;
}
setStatus('Analyserer…', 'busy');
els.runButton.disabled = true;
els.results.innerHTML = `<div class="empty-state"><h3>Working…</h3><p>Pass 1 extracts facts. If anything is missing we'll ask for clarification. Otherwise Pass 2 runs hard-RAG retrieval + draft + self-check + translate.</p></div>`;
const form = new FormData();
form.append('payload', JSON.stringify(payload));
uploadFiles.forEach((f) => form.append('files[]', f));
let response;
try {
response = await fetch('api/korrespond.php', { method: 'POST', body: form, credentials: 'same-origin' });
} catch (err) {
setStatus(`Network error: ${err.message || err}`, 'error');
els.runButton.disabled = false;
return;
}
if (!response.ok || !response.body) {
if (response.status === 402 || response.status === 429) {
const d = await response.json().catch(() => ({}));
if (typeof window.dbnFreeTierError === 'function') window.dbnFreeTierError(response.status, d);
} else {
setStatus(`Request failed (${response.status}).`, 'error');
}
els.runButton.disabled = false;
return;
}
const creditsRemaining = response.headers.get('X-Credits-Remaining');
if (creditsRemaining !== null && typeof window.dbnUpdateCredits === 'function') {
window.dbnUpdateCredits(parseInt(creditsRemaining, 10));
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let finalResult = null;
let errorEvent = null;
let clarifyEvent = null;
while (true) {
let chunk;
try { chunk = await reader.read(); }
catch (err) {
setStatus(`Stream error: ${err.message || err}`, 'error');
els.runButton.disabled = false;
return;
}
const { done, value } = chunk;
if (value) {
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
let evt; try { evt = JSON.parse(trimmed); } catch (_) { continue; }
if (!evt || !evt.event) continue;
if (evt.event === 'progress') { setStatus(evt.detail || 'Working…', 'busy'); continue; }
if (evt.event === 'start') { setStatus(`Started — ${evt.body} / ${evt.output_type}`, 'busy'); continue; }
if (evt.event === 'classify') { lastClassify = evt.result; renderClassifySummary(evt.result); continue; }
if (evt.event === 'retrieval'){ setStatus(`Hentet ${evt.sources_count} lovkilder for ${(evt.applicable_acts || []).join(', ')}`, 'busy'); continue; }
if (evt.event === 'clarify') { clarifyEvent = evt; continue; }
if (evt.event === 'final') { finalResult = evt.result; continue; }
if (evt.event === 'error') { errorEvent = evt; continue; }
}
}
if (done) break;
}
els.runButton.disabled = false;
if (errorEvent) {
setStatus(`${errorEvent.code}: ${errorEvent.message}`, 'error');
return;
}
if (clarifyEvent) {
setStatus('Need clarification before drafting.', 'busy');
showClarify(clarifyEvent.questions);
return;
}
if (!finalResult) {
setStatus('Stream ended without a draft.', 'error');
return;
}
setStatus(`Done in ${Math.round((finalResult.latency_ms || 0) / 1000)} s · ${(finalResult.cited_law || []).length} cited source(s)`, 'ok');
renderFinal(finalResult);
pendingClarifications = {}; // reset for next run
}
// ── Rendering ───────────────────────────────────────────────────────────────
function renderClassifySummary(c) {
if (!c || !c.summary) return;
let block = els.results.querySelector('#korrClassifyBlock');
if (!block) {
block = document.createElement('div');
block.id = 'korrClassifyBlock';
block.className = 'dr-result-block';
els.results.innerHTML = '';
els.results.appendChild(block);
}
block.innerHTML = `
<h3 style="margin:0 0 10px;font-size:0.95rem;color:var(--ink)">Sammendrag (Pass 1)</h3>
<p>${esc(c.summary)}</p>
${c.applicable_acts && c.applicable_acts.length ? `<p class="upload-hint"><strong>Antatt rettslig grunnlag:</strong> ${c.applicable_acts.map(esc).join(', ')}</p>` : ''}
${c.deadlines && c.deadlines.length ? `<p class="upload-hint"><strong>Frister:</strong> ${c.deadlines.map(esc).join(', ')}</p>` : ''}
`;
}
function renderFinal(data) {
const userLang = data.draft_user_lang || 'en';
const userLangLabel = LANG_LABELS[userLang] || userLang.toUpperCase();
const flags = data.self_check || {};
const cited = data.cited_law || [];
const flagBadge = (key, label) => {
const v = flags[key] || 'ok';
const cls = v === 'ok' ? 'is-ok' : (v === 'warn' ? 'is-warn' : 'is-error');
const icon = v === 'ok' ? '✓' : '!';
return `<span class="korr-flag ${cls}">${icon} ${esc(label)}</span>`;
};
const draftNo = data.draft_no || '';
const draftUser = data.draft_user || '';
const isSameLang = userLang === 'no';
els.results.innerHTML = `
<div class="korr-result-head">
<span class="tool-badge">${esc(data.recipient_body || '')} · ${esc(data.output_type || '')} · ${esc(data.tone || '')}</span>
<div class="korr-flags">
${flagBadge('citations_verified', 'Citations verified')}
${flagBadge('deadline_mentioned', 'Deadline')}
${flagBadge('goal_addressed', 'Goal addressed')}
${flagBadge('tone', 'Tone')}
</div>
</div>
<div class="korr-drafts ${isSameLang ? 'is-single' : ''}">
<div class="korr-draft-col">
<div class="korr-draft-head">
<h3>Norsk (bokmål) — kanonisk</h3>
<div class="korr-draft-actions">
<button type="button" class="secondary-button" data-copy="no">Copy</button>
<button type="button" class="secondary-button" data-download="no">Download .txt</button>
</div>
</div>
<pre class="korr-draft-body" id="korrDraftNo">${esc(draftNo)}</pre>
</div>
${isSameLang ? '' : `
<div class="korr-draft-col">
<div class="korr-draft-head">
<h3>${esc(userLangLabel)} — reference</h3>
<div class="korr-draft-actions">
<button type="button" class="secondary-button" data-copy="user">Copy</button>
<button type="button" class="secondary-button" data-download="user">Download .txt</button>
</div>
</div>
<pre class="korr-draft-body" id="korrDraftUser">${esc(draftUser)}</pre>
</div>`}
</div>
${cited.length ? `
<details class="korr-cited" open>
<summary><strong>Cited law (${cited.length})</strong> — each reference traces to a real corpus passage</summary>
<div class="korr-cited-list">
${cited.map((s) => `
<div class="korr-cited-item">
<div class="korr-cited-head"><strong>[${s.n}] ${esc(s.title)}</strong>${s.section ? ' — ' + esc(s.section) : ''}</div>
<p class="korr-cited-excerpt">${esc(s.excerpt || '')}</p>
${s.source_url ? `<a href="${esc(s.source_url)}" target="_blank" rel="noopener">View source</a>` : ''}
</div>
`).join('')}
</div>
</details>
` : '<p class="upload-hint"><em>No cited law sources — draft is plain-language (no § references available from corpus).</em></p>'}
${data.disclaimer ? `<p class="upload-hint" style="margin-top:16px;font-style:italic">${esc(data.disclaimer)}</p>` : ''}
`;
// Wire copy/download
els.results.querySelectorAll('[data-copy]').forEach((btn) => {
btn.addEventListener('click', () => {
const target = btn.dataset.copy === 'no' ? draftNo : draftUser;
navigator.clipboard?.writeText(target).then(
() => { btn.textContent = 'Copied ✓'; setTimeout(() => btn.textContent = 'Copy', 1500); },
() => { btn.textContent = 'Failed'; }
);
});
});
els.results.querySelectorAll('[data-download]').forEach((btn) => {
btn.addEventListener('click', () => {
const target = btn.dataset.download === 'no' ? draftNo : draftUser;
const suffix = btn.dataset.download === 'no' ? 'no' : userLang;
downloadText(`korrespond-${data.recipient_body}-${suffix}.txt`, target);
});
});
}
// ── utils ───────────────────────────────────────────────────────────────────
function setStatus(message, kind) {
if (!els.status) return;
els.status.textContent = message || '';
els.status.dataset.kind = kind || '';
}
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function downloadText(filename, text) {
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
}
})();