Korrespond: add Refine pass with jurisdiction-scoped formal citations
After the first draft is rendered, a "Refine with citations" panel offers a
3rd-pass rewrite scoped to the user's choice of Norwegian law, ECHR (EMK +
HUDOC case law), or both. Refine pulls fresh corpus chunks limited to the
chosen jurisdiction's slices, rewrites inline cites in formal style ("jf.
forvaltningsloven § 17", "jf. Strand Lobben m.fl. mot Norge, EMD-37283/13,
§§ 207–214"), and appends a Rettskilder block listing every authority.
Hard-RAG grounding carries through — refine cannot cite anything that
wasn't retrieved. Costs 1 additional credit; the original draft stays in
place and the refined version appears below it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -7136,3 +7136,44 @@ body.lt-landing {
|
||||
/* Required hint */
|
||||
.required-hint { color: var(--dbn-red, #ba0c2f); font-size: 0.78rem; font-weight: 500; }
|
||||
|
||||
/* Korrespond — refine panel */
|
||||
.korr-refine-panel {
|
||||
margin-top: 22px;
|
||||
padding: 16px 18px;
|
||||
background: rgba(0, 32, 91, 0.04);
|
||||
border: 1px dashed rgba(0, 32, 91, 0.35);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.korr-refine-panel h3 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1rem;
|
||||
color: var(--dbn-blue, #00205b);
|
||||
}
|
||||
.korr-refine-panel h3 small {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.korr-refine-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin: 10px 0 4px;
|
||||
}
|
||||
.korr-refine-controls label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.88rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.korr-refine-controls button {
|
||||
margin-left: auto;
|
||||
}
|
||||
.korr-refined {
|
||||
margin-top: 18px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid rgba(0, 32, 91, 0.18);
|
||||
}
|
||||
|
||||
|
||||
+216
-2
@@ -1,6 +1,7 @@
|
||||
/* 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.
|
||||
Three-pass flow: Pass 1 may return clarify questions; Pass 2 returns Norwegian
|
||||
+ working-language drafts with verified law citations. Pass 3 (opt-in) refines
|
||||
the draft with jurisdiction-scoped formal citations + Rettskilder appendix.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
@@ -9,6 +10,7 @@
|
||||
let lang = window.DBN_TOOLS_LANG || localStorage.getItem('dbn-ui-lang') || 'en';
|
||||
let uploadFiles = [];
|
||||
let lastClassify = null;
|
||||
let lastFinal = null;
|
||||
let pendingClarifications = {};
|
||||
|
||||
const LANG_LABELS = { en: 'English', no: 'Norsk', uk: 'Українська', pl: 'Polski' };
|
||||
@@ -295,10 +297,111 @@
|
||||
}
|
||||
|
||||
setStatus(`Done in ${Math.round((finalResult.latency_ms || 0) / 1000)} s · ${(finalResult.cited_law || []).length} cited source(s)`, 'ok');
|
||||
lastFinal = finalResult;
|
||||
renderFinal(finalResult);
|
||||
pendingClarifications = {}; // reset for next run
|
||||
}
|
||||
|
||||
// ── Pass 3: refine with jurisdiction-scoped formal citations ────────────────
|
||||
async function runRefine(jurisdiction) {
|
||||
if (!lastFinal || !lastClassify) {
|
||||
setStatus('No draft to refine. Run a draft first.', 'error');
|
||||
return;
|
||||
}
|
||||
const refineBtn = document.getElementById('korrRefineBtn');
|
||||
if (refineBtn) { refineBtn.disabled = true; refineBtn.textContent = 'Refining…'; }
|
||||
setStatus(`Refining draft with ${jurisdiction} authorities…`, 'busy');
|
||||
|
||||
const payload = {
|
||||
jurisdiction,
|
||||
language: lang,
|
||||
original_draft_no: lastFinal.draft_no || '',
|
||||
classify: lastClassify,
|
||||
intake: {
|
||||
recipient_body: lastFinal.recipient_body,
|
||||
output_type: lastFinal.output_type,
|
||||
tone: lastFinal.tone,
|
||||
goal: lastFinal.goal,
|
||||
},
|
||||
};
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch('api/korrespond-refine.php', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} catch (err) {
|
||||
setStatus(`Network error: ${err.message || err}`, 'error');
|
||||
if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = 'Refine citations'; }
|
||||
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(`Refine failed (${response.status}).`, 'error');
|
||||
}
|
||||
if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = 'Refine citations'; }
|
||||
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;
|
||||
|
||||
while (true) {
|
||||
let chunk;
|
||||
try { chunk = await reader.read(); }
|
||||
catch (err) {
|
||||
setStatus(`Stream error: ${err.message || err}`, 'error');
|
||||
if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = 'Refine citations'; }
|
||||
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 || 'Refining…', 'busy'); continue; }
|
||||
if (evt.event === 'start') { setStatus(`Refining (${evt.jurisdiction})…`, 'busy'); continue; }
|
||||
if (evt.event === 'retrieval') { setStatus(`Hentet ${evt.sources_count} rettskilder for ${evt.jurisdiction}…`, 'busy'); continue; }
|
||||
if (evt.event === 'final') { finalResult = evt.result; continue; }
|
||||
if (evt.event === 'error') { errorEvent = evt; continue; }
|
||||
}
|
||||
}
|
||||
if (done) break;
|
||||
}
|
||||
|
||||
if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = 'Refine citations'; }
|
||||
|
||||
if (errorEvent) {
|
||||
setStatus(`${errorEvent.code}: ${errorEvent.message}`, 'error');
|
||||
return;
|
||||
}
|
||||
if (!finalResult) {
|
||||
setStatus('Refine stream ended without a result.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus(`Refined in ${Math.round((finalResult.latency_ms || 0) / 1000)} s · ${(finalResult.cited_law || []).length} cited authority(ies) · ${finalResult.jurisdiction}`, 'ok');
|
||||
renderRefined(finalResult);
|
||||
}
|
||||
|
||||
// ── Rendering ───────────────────────────────────────────────────────────────
|
||||
function renderClassifySummary(c) {
|
||||
if (!c || !c.summary) return;
|
||||
@@ -386,6 +489,18 @@
|
||||
` : '<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>` : ''}
|
||||
|
||||
<section class="korr-refine-panel" id="korrRefinePanel" aria-labelledby="korrRefineTitle">
|
||||
<h3 id="korrRefineTitle">Refine with formal citations <small>(+1 credit)</small></h3>
|
||||
<p class="upload-hint">Optional 2nd pass: pull fresh authorities, rewrite citations in formal style ("jf. forvaltningsloven § 17", "jf. Strand Lobben m.fl. mot Norge, EMD-37283/13, §§ 207–214"), and append a <em>Rettskilder</em> block at the bottom.</p>
|
||||
<div class="korr-refine-controls" role="radiogroup" aria-label="Jurisdiction">
|
||||
<label><input type="radio" name="korrJurisdiction" value="norwegian" checked> Norwegian law only</label>
|
||||
<label><input type="radio" name="korrJurisdiction" value="echr"> ECHR (EMK + HUDOC)</label>
|
||||
<label><input type="radio" name="korrJurisdiction" value="both"> Both</label>
|
||||
<button type="button" id="korrRefineBtn" class="primary-button">Refine citations</button>
|
||||
</div>
|
||||
<div id="korrRefinedSlot"></div>
|
||||
</section>
|
||||
`;
|
||||
|
||||
// Wire copy/download
|
||||
@@ -405,6 +520,105 @@
|
||||
downloadText(`korrespond-${data.recipient_body}-${suffix}.txt`, target);
|
||||
});
|
||||
});
|
||||
|
||||
// Wire refine
|
||||
const refineBtn = document.getElementById('korrRefineBtn');
|
||||
refineBtn?.addEventListener('click', () => {
|
||||
const choice = document.querySelector('input[name="korrJurisdiction"]:checked');
|
||||
runRefine(choice ? choice.value : 'norwegian');
|
||||
});
|
||||
}
|
||||
|
||||
function renderRefined(data) {
|
||||
const slot = document.getElementById('korrRefinedSlot');
|
||||
if (!slot) return;
|
||||
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 isSameLang = userLang === 'no';
|
||||
const draftNo = data.draft_no || '';
|
||||
const draftUser = data.draft_user || '';
|
||||
const jurLabel = data.jurisdiction === 'echr' ? 'ECHR (EMK + HUDOC)'
|
||||
: data.jurisdiction === 'both' ? 'Norwegian + ECHR'
|
||||
: 'Norwegian 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>`;
|
||||
};
|
||||
|
||||
slot.innerHTML = `
|
||||
<div class="korr-refined">
|
||||
<div class="korr-result-head">
|
||||
<span class="tool-badge">Refined · ${esc(jurLabel)}</span>
|
||||
<div class="korr-flags">
|
||||
${flagBadge('citations_verified', 'Citations verified')}
|
||||
${flagBadge('deadline_mentioned', 'Deadline')}
|
||||
${flagBadge('goal_addressed', 'Goal addressed')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="korr-drafts ${isSameLang ? 'is-single' : ''}">
|
||||
<div class="korr-draft-col">
|
||||
<div class="korr-draft-head">
|
||||
<h3>Norsk (bokmål) — refined</h3>
|
||||
<div class="korr-draft-actions">
|
||||
<button type="button" class="secondary-button" data-rcopy="no">Copy</button>
|
||||
<button type="button" class="secondary-button" data-rdownload="no">Download .txt</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre class="korr-draft-body">${esc(draftNo)}</pre>
|
||||
</div>
|
||||
${isSameLang ? '' : `
|
||||
<div class="korr-draft-col">
|
||||
<div class="korr-draft-head">
|
||||
<h3>${esc(userLangLabel)} — refined</h3>
|
||||
<div class="korr-draft-actions">
|
||||
<button type="button" class="secondary-button" data-rcopy="user">Copy</button>
|
||||
<button type="button" class="secondary-button" data-rdownload="user">Download .txt</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre class="korr-draft-body">${esc(draftUser)}</pre>
|
||||
</div>`}
|
||||
</div>
|
||||
|
||||
${cited.length ? `
|
||||
<details class="korr-cited" open>
|
||||
<summary><strong>Cited authorities (${cited.length})</strong> — ${esc(jurLabel)}</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) : ''}${s.authority_type ? ' <small>(' + esc(s.authority_type) + ')</small>' : ''}</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>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
slot.querySelectorAll('[data-rcopy]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const target = btn.dataset.rcopy === 'no' ? draftNo : draftUser;
|
||||
navigator.clipboard?.writeText(target).then(
|
||||
() => { btn.textContent = 'Copied ✓'; setTimeout(() => btn.textContent = 'Copy', 1500); },
|
||||
() => { btn.textContent = 'Failed'; }
|
||||
);
|
||||
});
|
||||
});
|
||||
slot.querySelectorAll('[data-rdownload]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const target = btn.dataset.rdownload === 'no' ? draftNo : draftUser;
|
||||
const suffix = btn.dataset.rdownload === 'no' ? 'no' : userLang;
|
||||
downloadText(`korrespond-refined-${data.recipient_body}-${data.jurisdiction}-${suffix}.txt`, target);
|
||||
});
|
||||
});
|
||||
|
||||
slot.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
// ── utils ───────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user