Files
dobetternorge-tools/includes/KorrespondAgent.php
T
daveadmin b78a49e060 Add Korrespond tool: drafts replies & new correspondence to NO authorities
Two-pass wizard for drafting to NAV, Barnevernet, schools, Bufdir, kommune,
Statsforvalter, Trygderetten. Pass 1 (gpt-4o-mini) classifies the situation
and emits clarify questions if facts are missing; user answers inline and
resubmits without losing context. Pass 2 retrieves law passages via hard-RAG
(ClientRagPipeline with body-specific slice presets), drafts in Norwegian
bokmål with gpt-4o using [CITE:N] tokens, self-checks that every citation
maps to a real corpus passage, then translates to the working language.
Result is side-by-side Norwegian + EN/PL/UK with copy/download per side
and an expandable Cited Law panel.

Credit deducts only when Pass 2 actually runs, not on a clarify cycle.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 11:27:13 +02:00

626 lines
27 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
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:
* 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
*
* Canonical draft is always Norwegian bokmål. User-language draft is a translation.
*/
final class DbnKorrespondAgent
{
private const CLASSIFY_DEPLOYMENT = 'gpt-4o-mini';
private const DRAFT_DEPLOYMENT = 'gpt-4o';
private const SELFCHECK_DEPLOYMENT = 'gpt-4o-mini';
private const MAX_CONTEXT_CHARS = 24000;
private const MAX_DRAFT_TOKENS = 2000;
/** Recipient-body presets → default slice toggles for law retrieval. */
private const BODY_PRESETS = [
'barnehage' => ['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<array{key:string,question:string}>,
* 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 = <<<PROMPT
You analyse a Norwegian correspondence drafting request. The user wants to write a {$modeLabel}
to {$bodyLabel}. Read the situation carefully and extract structured facts.
Return JSON only:
{
"summary": "One-paragraph factual summary in Norwegian bokmål, neutral tone.",
"parties": ["who the user is", "who the other party is", ...],
"decision_or_action": "What the recipient did, or what the user wants the recipient to do",
"deadlines": ["YYYY-MM-DD", "or relative deadline as plain text"],
"applicable_acts": ["forvaltningsloven", "barnevernsloven", "NAV-loven", "opplæringslova", "barnehageloven", "EMK"],
"jurisdiction": "kommune/fylke if known, else null",
"missing_facts": [{"key":"deadline","question":"Norwegian bokmål question to user"}],
"suggested_goal": "One-line concrete goal for this letter, in Norwegian"
}
Rules:
- applicable_acts: pick ONLY from this controlled list, based on the recipient and situation:
forvaltningsloven, barnevernsloven, NAV-loven, opplæringslova, barnehageloven, EMK, barneloven, sosialtjenesteloven
- missing_facts: include up to 4 items the drafter genuinely needs (date of decision, deadline, case
number, specific decision being appealed, etc.). Leave EMPTY if intake is complete.
- For "reply" mode if no case reference is supplied, missing_facts SHOULD include one for it.
- Write missing-fact questions in Norwegian bokmål, short and clear.
Intake:
{$context}
PROMPT;
$default = [
'summary' => '',
'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 = <<<PROMPT
Du skriver utkast til korrespondanse til {$bodyLabel}. Skriv på norsk bokmål.
Tone: {$toneLabel}.
{$goalLine}
{$outputBlock}
REGLER FOR LOVHENVISNINGER (kritisk):
- Du har KUN lov til å sitere §-er eller artikler som finnes i de hentede lovpassasjene under.
- Hver gang du siterer en §, MÅ du legge ved en token på formen [CITE:N] der N er id-nummeret
fra passasjelisten. Eksempel: "etter forvaltningsloven § 17 [CITE:2]".
- Hvis du ikke finner dekning i passasjene, skriv UTEN §-henvisning.
- IKKE finn på §-nummer eller artikkelnumre.
{$sourcesBlock}
KONTEKST (intake fra brukeren):
{$context}
Skriv kun utkastet. Ingen forklaring eller preamble. Bruk linjeskift som passer formatet.
PROMPT;
try {
return $this->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 = <<<PROMPT
Translate the Norwegian (bokmål) {$format} below into {$target}.
- Preserve all numeric bracket citations like [3] exactly where they appear.
- Preserve § references and any case/reference numbers verbatim.
- Keep paragraph structure and tone.
- Do not add commentary.
Norwegian source:
{$norwegianDraft}
PROMPT;
try {
return $this->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' => <<<EOT
FORMAT: E-post.
- Begynn med "Til: {$bodyLabel}" og "Emne: …".
- Skriv en kort innledning, faktiske forhold, juridisk grunnlag (med [CITE:N]-tokens), konkret anmodning og frist for tilbakemelding.
- Avslutt med "Med vennlig hilsen, [Navn]".
EOT,
'formal' => <<<EOT
FORMAT: Formelt brev (utskriftsklart).
- Topplinje: avsender (plassholder), mottaker ({$bodyLabel}), sted og dato.
- Emnelinje med saksnummer hvis kjent.
- Punkter: 1. Saksforhold, 2. Rettslig grunnlag (med [CITE:N]), 3. Anmodning/krav, 4. Frist, 5. Underskrift.
EOT,
'filing' => <<<EOT
FORMAT: Prosesskriv/klage til {$bodyLabel}.
- Topplinje: Til {$bodyLabel}, saksnummer, dato.
- Påstand (hva som kreves), saksforhold (faktiske forhold), anførsler (rettslig grunnlag med [CITE:N]), bevis, sluttpåstand.
- Bruk juridisk struktur, men hold språket klart.
EOT,
'call_prep' => <<<EOT
FORMAT: Forberedelse til telefonsamtale (interne notater til brukeren, IKKE et brev).
Bruk overskrifter:
- "Åpningslinje:" (én setning brukeren kan si først)
- "Nøkkelfakta å nevne:" (35 punkter)
- "Lover å vise til hvis presset:" (med [CITE:N])
- "Spørsmål å stille:" (35 punkter)
- "Hvis de nekter — neste skritt:" (eskaleringsvei: Statsforvalter / klage / advokat)
EOT,
default => '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',
};
}
}