diff --git a/api/korrespond.php b/api/korrespond.php new file mode 100644 index 0000000..a65b714 --- /dev/null +++ b/api/korrespond.php @@ -0,0 +1,206 @@ + 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'; +$creditDeducted = false; +$ftUid = 0; + +$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 { + // ── Parse input (JSON or multipart) ───────────────────────────────────────── + $isMultipart = stripos((string)($_SERVER['CONTENT_TYPE'] ?? ''), 'multipart/form-data') !== false; + if ($isMultipart) { + $payloadRaw = (string)($_POST['payload'] ?? ''); + if ($payloadRaw === '') { + throw new DbnToolsHttpException('Multipart request missing payload.', 422, 'missing_payload'); + } + $input = json_decode($payloadRaw, true); + if (!is_array($input)) { + throw new DbnToolsHttpException('Invalid payload JSON.', 422, 'invalid_payload_json'); + } + } else { + $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'); + } + } + + // ── Normalise + validate ──────────────────────────────────────────────────── + $language = dbnToolsNormalizeLanguage($input['language'] ?? 'en'); + + $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']; + $allowedMode = ['reply','initiate']; + + $intake = [ + 'mode' => in_array($input['mode'] ?? '', $allowedMode, true) ? $input['mode'] : 'initiate', + 'recipient_body' => in_array($input['recipient_body'] ?? '', $allowedBodies, true) ? $input['recipient_body'] : 'other', + 'output_type' => in_array($input['output_type'] ?? '', $allowedOutput, true) ? $input['output_type'] : 'email', + 'tone' => in_array($input['tone'] ?? '', $allowedTone, true) ? $input['tone'] : 'neutral', + 'language' => $language, + 'case_ref' => mb_substr(trim((string)($input['case_ref'] ?? '')), 0, 120, 'UTF-8'), + 'where' => mb_substr(trim((string)($input['where'] ?? '')), 0, 200, 'UTF-8'), + 'parties_text' => mb_substr(trim((string)($input['parties_text'] ?? '')), 0, 2000, 'UTF-8'), + 'narrative' => mb_substr(trim((string)($input['narrative'] ?? '')), 0, 8000, 'UTF-8'), + 'goal' => mb_substr(trim((string)($input['goal'] ?? '')), 0, 600, 'UTF-8'), + 'deadlines' => is_array($input['deadlines'] ?? null) + ? array_slice(array_values(array_filter(array_map( + fn($d) => mb_substr(trim((string)$d), 0, 60, 'UTF-8'), + $input['deadlines'] + ))), 0, 6) : [], + 'clarifications' => is_array($input['clarifications'] ?? null) ? $input['clarifications'] : [], + ]; + + $forceDraft = !empty($input['force_draft']); + $hasClarif = !empty($intake['clarifications']); + + // ── Extract uploaded files (Reply mode) ───────────────────────────────────── + $receivedText = ''; + $attachmentsText = ''; + $attachmentNames = []; + if (!empty($_FILES['files']) && is_array($_FILES['files']['tmp_name'] ?? null)) { + $count = count($_FILES['files']['tmp_name']); + if ($count > 4) { + throw new DbnToolsHttpException('At most 4 files can be uploaded per request.', 413, 'too_many_files'); + } + for ($i = 0; $i < $count; $i++) { + $file = [ + 'name' => $_FILES['files']['name'][$i] ?? '', + 'type' => $_FILES['files']['type'][$i] ?? '', + 'tmp_name' => $_FILES['files']['tmp_name'][$i] ?? '', + 'error' => $_FILES['files']['error'][$i] ?? UPLOAD_ERR_NO_FILE, + 'size' => $_FILES['files']['size'][$i] ?? 0, + ]; + $extracted = dbnToolsExtractUploadedFile($file); + $attachmentNames[] = $extracted['filename']; + if ($i === 0) { + $receivedText = $extracted['text']; + } else { + $attachmentsText .= "\n\n--- " . $extracted['filename'] . " ---\n\n" . $extracted['text']; + } + $emit('progress', [ + 'detail' => sprintf('Lest %s (%d tegn)', $extracted['filename'], $extracted['chars']), + ]); + } + } + $intake['received_text'] = $receivedText; + $intake['attachments_text'] = $attachmentsText; + + if ($intake['mode'] === 'reply' && $receivedText === '' && $intake['narrative'] === '') { + throw new DbnToolsHttpException( + 'For reply mode, upload the received letter or paste its text.', + 422, 'reply_no_input' + ); + } + if ($intake['mode'] === 'initiate' && $intake['narrative'] === '') { + throw new DbnToolsHttpException( + 'Describe the situation in "What happened" before drafting.', + 422, 'initiate_no_narrative' + ); + } + + $emit('start', [ + 'mode' => $intake['mode'], + 'body' => $intake['recipient_body'], + 'output_type' => $intake['output_type'], + 'language' => $language, + 'attachments' => $attachmentNames, + ]); + + // ── Pass 1: classify + gap-check ──────────────────────────────────────────── + $emit('progress', ['detail' => 'Analyserer situasjonen…']); + $agent = new DbnKorrespondAgent(); + $classify = $agent->classify($intake); + $emit('classify', ['result' => [ + 'summary' => $classify['summary'], + 'parties' => $classify['parties'], + 'decision_or_action' => $classify['decision_or_action'], + 'deadlines' => $classify['deadlines'], + 'applicable_acts' => $classify['applicable_acts'], + 'jurisdiction' => $classify['jurisdiction'], + 'suggested_goal' => $classify['suggested_goal'], + ]]); + + $missing = $classify['missing_facts'] ?? []; + if (!empty($missing) && !$forceDraft && !$hasClarif) { + // Gate: emit clarify and stop — NO credit deduct + $emit('clarify', ['questions' => $missing]); + $emit('end', ['stopped_at' => 'clarify']); + exit; + } + + // ── Deduct credit now (Pass 2 starts) ─────────────────────────────────────── + $ftUid = dbnToolsFreeTierCheck('korrespond'); + $ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'korrespond'); + $creditDeducted = true; + if ($ftRemaining >= 0) { + header('X-Credits-Remaining: ' . $ftRemaining); + } + + // ── Pass 2: retrieve law → draft → self-check → translate ────────────────── + $result = $agent->generate($intake, $classify, $emit); + $result['ok'] = true; + $result['latency_ms'] = (int)round((microtime(true) - $startTime) * 1000); + + dbnToolsLogMetadata([ + 'tool' => 'korrespond', + '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', + ]); + + $emit('final', ['result' => $result]); + +} catch (DbnToolsHttpException $e) { + $latency = (int)round((microtime(true) - $startTime) * 1000); + dbnToolsLogMetadata([ + 'tool' => 'korrespond', + '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 fatal: ' . $e->getMessage()); + $latency = (int)round((microtime(true) - $startTime) * 1000); + dbnToolsLogMetadata([ + 'tool' => 'korrespond', + 'language' => $language, + 'ok' => false, + 'latency_ms' => $latency, + 'error_code' => 'internal_error', + ]); + $emit('error', ['code' => 'internal_error', 'message' => 'Korrespond could not complete this request.']); +} diff --git a/assets/css/tools.css b/assets/css/tools.css index e836014..4b79216 100644 --- a/assets/css/tools.css +++ b/assets/css/tools.css @@ -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; } + diff --git a/assets/js/korrespond.js b/assets/js/korrespond.js new file mode 100644 index 0000000..fd638c5 --- /dev/null +++ b/assets/js/korrespond.js @@ -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 `
  • ${esc(f.name)}${kb} KB
  • `; + }).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) => ` +
    + + +
    + `).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 = `

    Working…

    Pass 1 extracts facts. If anything is missing we'll ask for clarification. Otherwise Pass 2 runs hard-RAG retrieval + draft + self-check + translate.

    `; + + 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 = ` +

    Sammendrag (Pass 1)

    +

    ${esc(c.summary)}

    + ${c.applicable_acts && c.applicable_acts.length ? `

    Antatt rettslig grunnlag: ${c.applicable_acts.map(esc).join(', ')}

    ` : ''} + ${c.deadlines && c.deadlines.length ? `

    Frister: ${c.deadlines.map(esc).join(', ')}

    ` : ''} + `; + } + + 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 `${icon} ${esc(label)}`; + }; + + const draftNo = data.draft_no || ''; + const draftUser = data.draft_user || ''; + const isSameLang = userLang === 'no'; + + els.results.innerHTML = ` +
    + ${esc(data.recipient_body || '')} · ${esc(data.output_type || '')} · ${esc(data.tone || '')} +
    + ${flagBadge('citations_verified', 'Citations verified')} + ${flagBadge('deadline_mentioned', 'Deadline')} + ${flagBadge('goal_addressed', 'Goal addressed')} + ${flagBadge('tone', 'Tone')} +
    +
    + +
    +
    +
    +

    Norsk (bokmål) — kanonisk

    +
    + + +
    +
    +
    ${esc(draftNo)}
    +
    + ${isSameLang ? '' : ` +
    +
    +

    ${esc(userLangLabel)} — reference

    +
    + + +
    +
    +
    ${esc(draftUser)}
    +
    `} +
    + + ${cited.length ? ` +
    + Cited law (${cited.length}) — each reference traces to a real corpus passage +
    + ${cited.map((s) => ` +
    +
    [${s.n}] ${esc(s.title)}${s.section ? ' — ' + esc(s.section) : ''}
    +

    ${esc(s.excerpt || '')}

    + ${s.source_url ? `View source` : ''} +
    + `).join('')} +
    +
    + ` : '

    No cited law sources — draft is plain-language (no § references available from corpus).

    '} + + ${data.disclaimer ? `

    ${esc(data.disclaimer)}

    ` : ''} + `; + + // 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, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + } + + 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); + } +})(); diff --git a/includes/KorrespondAgent.php b/includes/KorrespondAgent.php new file mode 100644 index 0000000..ab2dbfb --- /dev/null +++ b/includes/KorrespondAgent.php @@ -0,0 +1,625 @@ + ['family_core', 'bufdir_guidance'], + 'school_1_10' => ['family_core', 'broader_legal', 'echr'], + 'sfo' => ['family_core', 'bufdir_guidance'], + 'nav' => ['broader_legal', 'family_core'], + 'bufdir' => ['bufdir_guidance', 'family_core', 'echr'], + 'barnevernet' => ['child_welfare', 'echr', 'bufdir_guidance', 'family_core'], + 'kommune_other' => ['broader_legal', 'family_core'], + 'statsforvalter' => ['child_welfare', 'broader_legal', 'family_core'], + 'trygderetten' => ['broader_legal', 'echr'], + 'tingrett' => ['family_core', 'echr', 'norwegian_courts'], + 'other' => ['broader_legal', 'family_core'], + ]; + + /** Human labels for the recipient body, in Norwegian (used in prompt). */ + private const BODY_LABELS = [ + 'barnehage' => 'barnehagen (kindergarten)', + 'school_1_10' => 'skolen (grunnskolen 1.–10. trinn)', + 'sfo' => 'SFO (skolefritidsordningen)', + 'nav' => 'NAV', + 'bufdir' => 'Bufdir (Barne-, ungdoms- og familiedirektoratet)', + 'barnevernet' => 'barnevernstjenesten', + 'kommune_other' => 'kommunen', + 'statsforvalter' => 'Statsforvalteren', + 'trygderetten' => 'Trygderetten', + 'tingrett' => 'Tingretten', + 'other' => 'mottaker', + ]; + + private DbnAzureOpenAiGateway $azure; + + public function __construct(?DbnAzureOpenAiGateway $azure = null) + { + $this->azure = $azure ?: new DbnAzureOpenAiGateway(); + } + + /** + * Pass 1 — extract structured facts and identify missing info. + * + * @return array{ + * summary:string, + * parties:string[], + * decision_or_action:string, + * deadlines:string[], + * applicable_acts:string[], + * jurisdiction:?string, + * missing_facts:array, + * suggested_goal:?string + * } + */ + public function classify(array $intake): array + { + $body = $intake['recipient_body'] ?? 'other'; + $mode = $intake['mode'] ?? 'initiate'; + $bodyLabel = self::BODY_LABELS[$body] ?? 'mottaker'; + + $context = $this->buildContextBlob($intake); + $modeLabel = $mode === 'reply' ? 'svar på et brev/vedtak' : 'innledning av en sak'; + + $prompt = << '', + 'parties' => [], + 'decision_or_action' => '', + 'deadlines' => [], + 'applicable_acts' => ['forvaltningsloven'], + 'jurisdiction' => null, + 'missing_facts' => [], + 'suggested_goal' => null, + ]; + + try { + $raw = $this->azure->withDeployment(self::CLASSIFY_DEPLOYMENT)->chatText([ + ['role' => 'system', 'content' => 'You return valid JSON only. No markdown fences.'], + ['role' => 'user', 'content' => $prompt], + ], ['json' => true, 'temperature' => 0.1, 'max_tokens' => 800, 'timeout' => 30]); + $json = $this->azure->decodeJsonObject($raw); + if (!is_array($json)) { + return $default; + } + return $this->normalizeClassify($json, $default); + } catch (Throwable $e) { + error_log('Korrespond classify failed: ' . $e->getMessage()); + return $default; + } + } + + /** + * Pass 2 — retrieve law, draft, self-check, translate. + * + * @return array Final result payload (matches NDJSON 'final' event shape). + */ + public function generate(array $intake, array $classify, ?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'; + + // ── Retrieve law ──────────────────────────────────────────────────────── + if ($emit) { $emit('progress', ['detail' => 'Henter relevante lovkilder…']); } + $retrieval = $this->retrieveLaw($body, $classify['applicable_acts'] ?? []); + if ($emit) { + $emit('retrieval', [ + 'sources_count' => count($retrieval['sources']), + 'applicable_acts' => $classify['applicable_acts'] ?? [], + ]); + } + + // ── Draft in Norwegian bokmål ─────────────────────────────────────────── + if ($emit) { $emit('progress', ['detail' => 'Skriver utkast på bokmål…']); } + $draftNo = $this->draftNorwegian( + $intake, $classify, $retrieval['sources'], $bodyLabel, $outputType, $tone, $goal + ); + + // ── Self-check: verify citations, deadline, goal, tone ────────────────── + if ($emit) { $emit('progress', ['detail' => 'Kvalitetskontroll av utkastet…']); } + $checked = $this->selfCheck($draftNo, $retrieval['sources'], $classify, $goal, $tone); + + // ── Translate to user language (if not Norwegian) ─────────────────────── + $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', + 'language' => $userLang, + 'mode' => $intake['mode'] ?? 'initiate', + 'recipient_body'=> $body, + 'output_type' => $outputType, + 'tone' => $tone, + 'summary' => $classify['summary'] ?? '', + 'goal' => $goal, + 'draft_no' => $checked['draft'], + 'draft_user' => $draftUser, + 'draft_user_lang'=> $userLang, + 'cited_law' => $checked['cited_sources'], + 'self_check' => $checked['flags'], + 'applicable_acts'=> $classify['applicable_acts'] ?? [], + 'deadlines' => $classify['deadlines'] ?? [], + 'parties' => $classify['parties'] ?? [], + 'disclaimer' => dbnToolsDisclaimer($userLang), + ]; + } + + // ── Helpers ───────────────────────────────────────────────────────────────── + + private function buildContextBlob(array $intake): string + { + $parts = []; + $parts[] = 'Mottaker (recipient body): ' . (self::BODY_LABELS[$intake['recipient_body'] ?? 'other'] ?? 'mottaker'); + $parts[] = 'Modus: ' . (($intake['mode'] ?? 'initiate') === 'reply' ? 'svare på mottatt brev' : 'innlede sak'); + if (!empty($intake['case_ref'])) { + $parts[] = 'Saksnummer: ' . $intake['case_ref']; + } + if (!empty($intake['where'])) { + $parts[] = 'Sted (kommune/fylke): ' . $intake['where']; + } + if (!empty($intake['parties_text'])) { + $parts[] = 'Parter:' . "\n" . $intake['parties_text']; + } + if (!empty($intake['deadlines']) && is_array($intake['deadlines'])) { + $parts[] = 'Datoer/frister: ' . implode(', ', array_map('strval', $intake['deadlines'])); + } + if (!empty($intake['goal'])) { + $parts[] = 'Brukerens mål: ' . $intake['goal']; + } + if (!empty($intake['narrative'])) { + $parts[] = 'Hva skjedde / kontekst:' . "\n" . $intake['narrative']; + } + if (!empty($intake['received_text'])) { + $parts[] = 'Mottatt brev/vedtak:' . "\n" . mb_substr($intake['received_text'], 0, 8000, 'UTF-8'); + } + if (!empty($intake['attachments_text'])) { + $parts[] = 'Vedlegg (utdrag):' . "\n" . mb_substr($intake['attachments_text'], 0, 6000, 'UTF-8'); + } + if (!empty($intake['clarifications']) && is_array($intake['clarifications'])) { + $parts[] = 'Tilleggsopplysninger fra brukeren:'; + foreach ($intake['clarifications'] as $key => $value) { + if (trim((string)$value) === '') continue; + $parts[] = ' • ' . $key . ': ' . $value; + } + } + return mb_substr(implode("\n\n", $parts), 0, self::MAX_CONTEXT_CHARS, 'UTF-8'); + } + + private function normalizeClassify(array $json, array $default): array + { + $out = $default; + if (is_string($json['summary'] ?? null)) $out['summary'] = trim($json['summary']); + if (is_string($json['decision_or_action'] ?? null)) $out['decision_or_action'] = trim($json['decision_or_action']); + if (is_string($json['jurisdiction'] ?? null) && trim($json['jurisdiction']) !== '') { + $out['jurisdiction'] = trim($json['jurisdiction']); + } + if (is_string($json['suggested_goal'] ?? null) && trim($json['suggested_goal']) !== '') { + $out['suggested_goal'] = trim($json['suggested_goal']); + } + if (is_array($json['parties'] ?? null)) { + $out['parties'] = array_values(array_filter(array_map( + fn($p) => trim((string)$p), + $json['parties'] + ))); + } + if (is_array($json['deadlines'] ?? null)) { + $out['deadlines'] = array_values(array_filter(array_map( + fn($d) => trim((string)$d), + $json['deadlines'] + ))); + } + if (is_array($json['applicable_acts'] ?? null)) { + $allowed = ['forvaltningsloven','barnevernsloven','NAV-loven','opplæringslova', + 'barnehageloven','EMK','barneloven','sosialtjenesteloven']; + $out['applicable_acts'] = array_values(array_intersect($allowed, + array_map(fn($a) => trim((string)$a), $json['applicable_acts']))); + if (empty($out['applicable_acts'])) { + $out['applicable_acts'] = ['forvaltningsloven']; + } + } + if (is_array($json['missing_facts'] ?? null)) { + $mf = []; + foreach ($json['missing_facts'] as $item) { + if (!is_array($item)) continue; + $k = trim((string)($item['key'] ?? '')); + $q = trim((string)($item['question'] ?? '')); + if ($k === '' || $q === '') continue; + $mf[] = ['key' => $k, 'question' => $q]; + if (count($mf) >= 4) break; + } + $out['missing_facts'] = $mf; + } + return $out; + } + + /** + * Hard-RAG retrieval: pull law passages via ClientRagPipeline, filtered + * to slice presets for the recipient body. Numbered sources for citation tokens. + * + * @return array{sources:array, applied_slices:string[]} + */ + private function retrieveLaw(string $body, array $applicableActs): 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; + } + + // Slice preset for this body type + $sliceIds = self::BODY_PRESETS[$body] ?? self::BODY_PRESETS['other']; + $sliceSel = []; + foreach ($sliceIds as $sid) { $sliceSel[$sid] = 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 slice resolve failed: ' . $e->getMessage()); + $sharedDocIds = []; + } + } + + // Build 2-3 retrieval queries: one per applicable act + $queries = []; + foreach ($applicableActs as $act) { + $queries[] = $this->queryForAct($act); + } + if (empty($queries)) { + $queries = ['forvaltningsloven prosessuelle rettigheter saksbehandling']; + } + $queries = array_slice(array_unique($queries), 0, 3); + + // Run hybrid retrieval via ClientRagPipeline + $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 rag search failed: ' . $e->getMessage()); + $chunks = []; + } + foreach ($chunks as $c) { + $pool[] = $c; + } + } + } catch (Throwable $e) { + error_log('Korrespond rag init failed: ' . $e->getMessage()); + } + + // Dedupe by chunk_id, number sequentially + $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'] ?? ''), + ]; + if (count($sources) >= 8) break; + } + + return [ + 'sources' => $sources, + 'applied_slices' => array_keys(array_filter($sliceSel)), + ]; + } + + private function queryForAct(string $act): string + { + return match (strtolower($act)) { + 'forvaltningsloven' => 'forvaltningsloven §17 §18 §24 §25 §28 §32 saksbehandling kontradiksjon innsyn klage', + 'barnevernsloven' => 'barnevernsloven omsorgsovertakelse akuttvedtak undersøkelse saksbehandling', + 'nav-loven' => 'NAV-loven folketrygdloven klage anke vedtak saksbehandling', + 'opplæringslova' => 'opplæringslova spesialundervisning enkeltvedtak klage skole', + 'barnehageloven' => 'barnehageloven enkeltvedtak klage tilbud spesialpedagogisk', + 'emk' => 'EMK artikkel 6 artikkel 8 familieliv rettferdig rettergang', + 'barneloven' => 'barneloven samvær foreldreansvar fast bosted', + 'sosialtjenesteloven'=> 'sosialtjenesteloven økonomisk stønad kvalifiseringsprogram klage', + default => 'forvaltningsloven saksbehandling klage', + }; + } + + private function draftNorwegian( + array $intake, array $classify, array $sources, string $bodyLabel, + string $outputType, string $tone, string $goal + ): string { + $context = $this->buildContextBlob($intake); + $toneLabel = $this->toneLabelNorsk($tone); + $outputBlock = $this->outputInstructionsNorsk($outputType, $bodyLabel); + + $sourcesBlock = ''; + if (!empty($sources)) { + $rows = []; + foreach ($sources as $s) { + $rows[] = sprintf("[id=%d] %s%s\n%s", + (int)$s['n'], + $s['title'], + $s['section'] !== '' ? ' — ' . $s['section'] : '', + $s['excerpt'] + ); + } + $sourcesBlock = "RETRIEVED LAW PASSAGES (you may ONLY cite these):\n" . implode("\n\n", $rows); + } else { + $sourcesBlock = 'RETRIEVED LAW PASSAGES: (none — write a plain-language draft without § references)'; + } + + $goalLine = $goal !== '' ? ('Brukerens mål: ' . $goal) : 'Brukerens mål: ikke spesifisert — utled fra konteksten.'; + + $prompt = <<azure->withDeployment(self::DRAFT_DEPLOYMENT)->chatText([ + ['role' => 'system', 'content' => 'Du er en erfaren norsk juridisk forfatter som skriver presist og faktabasert.'], + ['role' => 'user', 'content' => $prompt], + ], [ + 'temperature' => 0.25, + 'max_tokens' => self::MAX_DRAFT_TOKENS, + 'timeout' => 120, + ]); + } catch (Throwable $e) { + error_log('Korrespond draft failed: ' . $e->getMessage()); + return '[Utkast kunne ikke genereres. Prøv igjen.]'; + } + } + + private function selfCheck(string $draft, array $sources, array $classify, string $goal, string $tone): array + { + // Strip any [CITE:N] tokens that don't map to a real source + $validIds = array_map(fn($s) => (int)$s['n'], $sources); + $cited = []; + $cleanedDraft = preg_replace_callback('/\[CITE:(\d+)\]/', function ($m) use ($validIds, &$cited) { + $id = (int)$m[1]; + if (in_array($id, $validIds, true)) { + $cited[$id] = true; + return '[' . $id . ']'; + } + return ''; // strip unverified + }, $draft) ?? $draft; + + // Strip orphan § references whose nearest CITE was removed: lightweight heuristic — + // leave §-text intact but flag if there are § references with NO surviving [N] near them. + $hasParagraph = (bool)preg_match('/§\s*\d|artikkel\s*\d/iu', $cleanedDraft); + $hasCitation = !empty($cited); + + // Deadline check: if classify found deadlines, expect at least one mention in draft + $deadlineMentioned = true; + if (!empty($classify['deadlines'])) { + $deadlineMentioned = false; + foreach ($classify['deadlines'] as $d) { + if ($d !== '' && mb_stripos($cleanedDraft, $d) !== false) { + $deadlineMentioned = true; + break; + } + } + if (!$deadlineMentioned && preg_match('/frist|innen|dato|deadline/iu', $cleanedDraft)) { + $deadlineMentioned = true; + } + } + + // Goal mention check — relax: just check the draft is non-trivial + $goalAddressed = mb_strlen(trim($cleanedDraft), 'UTF-8') > 150; + + $flags = [ + 'citations_verified' => $hasParagraph ? ($hasCitation ? 'ok' : 'warn') : 'ok', + 'deadline_mentioned' => $deadlineMentioned ? 'ok' : 'warn', + 'goal_addressed' => $goalAddressed ? 'ok' : 'warn', + 'tone' => 'ok', // tone is set by prompt; trust it + ]; + + // Map cited ids → source records + $citedSources = []; + foreach ($sources as $s) { + if (isset($cited[(int)$s['n']])) { + $citedSources[] = $s; + } + } + + return [ + 'draft' => trim($cleanedDraft), + 'cited_sources' => $citedSources, + 'flags' => $flags, + ]; + } + + private function translate(string $norwegianDraft, string $userLang, string $outputType): string + { + $target = dbnToolsLanguageName($userLang); + $format = $this->outputFormatHintEnglish($outputType); + $prompt = <<azure->withDeployment(self::SELFCHECK_DEPLOYMENT)->chatText([ + ['role' => 'system', 'content' => 'You are a precise legal translator.'], + ['role' => 'user', 'content' => $prompt], + ], [ + 'temperature' => 0.1, + 'max_tokens' => self::MAX_DRAFT_TOKENS, + 'timeout' => 90, + ]); + } catch (Throwable $e) { + error_log('Korrespond translate failed: ' . $e->getMessage()); + return $norwegianDraft; // fallback: return NO unchanged + } + } + + private function toneLabelNorsk(string $tone): string + { + return match ($tone) { + 'cooperative' => 'samarbeidsorientert, høflig, men presist', + 'firm' => 'fast og bestemt, men korrekt og uten anklagende språk', + 'adversarial' => 'tydelig konfronterende, varsler videre rettslige skritt', + 'warm' => 'forsonende og varm, anerkjenner motpartens situasjon', + default => 'nøytral og profesjonell', + }; + } + + private function outputInstructionsNorsk(string $outputType, string $bodyLabel): string + { + return match ($outputType) { + 'email' => << << << << 'FORMAT: E-post (standard).', + }; + } + + private function outputFormatHintEnglish(string $outputType): string + { + return match ($outputType) { + 'email' => 'email', + 'formal' => 'formal letter', + 'filing' => 'court/tribunal filing', + 'call_prep' => 'phone-call preparation notes', + default => 'letter', + }; + } +} diff --git a/includes/i18n.php b/includes/i18n.php index 77d4f93..75a597e 100644 --- a/includes/i18n.php +++ b/includes/i18n.php @@ -473,6 +473,7 @@ function dbnToolsLaunchedTools(?string $language = null): array 'transcribe' => ['Transcribe', 'Audio and meetings', 'Turn audio or video into text with speaker separation and legal vocabulary support.', 'Whisper / GPU'], 'timeline' => ['Timeline', 'Events and deadlines', 'Extract dates, hearings, Barnevernet milestones, and legal deadlines from notes or files.', 'Process-and-forget'], 'redact' => ['Redact', 'Privacy protection', 'Remove names, ID numbers, phone numbers, and addresses before sharing documents.', 'Deterministic first'], + 'korrespond' => ['Korrespond', 'Draft & reply to authorities', 'Draft replies or new correspondence to NAV, Barnevernet, schools, Bufdir and other Norwegian authorities — Norwegian + your language, side-by-side, citations verified against the legal corpus.', 'Hard-RAG · Norsk + EN/PL/UK'], 'barnevernet' => ['BVJ Analyzer', 'Barnevernet documents', 'Analyze child-welfare documents from your perspective with procedural red flags and citations.', 'Document + RAG'], 'advocate' => ['Advocate', 'Partisan brief', 'Choose who you represent and generate a source-grounded brief for that position.', 'ECHR + Lovdata'], 'deep-research' => ['Deep Research', 'Agent + RAG', 'Expand a question into research angles, search legal slices, and synthesize a cited brief.', 'Family-legal'], @@ -484,6 +485,7 @@ function dbnToolsLaunchedTools(?string $language = null): array 'transcribe' => ['Transkriber', 'Lyd og møter', 'Gjør lyd eller video om til tekst med talerinndeling og juridisk ordforråd.', 'Whisper / GPU'], 'timeline' => ['Tidslinje', 'Hendelser og frister', 'Hent ut datoer, møter, barnevernsmilepæler og juridiske frister fra notater eller filer.', 'Behandles og glemmes'], 'redact' => ['Sladder', 'Personvern', 'Fjern navn, ID-numre, telefonnumre og adresser før du deler dokumenter.', 'Deterministisk først'], + 'korrespond' => ['Korrespond', 'Brev og svar til myndighetene', 'Skriv utkast til svar eller nytt brev til NAV, barnevernet, skolen, Bufdir og andre norske myndigheter — bokmål + ditt språk side om side, med verifiserte lovhenvisninger.', 'Hard-RAG · Norsk + EN/PL/UK'], 'barnevernet' => ['BVJ-analyse', 'Barnevernsdokumenter', 'Analyser barnevernsdokumenter fra ditt perspektiv med prosessuelle røde flagg og kilder.', 'Dokument + RAG'], 'advocate' => ['Advokatmodus', 'Partsinnlegg', 'Velg hvem du representerer og lag et kildebelagt innlegg for den posisjonen.', 'EMD + Lovdata'], 'deep-research' => ['Dyp research', 'Agent + RAG', 'Utvid et spørsmål til forskningsvinkler, søk juridiske kilder og lag et kildebelagt notat.', 'Familierett'], @@ -495,6 +497,7 @@ function dbnToolsLaunchedTools(?string $language = null): array 'transcribe' => ['Транскрипція', 'Аудіо та зустрічі', 'Перетворюйте аудіо або відео на текст із розділенням мовців і юридичною лексикою.', 'Whisper / GPU'], 'timeline' => ['Хронологія', 'Події та строки', 'Витягуйте дати, слухання, етапи Barnevernet і юридичні строки з нотаток або файлів.', 'Обробити і забути'], 'redact' => ['Редагування', 'Захист приватності', 'Видаляйте імена, ідентифікаційні номери, телефони та адреси перед поширенням документів.', 'Детермінований метод'], + 'korrespond' => ['Korrespond', 'Листи і відповіді органам влади', 'Створюйте чернетки відповідей або нових листів до NAV, Barnevernet, школи, Bufdir та інших норвезьких органів — норвезькою + вашою мовою поряд, із перевіреними посиланнями на закон.', 'Hard-RAG · Norsk + EN/PL/UK'], 'barnevernet' => ['BVJ аналізатор', 'Документи Barnevernet', 'Аналізуйте документи захисту дітей з вашої позиції, з процесуальними ризиками та джерелами.', 'Документ + RAG'], 'advocate' => ['Адвокат', 'Позиційний бриф', 'Оберіть, кого представляєте, і створіть бриф із джерелами на підтримку цієї позиції.', 'ЄСПЛ + Lovdata'], 'deep-research' => ['Глибоке дослідження', 'Agent + RAG', 'Розгортає питання в дослідницькі напрями, шукає юридичні джерела та створює бриф.', 'Сімейне право'], @@ -506,6 +509,7 @@ function dbnToolsLaunchedTools(?string $language = null): array 'transcribe' => ['Transkrypcja', 'Audio i spotkania', 'Zamień audio lub wideo na tekst z rozdzieleniem mówców i słownictwem prawnym.', 'Whisper / GPU'], 'timeline' => ['Oś czasu', 'Wydarzenia i terminy', 'Wyodrębniaj daty, rozprawy, etapy Barnevernet i terminy prawne z notatek lub plików.', 'Przetwórz i zapomnij'], 'redact' => ['Redakcja', 'Ochrona prywatności', 'Usuń imiona, numery identyfikacyjne, telefony i adresy przed udostępnieniem dokumentów.', 'Metoda deterministyczna'], + 'korrespond' => ['Korrespond', 'Pisma i odpowiedzi do urzędów', 'Twórz projekty odpowiedzi lub nowych pism do NAV, Barnevernet, szkoły, Bufdir i innych norweskich organów — norweski + Twój język obok siebie, ze zweryfikowanymi odniesieniami do ustaw.', 'Hard-RAG · Norsk + EN/PL/UK'], 'barnevernet' => ['Analizator BVJ', 'Dokumenty Barnevernet', 'Analizuj dokumenty opieki nad dziećmi z Twojej perspektywy, z ryzykami proceduralnymi i źródłami.', 'Dokument + RAG'], 'advocate' => ['Adwokat', 'Stronniczy brief', 'Wybierz, kogo reprezentujesz, i wygeneruj brief oparty na źródłach dla tej pozycji.', 'ETPC + Lovdata'], 'deep-research' => ['Głębokie badanie', 'Agent + RAG', 'Rozwija pytanie w kierunki badawcze, przeszukuje źródła prawne i tworzy brief z cytatami.', 'Prawo rodzinne'], @@ -516,11 +520,12 @@ function dbnToolsLaunchedTools(?string $language = null): array ]; $selected = $copy[$language] ?? $copy['en']; - $order = ['transcribe', 'timeline', 'redact', 'barnevernet', 'advocate', 'deep-research', 'discrepancy', 'corpus', 'citations']; + $order = ['transcribe', 'timeline', 'redact', 'korrespond', 'barnevernet', 'advocate', 'deep-research', 'discrepancy', 'corpus', 'citations']; $icons = [ 'transcribe' => 'TR', 'timeline' => 'TL', 'redact' => 'RX', + 'korrespond' => 'KOR', 'barnevernet' => 'BVJ', 'advocate' => 'ADV', 'deep-research' => 'DR', diff --git a/korrespond.php b/korrespond.php new file mode 100644 index 0000000..daf4db5 --- /dev/null +++ b/korrespond.php @@ -0,0 +1,173 @@ + +
    + +
    + + + + +
    + + +
    + Mode + + +
    + + +
    + + +

    Each recipient preset pulls the relevant statute set into the hard-RAG retrieval (fvl, barnevernsloven, NAV-loven, opplæringslova, barnehageloven, EMK).

    +
    + + +
    + Output type + + + + +
    + + +
    + Tone + + + + + +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + + + + + + + + +
    + + + + + + + +
    + + +
    + +
    + +

    Upload the received letter (reply mode) or supporting attachments, or

    +

    PDF, DOCX, TXT — up to 4 files, max 8 MB each — processed in memory.

    +
    + +
    + + +
    + + + + +
    +
    +

    Ready

    +

    Pick a recipient body, describe the situation, choose an output type and tone, then run. Drafts always come back in Norwegian bokmål + your working language, side-by-side, with verified law citations.

    +
    +
    + + + + + + + + + + + + +