diff --git a/api/korrespond-refine.php b/api/korrespond-refine.php
new file mode 100644
index 0000000..4a3cdc1
--- /dev/null
+++ b/api/korrespond-refine.php
@@ -0,0 +1,120 @@
+ 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.']);
+}
diff --git a/assets/css/tools.css b/assets/css/tools.css
index 4b79216..f2aacd3 100644
--- a/assets/css/tools.css
+++ b/assets/css/tools.css
@@ -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);
+}
+
diff --git a/assets/js/korrespond.js b/assets/js/korrespond.js
index fd638c5..0d99965 100644
--- a/assets/js/korrespond.js
+++ b/assets/js/korrespond.js
@@ -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 @@
` : '
No cited law sources — draft is plain-language (no § references available from corpus).
'}
${data.disclaimer ? `${esc(data.disclaimer)}
` : ''}
+
+
`;
// 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 `${icon} ${esc(label)}`;
+ };
+
+ slot.innerHTML = `
+
+
+
Refined · ${esc(jurLabel)}
+
+ ${flagBadge('citations_verified', 'Citations verified')}
+ ${flagBadge('deadline_mentioned', 'Deadline')}
+ ${flagBadge('goal_addressed', 'Goal addressed')}
+
+
+
+
+
+
+
Norsk (bokmål) — refined
+
+
+
+
+
+
${esc(draftNo)}
+
+ ${isSameLang ? '' : `
+
+
+
${esc(userLangLabel)} — refined
+
+
+
+
+
+
${esc(draftUser)}
+
`}
+
+
+ ${cited.length ? `
+
+ Cited authorities (${cited.length}) — ${esc(jurLabel)}
+
+ ${cited.map((s) => `
+
+
[${s.n}] ${esc(s.title)}${s.section ? ' — ' + esc(s.section) : ''}${s.authority_type ? ' (' + esc(s.authority_type) + ')' : ''}
+
${esc(s.excerpt || '')}
+ ${s.source_url ? `
View source` : ''}
+
+ `).join('')}
+
+ ` : ''}
+
+ `;
+
+ 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 ───────────────────────────────────────────────────────────────────
diff --git a/includes/KorrespondAgent.php b/includes/KorrespondAgent.php
index ab2dbfb..08ac036 100644
--- a/includes/KorrespondAgent.php
+++ b/includes/KorrespondAgent.php
@@ -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 2–4 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 = <<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',
+ };
+ }
}