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>
This commit is contained in:
@@ -0,0 +1,625 @@
|
||||
<?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:" (3–5 punkter)
|
||||
- "Lover å vise til hvis presset:" (med [CITE:N])
|
||||
- "Spørsmål å stille:" (3–5 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user