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:
@@ -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 2–4 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, §§ 207–214"
|
||||
- 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user