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 @@
+
+
+
+
+
+
Before we draft, clarify:
+
Answer what you can, then click Continue draft. Or click Draft anyway to proceed with what we have.
+
+
+
+
+
+
+
+
+
+
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.