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:
2026-05-19 11:50:36 +02:00
parent b78a49e060
commit 5d8ae6b447
4 changed files with 696 additions and 3 deletions
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/../includes/KorrespondAgent.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
@ini_set('output_buffering', '0');
@ini_set('zlib.output_compression', '0');
@ini_set('implicit_flush', '1');
while (ob_get_level() > 0) { @ob_end_clean(); }
ob_implicit_flush(true);
header('Content-Type: application/x-ndjson; charset=utf-8');
header('Cache-Control: no-store');
header('X-Accel-Buffering: no');
$startTime = microtime(true);
$language = 'en';
$emit = function (string $event, array $payload = []) use ($startTime): void {
$payload['event'] = $event;
$payload['t_ms'] = (int)round((microtime(true) - $startTime) * 1000);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
@flush();
};
try {
$raw = file_get_contents('php://input');
if ($raw === false || strlen($raw) > 200000) {
throw new DbnToolsHttpException('Request body unreadable or too large.', 413, 'body_too_large');
}
$input = json_decode((string)$raw, true);
if (!is_array($input)) {
throw new DbnToolsHttpException('Request body must be valid JSON.', 400, 'invalid_json');
}
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
$jurisdiction = $input['jurisdiction'] ?? 'norwegian';
if (!in_array($jurisdiction, ['norwegian', 'echr', 'both'], true)) {
throw new DbnToolsHttpException('Invalid jurisdiction.', 422, 'invalid_jurisdiction');
}
$originalDraftNo = trim((string)($input['original_draft_no'] ?? ''));
if ($originalDraftNo === '') {
throw new DbnToolsHttpException('original_draft_no is required.', 422, 'missing_original_draft');
}
if (mb_strlen($originalDraftNo, 'UTF-8') > 12000) {
throw new DbnToolsHttpException('original_draft_no is too large.', 413, 'original_too_large');
}
$classify = is_array($input['classify'] ?? null) ? $input['classify'] : [];
$intake = is_array($input['intake'] ?? null) ? $input['intake'] : [];
// Sanitise intake to expected fields only
$allowedBodies = ['barnehage','school_1_10','sfo','nav','bufdir','barnevernet',
'kommune_other','statsforvalter','trygderetten','tingrett','other'];
$allowedOutput = ['email','formal','filing','call_prep'];
$allowedTone = ['cooperative','neutral','firm','adversarial','warm'];
$intake = [
'recipient_body' => in_array($intake['recipient_body'] ?? '', $allowedBodies, true) ? $intake['recipient_body'] : 'other',
'output_type' => in_array($intake['output_type'] ?? '', $allowedOutput, true) ? $intake['output_type'] : 'email',
'tone' => in_array($intake['tone'] ?? '', $allowedTone, true) ? $intake['tone'] : 'neutral',
'language' => $language,
'goal' => mb_substr(trim((string)($intake['goal'] ?? '')), 0, 600, 'UTF-8'),
];
// Credit gate (refine is a paid pass)
$ftUid = dbnToolsFreeTierCheck('korrespond_refine');
$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'korrespond_refine');
if ($ftRemaining >= 0) {
header('X-Credits-Remaining: ' . $ftRemaining);
}
$emit('start', [
'jurisdiction' => $jurisdiction,
'body' => $intake['recipient_body'],
'language' => $language,
]);
$agent = new DbnKorrespondAgent();
$result = $agent->refine($intake, $classify, $originalDraftNo, $jurisdiction, $emit);
$result['ok'] = true;
$result['latency_ms'] = (int)round((microtime(true) - $startTime) * 1000);
dbnToolsLogMetadata([
'tool' => 'korrespond_refine',
'language' => $language,
'ok' => true,
'latency_ms' => $result['latency_ms'],
'source_count' => is_array($result['cited_law'] ?? null) ? count($result['cited_law']) : 0,
'deployment' => 'gpt-4o',
'jurisdiction' => $jurisdiction,
]);
$emit('final', ['result' => $result]);
} catch (DbnToolsHttpException $e) {
$latency = (int)round((microtime(true) - $startTime) * 1000);
dbnToolsLogMetadata([
'tool' => 'korrespond_refine',
'language' => $language,
'ok' => false,
'latency_ms' => $latency,
'error_code' => $e->errorCode,
]);
$emit('error', ['code' => $e->errorCode, 'message' => $e->getMessage(), 'status' => $e->status]);
} catch (Throwable $e) {
error_log('Korrespond refine fatal: ' . $e->getMessage());
$latency = (int)round((microtime(true) - $startTime) * 1000);
dbnToolsLogMetadata([
'tool' => 'korrespond_refine',
'language' => $language,
'ok' => false,
'latency_ms' => $latency,
'error_code' => 'internal_error',
]);
$emit('error', ['code' => 'internal_error', 'message' => 'Korrespond refine could not complete this request.']);
}
+41
View File
@@ -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
View File
@@ -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, §§ 207214"), 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 ───────────────────────────────────────────────────────────────────
+319 -1
View File
@@ -8,10 +8,13 @@ require_once __DIR__ . '/AzureOpenAiGateway.php';
* Korrespond — drafts replies or new correspondence to Norwegian authorities
* (NAV, Barnevernet, schools, Bufdir, kommune, Statsforvalter, Trygderetten).
*
* Two-pass wizard with hard-RAG citation grounding:
* Three passes (the third is opt-in):
* Pass 1 — classify(): fact-extract + gap-check (Azure gpt-4o-mini)
* returns missing_facts[]; caller may emit clarify and stop
* Pass 2 — generate(): retrieve law → draft (Azure gpt-4o) → self-check → translate
* Pass 3 — refine(): retrieve law scoped to a user-picked jurisdiction
* (norwegian|echr|both), rewrite the existing draft with formally-styled
* citations, append a "Rettskilder" / "Legal authorities" block.
*
* Canonical draft is always Norwegian bokmål. User-language draft is a translation.
*/
@@ -622,4 +625,319 @@ EOT,
default => 'letter',
};
}
// ── Pass 3: refine with jurisdiction-scoped formal citations ────────────────
/**
* Refine an existing draft by retrieving law scoped to the chosen jurisdiction,
* rewriting inline citations into a formal style, and appending a Rettskilder
* (legal authorities) block.
*
* @param array $intake Original intake (recipient_body, output_type, tone, language, …)
* @param array $classify Pass 1 classify result (summary, applicable_acts, deadlines, …)
* @param string $originalDraftNo The Norwegian draft from Pass 2
* @param string $jurisdiction 'norwegian' | 'echr' | 'both'
* @param callable|null $emit Stream emitter
*
* @return array Final refined result payload.
*/
public function refine(
array $intake,
array $classify,
string $originalDraftNo,
string $jurisdiction,
?callable $emit = null
): array {
$body = $intake['recipient_body'] ?? 'other';
$outputType = $intake['output_type'] ?? 'email';
$tone = $intake['tone'] ?? 'neutral';
$userLang = dbnToolsNormalizeUiLanguage($intake['language'] ?? 'en');
$goal = trim((string)($intake['goal'] ?? ($classify['suggested_goal'] ?? '')));
$bodyLabel = self::BODY_LABELS[$body] ?? 'mottaker';
$jurisdiction = in_array($jurisdiction, ['norwegian', 'echr', 'both'], true) ? $jurisdiction : 'norwegian';
if ($emit) { $emit('progress', ['detail' => 'Henter rettskilder for ' . $this->jurisdictionLabelNorsk($jurisdiction) . '…']); }
$retrieval = $this->retrieveLawForJurisdiction($jurisdiction, $body, $classify);
if ($emit) {
$emit('retrieval', [
'sources_count' => count($retrieval['sources']),
'jurisdiction' => $jurisdiction,
'applied_slices' => $retrieval['applied_slices'],
]);
}
if ($emit) { $emit('progress', ['detail' => 'Skriver om med formelle henvisninger…']); }
$refinedNo = $this->rewriteWithFormalCites(
$originalDraftNo, $retrieval['sources'], $bodyLabel, $outputType, $tone, $goal, $jurisdiction
);
if ($emit) { $emit('progress', ['detail' => 'Kvalitetskontroll og Rettskilder…']); }
$checked = $this->selfCheck($refinedNo, $retrieval['sources'], $classify, $goal, $tone);
$draftUser = $checked['draft'];
if ($userLang !== 'no') {
if ($emit) { $emit('progress', ['detail' => 'Oversetter til ' . dbnToolsLanguageName($userLang) . '…']); }
$draftUser = $this->translate($checked['draft'], $userLang, $outputType);
}
return [
'tool' => 'korrespond_refine',
'language' => $userLang,
'jurisdiction' => $jurisdiction,
'recipient_body' => $body,
'output_type' => $outputType,
'tone' => $tone,
'draft_no' => $checked['draft'],
'draft_user' => $draftUser,
'draft_user_lang'=> $userLang,
'cited_law' => $checked['cited_sources'],
'self_check' => $checked['flags'],
'applied_slices' => $retrieval['applied_slices'],
'disclaimer' => dbnToolsDisclaimer($userLang),
];
}
/**
* Retrieve law scoped to user's jurisdiction choice.
* - norwegian: body preset slices MINUS echr
* - echr: only echr slice; queries target EMK articles + HUDOC case law
* - both: union (full body preset + extra ECHR queries)
*
* @return array{sources:array, applied_slices:string[]}
*/
private function retrieveLawForJurisdiction(string $jurisdiction, string $body, array $classify): array
{
$client = dbnToolsRequireClient();
$package = dbnToolsFetchPackage(dbnToolsRequiredPackageSlug());
if (!$package) {
return ['sources' => [], 'applied_slices' => []];
}
dbnToolsBootCaveau();
$aiPortalRoot = dbnToolsAiPortalRoot();
$v6 = $aiPortalRoot . '/platform/includes/dbn_v6.php';
if (is_file($v6)) {
require_once $v6;
}
$bodyPreset = self::BODY_PRESETS[$body] ?? self::BODY_PRESETS['other'];
// Build slice selection per jurisdiction
$sliceSel = [];
if ($jurisdiction === 'norwegian') {
foreach ($bodyPreset as $s) { if ($s !== 'echr') { $sliceSel[$s] = true; } }
if (empty($sliceSel)) { $sliceSel['family_core'] = true; }
} elseif ($jurisdiction === 'echr') {
$sliceSel['echr'] = true;
} else { // both
foreach ($bodyPreset as $s) { $sliceSel[$s] = true; }
$sliceSel['echr'] = true;
}
$sliceSel = function_exists('dbnV6NormalizeSliceSelection')
? dbnV6NormalizeSliceSelection($sliceSel)
: $sliceSel;
$ragDb = dbnToolsRagDb();
$sharedDocIds = [];
if (function_exists('dbnV6ResolveSelectedDocIds')) {
try {
$sharedDocIds = dbnV6ResolveSelectedDocIds($ragDb, $sliceSel);
} catch (Throwable $e) {
error_log('Korrespond refine slice resolve failed: ' . $e->getMessage());
$sharedDocIds = [];
}
}
// Build retrieval queries
$queries = $this->refineQueries($jurisdiction, $body, $classify);
$pool = [];
try {
if (!class_exists('ClientRagPipeline')) {
return ['sources' => [], 'applied_slices' => array_keys(array_filter($sliceSel))];
}
$rag = new ClientRagPipeline((int)$client['id'], 'http://10.0.1.10:4000', 60);
foreach ($queries as $q) {
try {
$chunks = $rag->searchAll($q, 5, null, [
'search_private' => false,
'search_shared' => true,
'package_ids' => [(int)$package['id']],
'shared_doc_ids' => $sharedDocIds,
'chunk_limit' => 5,
'search_method' => 'hybrid',
'reranker_enabled' => true,
'include_beta_website' => false,
'include_primary_website' => false,
]);
} catch (Throwable $e) {
error_log('Korrespond refine rag search failed: ' . $e->getMessage());
$chunks = [];
}
foreach ($chunks as $c) {
$pool[] = $c;
}
}
} catch (Throwable $e) {
error_log('Korrespond refine rag init failed: ' . $e->getMessage());
}
// Dedupe + number — bump source cap to 12 since refine cites more authorities
$seen = [];
$sources = [];
$n = 1;
foreach ($pool as $c) {
$cid = (string)($c['chunk_id'] ?? $c['id'] ?? '');
if ($cid === '' || isset($seen[$cid])) continue;
$seen[$cid] = true;
$text = (string)($c['chunk_text'] ?? $c['content'] ?? $c['text'] ?? '');
$title = (string)($c['document_title'] ?? $c['title'] ?? 'Lovkilde');
$section = (string)($c['section_title'] ?? $c['section'] ?? '');
$sources[] = [
'n' => $n++,
'chunk_id' => $cid,
'title' => $title,
'section' => $section,
'excerpt' => dbnToolsExcerpt($text, 500),
'full_text' => mb_substr($text, 0, 1800, 'UTF-8'),
'source_url'=> (string)($c['source_url'] ?? $c['url'] ?? ''),
'authority_type' => (string)($c['authority_type'] ?? ''),
];
if (count($sources) >= 12) break;
}
return [
'sources' => $sources,
'applied_slices' => array_keys(array_filter($sliceSel)),
];
}
/** Build 24 retrieval queries appropriate for the jurisdiction. */
private function refineQueries(string $jurisdiction, string $body, array $classify): array
{
$queries = [];
$summaryHint = mb_substr((string)($classify['summary'] ?? ''), 0, 220, 'UTF-8');
if ($jurisdiction === 'norwegian' || $jurisdiction === 'both') {
foreach (($classify['applicable_acts'] ?? []) as $act) {
if (strtolower($act) === 'emk') continue; // handled in ECHR branch
$queries[] = $this->queryForAct($act);
}
if (empty(array_filter($queries))) {
$queries[] = 'forvaltningsloven saksbehandling klage';
}
}
if ($jurisdiction === 'echr' || $jurisdiction === 'both') {
// Always pull EMK articles + general HUDOC vs Norway case law
$queries[] = 'EMK artikkel 8 familieliv rettferdig rettergang';
$queries[] = 'EMD HUDOC Norway family life article 8 case law violation';
// Body-specific high-value case anchors
if (in_array($body, ['barnevernet', 'bufdir', 'statsforvalter', 'tingrett'], true)) {
$queries[] = 'Strand Lobben Norway adoption family ties biological';
}
if ($summaryHint !== '') {
$queries[] = $summaryHint . ' EMK EMD case law';
}
}
$queries = array_values(array_unique(array_filter(array_map('trim', $queries))));
return array_slice($queries, 0, 5);
}
private function rewriteWithFormalCites(
string $originalDraft,
array $sources,
string $bodyLabel,
string $outputType,
string $tone,
string $goal,
string $jurisdiction
): string {
$toneLabel = $this->toneLabelNorsk($tone);
$jurLabel = $this->jurisdictionLabelNorsk($jurisdiction);
$outputBlock = $this->outputInstructionsNorsk($outputType, $bodyLabel);
$sourcesBlock = '';
if (!empty($sources)) {
$rows = [];
foreach ($sources as $s) {
$authority = $s['authority_type'] !== '' ? ' (' . $s['authority_type'] . ')' : '';
$rows[] = sprintf("[id=%d] %s%s%s\n%s",
(int)$s['n'],
$s['title'],
$s['section'] !== '' ? ' — ' . $s['section'] : '',
$authority,
$s['excerpt']
);
}
$sourcesBlock = "RETRIEVED LEGAL AUTHORITIES (you may ONLY cite these):\n" . implode("\n\n", $rows);
} else {
$sourcesBlock = 'RETRIEVED LEGAL AUTHORITIES: (none — keep the existing inline references without expansion)';
}
$goalLine = $goal !== '' ? ('Brukerens mål: ' . $goal) : 'Brukerens mål: utled fra utkastet.';
$prompt = <<<PROMPT
Du skal SKRIVE OM utkastet under slik at lovhenvisninger blir formelle og presise.
Skriv på norsk bokmål. Tone: {$toneLabel}. Rettsområde: {$jurLabel}.
{$goalLine}
{$outputBlock}
REGLER FOR FORMELLE HENVISNINGER (kritisk):
- Bevar utkastets struktur, fakta og overordnede budskap. Endre kun stilen på henvisningene.
- For norske lover: bruk formelle henvisninger som "jf. forvaltningsloven § 17", "i medhold av barnevernsloven § 6-3", "etter opplæringslova § 9 A-4".
- For EMK-artikler: "jf. EMK artikkel 8", "etter EMK art. 6 nr. 1".
- For EMD-dommer: bruk fullt navn + saksnummer + dato + paragraf, f.eks.
"jf. Strand Lobben m.fl. mot Norge, EMD-37283/13, dom 10. september 2019, §§ 207214"
- Hver gang du siterer en autoritet, MÅ du legge ved [CITE:N] der N er id-nummeret fra
listen under. Bare disse autoritetene er tillatt. IKKE finn på saksnummer, datoer eller §-numre.
- Hvis det opprinnelige utkastet siterte noe som ikke finnes i autoritetslisten, omformuler
uten den henvisningen.
PÅ SLUTTEN av utkastet — etter signaturen / sluttpåstanden — legg til en blokk:
Rettskilder
-----------
[1] Full formell referanse for autoritet 1
[2] Full formell referanse for autoritet 2
...
Listen skal kun inneholde autoriteter som faktisk er sitert i utkastet over.
{$sourcesBlock}
OPPRINNELIG UTKAST (skriv om dette):
{$originalDraft}
Returner kun det omskrevne utkastet med Rettskilder-blokken. Ingen forklaring eller preamble.
PROMPT;
try {
return $this->azure->withDeployment(self::DRAFT_DEPLOYMENT)->chatText([
['role' => 'system', 'content' => 'Du er en erfaren norsk juridisk skribent som skriver presise prosesskriv med formelle rettshenvisninger.'],
['role' => 'user', 'content' => $prompt],
], [
'temperature' => 0.2,
'max_tokens' => self::MAX_DRAFT_TOKENS,
'timeout' => 120,
]);
} catch (Throwable $e) {
error_log('Korrespond refine rewrite failed: ' . $e->getMessage());
return $originalDraft;
}
}
private function jurisdictionLabelNorsk(string $jurisdiction): string
{
return match ($jurisdiction) {
'norwegian' => 'norsk rett',
'echr' => 'EMK og EMD-praksis',
'both' => 'norsk rett + EMK/EMD',
default => 'norsk rett',
};
}
}