b21bfb2f1d
- PricingCatalog.php: single source of truth for plans (free/plus/pro), top-ups, Stripe price env keys, tool costs (0–6 credits), STT variable billing, feature limits - FreeTier.php: monthly-first credit deduction, ledger (user_tool_credit_ledger), STT reservation/settle/release, monthly reset, trial logic - StripeClient.php: canonical SKUs (plus/pro/topup_100/300/1000), legacy aliases kept - stripe-checkout.php: subscription vs payment mode, trial gating, catalog metadata - stripe-webhook.php: idempotent via stripe_events, handles subscription lifecycle + invoice.paid renewal + one-time topup credit grants - All API tools: success-based credit deduction (check before, charge after) - transcribe.php: file-size heuristic reservation, settle from actual provider duration - ask.php + LegalTools.php: ToolModels engine resolution — Pro gets gpt-4o - KorrespondAgent.php + korrespond.php: tier-gated draft deployment — Free/Plus gets gpt-4o-mini, Pro gets gpt-4o - pricing.php: NOK-only, plan cards, top-up packs, Organisation contact card, tool cost table, separate monthly/prepaid balance display - 003_pricing_credit_catalog.sql: ledger and STT reservation tables Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1061 lines
47 KiB
PHP
1061 lines
47 KiB
PHP
<?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).
|
||
*
|
||
* 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;
|
||
|
||
/** 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();
|
||
}
|
||
|
||
/** 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(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'): array
|
||
{
|
||
$draftDeployment = ($engine === 'azure_full') ? 'gpt-4o' : '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'] ?? []);
|
||
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
|
||
);
|
||
|
||
// ── 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);
|
||
} 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): 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 $draftDeployment = self::DRAFT_DEPLOYMENT
|
||
): 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($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(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',
|
||
};
|
||
}
|
||
|
||
// ── 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' => 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);
|
||
} 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 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',
|
||
};
|
||
}
|
||
}
|