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,206 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../includes/KorrespondAgent.php';
|
||||||
|
|
||||||
|
dbnToolsRequireMethod('POST');
|
||||||
|
dbnToolsRequireAuth();
|
||||||
|
|
||||||
|
@ini_set('output_buffering', '0');
|
||||||
|
@ini_set('zlib.output_compression', '0');
|
||||||
|
@ini_set('implicit_flush', '1');
|
||||||
|
while (ob_get_level() > 0) { @ob_end_clean(); }
|
||||||
|
ob_implicit_flush(true);
|
||||||
|
|
||||||
|
header('Content-Type: application/x-ndjson; charset=utf-8');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
header('X-Accel-Buffering: no');
|
||||||
|
|
||||||
|
$startTime = microtime(true);
|
||||||
|
$language = 'en';
|
||||||
|
$creditDeducted = false;
|
||||||
|
$ftUid = 0;
|
||||||
|
|
||||||
|
$emit = function (string $event, array $payload = []) use ($startTime): void {
|
||||||
|
$payload['event'] = $event;
|
||||||
|
$payload['t_ms'] = (int)round((microtime(true) - $startTime) * 1000);
|
||||||
|
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
|
||||||
|
@flush();
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ── Parse input (JSON or multipart) ─────────────────────────────────────────
|
||||||
|
$isMultipart = stripos((string)($_SERVER['CONTENT_TYPE'] ?? ''), 'multipart/form-data') !== false;
|
||||||
|
if ($isMultipart) {
|
||||||
|
$payloadRaw = (string)($_POST['payload'] ?? '');
|
||||||
|
if ($payloadRaw === '') {
|
||||||
|
throw new DbnToolsHttpException('Multipart request missing payload.', 422, 'missing_payload');
|
||||||
|
}
|
||||||
|
$input = json_decode($payloadRaw, true);
|
||||||
|
if (!is_array($input)) {
|
||||||
|
throw new DbnToolsHttpException('Invalid payload JSON.', 422, 'invalid_payload_json');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
if ($raw === false || strlen($raw) > 200000) {
|
||||||
|
throw new DbnToolsHttpException('Request body unreadable or too large.', 413, 'body_too_large');
|
||||||
|
}
|
||||||
|
$input = json_decode((string)$raw, true);
|
||||||
|
if (!is_array($input)) {
|
||||||
|
throw new DbnToolsHttpException('Request body must be valid JSON.', 400, 'invalid_json');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Normalise + validate ────────────────────────────────────────────────────
|
||||||
|
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
|
||||||
|
|
||||||
|
$allowedBodies = ['barnehage','school_1_10','sfo','nav','bufdir','barnevernet',
|
||||||
|
'kommune_other','statsforvalter','trygderetten','tingrett','other'];
|
||||||
|
$allowedOutput = ['email','formal','filing','call_prep'];
|
||||||
|
$allowedTone = ['cooperative','neutral','firm','adversarial','warm'];
|
||||||
|
$allowedMode = ['reply','initiate'];
|
||||||
|
|
||||||
|
$intake = [
|
||||||
|
'mode' => in_array($input['mode'] ?? '', $allowedMode, true) ? $input['mode'] : 'initiate',
|
||||||
|
'recipient_body' => in_array($input['recipient_body'] ?? '', $allowedBodies, true) ? $input['recipient_body'] : 'other',
|
||||||
|
'output_type' => in_array($input['output_type'] ?? '', $allowedOutput, true) ? $input['output_type'] : 'email',
|
||||||
|
'tone' => in_array($input['tone'] ?? '', $allowedTone, true) ? $input['tone'] : 'neutral',
|
||||||
|
'language' => $language,
|
||||||
|
'case_ref' => mb_substr(trim((string)($input['case_ref'] ?? '')), 0, 120, 'UTF-8'),
|
||||||
|
'where' => mb_substr(trim((string)($input['where'] ?? '')), 0, 200, 'UTF-8'),
|
||||||
|
'parties_text' => mb_substr(trim((string)($input['parties_text'] ?? '')), 0, 2000, 'UTF-8'),
|
||||||
|
'narrative' => mb_substr(trim((string)($input['narrative'] ?? '')), 0, 8000, 'UTF-8'),
|
||||||
|
'goal' => mb_substr(trim((string)($input['goal'] ?? '')), 0, 600, 'UTF-8'),
|
||||||
|
'deadlines' => is_array($input['deadlines'] ?? null)
|
||||||
|
? array_slice(array_values(array_filter(array_map(
|
||||||
|
fn($d) => mb_substr(trim((string)$d), 0, 60, 'UTF-8'),
|
||||||
|
$input['deadlines']
|
||||||
|
))), 0, 6) : [],
|
||||||
|
'clarifications' => is_array($input['clarifications'] ?? null) ? $input['clarifications'] : [],
|
||||||
|
];
|
||||||
|
|
||||||
|
$forceDraft = !empty($input['force_draft']);
|
||||||
|
$hasClarif = !empty($intake['clarifications']);
|
||||||
|
|
||||||
|
// ── Extract uploaded files (Reply mode) ─────────────────────────────────────
|
||||||
|
$receivedText = '';
|
||||||
|
$attachmentsText = '';
|
||||||
|
$attachmentNames = [];
|
||||||
|
if (!empty($_FILES['files']) && is_array($_FILES['files']['tmp_name'] ?? null)) {
|
||||||
|
$count = count($_FILES['files']['tmp_name']);
|
||||||
|
if ($count > 4) {
|
||||||
|
throw new DbnToolsHttpException('At most 4 files can be uploaded per request.', 413, 'too_many_files');
|
||||||
|
}
|
||||||
|
for ($i = 0; $i < $count; $i++) {
|
||||||
|
$file = [
|
||||||
|
'name' => $_FILES['files']['name'][$i] ?? '',
|
||||||
|
'type' => $_FILES['files']['type'][$i] ?? '',
|
||||||
|
'tmp_name' => $_FILES['files']['tmp_name'][$i] ?? '',
|
||||||
|
'error' => $_FILES['files']['error'][$i] ?? UPLOAD_ERR_NO_FILE,
|
||||||
|
'size' => $_FILES['files']['size'][$i] ?? 0,
|
||||||
|
];
|
||||||
|
$extracted = dbnToolsExtractUploadedFile($file);
|
||||||
|
$attachmentNames[] = $extracted['filename'];
|
||||||
|
if ($i === 0) {
|
||||||
|
$receivedText = $extracted['text'];
|
||||||
|
} else {
|
||||||
|
$attachmentsText .= "\n\n--- " . $extracted['filename'] . " ---\n\n" . $extracted['text'];
|
||||||
|
}
|
||||||
|
$emit('progress', [
|
||||||
|
'detail' => sprintf('Lest %s (%d tegn)', $extracted['filename'], $extracted['chars']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$intake['received_text'] = $receivedText;
|
||||||
|
$intake['attachments_text'] = $attachmentsText;
|
||||||
|
|
||||||
|
if ($intake['mode'] === 'reply' && $receivedText === '' && $intake['narrative'] === '') {
|
||||||
|
throw new DbnToolsHttpException(
|
||||||
|
'For reply mode, upload the received letter or paste its text.',
|
||||||
|
422, 'reply_no_input'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ($intake['mode'] === 'initiate' && $intake['narrative'] === '') {
|
||||||
|
throw new DbnToolsHttpException(
|
||||||
|
'Describe the situation in "What happened" before drafting.',
|
||||||
|
422, 'initiate_no_narrative'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$emit('start', [
|
||||||
|
'mode' => $intake['mode'],
|
||||||
|
'body' => $intake['recipient_body'],
|
||||||
|
'output_type' => $intake['output_type'],
|
||||||
|
'language' => $language,
|
||||||
|
'attachments' => $attachmentNames,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Pass 1: classify + gap-check ────────────────────────────────────────────
|
||||||
|
$emit('progress', ['detail' => 'Analyserer situasjonen…']);
|
||||||
|
$agent = new DbnKorrespondAgent();
|
||||||
|
$classify = $agent->classify($intake);
|
||||||
|
$emit('classify', ['result' => [
|
||||||
|
'summary' => $classify['summary'],
|
||||||
|
'parties' => $classify['parties'],
|
||||||
|
'decision_or_action' => $classify['decision_or_action'],
|
||||||
|
'deadlines' => $classify['deadlines'],
|
||||||
|
'applicable_acts' => $classify['applicable_acts'],
|
||||||
|
'jurisdiction' => $classify['jurisdiction'],
|
||||||
|
'suggested_goal' => $classify['suggested_goal'],
|
||||||
|
]]);
|
||||||
|
|
||||||
|
$missing = $classify['missing_facts'] ?? [];
|
||||||
|
if (!empty($missing) && !$forceDraft && !$hasClarif) {
|
||||||
|
// Gate: emit clarify and stop — NO credit deduct
|
||||||
|
$emit('clarify', ['questions' => $missing]);
|
||||||
|
$emit('end', ['stopped_at' => 'clarify']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Deduct credit now (Pass 2 starts) ───────────────────────────────────────
|
||||||
|
$ftUid = dbnToolsFreeTierCheck('korrespond');
|
||||||
|
$ftRemaining = dbnToolsFreeTierDeduct($ftUid, 'korrespond');
|
||||||
|
$creditDeducted = true;
|
||||||
|
if ($ftRemaining >= 0) {
|
||||||
|
header('X-Credits-Remaining: ' . $ftRemaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pass 2: retrieve law → draft → self-check → translate ──────────────────
|
||||||
|
$result = $agent->generate($intake, $classify, $emit);
|
||||||
|
$result['ok'] = true;
|
||||||
|
$result['latency_ms'] = (int)round((microtime(true) - $startTime) * 1000);
|
||||||
|
|
||||||
|
dbnToolsLogMetadata([
|
||||||
|
'tool' => 'korrespond',
|
||||||
|
'language' => $language,
|
||||||
|
'ok' => true,
|
||||||
|
'latency_ms' => $result['latency_ms'],
|
||||||
|
'source_count' => is_array($result['cited_law'] ?? null) ? count($result['cited_law']) : 0,
|
||||||
|
'deployment' => 'gpt-4o',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$emit('final', ['result' => $result]);
|
||||||
|
|
||||||
|
} catch (DbnToolsHttpException $e) {
|
||||||
|
$latency = (int)round((microtime(true) - $startTime) * 1000);
|
||||||
|
dbnToolsLogMetadata([
|
||||||
|
'tool' => 'korrespond',
|
||||||
|
'language' => $language,
|
||||||
|
'ok' => false,
|
||||||
|
'latency_ms' => $latency,
|
||||||
|
'error_code' => $e->errorCode,
|
||||||
|
]);
|
||||||
|
$emit('error', ['code' => $e->errorCode, 'message' => $e->getMessage(), 'status' => $e->status]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('Korrespond fatal: ' . $e->getMessage());
|
||||||
|
$latency = (int)round((microtime(true) - $startTime) * 1000);
|
||||||
|
dbnToolsLogMetadata([
|
||||||
|
'tool' => 'korrespond',
|
||||||
|
'language' => $language,
|
||||||
|
'ok' => false,
|
||||||
|
'latency_ms' => $latency,
|
||||||
|
'error_code' => 'internal_error',
|
||||||
|
]);
|
||||||
|
$emit('error', ['code' => 'internal_error', 'message' => 'Korrespond could not complete this request.']);
|
||||||
|
}
|
||||||
@@ -6996,3 +6996,143 @@ body.lt-landing {
|
|||||||
.dr-brief { line-height:1.75; }
|
.dr-brief { line-height:1.75; }
|
||||||
summary { display:none; }
|
summary { display:none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Korrespond ──────────────────────────────────────────────────────────── */
|
||||||
|
.korr-goal-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 6px 0 14px;
|
||||||
|
}
|
||||||
|
.korr-chip {
|
||||||
|
background: rgba(15, 118, 110, 0.08);
|
||||||
|
border: 1px solid rgba(15, 118, 110, 0.25);
|
||||||
|
color: var(--ink, #1b2330);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s ease, border-color 0.12s ease;
|
||||||
|
}
|
||||||
|
.korr-chip:hover { background: rgba(15, 118, 110, 0.16); }
|
||||||
|
.korr-chip.is-active {
|
||||||
|
background: var(--dbn-blue, #00205b);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--dbn-blue, #00205b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.korr-clarify-panel {
|
||||||
|
background: rgba(183, 121, 31, 0.08);
|
||||||
|
border: 1px solid rgba(183, 121, 31, 0.35);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
margin: 18px 0;
|
||||||
|
}
|
||||||
|
.korr-clarify-panel h3 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: var(--ink, #1b2330);
|
||||||
|
}
|
||||||
|
.korr-clarify-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 14px 0;
|
||||||
|
}
|
||||||
|
.korr-clarify-item { display: grid; gap: 4px; }
|
||||||
|
.korr-clarify-item label { font-size: 0.9rem; }
|
||||||
|
.korr-clarify-item input {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.18);
|
||||||
|
border-radius: 6px;
|
||||||
|
font: inherit;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.korr-clarify-actions { display: flex; gap: 10px; }
|
||||||
|
|
||||||
|
.korr-result-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.korr-flags { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.korr-flag {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.korr-flag.is-ok { color: #0f766e; background: rgba(15, 118, 110, 0.08); }
|
||||||
|
.korr-flag.is-warn { color: #b7791f; background: rgba(183, 121, 31, 0.10); }
|
||||||
|
.korr-flag.is-error { color: #ba0c2f; background: rgba(186, 12, 47, 0.08); }
|
||||||
|
|
||||||
|
.korr-drafts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.korr-drafts.is-single { grid-template-columns: 1fr; }
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.korr-drafts { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
.korr-draft-col {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.10);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.korr-draft-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
.korr-draft-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: var(--dbn-blue, #00205b);
|
||||||
|
}
|
||||||
|
.korr-draft-actions { display: flex; gap: 6px; }
|
||||||
|
.korr-draft-body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'IBM Plex Mono', 'Menlo', 'Consolas', monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
color: var(--ink, #1b2330);
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.korr-cited {
|
||||||
|
margin-top: 18px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.10);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
.korr-cited summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--dbn-blue, #00205b);
|
||||||
|
}
|
||||||
|
.korr-cited-list { display: grid; gap: 12px; margin-top: 10px; }
|
||||||
|
.korr-cited-item {
|
||||||
|
border-left: 3px solid var(--dbn-blue, #00205b);
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(0, 32, 91, 0.03);
|
||||||
|
}
|
||||||
|
.korr-cited-head { font-size: 0.88rem; margin-bottom: 4px; }
|
||||||
|
.korr-cited-excerpt { font-size: 0.83rem; line-height: 1.5; margin: 0 0 4px; color: rgba(0, 0, 0, 0.75); }
|
||||||
|
.korr-cited-item a { font-size: 0.78rem; color: var(--dbn-blue, #00205b); }
|
||||||
|
|
||||||
|
/* Required hint */
|
||||||
|
.required-hint { color: var(--dbn-red, #ba0c2f); font-size: 0.78rem; font-weight: 500; }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,431 @@
|
|||||||
|
/* korrespond.js — page-scoped UI for /korrespond.php
|
||||||
|
Two-pass wizard: Pass 1 may return clarify questions; Pass 2 returns Norwegian
|
||||||
|
+ working-language drafts side by side with verified law citations.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const els = {};
|
||||||
|
let lang = window.DBN_TOOLS_LANG || localStorage.getItem('dbn-ui-lang') || 'en';
|
||||||
|
let uploadFiles = [];
|
||||||
|
let lastClassify = null;
|
||||||
|
let pendingClarifications = {};
|
||||||
|
|
||||||
|
const LANG_LABELS = { en: 'English', no: 'Norsk', uk: 'Українська', pl: 'Polski' };
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
if (document.body.dataset.activeTool !== 'korrespond') return;
|
||||||
|
|
||||||
|
Object.assign(els, {
|
||||||
|
form: document.getElementById('korrForm'),
|
||||||
|
status: document.getElementById('korrStatus'),
|
||||||
|
runButton: document.getElementById('korrRunButton'),
|
||||||
|
results: document.getElementById('korrResults'),
|
||||||
|
langButtons: Array.from(document.querySelectorAll('#korrLangSwitcher .lang-btn')),
|
||||||
|
modeRadios: Array.from(document.querySelectorAll('input[name="korrMode"]')),
|
||||||
|
bodySelect: document.getElementById('korrBody'),
|
||||||
|
outputRadios: Array.from(document.querySelectorAll('input[name="korrOutput"]')),
|
||||||
|
toneRadios: Array.from(document.querySelectorAll('input[name="korrTone"]')),
|
||||||
|
caseRef: document.getElementById('korrCaseRef'),
|
||||||
|
where: document.getElementById('korrWhere'),
|
||||||
|
deadline: document.getElementById('korrDeadline'),
|
||||||
|
parties: document.getElementById('korrParties'),
|
||||||
|
narrative: document.getElementById('korrNarrative'),
|
||||||
|
goal: document.getElementById('korrGoal'),
|
||||||
|
goalChips: Array.from(document.querySelectorAll('#korrGoalChips .korr-chip')),
|
||||||
|
uploadZone: document.getElementById('korrUploadZone'),
|
||||||
|
uploadInput: document.getElementById('korrUploadInput'),
|
||||||
|
uploadPrompt: document.getElementById('korrUploadPrompt'),
|
||||||
|
uploadFileInfo: document.getElementById('korrUploadFileInfo'),
|
||||||
|
uploadFileList: document.getElementById('korrUploadFileList'),
|
||||||
|
uploadClear: document.getElementById('korrUploadClear'),
|
||||||
|
clarifyPanel: document.getElementById('korrClarifyPanel'),
|
||||||
|
clarifyList: document.getElementById('korrClarifyList'),
|
||||||
|
clarifyContinue:document.getElementById('korrClarifyContinue'),
|
||||||
|
clarifyForce: document.getElementById('korrClarifyForce'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!els.form) return;
|
||||||
|
|
||||||
|
bindLang();
|
||||||
|
bindGoalChips();
|
||||||
|
bindUpload();
|
||||||
|
bindClarify();
|
||||||
|
els.form.addEventListener('submit', (e) => { e.preventDefault(); runRequest(false); });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Language switcher ───────────────────────────────────────────────────────
|
||||||
|
function bindLang() {
|
||||||
|
els.langButtons.forEach((b) => {
|
||||||
|
b.classList.toggle('is-active', b.dataset.lang === lang);
|
||||||
|
b.addEventListener('click', () => {
|
||||||
|
els.langButtons.forEach((x) => x.classList.remove('is-active'));
|
||||||
|
b.classList.add('is-active');
|
||||||
|
lang = b.dataset.lang || 'en';
|
||||||
|
localStorage.setItem('dbn-ui-lang', lang);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindGoalChips() {
|
||||||
|
els.goalChips.forEach((chip) => {
|
||||||
|
chip.addEventListener('click', () => {
|
||||||
|
els.goal.value = chip.dataset.goal || '';
|
||||||
|
els.goalChips.forEach((c) => c.classList.remove('is-active'));
|
||||||
|
chip.classList.add('is-active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── File upload ─────────────────────────────────────────────────────────────
|
||||||
|
function bindUpload() {
|
||||||
|
if (!els.uploadZone) return;
|
||||||
|
const onFiles = (fileList) => {
|
||||||
|
const files = Array.from(fileList || []).slice(0, 4);
|
||||||
|
if (uploadFiles.length + files.length > 4) {
|
||||||
|
setStatus('At most 4 files can be uploaded per request.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
files.forEach((f) => {
|
||||||
|
if (f.size > 8 * 1024 * 1024) {
|
||||||
|
setStatus(`${f.name} exceeds the 8 MB limit.`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ext = (f.name.split('.').pop() || '').toLowerCase();
|
||||||
|
if (!['pdf', 'docx', 'txt'].includes(ext)) {
|
||||||
|
setStatus(`${f.name} is not a supported file type (PDF, DOCX, TXT).`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploadFiles.push(f);
|
||||||
|
});
|
||||||
|
renderUploadList();
|
||||||
|
};
|
||||||
|
els.uploadInput.addEventListener('change', (e) => onFiles(e.target.files));
|
||||||
|
els.uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); els.uploadZone.classList.add('is-drop'); });
|
||||||
|
els.uploadZone.addEventListener('dragleave', () => els.uploadZone.classList.remove('is-drop'));
|
||||||
|
els.uploadZone.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
els.uploadZone.classList.remove('is-drop');
|
||||||
|
onFiles(e.dataTransfer?.files);
|
||||||
|
});
|
||||||
|
els.uploadClear?.addEventListener('click', () => {
|
||||||
|
uploadFiles = [];
|
||||||
|
els.uploadInput.value = '';
|
||||||
|
renderUploadList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUploadList() {
|
||||||
|
if (!uploadFiles.length) {
|
||||||
|
els.uploadFileInfo.classList.add('is-hidden');
|
||||||
|
els.uploadPrompt.classList.remove('is-hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
els.uploadPrompt.classList.add('is-hidden');
|
||||||
|
els.uploadFileInfo.classList.remove('is-hidden');
|
||||||
|
els.uploadFileList.innerHTML = uploadFiles.map((f) => {
|
||||||
|
const kb = (f.size / 1024).toFixed(0);
|
||||||
|
return `<li><span class="upload-filename">${esc(f.name)}</span><span class="upload-chars">${kb} KB</span></li>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Clarify panel ───────────────────────────────────────────────────────────
|
||||||
|
function bindClarify() {
|
||||||
|
els.clarifyContinue?.addEventListener('click', () => {
|
||||||
|
pendingClarifications = collectClarifications();
|
||||||
|
hideClarify();
|
||||||
|
runRequest(false);
|
||||||
|
});
|
||||||
|
els.clarifyForce?.addEventListener('click', () => {
|
||||||
|
pendingClarifications = collectClarifications();
|
||||||
|
hideClarify();
|
||||||
|
runRequest(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showClarify(questions) {
|
||||||
|
els.clarifyList.innerHTML = (questions || []).map((q, i) => `
|
||||||
|
<div class="korr-clarify-item">
|
||||||
|
<label for="clarify_${i}"><strong>${esc(q.question || '')}</strong></label>
|
||||||
|
<input type="text" id="clarify_${i}" data-key="${esc(q.key || '')}" placeholder="(optional)">
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
els.clarifyPanel.classList.remove('is-hidden');
|
||||||
|
els.clarifyPanel.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideClarify() {
|
||||||
|
els.clarifyPanel.classList.add('is-hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectClarifications() {
|
||||||
|
const inputs = els.clarifyList.querySelectorAll('input[data-key]');
|
||||||
|
const out = {};
|
||||||
|
inputs.forEach((inp) => {
|
||||||
|
const v = (inp.value || '').trim();
|
||||||
|
if (v) out[inp.dataset.key] = v;
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Submit ──────────────────────────────────────────────────────────────────
|
||||||
|
function getRadio(list) {
|
||||||
|
const checked = list.find((r) => r.checked);
|
||||||
|
return checked ? checked.value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload(forceDraft) {
|
||||||
|
const deadlines = [];
|
||||||
|
if (els.deadline.value.trim()) deadlines.push(els.deadline.value.trim());
|
||||||
|
return {
|
||||||
|
mode: getRadio(els.modeRadios) || 'initiate',
|
||||||
|
recipient_body: els.bodySelect.value || 'other',
|
||||||
|
output_type: getRadio(els.outputRadios) || 'email',
|
||||||
|
tone: getRadio(els.toneRadios) || 'neutral',
|
||||||
|
language: lang,
|
||||||
|
case_ref: els.caseRef.value.trim(),
|
||||||
|
where: els.where.value.trim(),
|
||||||
|
deadlines,
|
||||||
|
parties_text: els.parties.value.trim(),
|
||||||
|
narrative: els.narrative.value.trim(),
|
||||||
|
goal: els.goal.value.trim(),
|
||||||
|
clarifications: pendingClarifications,
|
||||||
|
force_draft: !!forceDraft,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runRequest(forceDraft) {
|
||||||
|
const payload = buildPayload(forceDraft);
|
||||||
|
if (!payload.recipient_body) {
|
||||||
|
setStatus('Pick a recipient body before drafting.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.mode === 'initiate' && !payload.narrative) {
|
||||||
|
setStatus('Describe the situation in "What happened" first.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.mode === 'reply' && !uploadFiles.length && !payload.narrative) {
|
||||||
|
setStatus('Reply mode needs the received letter (upload or paste it).', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('Analyserer…', 'busy');
|
||||||
|
els.runButton.disabled = true;
|
||||||
|
els.results.innerHTML = `<div class="empty-state"><h3>Working…</h3><p>Pass 1 extracts facts. If anything is missing we'll ask for clarification. Otherwise Pass 2 runs hard-RAG retrieval + draft + self-check + translate.</p></div>`;
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('payload', JSON.stringify(payload));
|
||||||
|
uploadFiles.forEach((f) => form.append('files[]', f));
|
||||||
|
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch('api/korrespond.php', { method: 'POST', body: form, credentials: 'same-origin' });
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`Network error: ${err.message || err}`, 'error');
|
||||||
|
els.runButton.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
if (response.status === 402 || response.status === 429) {
|
||||||
|
const d = await response.json().catch(() => ({}));
|
||||||
|
if (typeof window.dbnFreeTierError === 'function') window.dbnFreeTierError(response.status, d);
|
||||||
|
} else {
|
||||||
|
setStatus(`Request failed (${response.status}).`, 'error');
|
||||||
|
}
|
||||||
|
els.runButton.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const creditsRemaining = response.headers.get('X-Credits-Remaining');
|
||||||
|
if (creditsRemaining !== null && typeof window.dbnUpdateCredits === 'function') {
|
||||||
|
window.dbnUpdateCredits(parseInt(creditsRemaining, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder('utf-8');
|
||||||
|
let buffer = '';
|
||||||
|
let finalResult = null;
|
||||||
|
let errorEvent = null;
|
||||||
|
let clarifyEvent = null;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
let chunk;
|
||||||
|
try { chunk = await reader.read(); }
|
||||||
|
catch (err) {
|
||||||
|
setStatus(`Stream error: ${err.message || err}`, 'error');
|
||||||
|
els.runButton.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { done, value } = chunk;
|
||||||
|
if (value) {
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop();
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
let evt; try { evt = JSON.parse(trimmed); } catch (_) { continue; }
|
||||||
|
if (!evt || !evt.event) continue;
|
||||||
|
if (evt.event === 'progress') { setStatus(evt.detail || 'Working…', 'busy'); continue; }
|
||||||
|
if (evt.event === 'start') { setStatus(`Started — ${evt.body} / ${evt.output_type}`, 'busy'); continue; }
|
||||||
|
if (evt.event === 'classify') { lastClassify = evt.result; renderClassifySummary(evt.result); continue; }
|
||||||
|
if (evt.event === 'retrieval'){ setStatus(`Hentet ${evt.sources_count} lovkilder for ${(evt.applicable_acts || []).join(', ')}…`, 'busy'); continue; }
|
||||||
|
if (evt.event === 'clarify') { clarifyEvent = evt; continue; }
|
||||||
|
if (evt.event === 'final') { finalResult = evt.result; continue; }
|
||||||
|
if (evt.event === 'error') { errorEvent = evt; continue; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (done) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
els.runButton.disabled = false;
|
||||||
|
|
||||||
|
if (errorEvent) {
|
||||||
|
setStatus(`${errorEvent.code}: ${errorEvent.message}`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (clarifyEvent) {
|
||||||
|
setStatus('Need clarification before drafting.', 'busy');
|
||||||
|
showClarify(clarifyEvent.questions);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!finalResult) {
|
||||||
|
setStatus('Stream ended without a draft.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(`Done in ${Math.round((finalResult.latency_ms || 0) / 1000)} s · ${(finalResult.cited_law || []).length} cited source(s)`, 'ok');
|
||||||
|
renderFinal(finalResult);
|
||||||
|
pendingClarifications = {}; // reset for next run
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rendering ───────────────────────────────────────────────────────────────
|
||||||
|
function renderClassifySummary(c) {
|
||||||
|
if (!c || !c.summary) return;
|
||||||
|
let block = els.results.querySelector('#korrClassifyBlock');
|
||||||
|
if (!block) {
|
||||||
|
block = document.createElement('div');
|
||||||
|
block.id = 'korrClassifyBlock';
|
||||||
|
block.className = 'dr-result-block';
|
||||||
|
els.results.innerHTML = '';
|
||||||
|
els.results.appendChild(block);
|
||||||
|
}
|
||||||
|
block.innerHTML = `
|
||||||
|
<h3 style="margin:0 0 10px;font-size:0.95rem;color:var(--ink)">Sammendrag (Pass 1)</h3>
|
||||||
|
<p>${esc(c.summary)}</p>
|
||||||
|
${c.applicable_acts && c.applicable_acts.length ? `<p class="upload-hint"><strong>Antatt rettslig grunnlag:</strong> ${c.applicable_acts.map(esc).join(', ')}</p>` : ''}
|
||||||
|
${c.deadlines && c.deadlines.length ? `<p class="upload-hint"><strong>Frister:</strong> ${c.deadlines.map(esc).join(', ')}</p>` : ''}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFinal(data) {
|
||||||
|
const userLang = data.draft_user_lang || 'en';
|
||||||
|
const userLangLabel = LANG_LABELS[userLang] || userLang.toUpperCase();
|
||||||
|
const flags = data.self_check || {};
|
||||||
|
const cited = data.cited_law || [];
|
||||||
|
|
||||||
|
const flagBadge = (key, label) => {
|
||||||
|
const v = flags[key] || 'ok';
|
||||||
|
const cls = v === 'ok' ? 'is-ok' : (v === 'warn' ? 'is-warn' : 'is-error');
|
||||||
|
const icon = v === 'ok' ? '✓' : '!';
|
||||||
|
return `<span class="korr-flag ${cls}">${icon} ${esc(label)}</span>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const draftNo = data.draft_no || '';
|
||||||
|
const draftUser = data.draft_user || '';
|
||||||
|
const isSameLang = userLang === 'no';
|
||||||
|
|
||||||
|
els.results.innerHTML = `
|
||||||
|
<div class="korr-result-head">
|
||||||
|
<span class="tool-badge">${esc(data.recipient_body || '')} · ${esc(data.output_type || '')} · ${esc(data.tone || '')}</span>
|
||||||
|
<div class="korr-flags">
|
||||||
|
${flagBadge('citations_verified', 'Citations verified')}
|
||||||
|
${flagBadge('deadline_mentioned', 'Deadline')}
|
||||||
|
${flagBadge('goal_addressed', 'Goal addressed')}
|
||||||
|
${flagBadge('tone', 'Tone')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="korr-drafts ${isSameLang ? 'is-single' : ''}">
|
||||||
|
<div class="korr-draft-col">
|
||||||
|
<div class="korr-draft-head">
|
||||||
|
<h3>Norsk (bokmål) — kanonisk</h3>
|
||||||
|
<div class="korr-draft-actions">
|
||||||
|
<button type="button" class="secondary-button" data-copy="no">Copy</button>
|
||||||
|
<button type="button" class="secondary-button" data-download="no">Download .txt</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre class="korr-draft-body" id="korrDraftNo">${esc(draftNo)}</pre>
|
||||||
|
</div>
|
||||||
|
${isSameLang ? '' : `
|
||||||
|
<div class="korr-draft-col">
|
||||||
|
<div class="korr-draft-head">
|
||||||
|
<h3>${esc(userLangLabel)} — reference</h3>
|
||||||
|
<div class="korr-draft-actions">
|
||||||
|
<button type="button" class="secondary-button" data-copy="user">Copy</button>
|
||||||
|
<button type="button" class="secondary-button" data-download="user">Download .txt</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre class="korr-draft-body" id="korrDraftUser">${esc(draftUser)}</pre>
|
||||||
|
</div>`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${cited.length ? `
|
||||||
|
<details class="korr-cited" open>
|
||||||
|
<summary><strong>Cited law (${cited.length})</strong> — each reference traces to a real corpus passage</summary>
|
||||||
|
<div class="korr-cited-list">
|
||||||
|
${cited.map((s) => `
|
||||||
|
<div class="korr-cited-item">
|
||||||
|
<div class="korr-cited-head"><strong>[${s.n}] ${esc(s.title)}</strong>${s.section ? ' — ' + esc(s.section) : ''}</div>
|
||||||
|
<p class="korr-cited-excerpt">${esc(s.excerpt || '')}</p>
|
||||||
|
${s.source_url ? `<a href="${esc(s.source_url)}" target="_blank" rel="noopener">View source</a>` : ''}
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
` : '<p class="upload-hint"><em>No cited law sources — draft is plain-language (no § references available from corpus).</em></p>'}
|
||||||
|
|
||||||
|
${data.disclaimer ? `<p class="upload-hint" style="margin-top:16px;font-style:italic">${esc(data.disclaimer)}</p>` : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Wire copy/download
|
||||||
|
els.results.querySelectorAll('[data-copy]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const target = btn.dataset.copy === 'no' ? draftNo : draftUser;
|
||||||
|
navigator.clipboard?.writeText(target).then(
|
||||||
|
() => { btn.textContent = 'Copied ✓'; setTimeout(() => btn.textContent = 'Copy', 1500); },
|
||||||
|
() => { btn.textContent = 'Failed'; }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
els.results.querySelectorAll('[data-download]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const target = btn.dataset.download === 'no' ? draftNo : draftUser;
|
||||||
|
const suffix = btn.dataset.download === 'no' ? 'no' : userLang;
|
||||||
|
downloadText(`korrespond-${data.recipient_body}-${suffix}.txt`, target);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── utils ───────────────────────────────────────────────────────────────────
|
||||||
|
function setStatus(message, kind) {
|
||||||
|
if (!els.status) return;
|
||||||
|
els.status.textContent = message || '';
|
||||||
|
els.status.dataset.kind = kind || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s == null ? '' : s)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadText(filename, text) {
|
||||||
|
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url; a.download = filename;
|
||||||
|
document.body.appendChild(a); a.click(); a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-1
@@ -473,6 +473,7 @@ function dbnToolsLaunchedTools(?string $language = null): array
|
|||||||
'transcribe' => ['Transcribe', 'Audio and meetings', 'Turn audio or video into text with speaker separation and legal vocabulary support.', 'Whisper / GPU'],
|
'transcribe' => ['Transcribe', 'Audio and meetings', 'Turn audio or video into text with speaker separation and legal vocabulary support.', 'Whisper / GPU'],
|
||||||
'timeline' => ['Timeline', 'Events and deadlines', 'Extract dates, hearings, Barnevernet milestones, and legal deadlines from notes or files.', 'Process-and-forget'],
|
'timeline' => ['Timeline', 'Events and deadlines', 'Extract dates, hearings, Barnevernet milestones, and legal deadlines from notes or files.', 'Process-and-forget'],
|
||||||
'redact' => ['Redact', 'Privacy protection', 'Remove names, ID numbers, phone numbers, and addresses before sharing documents.', 'Deterministic first'],
|
'redact' => ['Redact', 'Privacy protection', 'Remove names, ID numbers, phone numbers, and addresses before sharing documents.', 'Deterministic first'],
|
||||||
|
'korrespond' => ['Korrespond', 'Draft & reply to authorities', 'Draft replies or new correspondence to NAV, Barnevernet, schools, Bufdir and other Norwegian authorities — Norwegian + your language, side-by-side, citations verified against the legal corpus.', 'Hard-RAG · Norsk + EN/PL/UK'],
|
||||||
'barnevernet' => ['BVJ Analyzer', 'Barnevernet documents', 'Analyze child-welfare documents from your perspective with procedural red flags and citations.', 'Document + RAG'],
|
'barnevernet' => ['BVJ Analyzer', 'Barnevernet documents', 'Analyze child-welfare documents from your perspective with procedural red flags and citations.', 'Document + RAG'],
|
||||||
'advocate' => ['Advocate', 'Partisan brief', 'Choose who you represent and generate a source-grounded brief for that position.', 'ECHR + Lovdata'],
|
'advocate' => ['Advocate', 'Partisan brief', 'Choose who you represent and generate a source-grounded brief for that position.', 'ECHR + Lovdata'],
|
||||||
'deep-research' => ['Deep Research', 'Agent + RAG', 'Expand a question into research angles, search legal slices, and synthesize a cited brief.', 'Family-legal'],
|
'deep-research' => ['Deep Research', 'Agent + RAG', 'Expand a question into research angles, search legal slices, and synthesize a cited brief.', 'Family-legal'],
|
||||||
@@ -484,6 +485,7 @@ function dbnToolsLaunchedTools(?string $language = null): array
|
|||||||
'transcribe' => ['Transkriber', 'Lyd og møter', 'Gjør lyd eller video om til tekst med talerinndeling og juridisk ordforråd.', 'Whisper / GPU'],
|
'transcribe' => ['Transkriber', 'Lyd og møter', 'Gjør lyd eller video om til tekst med talerinndeling og juridisk ordforråd.', 'Whisper / GPU'],
|
||||||
'timeline' => ['Tidslinje', 'Hendelser og frister', 'Hent ut datoer, møter, barnevernsmilepæler og juridiske frister fra notater eller filer.', 'Behandles og glemmes'],
|
'timeline' => ['Tidslinje', 'Hendelser og frister', 'Hent ut datoer, møter, barnevernsmilepæler og juridiske frister fra notater eller filer.', 'Behandles og glemmes'],
|
||||||
'redact' => ['Sladder', 'Personvern', 'Fjern navn, ID-numre, telefonnumre og adresser før du deler dokumenter.', 'Deterministisk først'],
|
'redact' => ['Sladder', 'Personvern', 'Fjern navn, ID-numre, telefonnumre og adresser før du deler dokumenter.', 'Deterministisk først'],
|
||||||
|
'korrespond' => ['Korrespond', 'Brev og svar til myndighetene', 'Skriv utkast til svar eller nytt brev til NAV, barnevernet, skolen, Bufdir og andre norske myndigheter — bokmål + ditt språk side om side, med verifiserte lovhenvisninger.', 'Hard-RAG · Norsk + EN/PL/UK'],
|
||||||
'barnevernet' => ['BVJ-analyse', 'Barnevernsdokumenter', 'Analyser barnevernsdokumenter fra ditt perspektiv med prosessuelle røde flagg og kilder.', 'Dokument + RAG'],
|
'barnevernet' => ['BVJ-analyse', 'Barnevernsdokumenter', 'Analyser barnevernsdokumenter fra ditt perspektiv med prosessuelle røde flagg og kilder.', 'Dokument + RAG'],
|
||||||
'advocate' => ['Advokatmodus', 'Partsinnlegg', 'Velg hvem du representerer og lag et kildebelagt innlegg for den posisjonen.', 'EMD + Lovdata'],
|
'advocate' => ['Advokatmodus', 'Partsinnlegg', 'Velg hvem du representerer og lag et kildebelagt innlegg for den posisjonen.', 'EMD + Lovdata'],
|
||||||
'deep-research' => ['Dyp research', 'Agent + RAG', 'Utvid et spørsmål til forskningsvinkler, søk juridiske kilder og lag et kildebelagt notat.', 'Familierett'],
|
'deep-research' => ['Dyp research', 'Agent + RAG', 'Utvid et spørsmål til forskningsvinkler, søk juridiske kilder og lag et kildebelagt notat.', 'Familierett'],
|
||||||
@@ -495,6 +497,7 @@ function dbnToolsLaunchedTools(?string $language = null): array
|
|||||||
'transcribe' => ['Транскрипція', 'Аудіо та зустрічі', 'Перетворюйте аудіо або відео на текст із розділенням мовців і юридичною лексикою.', 'Whisper / GPU'],
|
'transcribe' => ['Транскрипція', 'Аудіо та зустрічі', 'Перетворюйте аудіо або відео на текст із розділенням мовців і юридичною лексикою.', 'Whisper / GPU'],
|
||||||
'timeline' => ['Хронологія', 'Події та строки', 'Витягуйте дати, слухання, етапи Barnevernet і юридичні строки з нотаток або файлів.', 'Обробити і забути'],
|
'timeline' => ['Хронологія', 'Події та строки', 'Витягуйте дати, слухання, етапи Barnevernet і юридичні строки з нотаток або файлів.', 'Обробити і забути'],
|
||||||
'redact' => ['Редагування', 'Захист приватності', 'Видаляйте імена, ідентифікаційні номери, телефони та адреси перед поширенням документів.', 'Детермінований метод'],
|
'redact' => ['Редагування', 'Захист приватності', 'Видаляйте імена, ідентифікаційні номери, телефони та адреси перед поширенням документів.', 'Детермінований метод'],
|
||||||
|
'korrespond' => ['Korrespond', 'Листи і відповіді органам влади', 'Створюйте чернетки відповідей або нових листів до NAV, Barnevernet, школи, Bufdir та інших норвезьких органів — норвезькою + вашою мовою поряд, із перевіреними посиланнями на закон.', 'Hard-RAG · Norsk + EN/PL/UK'],
|
||||||
'barnevernet' => ['BVJ аналізатор', 'Документи Barnevernet', 'Аналізуйте документи захисту дітей з вашої позиції, з процесуальними ризиками та джерелами.', 'Документ + RAG'],
|
'barnevernet' => ['BVJ аналізатор', 'Документи Barnevernet', 'Аналізуйте документи захисту дітей з вашої позиції, з процесуальними ризиками та джерелами.', 'Документ + RAG'],
|
||||||
'advocate' => ['Адвокат', 'Позиційний бриф', 'Оберіть, кого представляєте, і створіть бриф із джерелами на підтримку цієї позиції.', 'ЄСПЛ + Lovdata'],
|
'advocate' => ['Адвокат', 'Позиційний бриф', 'Оберіть, кого представляєте, і створіть бриф із джерелами на підтримку цієї позиції.', 'ЄСПЛ + Lovdata'],
|
||||||
'deep-research' => ['Глибоке дослідження', 'Agent + RAG', 'Розгортає питання в дослідницькі напрями, шукає юридичні джерела та створює бриф.', 'Сімейне право'],
|
'deep-research' => ['Глибоке дослідження', 'Agent + RAG', 'Розгортає питання в дослідницькі напрями, шукає юридичні джерела та створює бриф.', 'Сімейне право'],
|
||||||
@@ -506,6 +509,7 @@ function dbnToolsLaunchedTools(?string $language = null): array
|
|||||||
'transcribe' => ['Transkrypcja', 'Audio i spotkania', 'Zamień audio lub wideo na tekst z rozdzieleniem mówców i słownictwem prawnym.', 'Whisper / GPU'],
|
'transcribe' => ['Transkrypcja', 'Audio i spotkania', 'Zamień audio lub wideo na tekst z rozdzieleniem mówców i słownictwem prawnym.', 'Whisper / GPU'],
|
||||||
'timeline' => ['Oś czasu', 'Wydarzenia i terminy', 'Wyodrębniaj daty, rozprawy, etapy Barnevernet i terminy prawne z notatek lub plików.', 'Przetwórz i zapomnij'],
|
'timeline' => ['Oś czasu', 'Wydarzenia i terminy', 'Wyodrębniaj daty, rozprawy, etapy Barnevernet i terminy prawne z notatek lub plików.', 'Przetwórz i zapomnij'],
|
||||||
'redact' => ['Redakcja', 'Ochrona prywatności', 'Usuń imiona, numery identyfikacyjne, telefony i adresy przed udostępnieniem dokumentów.', 'Metoda deterministyczna'],
|
'redact' => ['Redakcja', 'Ochrona prywatności', 'Usuń imiona, numery identyfikacyjne, telefony i adresy przed udostępnieniem dokumentów.', 'Metoda deterministyczna'],
|
||||||
|
'korrespond' => ['Korrespond', 'Pisma i odpowiedzi do urzędów', 'Twórz projekty odpowiedzi lub nowych pism do NAV, Barnevernet, szkoły, Bufdir i innych norweskich organów — norweski + Twój język obok siebie, ze zweryfikowanymi odniesieniami do ustaw.', 'Hard-RAG · Norsk + EN/PL/UK'],
|
||||||
'barnevernet' => ['Analizator BVJ', 'Dokumenty Barnevernet', 'Analizuj dokumenty opieki nad dziećmi z Twojej perspektywy, z ryzykami proceduralnymi i źródłami.', 'Dokument + RAG'],
|
'barnevernet' => ['Analizator BVJ', 'Dokumenty Barnevernet', 'Analizuj dokumenty opieki nad dziećmi z Twojej perspektywy, z ryzykami proceduralnymi i źródłami.', 'Dokument + RAG'],
|
||||||
'advocate' => ['Adwokat', 'Stronniczy brief', 'Wybierz, kogo reprezentujesz, i wygeneruj brief oparty na źródłach dla tej pozycji.', 'ETPC + Lovdata'],
|
'advocate' => ['Adwokat', 'Stronniczy brief', 'Wybierz, kogo reprezentujesz, i wygeneruj brief oparty na źródłach dla tej pozycji.', 'ETPC + Lovdata'],
|
||||||
'deep-research' => ['Głębokie badanie', 'Agent + RAG', 'Rozwija pytanie w kierunki badawcze, przeszukuje źródła prawne i tworzy brief z cytatami.', 'Prawo rodzinne'],
|
'deep-research' => ['Głębokie badanie', 'Agent + RAG', 'Rozwija pytanie w kierunki badawcze, przeszukuje źródła prawne i tworzy brief z cytatami.', 'Prawo rodzinne'],
|
||||||
@@ -516,11 +520,12 @@ function dbnToolsLaunchedTools(?string $language = null): array
|
|||||||
];
|
];
|
||||||
|
|
||||||
$selected = $copy[$language] ?? $copy['en'];
|
$selected = $copy[$language] ?? $copy['en'];
|
||||||
$order = ['transcribe', 'timeline', 'redact', 'barnevernet', 'advocate', 'deep-research', 'discrepancy', 'corpus', 'citations'];
|
$order = ['transcribe', 'timeline', 'redact', 'korrespond', 'barnevernet', 'advocate', 'deep-research', 'discrepancy', 'corpus', 'citations'];
|
||||||
$icons = [
|
$icons = [
|
||||||
'transcribe' => 'TR',
|
'transcribe' => 'TR',
|
||||||
'timeline' => 'TL',
|
'timeline' => 'TL',
|
||||||
'redact' => 'RX',
|
'redact' => 'RX',
|
||||||
|
'korrespond' => 'KOR',
|
||||||
'barnevernet' => 'BVJ',
|
'barnevernet' => 'BVJ',
|
||||||
'advocate' => 'ADV',
|
'advocate' => 'ADV',
|
||||||
'deep-research' => 'DR',
|
'deep-research' => 'DR',
|
||||||
|
|||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
$toolName = 'korrespond';
|
||||||
|
$toolTitle = 'Korrespond';
|
||||||
|
$toolKind = 'Draft & reply to Norwegian authorities';
|
||||||
|
$toolBadge = 'Hard-RAG · Norsk + EN/PL/UK';
|
||||||
|
$extraScripts = ['assets/js/korrespond.js'];
|
||||||
|
require_once __DIR__ . '/includes/layout.php';
|
||||||
|
?>
|
||||||
|
<form id="korrForm" class="tool-form deep-research" enctype="multipart/form-data">
|
||||||
|
|
||||||
|
<div class="lang-switcher" id="korrLangSwitcher" role="group" aria-label="UI language">
|
||||||
|
<button type="button" class="lang-btn is-active" data-lang="en">🇬🇧 EN</button>
|
||||||
|
<button type="button" class="lang-btn" data-lang="no">🇳🇴 NO</button>
|
||||||
|
<button type="button" class="lang-btn" data-lang="uk">🇺🇦 UK</button>
|
||||||
|
<button type="button" class="lang-btn" data-lang="pl">🇵🇱 PL</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mode toggle -->
|
||||||
|
<div class="control-row" id="korrModeControl">
|
||||||
|
<span class="control-label">Mode</span>
|
||||||
|
<label><input type="radio" name="korrMode" value="reply"> <strong>Reply</strong> <small class="control-hint">to a letter / decision / notice you received</small></label>
|
||||||
|
<label><input type="radio" name="korrMode" value="initiate" checked> <strong>Initiate</strong> <small class="control-hint">start a new correspondence</small></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recipient body -->
|
||||||
|
<div class="adv-role-row">
|
||||||
|
<label class="control-label" for="korrBody">Recipient body</label>
|
||||||
|
<select id="korrBody" class="adv-role-select" required>
|
||||||
|
<option value="">— Velg mottaker —</option>
|
||||||
|
<option value="barnehage">Barnehage</option>
|
||||||
|
<option value="school_1_10">Skole (1.–10. trinn)</option>
|
||||||
|
<option value="sfo">SFO</option>
|
||||||
|
<option value="nav">NAV</option>
|
||||||
|
<option value="bufdir">Bufdir (adopsjon, surrogati)</option>
|
||||||
|
<option value="barnevernet">Barnevernet</option>
|
||||||
|
<option value="kommune_other">Kommunen (annet)</option>
|
||||||
|
<option value="statsforvalter">Statsforvalteren</option>
|
||||||
|
<option value="trygderetten">Trygderetten</option>
|
||||||
|
<option value="tingrett">Tingretten</option>
|
||||||
|
<option value="other">Annet</option>
|
||||||
|
</select>
|
||||||
|
<p class="upload-hint">Each recipient preset pulls the relevant statute set into the hard-RAG retrieval (fvl, barnevernsloven, NAV-loven, opplæringslova, barnehageloven, EMK).</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Output type -->
|
||||||
|
<div class="control-row" id="korrOutputControl">
|
||||||
|
<span class="control-label">Output type</span>
|
||||||
|
<label><input type="radio" name="korrOutput" value="email" checked> Email</label>
|
||||||
|
<label><input type="radio" name="korrOutput" value="formal"> Formal letter</label>
|
||||||
|
<label><input type="radio" name="korrOutput" value="filing"> Court/tribunal filing</label>
|
||||||
|
<label><input type="radio" name="korrOutput" value="call_prep"> Phone-call prep</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tone -->
|
||||||
|
<div class="control-row" id="korrToneControl">
|
||||||
|
<span class="control-label">Tone</span>
|
||||||
|
<label><input type="radio" name="korrTone" value="cooperative"> Cooperative</label>
|
||||||
|
<label><input type="radio" name="korrTone" value="neutral" checked> Neutral-professional ★</label>
|
||||||
|
<label><input type="radio" name="korrTone" value="firm"> Firm</label>
|
||||||
|
<label><input type="radio" name="korrTone" value="adversarial"> Adversarial</label>
|
||||||
|
<label><input type="radio" name="korrTone" value="warm"> Conciliatory-warm</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Case context fields -->
|
||||||
|
<div class="dr-control-grid">
|
||||||
|
<div class="dr-control-card">
|
||||||
|
<label for="korrCaseRef">Case reference (saksnummer)</label>
|
||||||
|
<input type="text" id="korrCaseRef" maxlength="120" placeholder="e.g. 2026/12345">
|
||||||
|
</div>
|
||||||
|
<div class="dr-control-card">
|
||||||
|
<label for="korrWhere">Where (kommune / fylke)</label>
|
||||||
|
<input type="text" id="korrWhere" maxlength="200" placeholder="e.g. Trondheim kommune">
|
||||||
|
</div>
|
||||||
|
<div class="dr-control-card">
|
||||||
|
<label for="korrDeadline">Next deadline</label>
|
||||||
|
<input type="text" id="korrDeadline" maxlength="60" placeholder="YYYY-MM-DD or '3 weeks'">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="input-label" for="korrParties">Who is involved? <span class="optional-hint">(names + roles)</span></label>
|
||||||
|
<textarea id="korrParties" rows="2" maxlength="2000" placeholder="e.g. Me (parent), caseworker Anna Hansen, child Ola (age 5). Use the Redact tool first if you'll share externally."></textarea>
|
||||||
|
|
||||||
|
<label class="input-label" for="korrNarrative">What happened / context <span class="required-hint">(required for initiate mode)</span></label>
|
||||||
|
<textarea id="korrNarrative" rows="6" maxlength="8000" placeholder="Describe the situation: what happened, when, who decided what, what you want."></textarea>
|
||||||
|
|
||||||
|
<label class="input-label" for="korrGoal">Goal of this letter <span class="optional-hint">(or pick a chip)</span></label>
|
||||||
|
<input type="text" id="korrGoal" maxlength="600" placeholder="e.g. request access to all case documents under forvaltningsloven §18">
|
||||||
|
<div class="korr-goal-chips" id="korrGoalChips" role="group" aria-label="Common goals">
|
||||||
|
<button type="button" class="korr-chip" data-goal="Request access to case documents (forvaltningsloven §18)">Access to docs (fvl §18)</button>
|
||||||
|
<button type="button" class="korr-chip" data-goal="Appeal the decision (forvaltningsloven §28)">Appeal (fvl §28)</button>
|
||||||
|
<button type="button" class="korr-chip" data-goal="Request a meeting">Request meeting</button>
|
||||||
|
<button type="button" class="korr-chip" data-goal="Request reasoned written decision (forvaltningsloven §24-25)">Reasoned decision (fvl §24-25)</button>
|
||||||
|
<button type="button" class="korr-chip" data-goal="Invoke right to be heard (forvaltningsloven §17)">Right to be heard (fvl §17)</button>
|
||||||
|
<button type="button" class="korr-chip" data-goal="Complaint about caseworker conduct">Complaint</button>
|
||||||
|
<button type="button" class="korr-chip" data-goal="Clarify status / timeline of the case">Clarify timeline</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File upload (required for Reply mode) -->
|
||||||
|
<div class="upload-zone" id="korrUploadZone" role="region" aria-label="File upload">
|
||||||
|
<input type="file" id="korrUploadInput" multiple accept=".pdf,.docx,.txt" aria-label="Choose received letter or attachments">
|
||||||
|
<div id="korrUploadPrompt" class="upload-prompt">
|
||||||
|
<span class="upload-icon" aria-hidden="true">⇧</span>
|
||||||
|
<p>Upload the received letter (reply mode) or supporting attachments, or <label for="korrUploadInput" class="upload-browse">browse</label></p>
|
||||||
|
<p class="upload-hint"><strong>PDF</strong>, <strong>DOCX</strong>, <strong>TXT</strong> — up to 4 files, max 8 MB each — processed in memory.</p>
|
||||||
|
</div>
|
||||||
|
<div id="korrUploadFileInfo" class="upload-file is-hidden">
|
||||||
|
<ul id="korrUploadFileList" class="upload-file-list"></ul>
|
||||||
|
<button type="button" id="korrUploadClear" class="upload-clear">× Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-footer">
|
||||||
|
<p id="korrStatus" class="form-status" role="status" aria-live="polite"></p>
|
||||||
|
<button id="korrRunButton" type="submit">Draft</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Clarify panel (shown if Pass 1 returns missing_facts) -->
|
||||||
|
<section id="korrClarifyPanel" class="korr-clarify-panel is-hidden" aria-labelledby="korrClarifyTitle">
|
||||||
|
<h3 id="korrClarifyTitle">Before we draft, clarify:</h3>
|
||||||
|
<p class="upload-hint">Answer what you can, then click <em>Continue draft</em>. Or click <em>Draft anyway</em> to proceed with what we have.</p>
|
||||||
|
<div id="korrClarifyList" class="korr-clarify-list"></div>
|
||||||
|
<div class="korr-clarify-actions">
|
||||||
|
<button type="button" id="korrClarifyContinue" class="primary-button">Continue draft</button>
|
||||||
|
<button type="button" id="korrClarifyForce" class="secondary-button">Draft anyway</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="korrResults" class="results deep-research-results" aria-live="polite">
|
||||||
|
<div class="empty-state">
|
||||||
|
<h3>Ready</h3>
|
||||||
|
<p>Pick a recipient body, describe the situation, choose an output type and tone, then run. Drafts always come back in Norwegian bokmål + your working language, side-by-side, with verified law citations.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Hidden stubs so tools.js element refs don't crash on this page -->
|
||||||
|
<div class="is-hidden" id="languageControl" aria-hidden="true">
|
||||||
|
<input type="radio" name="language" value="en" checked>
|
||||||
|
<input type="radio" name="language" value="no">
|
||||||
|
<input type="radio" name="language" value="uk">
|
||||||
|
<input type="radio" name="language" value="pl">
|
||||||
|
</div>
|
||||||
|
<div class="is-hidden" id="redactionControl" aria-hidden="true"></div>
|
||||||
|
<div class="is-hidden" id="audioZone" aria-hidden="true">
|
||||||
|
<input type="file" id="audioInput" style="display:none">
|
||||||
|
<div id="audioPrompt"></div>
|
||||||
|
<div id="audioFileInfo"><ol id="audioQueueList"></ol><button type="button" id="audioClear"></button></div>
|
||||||
|
</div>
|
||||||
|
<div class="is-hidden" id="diarizeControl" aria-hidden="true">
|
||||||
|
<input type="checkbox" id="diarizeCheck">
|
||||||
|
<input type="number" id="numSpeakersInput">
|
||||||
|
</div>
|
||||||
|
<div class="is-hidden" id="transcribeLangControl" aria-hidden="true"><input type="radio" name="transcribeLang" value="no" checked></div>
|
||||||
|
<div class="is-hidden" id="vocabControl" aria-hidden="true">
|
||||||
|
<div id="vocabPresets"></div>
|
||||||
|
<textarea id="initPromptInput"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="is-hidden" id="aliasSection" aria-hidden="true">
|
||||||
|
<button type="button" id="addAliasRow"></button>
|
||||||
|
<div id="aliasRows"></div>
|
||||||
|
</div>
|
||||||
|
<div class="is-hidden" id="exemptSection" aria-hidden="true">
|
||||||
|
<button type="button" id="addExemptRow"></button>
|
||||||
|
<div id="exemptRows"></div>
|
||||||
|
</div>
|
||||||
|
<div class="is-hidden" id="uploadZone" aria-hidden="true">
|
||||||
|
<input type="file" id="uploadInput">
|
||||||
|
<div id="uploadPrompt"></div>
|
||||||
|
<div id="uploadFileInfo"><ul id="uploadFileList"></ul><button type="button" id="uploadClear"></button></div>
|
||||||
|
</div>
|
||||||
|
<textarea class="is-hidden" id="toolInput" aria-hidden="true"></textarea>
|
||||||
|
<?php require_once __DIR__ . '/includes/layout_footer.php'; ?>
|
||||||
Reference in New Issue
Block a user