Korrespond: add Refine pass with jurisdiction-scoped formal citations

After the first draft is rendered, a "Refine with citations" panel offers a
3rd-pass rewrite scoped to the user's choice of Norwegian law, ECHR (EMK +
HUDOC case law), or both. Refine pulls fresh corpus chunks limited to the
chosen jurisdiction's slices, rewrites inline cites in formal style ("jf.
forvaltningsloven § 17", "jf. Strand Lobben m.fl. mot Norge, EMD-37283/13,
§§ 207–214"), and appends a Rettskilder block listing every authority.
Hard-RAG grounding carries through — refine cannot cite anything that
wasn't retrieved. Costs 1 additional credit; the original draft stays in
place and the refined version appears below it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 11:50:36 +02:00
parent b78a49e060
commit 5d8ae6b447
4 changed files with 696 additions and 3 deletions
+319 -1
View File
@@ -8,10 +8,13 @@ require_once __DIR__ . '/AzureOpenAiGateway.php';
* Korrespond — drafts replies or new correspondence to Norwegian authorities
* (NAV, Barnevernet, schools, Bufdir, kommune, Statsforvalter, Trygderetten).
*
* Two-pass wizard with hard-RAG citation grounding:
* Three passes (the third is opt-in):
* Pass 1 — classify(): fact-extract + gap-check (Azure gpt-4o-mini)
* returns missing_facts[]; caller may emit clarify and stop
* Pass 2 — generate(): retrieve law → draft (Azure gpt-4o) → self-check → translate
* Pass 3 — refine(): retrieve law scoped to a user-picked jurisdiction
* (norwegian|echr|both), rewrite the existing draft with formally-styled
* citations, append a "Rettskilder" / "Legal authorities" block.
*
* Canonical draft is always Norwegian bokmål. User-language draft is a translation.
*/
@@ -622,4 +625,319 @@ EOT,
default => 'letter',
};
}
// ── Pass 3: refine with jurisdiction-scoped formal citations ────────────────
/**
* Refine an existing draft by retrieving law scoped to the chosen jurisdiction,
* rewriting inline citations into a formal style, and appending a Rettskilder
* (legal authorities) block.
*
* @param array $intake Original intake (recipient_body, output_type, tone, language, …)
* @param array $classify Pass 1 classify result (summary, applicable_acts, deadlines, …)
* @param string $originalDraftNo The Norwegian draft from Pass 2
* @param string $jurisdiction 'norwegian' | 'echr' | 'both'
* @param callable|null $emit Stream emitter
*
* @return array Final refined result payload.
*/
public function refine(
array $intake,
array $classify,
string $originalDraftNo,
string $jurisdiction,
?callable $emit = null
): array {
$body = $intake['recipient_body'] ?? 'other';
$outputType = $intake['output_type'] ?? 'email';
$tone = $intake['tone'] ?? 'neutral';
$userLang = dbnToolsNormalizeUiLanguage($intake['language'] ?? 'en');
$goal = trim((string)($intake['goal'] ?? ($classify['suggested_goal'] ?? '')));
$bodyLabel = self::BODY_LABELS[$body] ?? 'mottaker';
$jurisdiction = in_array($jurisdiction, ['norwegian', 'echr', 'both'], true) ? $jurisdiction : 'norwegian';
if ($emit) { $emit('progress', ['detail' => 'Henter rettskilder for ' . $this->jurisdictionLabelNorsk($jurisdiction) . '…']); }
$retrieval = $this->retrieveLawForJurisdiction($jurisdiction, $body, $classify);
if ($emit) {
$emit('retrieval', [
'sources_count' => count($retrieval['sources']),
'jurisdiction' => $jurisdiction,
'applied_slices' => $retrieval['applied_slices'],
]);
}
if ($emit) { $emit('progress', ['detail' => 'Skriver om med formelle henvisninger…']); }
$refinedNo = $this->rewriteWithFormalCites(
$originalDraftNo, $retrieval['sources'], $bodyLabel, $outputType, $tone, $goal, $jurisdiction
);
if ($emit) { $emit('progress', ['detail' => 'Kvalitetskontroll og Rettskilder…']); }
$checked = $this->selfCheck($refinedNo, $retrieval['sources'], $classify, $goal, $tone);
$draftUser = $checked['draft'];
if ($userLang !== 'no') {
if ($emit) { $emit('progress', ['detail' => 'Oversetter til ' . dbnToolsLanguageName($userLang) . '…']); }
$draftUser = $this->translate($checked['draft'], $userLang, $outputType);
}
return [
'tool' => 'korrespond_refine',
'language' => $userLang,
'jurisdiction' => $jurisdiction,
'recipient_body' => $body,
'output_type' => $outputType,
'tone' => $tone,
'draft_no' => $checked['draft'],
'draft_user' => $draftUser,
'draft_user_lang'=> $userLang,
'cited_law' => $checked['cited_sources'],
'self_check' => $checked['flags'],
'applied_slices' => $retrieval['applied_slices'],
'disclaimer' => dbnToolsDisclaimer($userLang),
];
}
/**
* Retrieve law scoped to user's jurisdiction choice.
* - norwegian: body preset slices MINUS echr
* - echr: only echr slice; queries target EMK articles + HUDOC case law
* - both: union (full body preset + extra ECHR queries)
*
* @return array{sources:array, applied_slices:string[]}
*/
private function retrieveLawForJurisdiction(string $jurisdiction, string $body, array $classify): array
{
$client = dbnToolsRequireClient();
$package = dbnToolsFetchPackage(dbnToolsRequiredPackageSlug());
if (!$package) {
return ['sources' => [], 'applied_slices' => []];
}
dbnToolsBootCaveau();
$aiPortalRoot = dbnToolsAiPortalRoot();
$v6 = $aiPortalRoot . '/platform/includes/dbn_v6.php';
if (is_file($v6)) {
require_once $v6;
}
$bodyPreset = self::BODY_PRESETS[$body] ?? self::BODY_PRESETS['other'];
// Build slice selection per jurisdiction
$sliceSel = [];
if ($jurisdiction === 'norwegian') {
foreach ($bodyPreset as $s) { if ($s !== 'echr') { $sliceSel[$s] = true; } }
if (empty($sliceSel)) { $sliceSel['family_core'] = true; }
} elseif ($jurisdiction === 'echr') {
$sliceSel['echr'] = true;
} else { // both
foreach ($bodyPreset as $s) { $sliceSel[$s] = true; }
$sliceSel['echr'] = true;
}
$sliceSel = function_exists('dbnV6NormalizeSliceSelection')
? dbnV6NormalizeSliceSelection($sliceSel)
: $sliceSel;
$ragDb = dbnToolsRagDb();
$sharedDocIds = [];
if (function_exists('dbnV6ResolveSelectedDocIds')) {
try {
$sharedDocIds = dbnV6ResolveSelectedDocIds($ragDb, $sliceSel);
} catch (Throwable $e) {
error_log('Korrespond refine slice resolve failed: ' . $e->getMessage());
$sharedDocIds = [];
}
}
// Build retrieval queries
$queries = $this->refineQueries($jurisdiction, $body, $classify);
$pool = [];
try {
if (!class_exists('ClientRagPipeline')) {
return ['sources' => [], 'applied_slices' => array_keys(array_filter($sliceSel))];
}
$rag = new ClientRagPipeline((int)$client['id'], 'http://10.0.1.10:4000', 60);
foreach ($queries as $q) {
try {
$chunks = $rag->searchAll($q, 5, null, [
'search_private' => false,
'search_shared' => true,
'package_ids' => [(int)$package['id']],
'shared_doc_ids' => $sharedDocIds,
'chunk_limit' => 5,
'search_method' => 'hybrid',
'reranker_enabled' => true,
'include_beta_website' => false,
'include_primary_website' => false,
]);
} catch (Throwable $e) {
error_log('Korrespond refine rag search failed: ' . $e->getMessage());
$chunks = [];
}
foreach ($chunks as $c) {
$pool[] = $c;
}
}
} catch (Throwable $e) {
error_log('Korrespond refine rag init failed: ' . $e->getMessage());
}
// Dedupe + number — bump source cap to 12 since refine cites more authorities
$seen = [];
$sources = [];
$n = 1;
foreach ($pool as $c) {
$cid = (string)($c['chunk_id'] ?? $c['id'] ?? '');
if ($cid === '' || isset($seen[$cid])) continue;
$seen[$cid] = true;
$text = (string)($c['chunk_text'] ?? $c['content'] ?? $c['text'] ?? '');
$title = (string)($c['document_title'] ?? $c['title'] ?? 'Lovkilde');
$section = (string)($c['section_title'] ?? $c['section'] ?? '');
$sources[] = [
'n' => $n++,
'chunk_id' => $cid,
'title' => $title,
'section' => $section,
'excerpt' => dbnToolsExcerpt($text, 500),
'full_text' => mb_substr($text, 0, 1800, 'UTF-8'),
'source_url'=> (string)($c['source_url'] ?? $c['url'] ?? ''),
'authority_type' => (string)($c['authority_type'] ?? ''),
];
if (count($sources) >= 12) break;
}
return [
'sources' => $sources,
'applied_slices' => array_keys(array_filter($sliceSel)),
];
}
/** Build 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',
};
}
}