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)}

` : ''} + +
+

Refine with formal citations (+1 credit)

+

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 Rettskilder block at the bottom.

+
+ + + + +
+
+
`; // 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', + }; + } }