Files
dobetternorge-tools/includes/KorrespondAgent.php
T
daveadmin 3f7d4eef13 feat(tools): add letter length + summary depth controls; harden korrespond §-discipline
- Summarize: new depth param (brief/standard/detailed) with depth-aware prompt
  instructions and coverage mandate; wired through API + JS
- Korrespond: new letter length param (concise/standard/detailed) injected as
  Lengde: instruction in draft pass; wired through API + JS
- Korrespond draft prompt: add §-discipline rule (cite only directly relevant §§)
  plus Opphevet guard (aligned with dobetterlegal-tools)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 13:44:02 +02:00

1093 lines
49 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';
require_once __DIR__ . '/DbnGatewayFactory.php';
/**
* Korrespond — drafts replies or new correspondence to Norwegian authorities
* (NAV, Barnevernet, schools, Bufdir, kommune, Statsforvalter, Trygderetten).
*
* Three passes (the third is opt-in):
* Pass 1 — classify(): fact-extract + gap-check (Azure gpt-4o-mini)
* returns missing_facts[]; caller may emit clarify and stop
* Pass 2 — generate(): retrieve law → draft (Azure gpt-4o) → self-check → translate
* Pass 3 — refine(): retrieve law scoped to a user-picked jurisdiction
* (norwegian|echr|both), rewrite the existing draft with formally-styled
* citations, append a "Rettskilder" / "Legal authorities" block.
*
* Canonical draft is always Norwegian bokmål. User-language draft is a translation.
*/
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;
private function resolveDeployment(string $azureDeployment, bool $heavy = false): string
{
if (!($this->azure instanceof DbnBedrockGateway)) {
return $azureDeployment;
}
return $heavy ? DbnBedrockModelRouter::LITELLM_SONNET : DbnBedrockModelRouter::LITELLM_HAIKU;
}
/** 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|DbnBedrockGateway $azure;
public function __construct(DbnAzureOpenAiGateway|DbnBedrockGateway|null $azure = null)
{
$this->azure = $azure ?: DbnGatewayFactory::makeForTool('korrespond');
}
/** Localized chrome/progress strings, keyed by user UI language. */
public static function L(string $key, string $lang, array $vars = []): string
{
$strings = [
'analyzing' => [
'en' => 'Analyzing the situation…',
'no' => 'Analyserer situasjonen…',
'pl' => 'Analiza sytuacji…',
'uk' => 'Аналізую ситуацію…',
],
'fetching_law' => [
'en' => 'Fetching relevant legal sources…',
'no' => 'Henter relevante lovkilder…',
'pl' => 'Pobieranie odpowiednich źródeł prawnych…',
'uk' => 'Завантажую відповідні юридичні джерела…',
],
'drafting_no' => [
'en' => 'Writing draft in Norwegian (bokmål)…',
'no' => 'Skriver utkast på bokmål…',
'pl' => 'Pisanie projektu po norwesku (bokmål)…',
'uk' => 'Пишу чернетку норвезькою (bokmål)…',
],
'quality_check' => [
'en' => 'Quality-checking the draft…',
'no' => 'Kvalitetskontroll av utkastet…',
'pl' => 'Sprawdzanie jakości projektu…',
'uk' => 'Перевірка якості чернетки…',
],
'legal_check' => [
'en' => 'Verifying legal authorities…',
'no' => 'Verifiserer rettskilder…',
'pl' => 'Weryfikacja źródeł prawnych…',
'uk' => 'Перевірка правових джерел…',
],
'translating_to' => [
'en' => 'Translating to {lang}…',
'no' => 'Oversetter til {lang}…',
'pl' => 'Tłumaczenie na {lang}…',
'uk' => 'Переклад на {lang}…',
],
'fetching_for_jur' => [
'en' => 'Fetching authorities for {jur}…',
'no' => 'Henter rettskilder for {jur}…',
'pl' => 'Pobieranie autorytetów dla {jur}…',
'uk' => 'Завантажую джерела для {jur}…',
],
'rewriting_formal' => [
'en' => 'Rewriting with formal citations…',
'no' => 'Skriver om med formelle henvisninger…',
'pl' => 'Przepisywanie z formalnymi cytatami…',
'uk' => 'Переписую з формальними цитатами…',
],
'check_and_authorities' => [
'en' => 'Quality-check and Legal authorities block…',
'no' => 'Kvalitetskontroll og Rettskilder…',
'pl' => 'Kontrola jakości i blok źródeł prawnych…',
'uk' => 'Перевірка якості і блок джерел права…',
],
'file_read' => [
'en' => 'Read {name} ({chars} chars)',
'no' => 'Lest {name} ({chars} tegn)',
'pl' => 'Przeczytano {name} ({chars} znaków)',
'uk' => 'Прочитано {name} ({chars} символів)',
],
];
$lang = in_array($lang, ['en', 'no', 'pl', 'uk'], true) ? $lang : 'en';
$tmpl = $strings[$key][$lang] ?? $strings[$key]['en'] ?? $key;
foreach ($vars as $k => $v) {
$tmpl = str_replace('{' . $k . '}', (string)$v, $tmpl);
}
return $tmpl;
}
/** Localized jurisdiction labels for chrome (status messages). */
public static function jurisdictionChromeLabel(string $jurisdiction, string $lang): string
{
$map = [
'norwegian' => ['en' => 'Norwegian law', 'no' => 'norsk rett', 'pl' => 'prawo norweskie', 'uk' => 'норвезьке право'],
'echr' => ['en' => 'ECHR + HUDOC', 'no' => 'EMK og EMD-praksis', 'pl' => 'EKPC + HUDOC', 'uk' => 'ЄКПЛ + HUDOC'],
'both' => ['en' => 'NO + ECHR', 'no' => 'norsk rett + EMK/EMD', 'pl' => 'NO + EKPC', 'uk' => 'NO + ЄКПЛ'],
];
$lang = in_array($lang, ['en', 'no', 'pl', 'uk'], true) ? $lang : 'en';
return $map[$jurisdiction][$lang] ?? $map['norwegian']['en'];
}
/**
* 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';
$userLang = dbnToolsNormalizeUiLanguage($intake['language'] ?? 'en');
$userLangName = dbnToolsLanguageName($userLang); // e.g. "English", "Norwegian", "Polish", "Ukrainian"
$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":"<question in {$userLangName}>"}],
"suggested_goal": "One-line concrete goal for this letter, in Norwegian bokmål"
}
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.
- IMPORTANT: write each missing-fact "question" field in **{$userLangName}**, short and clear.
Do NOT write the question in Norwegian if the user's language is not Norwegian.
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($this->resolveDeployment(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, string $engine = 'azure_mini', ?string $persona = null, string $length = 'standard'): array
{
$draftDeployment = ($this->azure instanceof DbnBedrockGateway)
? (($engine === 'claude_sonnet' || $engine === 'azure_full')
? DbnBedrockModelRouter::LITELLM_SONNET
: DbnBedrockModelRouter::LITELLM_HAIKU)
: (($engine === 'azure_full') ? self::DRAFT_DEPLOYMENT : 'gpt-4o-mini');
$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' => self::L('fetching_law', $userLang)]); }
$retrieval = $this->retrieveLaw($body, $classify['applicable_acts'] ?? [], $persona);
if ($emit) {
$emit('retrieval', [
'sources_count' => count($retrieval['sources']),
'applicable_acts' => $classify['applicable_acts'] ?? [],
]);
}
// ── Draft in Norwegian bokmål ───────────────────────────────────────────
if ($emit) { $emit('progress', ['detail' => self::L('drafting_no', $userLang)]); }
$draftNo = $this->draftNorwegian(
$intake, $classify, $retrieval['sources'], $bodyLabel, $outputType, $tone, $goal, $draftDeployment, $length
);
// ── Self-check: verify citations, deadline, goal, tone ──────────────────
if ($emit) { $emit('progress', ['detail' => self::L('quality_check', $userLang)]); }
$checked = $this->selfCheck($draftNo, $retrieval['sources'], $classify, $goal, $tone);
// ── Legal check: dbn-legal-agent-v3 threshold verification ────────────────
// Emit progress to keep H2 stream alive during the blocking LiteLLM call (60s idle timeout)
if ($emit) { $emit('progress', ['detail' => self::L('legal_check', $userLang)]); }
$legalCheck = [];
try {
$legalCheck = dbnToolsRunLegalCheck($checked['draft'], $body, null, $persona);
} catch (Throwable $e) { /* silent — non-critical */ }
// ── Translate to user language (if not Norwegian) ───────────────────────
$draftUser = $checked['draft'];
if ($userLang !== 'no') {
if ($emit) { $emit('progress', ['detail' => self::L('translating_to', $userLang, ['lang' => 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'],
'legal_check' => $legalCheck,
'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;
}
}
// Inject Min Sak (private case corpus) hits if user opted in and is on a paid tier.
if (!empty($intake['use_my_case'])) {
$caseQuery = trim((string)($intake['goal'] ?? '') . ' ' . (string)($intake['narrative'] ?? ''));
if ($caseQuery !== '') {
$caseBlock = dbnToolsCaseContext(true, $caseQuery, 5);
if ($caseBlock !== '') {
$parts[] = $caseBlock;
}
}
}
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, ?string $persona = null): array
{
$client = dbnToolsRequireClient();
$personaResolved = dbnToolsResolvePersona((int)$client['id'], $persona);
$packageIds = array_values(array_filter(
array_map('intval', $personaResolved['package_ids'] ?? []),
static fn(int $id): bool => $id > 0
));
if (!$packageIds) {
$package = dbnToolsFetchPackage(dbnToolsRequiredPackageSlug());
if (!$package) {
return ['sources' => [], 'applied_slices' => []];
}
$packageIds = [(int)$package['id']];
}
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' => $packageIds,
'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 $draftDeployment = self::DRAFT_DEPLOYMENT,
string $length = 'standard'
): 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.';
$lengthLabel = match($length) {
'concise' => 'Kort og poengtert — maks 2-3 avsnitt. Kom raskt til poenget.',
'detailed' => 'Utfyllende — inkluder full bakgrunn, alle argumenter og rettslig begrunnelse.',
default => 'Standard lengde — 4-6 avsnitt.',
};
$prompt = <<<PROMPT
Du skriver utkast til korrespondanse til {$bodyLabel}. Skriv på norsk bokmål.
Tone: {$toneLabel}.
Lengde: {$lengthLabel}
{$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.
- IKKE siter §-er som er markert "(Opphevet)" eller "Opphevet ved lov" i passasjene — de gjelder ikke lenger.
- Siter kun §-er som er DIREKTE RELEVANTE for den juridiske saken i brevet. Ikke siter §-er fra lovteksten som ikke brukes i argumentasjonen.
{$sourcesBlock}
KONTEKST (intake fra brukeren):
{$context}
Skriv kun utkastet. Ingen forklaring eller preamble. Bruk linjeskift som passer formatet.
PROMPT;
try {
return $this->azure->withDeployment($draftDeployment)->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($this->resolveDeployment(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',
};
}
// ── Pass 3: refine with jurisdiction-scoped formal citations ────────────────
/**
* Refine an existing draft by retrieving law scoped to the chosen jurisdiction,
* rewriting inline citations into a formal style, and appending a Rettskilder
* (legal authorities) block.
*
* @param array $intake Original intake (recipient_body, output_type, tone, language, …)
* @param array $classify Pass 1 classify result (summary, applicable_acts, deadlines, …)
* @param string $originalDraftNo The Norwegian draft from Pass 2
* @param string $jurisdiction 'norwegian' | 'echr' | 'both'
* @param callable|null $emit Stream emitter
*
* @return array Final refined result payload.
*/
public function refine(
array $intake,
array $classify,
string $originalDraftNo,
string $jurisdiction,
?callable $emit = null,
?string $persona = null
): array {
$body = $intake['recipient_body'] ?? 'other';
$outputType = $intake['output_type'] ?? 'email';
$tone = $intake['tone'] ?? 'neutral';
$userLang = dbnToolsNormalizeUiLanguage($intake['language'] ?? 'en');
$goal = trim((string)($intake['goal'] ?? ($classify['suggested_goal'] ?? '')));
$bodyLabel = self::BODY_LABELS[$body] ?? 'mottaker';
$jurisdiction = in_array($jurisdiction, ['norwegian', 'echr', 'both'], true) ? $jurisdiction : 'norwegian';
if ($emit) { $emit('progress', ['detail' => self::L('fetching_for_jur', $userLang, ['jur' => self::jurisdictionChromeLabel($jurisdiction, $userLang)])]); }
$retrieval = $this->retrieveLawForJurisdiction($jurisdiction, $body, $classify);
if ($emit) {
$emit('retrieval', [
'sources_count' => count($retrieval['sources']),
'jurisdiction' => $jurisdiction,
'applied_slices' => $retrieval['applied_slices'],
]);
}
if ($emit) { $emit('progress', ['detail' => self::L('rewriting_formal', $userLang)]); }
$refinedNo = $this->rewriteWithFormalCites(
$originalDraftNo, $retrieval['sources'], $bodyLabel, $outputType, $tone, $goal, $jurisdiction
);
if ($emit) { $emit('progress', ['detail' => self::L('check_and_authorities', $userLang)]); }
$checked = $this->selfCheck($refinedNo, $retrieval['sources'], $classify, $goal, $tone);
if ($emit) { $emit('progress', ['detail' => self::L('legal_check', $userLang)]); }
$legalCheckRefine = [];
try {
$legalCheckRefine = dbnToolsRunLegalCheck($checked['draft'], $body, null, $persona);
} catch (Throwable $e) { /* silent — non-critical */ }
$draftUser = $checked['draft'];
if ($userLang !== 'no') {
if ($emit) { $emit('progress', ['detail' => self::L('translating_to', $userLang, ['lang' => dbnToolsLanguageName($userLang)])]); }
$draftUser = $this->translate($checked['draft'], $userLang, $outputType);
}
return [
'tool' => 'korrespond_refine',
'language' => $userLang,
'jurisdiction' => $jurisdiction,
'recipient_body' => $body,
'output_type' => $outputType,
'tone' => $tone,
'draft_no' => $checked['draft'],
'draft_user' => $draftUser,
'draft_user_lang'=> $userLang,
'cited_law' => $checked['cited_sources'],
'self_check' => $checked['flags'],
'legal_check' => $legalCheckRefine,
'applied_slices' => $retrieval['applied_slices'],
'disclaimer' => dbnToolsDisclaimer($userLang),
];
}
/**
* Retrieve law scoped to user's jurisdiction choice.
* - norwegian: body preset slices MINUS echr
* - echr: only echr slice; queries target EMK articles + HUDOC case law
* - both: union (full body preset + extra ECHR queries)
*
* @return array{sources:array, applied_slices:string[]}
*/
private function retrieveLawForJurisdiction(string $jurisdiction, string $body, array $classify): array
{
$client = dbnToolsRequireClient();
$package = dbnToolsFetchPackage(dbnToolsRequiredPackageSlug());
if (!$package) {
return ['sources' => [], 'applied_slices' => []];
}
dbnToolsBootCaveau();
$aiPortalRoot = dbnToolsAiPortalRoot();
$v6 = $aiPortalRoot . '/platform/includes/dbn_v6.php';
if (is_file($v6)) {
require_once $v6;
}
$bodyPreset = self::BODY_PRESETS[$body] ?? self::BODY_PRESETS['other'];
// Build slice selection per jurisdiction
$sliceSel = [];
if ($jurisdiction === 'norwegian') {
foreach ($bodyPreset as $s) { if ($s !== 'echr') { $sliceSel[$s] = true; } }
if (empty($sliceSel)) { $sliceSel['family_core'] = true; }
} elseif ($jurisdiction === 'echr') {
$sliceSel['echr'] = true;
} else { // both
foreach ($bodyPreset as $s) { $sliceSel[$s] = true; }
$sliceSel['echr'] = true;
}
$sliceSel = function_exists('dbnV6NormalizeSliceSelection')
? dbnV6NormalizeSliceSelection($sliceSel)
: $sliceSel;
$ragDb = dbnToolsRagDb();
$sharedDocIds = [];
if (function_exists('dbnV6ResolveSelectedDocIds')) {
try {
$sharedDocIds = dbnV6ResolveSelectedDocIds($ragDb, $sliceSel);
} catch (Throwable $e) {
error_log('Korrespond refine slice resolve failed: ' . $e->getMessage());
$sharedDocIds = [];
}
}
// Build retrieval queries
$queries = $this->refineQueries($jurisdiction, $body, $classify);
$pool = [];
try {
if (!class_exists('ClientRagPipeline')) {
return ['sources' => [], 'applied_slices' => array_keys(array_filter($sliceSel))];
}
$rag = new ClientRagPipeline((int)$client['id'], 'http://10.0.1.10:4000', 60);
foreach ($queries as $q) {
try {
$chunks = $rag->searchAll($q, 5, null, [
'search_private' => false,
'search_shared' => true,
'package_ids' => [(int)$package['id']],
'shared_doc_ids' => $sharedDocIds,
'chunk_limit' => 5,
'search_method' => 'hybrid',
'reranker_enabled' => true,
'include_beta_website' => false,
'include_primary_website' => false,
]);
} catch (Throwable $e) {
error_log('Korrespond refine rag search failed: ' . $e->getMessage());
$chunks = [];
}
foreach ($chunks as $c) {
$pool[] = $c;
}
}
} catch (Throwable $e) {
error_log('Korrespond refine rag init failed: ' . $e->getMessage());
}
// Dedupe + number — bump source cap to 12 since refine cites more authorities
$seen = [];
$sources = [];
$n = 1;
foreach ($pool as $c) {
$cid = (string)($c['chunk_id'] ?? $c['id'] ?? '');
if ($cid === '' || isset($seen[$cid])) continue;
$seen[$cid] = true;
$text = (string)($c['chunk_text'] ?? $c['content'] ?? $c['text'] ?? '');
$title = (string)($c['document_title'] ?? $c['title'] ?? 'Lovkilde');
$section = (string)($c['section_title'] ?? $c['section'] ?? '');
$sources[] = [
'n' => $n++,
'chunk_id' => $cid,
'title' => $title,
'section' => $section,
'excerpt' => dbnToolsExcerpt($text, 500),
'full_text' => mb_substr($text, 0, 1800, 'UTF-8'),
'source_url'=> (string)($c['source_url'] ?? $c['url'] ?? ''),
'authority_type' => (string)($c['authority_type'] ?? ''),
];
if (count($sources) >= 12) break;
}
return [
'sources' => $sources,
'applied_slices' => array_keys(array_filter($sliceSel)),
];
}
/** Build 24 retrieval queries appropriate for the jurisdiction. */
private function refineQueries(string $jurisdiction, string $body, array $classify): array
{
$queries = [];
$summaryHint = mb_substr((string)($classify['summary'] ?? ''), 0, 220, 'UTF-8');
if ($jurisdiction === 'norwegian' || $jurisdiction === 'both') {
foreach (($classify['applicable_acts'] ?? []) as $act) {
if (strtolower($act) === 'emk') continue; // handled in ECHR branch
$queries[] = $this->queryForAct($act);
}
if (empty(array_filter($queries))) {
$queries[] = 'forvaltningsloven saksbehandling klage';
}
}
if ($jurisdiction === 'echr' || $jurisdiction === 'both') {
// Always pull EMK articles + general HUDOC vs Norway case law
$queries[] = 'EMK artikkel 8 familieliv rettferdig rettergang';
$queries[] = 'EMD HUDOC Norway family life article 8 case law violation';
// Body-specific high-value case anchors
if (in_array($body, ['barnevernet', 'bufdir', 'statsforvalter', 'tingrett'], true)) {
$queries[] = 'Strand Lobben Norway adoption family ties biological';
}
if ($summaryHint !== '') {
$queries[] = $summaryHint . ' EMK EMD case law';
}
}
$queries = array_values(array_unique(array_filter(array_map('trim', $queries))));
return array_slice($queries, 0, 5);
}
private function rewriteWithFormalCites(
string $originalDraft,
array $sources,
string $bodyLabel,
string $outputType,
string $tone,
string $goal,
string $jurisdiction
): string {
$toneLabel = $this->toneLabelNorsk($tone);
$jurLabel = $this->jurisdictionLabelNorsk($jurisdiction);
$outputBlock = $this->outputInstructionsNorsk($outputType, $bodyLabel);
$sourcesBlock = '';
if (!empty($sources)) {
$rows = [];
foreach ($sources as $s) {
$authority = $s['authority_type'] !== '' ? ' (' . $s['authority_type'] . ')' : '';
$rows[] = sprintf("[id=%d] %s%s%s\n%s",
(int)$s['n'],
$s['title'],
$s['section'] !== '' ? ' — ' . $s['section'] : '',
$authority,
$s['excerpt']
);
}
$sourcesBlock = "RETRIEVED LEGAL AUTHORITIES (you may ONLY cite these):\n" . implode("\n\n", $rows);
} else {
$sourcesBlock = 'RETRIEVED LEGAL AUTHORITIES: (none — keep the existing inline references without expansion)';
}
$goalLine = $goal !== '' ? ('Brukerens mål: ' . $goal) : 'Brukerens mål: utled fra utkastet.';
$prompt = <<<PROMPT
Du skal SKRIVE OM utkastet under slik at lovhenvisninger blir formelle og presise.
Skriv på norsk bokmål. Tone: {$toneLabel}. Rettsområde: {$jurLabel}.
{$goalLine}
{$outputBlock}
REGLER FOR FORMELLE HENVISNINGER (kritisk):
- Bevar utkastets struktur, fakta og overordnede budskap. Endre kun stilen på henvisningene.
- For norske lover: bruk formelle henvisninger som "jf. forvaltningsloven § 17", "i medhold av barnevernsloven § 6-3", "etter opplæringslova § 9 A-4".
- For EMK-artikler: "jf. EMK artikkel 8", "etter EMK art. 6 nr. 1".
- For EMD-dommer: bruk fullt navn + saksnummer + dato + paragraf, f.eks.
"jf. Strand Lobben m.fl. mot Norge, EMD-37283/13, dom 10. september 2019, §§ 207214"
- Hver gang du siterer en autoritet, MÅ du legge ved [CITE:N] der N er id-nummeret fra
listen under. Bare disse autoritetene er tillatt. IKKE finn på saksnummer, datoer eller §-numre.
- Hvis det opprinnelige utkastet siterte noe som ikke finnes i autoritetslisten, omformuler
uten den henvisningen.
PÅ SLUTTEN av utkastet — etter signaturen / sluttpåstanden — legg til en blokk:
Rettskilder
-----------
[1] Full formell referanse for autoritet 1
[2] Full formell referanse for autoritet 2
...
Listen skal kun inneholde autoriteter som faktisk er sitert i utkastet over.
{$sourcesBlock}
OPPRINNELIG UTKAST (skriv om dette):
{$originalDraft}
Returner kun det omskrevne utkastet med Rettskilder-blokken. Ingen forklaring eller preamble.
PROMPT;
try {
return $this->azure->withDeployment(self::DRAFT_DEPLOYMENT)->chatText([
['role' => 'system', 'content' => 'Du er en erfaren norsk juridisk skribent som skriver presise prosesskriv med formelle rettshenvisninger.'],
['role' => 'user', 'content' => $prompt],
], [
'temperature' => 0.2,
'max_tokens' => self::MAX_DRAFT_TOKENS,
'timeout' => 120,
]);
} catch (Throwable $e) {
error_log('Korrespond refine rewrite failed: ' . $e->getMessage());
return $originalDraft;
}
}
private function jurisdictionLabelNorsk(string $jurisdiction): string
{
return match ($jurisdiction) {
'norwegian' => 'norsk rett',
'echr' => 'EMK og EMD-praksis',
'both' => 'norsk rett + EMK/EMD',
default => 'norsk rett',
};
}
}