Per-tool pages + multi-engine transcribe with expert controls
- Split monolithic index.php into per-tool pages (ask, search, summarize, timeline, redact, transcribe), each with its own URL and bookmarkable state - Shared shell: includes/layout.php + layout_footer.php; shared form: includes/tool_form.php used by all text-tool pages - index.php now redirects authenticated users to ask.php; unauthenticated users see the login gate only - transcribe.php: engine selector (GPU/OpenAI/Azure), model size (small/ medium/large-v3), diarize, language, expert settings (beam, VAD, task, initial prompt) - api/transcribe.php: engine routing — GPU (cuttlefish), OpenAI BYOK, Azure AI Speech; passes model/beam/task/vad/prompt to Whisper server - tools.js: data-active-tool body attr drives setTool() on load; <a> nav tabs skip click listeners; null guards on form/passcodeForm; engine radio toggle shows/hides BYOK key inputs and model selector; RTF shown in status - tools.css: styles for BYOK inputs, expert settings panel, prompt textarea Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+261
-77
@@ -6,12 +6,21 @@ require_once __DIR__ . '/../includes/LegalTools.php';
|
|||||||
dbnToolsRequireMethod('POST');
|
dbnToolsRequireMethod('POST');
|
||||||
dbnToolsRequireAuth();
|
dbnToolsRequireAuth();
|
||||||
|
|
||||||
$validLangs = ['auto', 'no', 'en', 'sv', 'da', 'de', 'fr', 'es', 'pl'];
|
// ── Common params ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$validLangs = ['auto', 'no', 'nn', 'en', 'sv', 'da', 'de', 'fr', 'es', 'pl', 'fi', 'nl', 'it', 'pt', 'ru', 'ar', 'tr', 'zh', 'ja', 'ko'];
|
||||||
$language = strtolower(trim((string)($_POST['language'] ?? 'auto')));
|
$language = strtolower(trim((string)($_POST['language'] ?? 'auto')));
|
||||||
if (!in_array($language, $validLangs, true)) $language = 'auto';
|
if (!in_array($language, $validLangs, true)) $language = 'auto';
|
||||||
|
|
||||||
$diarize = !empty($_POST['diarize']) && $_POST['diarize'] !== '0';
|
$diarize = !empty($_POST['diarize']) && $_POST['diarize'] !== '0';
|
||||||
$numSpeakers = isset($_POST['num_speakers']) ? max(0, min(20, (int)$_POST['num_speakers'])) : 0;
|
$numSpeakers = isset($_POST['num_speakers']) ? max(0, min(20, (int)$_POST['num_speakers'])) : 0;
|
||||||
|
$engine = in_array($_POST['engine'] ?? '', ['gpu', 'openai', 'azure'], true) ? $_POST['engine'] : 'gpu';
|
||||||
|
$validModels = ['tiny', 'base', 'small', 'medium', 'large-v2', 'large-v3'];
|
||||||
|
$model = in_array($_POST['model'] ?? '', $validModels, true) ? $_POST['model'] : 'small';
|
||||||
|
$beamSize = max(1, min(5, (int)($_POST['beam_size'] ?? 5)));
|
||||||
|
$task = ($_POST['task'] ?? 'transcribe') === 'translate' ? 'translate' : 'transcribe';
|
||||||
|
$vadFilter = !empty($_POST['vad_filter']) && $_POST['vad_filter'] !== '0';
|
||||||
|
$initPrompt = substr(trim((string)($_POST['initial_prompt'] ?? '')), 0, 500);
|
||||||
|
|
||||||
// ── Validate upload ───────────────────────────────────────────────────────────
|
// ── Validate upload ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -39,88 +48,41 @@ if (!in_array($ext, $allowedExts, true)) {
|
|||||||
dbnToolsError("Unsupported format: .{$ext}. Use MP3, WAV, OGG, M4A, FLAC, or WebM.", 415, 'unsupported_format');
|
dbnToolsError("Unsupported format: .{$ext}. Use MP3, WAV, OGG, M4A, FLAC, or WebM.", 415, 'unsupported_format');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Build Whisper request ─────────────────────────────────────────────────────
|
// OpenAI has a 25 MB file limit
|
||||||
|
if ($engine === 'openai' && $file['size'] > 25 * 1024 * 1024) {
|
||||||
$whisperBase = 'http://194.93.49.14:20019';
|
dbnToolsError('OpenAI Whisper API has a 25 MB file limit. Use the GPU engine for larger files.', 413, 'openai_file_too_large');
|
||||||
$endpoint = $diarize ? $whisperBase . '/transcribe/diarize' : $whisperBase . '/transcribe';
|
|
||||||
|
|
||||||
$boundary = '----DBN' . bin2hex(random_bytes(8));
|
|
||||||
$body = "--{$boundary}\r\n";
|
|
||||||
$body .= 'Content-Disposition: form-data; name="file"; filename="' . addslashes(basename($file['name'])) . '"' . "\r\n";
|
|
||||||
$body .= "Content-Type: application/octet-stream\r\n\r\n";
|
|
||||||
|
|
||||||
$fileContents = file_get_contents($file['tmp_name']);
|
|
||||||
if ($fileContents === false) {
|
|
||||||
dbnToolsError('Could not read uploaded file.', 500, 'file_read_error');
|
|
||||||
}
|
}
|
||||||
$body .= $fileContents . "\r\n";
|
|
||||||
|
|
||||||
if ($language !== 'auto') {
|
|
||||||
$body .= "--{$boundary}\r\n";
|
|
||||||
$body .= "Content-Disposition: form-data; name=\"language\"\r\n\r\n";
|
|
||||||
$body .= $language . "\r\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($diarize && $numSpeakers > 1) {
|
|
||||||
$body .= "--{$boundary}\r\n";
|
|
||||||
$body .= "Content-Disposition: form-data; name=\"num_speakers\"\r\n\r\n";
|
|
||||||
$body .= $numSpeakers . "\r\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
$body .= "--{$boundary}--\r\n";
|
|
||||||
|
|
||||||
// ── Call Whisper ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
$t0 = microtime(true);
|
$t0 = microtime(true);
|
||||||
|
|
||||||
if (function_exists('curl_init')) {
|
// ── Route to engine ───────────────────────────────────────────────────────────
|
||||||
$ch = curl_init($endpoint);
|
|
||||||
curl_setopt_array($ch, [
|
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
|
||||||
CURLOPT_POST => true,
|
|
||||||
CURLOPT_POSTFIELDS => $body,
|
|
||||||
CURLOPT_HTTPHEADER => [
|
|
||||||
"Content-Type: multipart/form-data; boundary={$boundary}",
|
|
||||||
'Accept: application/json',
|
|
||||||
],
|
|
||||||
CURLOPT_TIMEOUT => 600,
|
|
||||||
]);
|
|
||||||
$whisperBody = curl_exec($ch);
|
|
||||||
$httpCode = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
|
||||||
$curlErr = curl_error($ch);
|
|
||||||
curl_close($ch);
|
|
||||||
|
|
||||||
if ($whisperBody === false || $httpCode !== 200) {
|
if ($engine === 'openai') {
|
||||||
dbnToolsError('Whisper service error (HTTP ' . $httpCode . '): ' . $curlErr, 502, 'whisper_error');
|
$apiKey = trim((string)($_POST['openai_key'] ?? ''));
|
||||||
|
if (!$apiKey || !str_starts_with($apiKey, 'sk-')) {
|
||||||
|
dbnToolsError('A valid OpenAI API key (sk-…) is required for the OpenAI engine.', 400, 'missing_openai_key');
|
||||||
}
|
}
|
||||||
|
$result = transcribeViaOpenAI($file, $language, $task, $apiKey);
|
||||||
|
|
||||||
|
} elseif ($engine === 'azure') {
|
||||||
|
$apiKey = trim((string)($_POST['azure_key'] ?? ''));
|
||||||
|
$region = preg_replace('/[^a-z0-9]/', '', strtolower(trim((string)($_POST['azure_region'] ?? 'norwayeast'))));
|
||||||
|
if (!$apiKey) {
|
||||||
|
dbnToolsError('An Azure Speech API key is required for the Azure engine.', 400, 'missing_azure_key');
|
||||||
|
}
|
||||||
|
$result = transcribeViaAzure($file, $language, $apiKey, $region, $diarize);
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
$ctx = stream_context_create([
|
// GPU (default)
|
||||||
'http' => [
|
$result = transcribeViaWhisperGpu($file, $language, $diarize, $numSpeakers, $model, $beamSize, $task, $vadFilter, $initPrompt);
|
||||||
'method' => 'POST',
|
|
||||||
'timeout' => 600,
|
|
||||||
'header' => "Content-Type: multipart/form-data; boundary={$boundary}\r\nAccept: application/json\r\n",
|
|
||||||
'content' => $body,
|
|
||||||
'ignore_errors' => true,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
$whisperBody = @file_get_contents($endpoint, false, $ctx);
|
|
||||||
|
|
||||||
if ($whisperBody === false) {
|
|
||||||
dbnToolsError('Whisper service unreachable. The GPU may be offline.', 502, 'whisper_unreachable');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$latencyMs = (int)round((microtime(true) - $t0) * 1000);
|
$latencyMs = (int)round((microtime(true) - $t0) * 1000);
|
||||||
|
|
||||||
$whisper = json_decode($whisperBody, true);
|
// ── Speaker role labelling (GPU + diarize only) ───────────────────────────────
|
||||||
if (!is_array($whisper) || empty($whisper['text'])) {
|
|
||||||
dbnToolsError('Empty or invalid response from Whisper.', 502, 'whisper_empty');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Speaker role labelling ────────────────────────────────────────────────────
|
$segments = $result['segments'] ?? [];
|
||||||
|
$numDetected = (int)($result['num_speakers'] ?? 1);
|
||||||
$segments = is_array($whisper['segments'] ?? null) ? $whisper['segments'] : [];
|
|
||||||
$numDetected = (int)($whisper['num_speakers'] ?? 1);
|
|
||||||
|
|
||||||
if ($numDetected < 2 && $segments) {
|
if ($numDetected < 2 && $segments) {
|
||||||
$uniqueSpeakers = array_filter(array_unique(array_column($segments, 'speaker')));
|
$uniqueSpeakers = array_filter(array_unique(array_column($segments, 'speaker')));
|
||||||
@@ -132,10 +94,12 @@ if ($diarize && $numDetected > 1 && $segments) {
|
|||||||
$speakerRoles = dbnLabelSpeakerRoles($segments);
|
$speakerRoles = dbnLabelSpeakerRoles($segments);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Respond ───────────────────────────────────────────────────────────────────
|
// ── Log + respond ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
dbnToolsLogMetadata([
|
dbnToolsLogMetadata([
|
||||||
'tool' => 'transcribe',
|
'tool' => 'transcribe',
|
||||||
|
'engine' => $engine,
|
||||||
|
'model' => $model,
|
||||||
'language' => $language,
|
'language' => $language,
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'latency_ms' => $latencyMs,
|
'latency_ms' => $latencyMs,
|
||||||
@@ -144,17 +108,237 @@ dbnToolsLogMetadata([
|
|||||||
dbnToolsRespond([
|
dbnToolsRespond([
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'tool' => 'transcribe',
|
'tool' => 'transcribe',
|
||||||
'transcript' => (string)$whisper['text'],
|
'transcript' => (string)($result['text'] ?? ''),
|
||||||
'segments' => $segments,
|
'segments' => $segments,
|
||||||
'speaker_roles' => $speakerRoles,
|
'speaker_roles' => $speakerRoles,
|
||||||
'num_speakers' => $numDetected,
|
'num_speakers' => $numDetected,
|
||||||
'language' => (string)($whisper['language'] ?? $language),
|
'language' => (string)($result['language'] ?? $language),
|
||||||
'duration_sec' => round((float)($whisper['duration_seconds'] ?? 0), 2),
|
'duration_sec' => round((float)($result['duration_seconds'] ?? 0), 2),
|
||||||
'model' => (string)($whisper['model'] ?? 'whisper'),
|
'processing_sec'=> round((float)($result['processing_seconds'] ?? 0), 2),
|
||||||
|
'model' => (string)($result['model'] ?? ($engine === 'gpu' ? $model : $engine)),
|
||||||
|
'engine' => $engine,
|
||||||
'latency_ms' => $latencyMs,
|
'latency_ms' => $latencyMs,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ── Speaker role labelling helper ─────────────────────────────────────────────
|
|
||||||
|
// ── Engine implementations ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function transcribeViaWhisperGpu(array $file, string $language, bool $diarize, int $numSpeakers,
|
||||||
|
string $model, int $beamSize, string $task,
|
||||||
|
bool $vadFilter, string $initPrompt): array
|
||||||
|
{
|
||||||
|
$whisperBase = 'http://194.93.49.14:20019';
|
||||||
|
$endpoint = $diarize ? $whisperBase . '/transcribe/diarize' : $whisperBase . '/transcribe';
|
||||||
|
$boundary = '----DBN' . bin2hex(random_bytes(8));
|
||||||
|
|
||||||
|
$body = "--{$boundary}\r\n";
|
||||||
|
$body .= 'Content-Disposition: form-data; name="file"; filename="' . addslashes(basename($file['name'])) . '"' . "\r\n";
|
||||||
|
$body .= "Content-Type: application/octet-stream\r\n\r\n";
|
||||||
|
|
||||||
|
$fileContents = file_get_contents($file['tmp_name']);
|
||||||
|
if ($fileContents === false) {
|
||||||
|
dbnToolsError('Could not read uploaded file.', 500, 'file_read_error');
|
||||||
|
}
|
||||||
|
$body .= $fileContents . "\r\n";
|
||||||
|
|
||||||
|
$fields = [
|
||||||
|
'model' => $model,
|
||||||
|
'beam_size' => (string)$beamSize,
|
||||||
|
'task' => $task,
|
||||||
|
'vad_filter' => $vadFilter ? '1' : '0',
|
||||||
|
'initial_prompt' => $initPrompt,
|
||||||
|
];
|
||||||
|
if ($language !== 'auto') $fields['language'] = $language;
|
||||||
|
if ($diarize && $numSpeakers > 1) $fields['num_speakers'] = (string)$numSpeakers;
|
||||||
|
|
||||||
|
foreach ($fields as $name => $value) {
|
||||||
|
if ($value === '') continue;
|
||||||
|
$body .= "--{$boundary}\r\n";
|
||||||
|
$body .= "Content-Disposition: form-data; name=\"{$name}\"\r\n\r\n";
|
||||||
|
$body .= $value . "\r\n";
|
||||||
|
}
|
||||||
|
$body .= "--{$boundary}--\r\n";
|
||||||
|
|
||||||
|
$ch = curl_init($endpoint);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $body,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
"Content-Type: multipart/form-data; boundary={$boundary}",
|
||||||
|
'Accept: application/json',
|
||||||
|
],
|
||||||
|
CURLOPT_TIMEOUT => 600,
|
||||||
|
]);
|
||||||
|
$responseBody = curl_exec($ch);
|
||||||
|
$httpCode = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||||
|
$curlErr = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($responseBody === false || $httpCode !== 200) {
|
||||||
|
$detail = $curlErr ?: (is_string($responseBody) ? substr(strip_tags($responseBody), 0, 300) : '');
|
||||||
|
dbnToolsError('Whisper service error (HTTP ' . $httpCode . '): ' . $detail, 502, 'whisper_error');
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($responseBody, true);
|
||||||
|
if (!is_array($data) || empty($data['text'])) {
|
||||||
|
dbnToolsError('Empty or invalid response from Whisper.', 502, 'whisper_empty');
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function transcribeViaOpenAI(array $file, string $language, string $task, string $apiKey): array
|
||||||
|
{
|
||||||
|
$boundary = '----DBN' . bin2hex(random_bytes(8));
|
||||||
|
$body = "--{$boundary}\r\n";
|
||||||
|
$body .= 'Content-Disposition: form-data; name="file"; filename="' . addslashes(basename($file['name'])) . '"' . "\r\n";
|
||||||
|
$body .= "Content-Type: application/octet-stream\r\n\r\n";
|
||||||
|
$body .= file_get_contents($file['tmp_name']) . "\r\n";
|
||||||
|
$body .= "--{$boundary}\r\nContent-Disposition: form-data; name=\"model\"\r\n\r\nwhisper-1\r\n";
|
||||||
|
$body .= "--{$boundary}\r\nContent-Disposition: form-data; name=\"response_format\"\r\n\r\nverbose_json\r\n";
|
||||||
|
if ($language !== 'auto') {
|
||||||
|
$body .= "--{$boundary}\r\nContent-Disposition: form-data; name=\"language\"\r\n\r\n{$language}\r\n";
|
||||||
|
}
|
||||||
|
if ($task === 'translate') {
|
||||||
|
$body .= "--{$boundary}\r\nContent-Disposition: form-data; name=\"task\"\r\n\r\ntranslation\r\n";
|
||||||
|
}
|
||||||
|
$body .= "--{$boundary}--\r\n";
|
||||||
|
|
||||||
|
$ch = curl_init('https://api.openai.com/v1/audio/transcriptions');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $body,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
"Authorization: Bearer {$apiKey}",
|
||||||
|
"Content-Type: multipart/form-data; boundary={$boundary}",
|
||||||
|
'Accept: application/json',
|
||||||
|
],
|
||||||
|
CURLOPT_TIMEOUT => 300,
|
||||||
|
]);
|
||||||
|
$responseBody = curl_exec($ch);
|
||||||
|
$httpCode = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||||
|
$curlErr = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($responseBody === false || $httpCode !== 200) {
|
||||||
|
$detail = $curlErr ?: (is_string($responseBody) ? substr(strip_tags($responseBody), 0, 300) : '');
|
||||||
|
dbnToolsError('OpenAI API error (HTTP ' . $httpCode . '): ' . $detail, 502, 'openai_error');
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($responseBody, true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
dbnToolsError('Invalid response from OpenAI.', 502, 'openai_empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalise to internal shape
|
||||||
|
return [
|
||||||
|
'text' => (string)($data['text'] ?? ''),
|
||||||
|
'language' => (string)($data['language'] ?? $language),
|
||||||
|
'duration_seconds' => (float)($data['duration'] ?? 0),
|
||||||
|
'processing_seconds' => 0,
|
||||||
|
'segments' => array_map(fn($s) => [
|
||||||
|
'id' => $s['id'] ?? 0,
|
||||||
|
'start' => $s['start'] ?? 0,
|
||||||
|
'end' => $s['end'] ?? 0,
|
||||||
|
'text' => $s['text'] ?? '',
|
||||||
|
'speaker' => 'SPEAKER_00',
|
||||||
|
], $data['segments'] ?? []),
|
||||||
|
'model' => 'openai/whisper-1',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function transcribeViaAzure(array $file, string $language, string $apiKey,
|
||||||
|
string $region, bool $diarize): array
|
||||||
|
{
|
||||||
|
// Azure Batch Transcription — POST audio directly for short-form (<60 min)
|
||||||
|
// Uses the simple REST endpoint for synchronous short audio transcription.
|
||||||
|
$langCode = match($language) {
|
||||||
|
'no', 'nb' => 'nb-NO',
|
||||||
|
'nn' => 'nn-NO',
|
||||||
|
'en' => 'en-US',
|
||||||
|
'sv' => 'sv-SE',
|
||||||
|
'da' => 'da-DK',
|
||||||
|
'de' => 'de-DE',
|
||||||
|
'fr' => 'fr-FR',
|
||||||
|
'es' => 'es-ES',
|
||||||
|
'pl' => 'pl-PL',
|
||||||
|
'fi' => 'fi-FI',
|
||||||
|
'nl' => 'nl-NL',
|
||||||
|
'it' => 'it-IT',
|
||||||
|
'pt' => 'pt-PT',
|
||||||
|
default => 'nb-NO',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mime type map
|
||||||
|
$mimeMap = [
|
||||||
|
'wav' => 'audio/wav', 'mp3' => 'audio/mpeg', 'ogg' => 'audio/ogg',
|
||||||
|
'oga' => 'audio/ogg', 'm4a' => 'audio/mp4', 'mp4' => 'audio/mp4',
|
||||||
|
'flac' => 'audio/flac', 'webm' => 'audio/webm', 'aac' => 'audio/aac',
|
||||||
|
];
|
||||||
|
$fileExt = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||||||
|
$mimeType = $mimeMap[$fileExt] ?? 'audio/wav';
|
||||||
|
|
||||||
|
$endpoint = "https://{$region}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1"
|
||||||
|
. "?language={$langCode}&format=detailed";
|
||||||
|
|
||||||
|
$fileContents = file_get_contents($file['tmp_name']);
|
||||||
|
if ($fileContents === false) {
|
||||||
|
dbnToolsError('Could not read uploaded file.', 500, 'file_read_error');
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init($endpoint);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $fileContents,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
"Ocp-Apim-Subscription-Key: {$apiKey}",
|
||||||
|
"Content-Type: {$mimeType}",
|
||||||
|
'Accept: application/json',
|
||||||
|
],
|
||||||
|
CURLOPT_TIMEOUT => 300,
|
||||||
|
]);
|
||||||
|
$responseBody = curl_exec($ch);
|
||||||
|
$httpCode = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||||
|
$curlErr = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($responseBody === false || $httpCode !== 200) {
|
||||||
|
$detail = $curlErr ?: (is_string($responseBody) ? substr(strip_tags($responseBody), 0, 300) : '');
|
||||||
|
dbnToolsError('Azure Speech error (HTTP ' . $httpCode . '): ' . $detail, 502, 'azure_error');
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($responseBody, true);
|
||||||
|
if (!is_array($data) || empty($data['DisplayText'])) {
|
||||||
|
dbnToolsError('Empty or invalid response from Azure Speech.', 502, 'azure_empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalise to internal shape
|
||||||
|
$text = (string)($data['DisplayText'] ?? '');
|
||||||
|
$segs = [];
|
||||||
|
foreach (($data['NBest'][0]['Words'] ?? []) as $i => $word) {
|
||||||
|
$segs[] = [
|
||||||
|
'id' => $i,
|
||||||
|
'start' => round((float)($word['Offset'] ?? 0) / 10_000_000, 3),
|
||||||
|
'end' => round(((float)($word['Offset'] ?? 0) + (float)($word['Duration'] ?? 0)) / 10_000_000, 3),
|
||||||
|
'text' => (string)($word['Word'] ?? ''),
|
||||||
|
'speaker' => 'SPEAKER_00',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'text' => $text,
|
||||||
|
'language' => $langCode,
|
||||||
|
'duration_seconds' => 0,
|
||||||
|
'processing_seconds' => 0,
|
||||||
|
'segments' => $segs,
|
||||||
|
'model' => "azure/{$langCode}",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function dbnLabelSpeakerRoles(array $segments): array
|
function dbnLabelSpeakerRoles(array $segments): array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
$toolName = 'ask';
|
||||||
|
$toolTitle = 'Ask a legal question';
|
||||||
|
$toolKind = 'Source-grounded Legal Ask';
|
||||||
|
$toolBadge = 'family-legal';
|
||||||
|
require_once __DIR__ . '/includes/layout.php';
|
||||||
|
?>
|
||||||
|
<?php require_once __DIR__ . '/includes/tool_form.php'; ?>
|
||||||
|
<?php require_once __DIR__ . '/includes/layout_footer.php'; ?>
|
||||||
@@ -1218,3 +1218,74 @@ p {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Transcribe extended controls ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
.byok-input {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
width: 22rem;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.byok-input--short { width: 9rem; }
|
||||||
|
.byok-input:focus { outline: 2px solid var(--teal); outline-offset: 1px; }
|
||||||
|
|
||||||
|
.inline-hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expert-settings {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.expert-summary {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
list-style: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.expert-summary::-webkit-details-marker { display: none; }
|
||||||
|
.expert-summary::before {
|
||||||
|
content: '▶ ';
|
||||||
|
font-size: 0.65rem;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
.expert-settings[open] .expert-summary::before { content: '▼ '; }
|
||||||
|
.expert-body {
|
||||||
|
padding: 0.6rem 0.9rem 0.9rem;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.expert-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
.prompt-textarea {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
resize: vertical;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.prompt-textarea:focus { outline: 2px solid var(--teal); outline-offset: 1px; }
|
||||||
|
|
||||||
|
.control-hint { font-size: 0.74rem; color: var(--muted); font-weight: 400; }
|
||||||
|
|||||||
+96
-14
@@ -116,22 +116,26 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
transcribeLangControl: document.querySelector('#transcribeLangControl'),
|
transcribeLangControl: document.querySelector('#transcribeLangControl'),
|
||||||
});
|
});
|
||||||
|
|
||||||
els.tabs.forEach((button) => {
|
els.tabs.forEach((tab) => {
|
||||||
button.addEventListener('click', () => setTool(button.dataset.tool));
|
if (tab.tagName !== 'A') {
|
||||||
|
tab.addEventListener('click', () => setTool(tab.dataset.tool));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
els.form.addEventListener('submit', runTool);
|
els.form?.addEventListener('submit', runTool);
|
||||||
els.passcodeForm.addEventListener('submit', submitPasscode);
|
els.passcodeForm?.addEventListener('submit', submitPasscode);
|
||||||
els.healthButton.addEventListener('click', checkHealth);
|
els.healthButton.addEventListener('click', checkHealth);
|
||||||
setupUpload();
|
setupUpload();
|
||||||
setupAliases();
|
setupAliases();
|
||||||
setupAudio();
|
setupAudio();
|
||||||
|
setupTranscribeControls();
|
||||||
els.results.addEventListener('click', (e) => {
|
els.results.addEventListener('click', (e) => {
|
||||||
if (e.target.closest('#exportCsvBtn')) exportTimelineCSV(lastTimelineEvents);
|
if (e.target.closest('#exportCsvBtn')) exportTimelineCSV(lastTimelineEvents);
|
||||||
if (e.target.closest('#dlTxt')) downloadTranscriptTxt();
|
if (e.target.closest('#dlTxt')) downloadTranscriptTxt();
|
||||||
if (e.target.closest('#dlSrt')) downloadTranscriptSrt();
|
if (e.target.closest('#dlSrt')) downloadTranscriptSrt();
|
||||||
if (e.target.closest('#dlVtt')) downloadTranscriptVtt();
|
if (e.target.closest('#dlVtt')) downloadTranscriptVtt();
|
||||||
});
|
});
|
||||||
setTool(state.activeTool);
|
const activeTool = document.body.dataset.activeTool || state.activeTool;
|
||||||
|
setTool(activeTool);
|
||||||
|
|
||||||
if (state.authenticated) {
|
if (state.authenticated) {
|
||||||
checkHealth();
|
checkHealth();
|
||||||
@@ -559,16 +563,56 @@ function exportTimelineCSV(events) {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function currentTranscribeEngine() {
|
||||||
|
const el = document.querySelector('input[name="engine"]:checked');
|
||||||
|
return el ? el.value : 'gpu';
|
||||||
|
}
|
||||||
|
function currentTranscribeModel() {
|
||||||
|
const el = document.querySelector('input[name="model"]:checked');
|
||||||
|
return el ? el.value : 'small';
|
||||||
|
}
|
||||||
|
function currentBeamSize() {
|
||||||
|
const el = document.querySelector('input[name="beam_size"]:checked');
|
||||||
|
return el ? el.value : '5';
|
||||||
|
}
|
||||||
|
function currentTask() {
|
||||||
|
const el = document.querySelector('input[name="task"]:checked');
|
||||||
|
return el ? el.value : 'transcribe';
|
||||||
|
}
|
||||||
|
|
||||||
async function runTranscribe() {
|
async function runTranscribe() {
|
||||||
if (!lastAudioFile) {
|
if (!lastAudioFile) {
|
||||||
els.status.textContent = 'Choose an audio file before transcribing.';
|
els.status.textContent = 'Choose an audio file before transcribing.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const engine = currentTranscribeEngine();
|
||||||
|
|
||||||
|
// BYOK key validation before starting the upload
|
||||||
|
if (engine === 'openai') {
|
||||||
|
const key = document.getElementById('openaiKeyInput')?.value?.trim();
|
||||||
|
if (!key || !key.startsWith('sk-')) {
|
||||||
|
els.status.textContent = 'Enter a valid OpenAI API key (sk-…) before running.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lastAudioFile.size > 25 * 1024 * 1024) {
|
||||||
|
els.status.textContent = 'OpenAI Whisper has a 25 MB file limit. Switch to GPU engine for this file.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (engine === 'azure') {
|
||||||
|
const key = document.getElementById('azureKeyInput')?.value?.trim();
|
||||||
|
if (!key) {
|
||||||
|
els.status.textContent = 'Enter an Azure Speech API key before running.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
let elapsed = 0;
|
let elapsed = 0;
|
||||||
updateTranscribeTrace(0);
|
updateTranscribeTrace(0, engine);
|
||||||
els.status.textContent = 'Transcribing…';
|
els.status.textContent = 'Transcribing…';
|
||||||
|
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
@@ -576,19 +620,38 @@ async function runTranscribe() {
|
|||||||
const m = Math.floor(elapsed / 60);
|
const m = Math.floor(elapsed / 60);
|
||||||
const s = elapsed % 60;
|
const s = elapsed % 60;
|
||||||
els.status.textContent = m > 0 ? `Transcribing… ${m}:${pad2(s)}` : `Transcribing… ${s}s`;
|
els.status.textContent = m > 0 ? `Transcribing… ${m}:${pad2(s)}` : `Transcribing… ${s}s`;
|
||||||
updateTranscribeTrace(elapsed);
|
updateTranscribeTrace(elapsed, engine);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('audio', lastAudioFile);
|
formData.append('audio', lastAudioFile);
|
||||||
|
formData.append('engine', engine);
|
||||||
formData.append('language', currentTranscribeLang());
|
formData.append('language', currentTranscribeLang());
|
||||||
|
formData.append('model', currentTranscribeModel());
|
||||||
|
formData.append('beam_size', currentBeamSize());
|
||||||
|
formData.append('task', currentTask());
|
||||||
|
|
||||||
|
const vadCheck = document.getElementById('vadFilterCheck');
|
||||||
|
if (vadCheck?.checked) formData.append('vad_filter', '1');
|
||||||
|
|
||||||
|
const initPrompt = document.getElementById('initPromptInput')?.value?.trim();
|
||||||
|
if (initPrompt) formData.append('initial_prompt', initPrompt);
|
||||||
|
|
||||||
if (els.diarizeCheck?.checked) {
|
if (els.diarizeCheck?.checked) {
|
||||||
formData.append('diarize', '1');
|
formData.append('diarize', '1');
|
||||||
const n = parseInt(els.numSpeakersInput?.value || '', 10);
|
const n = parseInt(els.numSpeakersInput?.value || '', 10);
|
||||||
if (n >= 2) formData.append('num_speakers', String(n));
|
if (n >= 2) formData.append('num_speakers', String(n));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (engine === 'openai') {
|
||||||
|
formData.append('openai_key', document.getElementById('openaiKeyInput')?.value?.trim());
|
||||||
|
}
|
||||||
|
if (engine === 'azure') {
|
||||||
|
formData.append('azure_key', document.getElementById('azureKeyInput')?.value?.trim());
|
||||||
|
formData.append('azure_region', document.getElementById('azureRegionInput')?.value?.trim() || 'norwayeast');
|
||||||
|
}
|
||||||
|
|
||||||
const resp = await fetch('api/transcribe.php', {
|
const resp = await fetch('api/transcribe.php', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
@@ -603,7 +666,10 @@ async function runTranscribe() {
|
|||||||
renderTranscriptResults(data);
|
renderTranscriptResults(data);
|
||||||
|
|
||||||
const dur = data.duration_sec ? ` · Audio: ${Math.round(data.duration_sec)}s` : '';
|
const dur = data.duration_sec ? ` · Audio: ${Math.round(data.duration_sec)}s` : '';
|
||||||
els.status.textContent = `Done in ${data.latency_ms || 0} ms${dur}.`;
|
const proc = data.processing_sec ? ` · GPU: ${data.processing_sec.toFixed(1)}s` : '';
|
||||||
|
const rtf = (data.duration_sec && data.processing_sec)
|
||||||
|
? ` · RTF: ${(data.processing_sec / data.duration_sec).toFixed(2)}` : '';
|
||||||
|
els.status.textContent = `Done in ${data.latency_ms || 0} ms${dur}${proc}${rtf}.`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
els.status.textContent = error.message;
|
els.status.textContent = error.message;
|
||||||
renderTrace([{ label: 'Transcription error', detail: error.message, status: 'warning' }]);
|
renderTrace([{ label: 'Transcription error', detail: error.message, status: 'warning' }]);
|
||||||
@@ -613,17 +679,22 @@ async function runTranscribe() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTranscribeTrace(elapsed) {
|
function updateTranscribeTrace(elapsed, engine) {
|
||||||
|
const engineLabel = engine === 'openai' ? 'OpenAI API' : engine === 'azure' ? 'Azure Speech' : 'Whisper GPU';
|
||||||
let label, detail;
|
let label, detail;
|
||||||
if (elapsed < 10) {
|
if (elapsed < 10) {
|
||||||
label = 'Uploading to Whisper';
|
label = `Uploading to ${engineLabel}`;
|
||||||
detail = 'Sending audio to cuttlefish GPU…';
|
detail = engine === 'gpu'
|
||||||
|
? 'Sending audio to cuttlefish GPU…'
|
||||||
|
: `Sending audio to ${engineLabel}…`;
|
||||||
} else if (elapsed < 60) {
|
} else if (elapsed < 60) {
|
||||||
label = 'Processing on GPU';
|
label = `Processing — ${engineLabel}`;
|
||||||
detail = 'Whisper is transcribing. Large files take 1–3 minutes.';
|
detail = engine === 'gpu'
|
||||||
|
? 'Whisper is transcribing. Large files take 1–3 minutes.'
|
||||||
|
: `${engineLabel} is processing the audio.`;
|
||||||
} else if (elapsed < 120) {
|
} else if (elapsed < 120) {
|
||||||
label = 'Still processing…';
|
label = 'Still processing…';
|
||||||
detail = `${Math.floor(elapsed / 60)} min elapsed — Whisper is working through the audio.`;
|
detail = `${Math.floor(elapsed / 60)} min elapsed — ${engineLabel} is working through the audio.`;
|
||||||
} else {
|
} else {
|
||||||
label = 'Still processing…';
|
label = 'Still processing…';
|
||||||
detail = `${Math.floor(elapsed / 60)} min ${pad2(elapsed % 60)}s — long recordings can take several minutes.`;
|
detail = `${Math.floor(elapsed / 60)} min ${pad2(elapsed % 60)}s — long recordings can take several minutes.`;
|
||||||
@@ -789,6 +860,17 @@ function setupAudio() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupTranscribeControls() {
|
||||||
|
document.querySelectorAll('input[name="engine"]').forEach((radio) => {
|
||||||
|
radio.addEventListener('change', () => {
|
||||||
|
const engine = currentTranscribeEngine();
|
||||||
|
document.getElementById('openaiKeyControl')?.classList.toggle('is-hidden', engine !== 'openai');
|
||||||
|
document.getElementById('azureKeyControl')?.classList.toggle('is-hidden', engine !== 'azure');
|
||||||
|
document.getElementById('modelControl')?.classList.toggle('is-hidden', engine === 'openai' || engine === 'azure');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function handleAudio(file) {
|
function handleAudio(file) {
|
||||||
const allowedExts = ['mp3', 'wav', 'ogg', 'oga', 'm4a', 'mp4', 'flac', 'webm', 'aac'];
|
const allowedExts = ['mp3', 'wav', 'ogg', 'oga', 'm4a', 'mp4', 'flac', 'webm', 'aac'];
|
||||||
const ext = file.name.split('.').pop().toLowerCase();
|
const ext = file.name.split('.').pop().toLowerCase();
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
// Required vars: $toolName (string), $toolTitle (string), $toolKind (string), $toolBadge (string)
|
||||||
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
|
if (!dbnToolsIsAuthenticated()) {
|
||||||
|
$return = urlencode($_SERVER['REQUEST_URI'] ?? '/');
|
||||||
|
header('Location: /?return=' . $return);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$navItems = [
|
||||||
|
'ask' => ['Ask', 'Source-grounded'],
|
||||||
|
'search' => ['Search', 'Legal sources'],
|
||||||
|
'summarize' => ['Summarize', 'Pasted text'],
|
||||||
|
'timeline' => ['Timeline', 'Events'],
|
||||||
|
'redact' => ['Redact', 'Privacy'],
|
||||||
|
'transcribe' => ['Transcribe', 'Audio'],
|
||||||
|
];
|
||||||
|
$toolName = $toolName ?? 'ask';
|
||||||
|
$toolTitle = $toolTitle ?? 'Legal Tools';
|
||||||
|
$toolKind = $toolKind ?? '';
|
||||||
|
$toolBadge = $toolBadge ?? '';
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title><?= htmlspecialchars($toolTitle) ?> — Do Better Norge</title>
|
||||||
|
<link rel="stylesheet" href="assets/css/tools.css">
|
||||||
|
</head>
|
||||||
|
<body data-authenticated="true" data-active-tool="<?= htmlspecialchars($toolName) ?>">
|
||||||
|
<script>window.DBN_TOOLS_AUTHENTICATED = true;</script>
|
||||||
|
<main id="appShell" class="app-shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Do Better Norge</p>
|
||||||
|
<h1>Legal Tools</h1>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<span id="healthPill" class="status-pill">Session active</span>
|
||||||
|
<button id="healthButton" class="secondary-button" type="button">Health</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="disclaimer" role="note">
|
||||||
|
Legal information and preparation support, not final legal advice. Pasted text is processed in memory by default.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="workspace" aria-label="Legal tools workspace">
|
||||||
|
<nav class="tool-rail" aria-label="Tools">
|
||||||
|
<?php foreach ($navItems as $slug => [$label, $sub]): ?>
|
||||||
|
<a href="<?= $slug ?>.php" class="tool-tab<?= $slug === $toolName ? ' is-active' : '' ?>" data-tool="<?= $slug ?>"<?= $slug === $toolName ? ' aria-current="page"' : '' ?>>
|
||||||
|
<span><?= $label ?></span>
|
||||||
|
<small><?= $sub ?></small>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section class="tool-panel" aria-labelledby="toolTitle">
|
||||||
|
<div class="tool-heading">
|
||||||
|
<div>
|
||||||
|
<p id="toolKind" class="eyebrow"><?= htmlspecialchars($toolKind) ?></p>
|
||||||
|
<h2 id="toolTitle"><?= htmlspecialchars($toolTitle) ?></h2>
|
||||||
|
</div>
|
||||||
|
<span id="toolBadge" class="tool-badge"><?= htmlspecialchars($toolBadge) ?></span>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
</section><!-- /tool-panel -->
|
||||||
|
|
||||||
|
<aside class="reasoning-panel" aria-labelledby="reasoningTitle">
|
||||||
|
<div class="reasoning-head">
|
||||||
|
<p class="eyebrow">Evidence trail</p>
|
||||||
|
<h2 id="reasoningTitle">Reasoning</h2>
|
||||||
|
</div>
|
||||||
|
<ol id="traceList" class="trace-list">
|
||||||
|
<li>
|
||||||
|
<span class="trace-status waiting"></span>
|
||||||
|
<div>
|
||||||
|
<strong>Waiting</strong>
|
||||||
|
<p>Run a tool to see interpretation, retrieval, confidence, uncertainty, and next step.</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</aside>
|
||||||
|
</section><!-- /workspace -->
|
||||||
|
</main><!-- /appShell -->
|
||||||
|
<script src="assets/js/tools.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<form id="toolForm" class="tool-form">
|
||||||
|
<div class="control-row" id="languageControl">
|
||||||
|
<span class="control-label">Language</span>
|
||||||
|
<label><input type="radio" name="language" value="en" checked> English</label>
|
||||||
|
<label><input type="radio" name="language" value="no"> Norsk</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-row is-hidden" id="transcribeLangControl">
|
||||||
|
<span class="control-label">Language</span>
|
||||||
|
<label><input type="radio" name="transcribeLang" value="auto" checked> Auto-detect</label>
|
||||||
|
<label><input type="radio" name="transcribeLang" value="no"> Norsk</label>
|
||||||
|
<label><input type="radio" name="transcribeLang" value="en"> English</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-row is-hidden" id="diarizeControl">
|
||||||
|
<span class="control-label">Speakers</span>
|
||||||
|
<label><input type="checkbox" id="diarizeCheck" name="diarize"> Separate speakers</label>
|
||||||
|
<span class="control-label" style="margin-left:1.25rem">Count</span>
|
||||||
|
<input type="number" id="numSpeakersInput" name="num_speakers" min="2" max="10" placeholder="auto" class="num-speakers-input" aria-label="Expected speaker count">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-row is-hidden" id="redactionControl">
|
||||||
|
<span class="control-label">Mode</span>
|
||||||
|
<label><input type="radio" name="redactionMode" value="standard" checked> Standard</label>
|
||||||
|
<label><input type="radio" name="redactionMode" value="strict"> Strict</label>
|
||||||
|
<span class="control-label" style="margin-left:1.25rem">Region</span>
|
||||||
|
<label><input type="radio" name="redactionRegion" value="nordic" checked> Nordic</label>
|
||||||
|
<label><input type="radio" name="redactionRegion" value="european"> European</label>
|
||||||
|
<label><input type="radio" name="redactionRegion" value="echr"> ECHR</label>
|
||||||
|
<label><input type="radio" name="redactionRegion" value="global"> Global</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-zone is-hidden" id="audioZone" role="region" aria-label="Audio upload">
|
||||||
|
<input type="file" id="audioInput" accept="audio/*,video/mp4,video/webm" aria-label="Choose audio file">
|
||||||
|
<div id="audioPrompt" class="upload-prompt">
|
||||||
|
<span class="upload-icon" aria-hidden="true">▶</span>
|
||||||
|
<p>Drop audio file here, or <label for="audioInput" class="upload-browse">browse</label></p>
|
||||||
|
<p class="upload-hint"><strong>MP3</strong>, <strong>WAV</strong>, <strong>OGG</strong>, <strong>M4A</strong>, <strong>FLAC</strong>, <strong>WEBM</strong> — max 200 MB</p>
|
||||||
|
</div>
|
||||||
|
<div id="audioFileInfo" class="upload-file is-hidden">
|
||||||
|
<ul class="upload-file-list"><li id="audioFileLine"><span id="audioFileName" class="upload-filename"></span><span id="audioFileSize" class="upload-chars"></span></li></ul>
|
||||||
|
<button type="button" id="audioClear" class="upload-clear" aria-label="Clear audio file">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-zone is-hidden" id="uploadZone" role="region" aria-label="File upload">
|
||||||
|
<input type="file" id="uploadInput" multiple accept=".pdf,.docx,.txt" aria-label="Choose files">
|
||||||
|
<div id="uploadPrompt" class="upload-prompt">
|
||||||
|
<span class="upload-icon" aria-hidden="true">⇧</span>
|
||||||
|
<p>Drop up to 5 files (<strong>.pdf</strong>, <strong>.docx</strong>, <strong>.txt</strong>), or <label for="uploadInput" class="upload-browse">browse</label></p>
|
||||||
|
<p class="upload-hint">Text is extracted in memory and never stored.</p>
|
||||||
|
</div>
|
||||||
|
<div id="uploadFileInfo" class="upload-file is-hidden">
|
||||||
|
<ul id="uploadFileList" class="upload-file-list"></ul>
|
||||||
|
<button type="button" id="uploadClear" class="upload-clear" aria-label="Clear uploaded files">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alias-section is-hidden" id="aliasSection">
|
||||||
|
<div class="alias-header">
|
||||||
|
<span class="control-label">Name aliases</span>
|
||||||
|
<button type="button" id="addAliasRow" class="alias-add-btn">+ Add</button>
|
||||||
|
</div>
|
||||||
|
<div id="aliasRows"></div>
|
||||||
|
<p class="alias-hint">Replace a name with a bracketed alias, e.g. “David Jr” → [Junior]</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="input-label" for="toolInput" id="inputLabel">Question</label>
|
||||||
|
<textarea id="toolInput" name="toolInput" rows="10" required></textarea>
|
||||||
|
|
||||||
|
<div class="form-footer">
|
||||||
|
<p id="toolStatus" class="form-status" role="status" aria-live="polite"></p>
|
||||||
|
<button id="runButton" type="submit">Run Tool</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section id="results" class="results" aria-live="polite">
|
||||||
|
<div class="empty-state">
|
||||||
|
<h3>Ready</h3>
|
||||||
|
<p>Choose a tool, run a request, and the answer will show the evidence trail beside it.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -2,7 +2,14 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
require_once __DIR__ . '/includes/bootstrap.php';
|
require_once __DIR__ . '/includes/bootstrap.php';
|
||||||
$authenticated = dbnToolsIsAuthenticated();
|
|
||||||
|
if (dbnToolsIsAuthenticated()) {
|
||||||
|
$return = $_GET['return'] ?? '';
|
||||||
|
$dest = ($return && str_starts_with($return, '/') && !str_contains($return, '//'))
|
||||||
|
? $return : 'ask.php';
|
||||||
|
header('Location: ' . $dest);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
?>
|
?>
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -12,16 +19,17 @@ $authenticated = dbnToolsIsAuthenticated();
|
|||||||
<title>Do Better Norge — AI Legal Research</title>
|
<title>Do Better Norge — AI Legal Research</title>
|
||||||
<meta name="description" content="AI-powered family law research for Norway. Source-cited answers from curated legal corpora, with a post-generation reviewer pass. Powered by CaveauAI.">
|
<meta name="description" content="AI-powered family law research for Norway. Source-cited answers from curated legal corpora, with a post-generation reviewer pass. Powered by CaveauAI.">
|
||||||
<meta name="robots" content="index, follow">
|
<meta name="robots" content="index, follow">
|
||||||
<link rel="canonical" href="https://ai.dobetternorge.no/">
|
<link rel="canonical" href="https://tools.dobetternorge.no/">
|
||||||
<meta property="og:title" content="Do Better Norge — AI Legal Research">
|
<meta property="og:title" content="Do Better Norge — AI Legal Research">
|
||||||
<meta property="og:description" content="Source-cited answers from curated Norwegian law corpora. Every claim checked against real sources before it reaches you.">
|
<meta property="og:description" content="Source-cited answers from curated Norwegian law corpora. Every claim checked against real sources before it reaches you.">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:url" content="https://ai.dobetternorge.no/">
|
<meta property="og:url" content="https://tools.dobetternorge.no/">
|
||||||
<meta name="theme-color" content="#f7f8fb">
|
<meta name="theme-color" content="#f7f8fb">
|
||||||
<link rel="stylesheet" href="assets/css/tools.css">
|
<link rel="stylesheet" href="assets/css/tools.css">
|
||||||
</head>
|
</head>
|
||||||
<body data-authenticated="<?= $authenticated ? 'true' : 'false' ?>">
|
<body data-authenticated="false">
|
||||||
<div id="publicLanding" class="showcase-page<?= $authenticated ? ' is-hidden' : '' ?>">
|
|
||||||
|
<div id="publicLanding" class="showcase-page">
|
||||||
|
|
||||||
<header class="showcase-header">
|
<header class="showcase-header">
|
||||||
<div class="showcase-header-inner">
|
<div class="showcase-header-inner">
|
||||||
@@ -156,164 +164,7 @@ $authenticated = dbnToolsIsAuthenticated();
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main id="appShell" class="app-shell<?= $authenticated ? '' : ' is-hidden' ?>">
|
<script>window.DBN_TOOLS_AUTHENTICATED = false;</script>
|
||||||
<header class="topbar">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Do Better Norge</p>
|
|
||||||
<h1>Legal Tools Hub</h1>
|
|
||||||
</div>
|
|
||||||
<div class="topbar-actions">
|
|
||||||
<span id="healthPill" class="status-pill">Session active</span>
|
|
||||||
<button id="healthButton" class="secondary-button" type="button">Health</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="disclaimer" role="note">
|
|
||||||
Legal information and preparation support, not final legal advice. Pasted text is processed in memory by default.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="workspace" aria-label="Legal tools workspace">
|
|
||||||
<nav class="tool-rail" aria-label="Tools">
|
|
||||||
<button type="button" class="tool-tab is-active" data-tool="ask" aria-pressed="true">
|
|
||||||
<span>Ask</span>
|
|
||||||
<small>Source-grounded</small>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="tool-tab" data-tool="search" aria-pressed="false">
|
|
||||||
<span>Search</span>
|
|
||||||
<small>Legal sources</small>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="tool-tab" data-tool="summarize" aria-pressed="false">
|
|
||||||
<span>Summarize</span>
|
|
||||||
<small>Pasted text</small>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="tool-tab" data-tool="timeline" aria-pressed="false">
|
|
||||||
<span>Timeline</span>
|
|
||||||
<small>Events</small>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="tool-tab" data-tool="redact" aria-pressed="false">
|
|
||||||
<span>Redact</span>
|
|
||||||
<small>Privacy</small>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="tool-tab" data-tool="transcribe" aria-pressed="false">
|
|
||||||
<span>Transcribe</span>
|
|
||||||
<small>Audio</small>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<section class="tool-panel" aria-labelledby="toolTitle">
|
|
||||||
<div class="tool-heading">
|
|
||||||
<div>
|
|
||||||
<p id="toolKind" class="eyebrow">Source-grounded Legal Ask</p>
|
|
||||||
<h2 id="toolTitle">Ask a legal question</h2>
|
|
||||||
</div>
|
|
||||||
<span id="toolBadge" class="tool-badge">family-legal</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="toolForm" class="tool-form">
|
|
||||||
<div class="control-row" id="languageControl">
|
|
||||||
<span class="control-label">Language</span>
|
|
||||||
<label><input type="radio" name="language" value="en" checked> English</label>
|
|
||||||
<label><input type="radio" name="language" value="no"> Norsk</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="control-row is-hidden" id="transcribeLangControl">
|
|
||||||
<span class="control-label">Language</span>
|
|
||||||
<label><input type="radio" name="transcribeLang" value="auto" checked> Auto-detect</label>
|
|
||||||
<label><input type="radio" name="transcribeLang" value="no"> Norsk</label>
|
|
||||||
<label><input type="radio" name="transcribeLang" value="en"> English</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="control-row is-hidden" id="diarizeControl">
|
|
||||||
<span class="control-label">Speakers</span>
|
|
||||||
<label><input type="checkbox" id="diarizeCheck" name="diarize"> Separate speakers</label>
|
|
||||||
<span class="control-label" style="margin-left:1.25rem">Count</span>
|
|
||||||
<input type="number" id="numSpeakersInput" name="num_speakers" min="2" max="10" placeholder="auto" class="num-speakers-input" aria-label="Expected speaker count">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="control-row is-hidden" id="redactionControl">
|
|
||||||
<span class="control-label">Mode</span>
|
|
||||||
<label><input type="radio" name="redactionMode" value="standard" checked> Standard</label>
|
|
||||||
<label><input type="radio" name="redactionMode" value="strict"> Strict</label>
|
|
||||||
<span class="control-label" style="margin-left:1.25rem">Region</span>
|
|
||||||
<label><input type="radio" name="redactionRegion" value="nordic" checked> Nordic</label>
|
|
||||||
<label><input type="radio" name="redactionRegion" value="european"> European</label>
|
|
||||||
<label><input type="radio" name="redactionRegion" value="echr"> ECHR</label>
|
|
||||||
<label><input type="radio" name="redactionRegion" value="global"> Global</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="upload-zone is-hidden" id="audioZone" role="region" aria-label="Audio upload">
|
|
||||||
<input type="file" id="audioInput" accept="audio/*,video/mp4,video/webm" aria-label="Choose audio file">
|
|
||||||
<div id="audioPrompt" class="upload-prompt">
|
|
||||||
<span class="upload-icon" aria-hidden="true">▶</span>
|
|
||||||
<p>Drop audio file here, or <label for="audioInput" class="upload-browse">browse</label></p>
|
|
||||||
<p class="upload-hint"><strong>MP3</strong>, <strong>WAV</strong>, <strong>OGG</strong>, <strong>M4A</strong>, <strong>FLAC</strong>, <strong>WEBM</strong> — max 200 MB</p>
|
|
||||||
</div>
|
|
||||||
<div id="audioFileInfo" class="upload-file is-hidden">
|
|
||||||
<ul class="upload-file-list"><li id="audioFileLine"><span id="audioFileName" class="upload-filename"></span><span id="audioFileSize" class="upload-chars"></span></li></ul>
|
|
||||||
<button type="button" id="audioClear" class="upload-clear" aria-label="Clear audio file">×</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="upload-zone is-hidden" id="uploadZone" role="region" aria-label="File upload">
|
|
||||||
<input type="file" id="uploadInput" multiple accept=".pdf,.docx,.txt" aria-label="Choose files">
|
|
||||||
<div id="uploadPrompt" class="upload-prompt">
|
|
||||||
<span class="upload-icon" aria-hidden="true">⇧</span>
|
|
||||||
<p>Drop up to 5 files (<strong>.pdf</strong>, <strong>.docx</strong>, <strong>.txt</strong>), or <label for="uploadInput" class="upload-browse">browse</label></p>
|
|
||||||
<p class="upload-hint">Text is extracted in memory and never stored.</p>
|
|
||||||
</div>
|
|
||||||
<div id="uploadFileInfo" class="upload-file is-hidden">
|
|
||||||
<ul id="uploadFileList" class="upload-file-list"></ul>
|
|
||||||
<button type="button" id="uploadClear" class="upload-clear" aria-label="Clear uploaded files">×</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="alias-section is-hidden" id="aliasSection">
|
|
||||||
<div class="alias-header">
|
|
||||||
<span class="control-label">Name aliases</span>
|
|
||||||
<button type="button" id="addAliasRow" class="alias-add-btn">+ Add</button>
|
|
||||||
</div>
|
|
||||||
<div id="aliasRows"></div>
|
|
||||||
<p class="alias-hint">Replace a name with a bracketed alias, e.g. “David Jr” → [Junior]</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="input-label" for="toolInput" id="inputLabel">Question</label>
|
|
||||||
<textarea id="toolInput" name="toolInput" rows="10" required></textarea>
|
|
||||||
|
|
||||||
<div class="form-footer">
|
|
||||||
<p id="toolStatus" class="form-status" role="status" aria-live="polite"></p>
|
|
||||||
<button id="runButton" type="submit">Run Tool</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<section id="results" class="results" aria-live="polite">
|
|
||||||
<div class="empty-state">
|
|
||||||
<h3>Ready</h3>
|
|
||||||
<p>Choose a tool, run a request, and the answer will show the evidence trail beside it.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<aside class="reasoning-panel" aria-labelledby="reasoningTitle">
|
|
||||||
<div class="reasoning-head">
|
|
||||||
<p class="eyebrow">Evidence trail</p>
|
|
||||||
<h2 id="reasoningTitle">Reasoning</h2>
|
|
||||||
</div>
|
|
||||||
<ol id="traceList" class="trace-list">
|
|
||||||
<li>
|
|
||||||
<span class="trace-status waiting"></span>
|
|
||||||
<div>
|
|
||||||
<strong>Waiting</strong>
|
|
||||||
<p>Run a tool to see interpretation, retrieval, confidence, uncertainty, and next step.</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
window.DBN_TOOLS_AUTHENTICATED = <?= $authenticated ? 'true' : 'false' ?>;
|
|
||||||
</script>
|
|
||||||
<script src="assets/js/tools.js" defer></script>
|
<script src="assets/js/tools.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
$toolName = 'redact';
|
||||||
|
$toolTitle = 'Redact sensitive details';
|
||||||
|
$toolKind = 'Redaction Assistant';
|
||||||
|
$toolBadge = 'deterministic first';
|
||||||
|
require_once __DIR__ . '/includes/layout.php';
|
||||||
|
?>
|
||||||
|
<?php require_once __DIR__ . '/includes/tool_form.php'; ?>
|
||||||
|
<?php require_once __DIR__ . '/includes/layout_footer.php'; ?>
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
$toolName = 'search';
|
||||||
|
$toolTitle = 'Search legal sources';
|
||||||
|
$toolKind = 'Legal Source Search';
|
||||||
|
$toolBadge = 'family-legal';
|
||||||
|
require_once __DIR__ . '/includes/layout.php';
|
||||||
|
?>
|
||||||
|
<?php require_once __DIR__ . '/includes/tool_form.php'; ?>
|
||||||
|
<?php require_once __DIR__ . '/includes/layout_footer.php'; ?>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
$toolName = 'summarize';
|
||||||
|
$toolTitle = 'Summarize pasted text';
|
||||||
|
$toolKind = 'Document Summarizer';
|
||||||
|
$toolBadge = 'process-and-forget';
|
||||||
|
require_once __DIR__ . '/includes/layout.php';
|
||||||
|
?>
|
||||||
|
<?php require_once __DIR__ . '/includes/tool_form.php'; ?>
|
||||||
|
<?php require_once __DIR__ . '/includes/layout_footer.php'; ?>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
$toolName = 'timeline';
|
||||||
|
$toolTitle = 'Build a timeline';
|
||||||
|
$toolKind = 'Timeline Builder';
|
||||||
|
$toolBadge = 'process-and-forget';
|
||||||
|
require_once __DIR__ . '/includes/layout.php';
|
||||||
|
?>
|
||||||
|
<?php require_once __DIR__ . '/includes/tool_form.php'; ?>
|
||||||
|
<?php require_once __DIR__ . '/includes/layout_footer.php'; ?>
|
||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
$toolName = 'transcribe';
|
||||||
|
$toolTitle = 'Transcribe audio';
|
||||||
|
$toolKind = 'Audio Transcription';
|
||||||
|
$toolBadge = 'Whisper / GPU';
|
||||||
|
require_once __DIR__ . '/includes/layout.php';
|
||||||
|
?>
|
||||||
|
<form id="toolForm" class="tool-form">
|
||||||
|
|
||||||
|
<div class="control-row" id="engineControl">
|
||||||
|
<span class="control-label">Engine</span>
|
||||||
|
<label><input type="radio" name="engine" value="gpu" checked id="engineGpu"> GPU (cuttlefish RTX 3060)</label>
|
||||||
|
<label><input type="radio" name="engine" value="openai" id="engineOpenai"> OpenAI Whisper API</label>
|
||||||
|
<label><input type="radio" name="engine" value="azure" id="engineAzure"> Azure AI Speech (nb-NO)</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-row is-hidden" id="openaiKeyControl">
|
||||||
|
<span class="control-label">API Key</span>
|
||||||
|
<input type="password" id="openaiKeyInput" name="openai_key" placeholder="sk-…" class="byok-input" autocomplete="off">
|
||||||
|
<small class="control-hint inline-hint">Used for this request only, never stored. Max 25 MB.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-row is-hidden" id="azureKeyControl">
|
||||||
|
<span class="control-label">API Key</span>
|
||||||
|
<input type="password" id="azureKeyInput" name="azure_key" placeholder="Azure Speech key" class="byok-input" autocomplete="off">
|
||||||
|
<span class="control-label" style="margin-left:1.25rem">Region</span>
|
||||||
|
<input type="text" id="azureRegionInput" name="azure_region" placeholder="norwayeast" class="byok-input byok-input--short" value="norwayeast">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-row" id="modelControl">
|
||||||
|
<span class="control-label">Model</span>
|
||||||
|
<label><input type="radio" name="model" value="small" checked> small <small class="control-hint">(fast)</small></label>
|
||||||
|
<label><input type="radio" name="model" value="medium"> medium <small class="control-hint">(balanced)</small></label>
|
||||||
|
<label><input type="radio" name="model" value="large-v3"> large-v3 <small class="control-hint">(best quality)</small></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-row" id="transcribeLangControl">
|
||||||
|
<span class="control-label">Language</span>
|
||||||
|
<label><input type="radio" name="transcribeLang" value="auto" checked> Auto-detect</label>
|
||||||
|
<label><input type="radio" name="transcribeLang" value="no"> Norsk (nb)</label>
|
||||||
|
<label><input type="radio" name="transcribeLang" value="nn"> Nynorsk</label>
|
||||||
|
<label><input type="radio" name="transcribeLang" value="en"> English</label>
|
||||||
|
<label><input type="radio" name="transcribeLang" value="sv"> Svenska</label>
|
||||||
|
<label><input type="radio" name="transcribeLang" value="da"> Dansk</label>
|
||||||
|
<label><input type="radio" name="transcribeLang" value="de"> Deutsch</label>
|
||||||
|
<label><input type="radio" name="transcribeLang" value="fr"> Français</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-row" id="diarizeControl">
|
||||||
|
<span class="control-label">Speakers</span>
|
||||||
|
<label><input type="checkbox" id="diarizeCheck" name="diarize"> Separate speakers</label>
|
||||||
|
<span class="control-label" style="margin-left:1.25rem">Count</span>
|
||||||
|
<input type="number" id="numSpeakersInput" name="num_speakers" min="2" max="20" placeholder="auto" class="num-speakers-input" aria-label="Expected speaker count">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-zone" id="audioZone" role="region" aria-label="Audio upload">
|
||||||
|
<input type="file" id="audioInput" accept="audio/*,video/mp4,video/webm" aria-label="Choose audio file">
|
||||||
|
<div id="audioPrompt" class="upload-prompt">
|
||||||
|
<span class="upload-icon" aria-hidden="true">▶</span>
|
||||||
|
<p>Drop audio file here, or <label for="audioInput" class="upload-browse">browse</label></p>
|
||||||
|
<p class="upload-hint"><strong>MP3</strong>, <strong>WAV</strong>, <strong>OGG</strong>, <strong>M4A</strong>, <strong>FLAC</strong>, <strong>WEBM</strong> — max 200 MB</p>
|
||||||
|
</div>
|
||||||
|
<div id="audioFileInfo" class="upload-file is-hidden">
|
||||||
|
<ul class="upload-file-list"><li id="audioFileLine"><span id="audioFileName" class="upload-filename"></span><span id="audioFileSize" class="upload-chars"></span></li></ul>
|
||||||
|
<button type="button" id="audioClear" class="upload-clear" aria-label="Clear audio file">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="expert-settings" id="expertSettings">
|
||||||
|
<summary class="expert-summary">Expert settings</summary>
|
||||||
|
<div class="expert-body">
|
||||||
|
<div class="control-row">
|
||||||
|
<span class="control-label">Task</span>
|
||||||
|
<label><input type="radio" name="task" value="transcribe" checked> Transcribe</label>
|
||||||
|
<label><input type="radio" name="task" value="translate"> Translate to English</label>
|
||||||
|
</div>
|
||||||
|
<div class="control-row">
|
||||||
|
<span class="control-label">Beam size</span>
|
||||||
|
<label><input type="radio" name="beam_size" value="1"> 1 <small class="control-hint">(fastest)</small></label>
|
||||||
|
<label><input type="radio" name="beam_size" value="3"> 3</label>
|
||||||
|
<label><input type="radio" name="beam_size" value="5" checked> 5 <small class="control-hint">(best)</small></label>
|
||||||
|
</div>
|
||||||
|
<div class="control-row">
|
||||||
|
<span class="control-label">VAD filter</span>
|
||||||
|
<label><input type="checkbox" name="vad_filter" id="vadFilterCheck" value="1"> Strip silence</label>
|
||||||
|
</div>
|
||||||
|
<div class="expert-field">
|
||||||
|
<label class="control-label" for="initPromptInput">Initial prompt</label>
|
||||||
|
<textarea id="initPromptInput" name="initial_prompt" rows="2" placeholder="Seed with domain vocabulary, e.g. Barnevernet, Fylkesnemnd, advokat, tingrett…" class="prompt-textarea"></textarea>
|
||||||
|
<p class="upload-hint">Helps Whisper recognise specialist terms. Not included in output.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- Hidden stubs so tools.js refs don't crash on this page -->
|
||||||
|
<div class="is-hidden" id="languageControl" aria-hidden="true"><input type="radio" name="language" value="en" checked></div>
|
||||||
|
<div class="is-hidden" id="redactionControl" aria-hidden="true"></div>
|
||||||
|
<div class="is-hidden" id="uploadZone" aria-hidden="true">
|
||||||
|
<input type="file" id="uploadInput" style="display:none">
|
||||||
|
<div id="uploadPrompt"></div>
|
||||||
|
<div id="uploadFileInfo"><ul id="uploadFileList"></ul><button type="button" id="uploadClear"></button></div>
|
||||||
|
</div>
|
||||||
|
<div class="is-hidden" id="aliasSection" aria-hidden="true">
|
||||||
|
<button type="button" id="addAliasRow"></button>
|
||||||
|
<div id="aliasRows"></div>
|
||||||
|
</div>
|
||||||
|
<label class="is-hidden" id="inputLabel" for="toolInput"></label>
|
||||||
|
<textarea id="toolInput" name="toolInput" rows="1" class="is-hidden" aria-hidden="true"></textarea>
|
||||||
|
|
||||||
|
<div class="form-footer">
|
||||||
|
<p id="toolStatus" class="form-status" role="status" aria-live="polite"></p>
|
||||||
|
<button id="runButton" type="submit">Run Tool</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section id="results" class="results" aria-live="polite">
|
||||||
|
<div class="empty-state">
|
||||||
|
<h3>Ready</h3>
|
||||||
|
<p>Choose a tool, run a request, and the answer will show the evidence trail beside it.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<?php require_once __DIR__ . '/includes/layout_footer.php'; ?>
|
||||||
Reference in New Issue
Block a user