${escapeHtml(title)}
${meta ? `` : ''}${escapeHtml(body)}
${chunkToggle}const state = { activeTool: 'ask', authenticated: Boolean(window.DBN_TOOLS_AUTHENTICATED), }; const REDACT_I18N = { en: { redactEngine: 'Engine', redactEngineAzureMini: 'Azure gpt-4o-mini', redactEngineAzureFull: 'Azure gpt-4o', redactEngineGpu: 'GPU (cuttlefish)', redactEngineRegex: 'Regex only', redactEngineHint: 'Azure engines use your BNL Azure credits. GPU runs the local LiteLLM proxy. Regex-only is instant and free but finds no names or organisations.', redactMode: 'Mode', redactModeStandard: 'Standard', redactModeStrict: 'Strict', redactModeHint: 'Standard: regex patterns + LLM scan for names/orgs/places. Strict: also replaces any capitalised two-word phrase as a potential name — more aggressive, may produce false positives.', redactRegion: 'Region', redactRegionNordic: 'Nordic', redactRegionEuropean: 'European', redactRegionEchr: 'ECHR', redactRegionGlobal: 'Global', redactRegionHint: 'Nordic: Norwegian fødselsnummer, phone, email, addresses. European: adds IBAN, SE personnummer, UK NI. ECHR: adds application numbers, DOB phrases. Global: adds US SSN, document numbers.', redactEntities: 'Redact', redactEntityNames: 'Names', redactEntityOrgs: 'Organisations', redactEntityPlaces: 'Places', redactEntityDob: 'Dates of birth', redactOfficials: 'Officials', redactKeepOfficials: 'Keep official names (judges, experts)', redactOfficialsHint: 'When checked, judges, expert witnesses and caseworkers keep their names in a labelled tag: [JUDGE: Andersen]. Uncheck to replace all names with generic role tags.', redactOutput: 'Output', redactOutputContextual: 'Contextual tags', redactOutputGeneric: 'Generic tags', redactOutputPseudo: 'Pseudonyms', redactOutputHint: 'Contextual: each person gets a role tag so their identity is traceable within the document. Generic: all names become [PERSON]. Pseudonyms: replaced with plausible fake Norwegian values.', redactExempt: 'Exempt names', redactExemptAdd: 'Add', redactExemptHint: 'Names listed here will never be redacted, even if the AI would otherwise remove them — e.g. a judge or expert who must remain identifiable.', redactExemptPlaceholder: 'Name to keep (e.g. Judge Andersen)', redactAliases: 'Name aliases', redactAliasAdd: 'Add', redactAliasHint: 'Replace a specific name with a custom bracketed label, e.g. "David Jr" → [Junior].', redactUploadAria: 'File upload', redactUploadDrop: 'Drop up to 5 files here, or', redactUploadBrowse: 'browse', redactUploadHint: 'text extracted in memory, never stored', redactUploadClear: '× Clear', redactInputLabel: 'Pasted text', redactInputPlaceholder: 'Paste text containing names, phone numbers, emails, addresses, or national ID numbers.', redactRun: 'Run', redactRunning: 'Redacting…', redactReadyTitle: 'Ready', redactReadyDesc: 'Paste text or upload a file, configure redaction options, then run.', }, no: { redactEngine: 'Motor', redactEngineAzureMini: 'Azure gpt-4o-mini', redactEngineAzureFull: 'Azure gpt-4o', redactEngineGpu: 'GPU (cuttlefish)', redactEngineRegex: 'Kun regex', redactEngineHint: 'Azure-motorer bruker BNL Azure-kreditter. GPU kjører lokal LiteLLM-proxy. Kun regex er øyeblikkelig og gratis, men finner ingen navn eller organisasjoner.', redactMode: 'Modus', redactModeStandard: 'Standard', redactModeStrict: 'Strikt', redactModeHint: 'Standard: regex-mønstre + LLM-skanning for navn/org/steder. Strikt: erstatter også enhver stor-stav-kombinasjon som potensielt navn — mer aggressivt, kan gi falske positiver.', redactRegion: 'Region', redactRegionNordic: 'Nordisk', redactRegionEuropean: 'Europeisk', redactRegionEchr: 'EMD', redactRegionGlobal: 'Global', redactRegionHint: 'Nordisk: norsk fødselsnummer, telefon, e-post, adresser. Europeisk: legger til IBAN, SE personnummer, UK NI. EMD: legger til saksnummer, fødselsdatofraser. Global: legger til US SSN, dokumentnummer.', redactEntities: 'Rediger', redactEntityNames: 'Navn', redactEntityOrgs: 'Organisasjoner', redactEntityPlaces: 'Steder', redactEntityDob: 'Fødselsdatoer', redactOfficials: 'Offisielle', redactKeepOfficials: 'Behold offisielle navn (dommere, sakkyndige)', redactOfficialsHint: 'Når avkrysset beholder dommere, sakkyndige og saksbehandlere sine navn i en merket tagg: [DOMMER: Andersen]. Fjern haken for å erstatte alle navn med generiske rolletaggar.', redactOutput: 'Utdata', redactOutputContextual: 'Kontekstuelle taggar', redactOutputGeneric: 'Generiske taggar', redactOutputPseudo: 'Pseudonymer', redactOutputHint: 'Kontekstuell: hver person får en rolletagg slik at identiteten kan spores i dokumentet. Generisk: alle navn blir [PERSON]. Pseudonymer: erstattes med troverdige falske norske verdier.', redactExempt: 'Unntak', redactExemptAdd: 'Legg til', redactExemptHint: 'Navn oppført her vil aldri bli redigert, selv om AI ellers ville fjernet dem — f.eks. en dommer eller sakkyndig som må forbli identifiserbar.', redactExemptPlaceholder: 'Navn som skal beholdes (f.eks. Dommer Andersen)', redactAliases: 'Navnealiaser', redactAliasAdd: 'Legg til', redactAliasHint: 'Erstatt et spesifikt navn med en egendefinert merkelapp, f.eks. «David Jr» → [Junior].', redactUploadAria: 'Filopplasting', redactUploadDrop: 'Slipp opptil 5 filer her, eller', redactUploadBrowse: 'bla', redactUploadHint: 'tekst hentes i minnet, lagres aldri', redactUploadClear: '× Tøm', redactInputLabel: 'Limt inn tekst', redactInputPlaceholder: 'Lim inn tekst med navn, telefonnummer, e-poster, adresser eller personnummer.', redactRun: 'Kjør', redactRunning: 'Redigerer…', redactReadyTitle: 'Klar', redactReadyDesc: 'Lim inn tekst eller last opp en fil, konfigurer redigeringsalternativene, og kjør.', }, uk: { redactEngine: 'Рушій', redactEngineAzureMini: 'Azure gpt-4o-mini', redactEngineAzureFull: 'Azure gpt-4o', redactEngineGpu: 'GPU (cuttlefish)', redactEngineRegex: 'Лише регулярні вирази', redactEngineHint: 'Рушії Azure використовують кредити BNL Azure. GPU запускає локальний проксі LiteLLM. Лише regex — миттєво і безкоштовно, але не знаходить імен або організацій.', redactMode: 'Режим', redactModeStandard: 'Стандартний', redactModeStrict: 'Суворий', redactModeHint: 'Стандарт: шаблони regex + LLM-сканування для імен/орг/місць. Суворий: також замінює будь-яку комбінацію слів з великої літери як потенційне ім\'я.', redactRegion: 'Регіон', redactRegionNordic: 'Nordisk', redactRegionEuropean: 'Європейський', redactRegionEchr: 'ЄСПЛ', redactRegionGlobal: 'Глобальний', redactRegionHint: 'Nordisk: норвезький фødselsnummer, телефон, email, адреси. Європейський: додає IBAN, SE personnummer, UK NI. ЄСПЛ: додає номери справ, фрази дати народження. Глобальний: додає US SSN.', redactEntities: 'Редагувати', redactEntityNames: 'Імена', redactEntityOrgs: 'Організації', redactEntityPlaces: 'Місця', redactEntityDob: 'Дати народження', redactOfficials: 'Офіційні особи', redactKeepOfficials: 'Зберігати офіційні імена (судді, експерти)', redactOfficialsHint: 'Якщо позначено, судді, експерти та соціальні працівники зберігають свої імена у позначеному тезі: [СУДДЯ: Andersen].', redactOutput: 'Вивід', redactOutputContextual: 'Контекстні теги', redactOutputGeneric: 'Загальні теги', redactOutputPseudo: 'Псевдоніми', redactOutputHint: 'Контекстний: кожна особа отримує тег ролі. Загальний: всі імена стають [PERSON]. Псевдоніми: замінюються правдоподібними норвезькими значеннями.', redactExempt: 'Виключені імена', redactExemptAdd: 'Додати', redactExemptHint: 'Імена, перелічені тут, ніколи не будуть відредаговані.', redactExemptPlaceholder: 'Ім\'я для збереження (напр. суддя Andersen)', redactAliases: 'Псевдоніми імен', redactAliasAdd: 'Додати', redactAliasHint: 'Замініть конкретне ім\'я на власну мітку, напр. «David Jr» → [Junior].', redactUploadAria: 'Завантаження файлів', redactUploadDrop: 'Перетягніть до 5 файлів сюди, або', redactUploadBrowse: 'огляд', redactUploadHint: 'текст обробляється в пам\'яті, ніколи не зберігається', redactUploadClear: '× Очистити', redactInputLabel: 'Вставлений текст', redactInputPlaceholder: 'Вставте текст з іменами, телефонами, адресами або ідентифікаційними номерами.', redactRun: 'Запустити', redactRunning: 'Редагування…', redactReadyTitle: 'Готово', redactReadyDesc: 'Вставте текст або завантажте файл, налаштуйте параметри, запустіть.', }, pl: { redactEngine: 'Silnik', redactEngineAzureMini: 'Azure gpt-4o-mini', redactEngineAzureFull: 'Azure gpt-4o', redactEngineGpu: 'GPU (cuttlefish)', redactEngineRegex: 'Tylko regex', redactEngineHint: 'Silniki Azure używają kredytów Azure BNL. GPU korzysta z lokalnego proxy LiteLLM. Tylko regex jest natychmiastowy i bezpłatny, ale nie znajdzie imion ani organizacji.', redactMode: 'Tryb', redactModeStandard: 'Standardowy', redactModeStrict: 'Ścisły', redactModeHint: 'Standardowy: wzorce regex + skanowanie LLM dla imion/org/miejsc. Ścisły: zastępuje też każdą kombinację słów pisanych wielką literą jako potencjalne imię.', redactRegion: 'Region', redactRegionNordic: 'Nordycki', redactRegionEuropean: 'Europejski', redactRegionEchr: 'ETPC', redactRegionGlobal: 'Globalny', redactRegionHint: 'Nordycki: norweski fødselsnummer, telefon, email, adresy. Europejski: dodaje IBAN, SE personnummer, UK NI. ETPC: dodaje numery spraw, frazy daty urodzenia. Globalny: dodaje US SSN.', redactEntities: 'Redaguj', redactEntityNames: 'Imiona', redactEntityOrgs: 'Organizacje', redactEntityPlaces: 'Miejsca', redactEntityDob: 'Daty urodzenia', redactOfficials: 'Urzędnicy', redactKeepOfficials: 'Zachowaj oficjalne nazwy (sędziowie, eksperci)', redactOfficialsHint: 'Gdy zaznaczone, sędziowie, biegli i pracownicy socjalni zachowują swoje nazwiska w oznaczonym tagu: [SĘDZIA: Andersen].', redactOutput: 'Wyjście', redactOutputContextual: 'Tagi kontekstowe', redactOutputGeneric: 'Tagi ogólne', redactOutputPseudo: 'Pseudonimy', redactOutputHint: 'Kontekstowe: każda osoba otrzymuje tag roli. Ogólne: wszystkie imiona stają się [PERSON]. Pseudonimy: zastąpione wiarygodnymi fałszywymi wartościami norweskimi.', redactExempt: 'Zwolnione nazwy', redactExemptAdd: 'Dodaj', redactExemptHint: 'Nazwy tu wpisane nigdy nie zostaną zredagowane.', redactExemptPlaceholder: 'Nazwa do zachowania (np. Sędzia Andersen)', redactAliases: 'Aliasy nazw', redactAliasAdd: 'Dodaj', redactAliasHint: 'Zastąp konkretną nazwę własną etykietą, np. «David Jr» → [Junior].', redactUploadAria: 'Przesyłanie pliku', redactUploadDrop: 'Upuść do 5 plików tutaj lub', redactUploadBrowse: 'przeglądaj', redactUploadHint: 'tekst wyodrębniany w pamięci, nigdy nie przechowywany', redactUploadClear: '× Wyczyść', redactInputLabel: 'Wklejony tekst', redactInputPlaceholder: 'Wklej tekst zawierający imiona, numery telefonów, adresy lub numery identyfikacyjne.', redactRun: 'Uruchom', redactRunning: 'Redagowanie…', redactReadyTitle: 'Gotowe', redactReadyDesc: 'Wklej tekst lub wgraj plik, skonfiguruj opcje redakcji, uruchom.', }, }; let lastTimelineEvents = []; let audioQueue = []; // [{file, status: 'pending'|'processing'|'done'|'error', result}] let lastTranscriptData = null; const VOCAB_PRESETS = { barnerett: 'Barnevernet, Fylkesnemnda, barnevernloven, barneloven, barnets beste, samvær, foreldreansvar, omsorgsovertakelse, sakkyndig, advokat, prosessfullmektig, dommer, vitne, tolk, bistandsadvokat, fosterforeldre, fosterhjem, akuttvedtak, statsforvalter, Bufetat, saksbehandler, rettslig medhold, begjæring, samtykke, tilsynsfører', mediation: 'rettsmekling, meklingsnemnd, familievernkontor, mekling, forlik, meklingsprotokoll, foreldreplan, avtale, prosessfullmektig, advokat, dommer, vitne, sakkyndig, statsforvalter, barnevernloven, barneloven, barnets beste, samvær, foreldreansvar, omsorgsovertakelse, tilsvar, stevning, kjennelse, anke', generell: 'bokmål, nynorsk, statsforvalter, kommunen, forvaltning, klage, vedtak, rettigheter, plikter, protokoll, referat, rapport, dokumentasjon, velferd', custom: '', }; let uiLang = localStorage.getItem('dbn-ui-lang') || 'en'; const TRANSCRIBE_I18N = { en: { engine: 'Engine', engineGpuLabel: 'GPU (cuttlefish RTX 3060)', engineOpenaiLabel: 'OpenAI Whisper API', engineAzureLabel: 'Azure AI Speech (nb-NO)', apiKey: 'API Key', apiKeyHint: 'Used for this request only, never stored. Max 25 MB.', region: 'Region', model: 'Model', modelFastest: 'Fastest', modelBalanced: 'Balanced', modelBest: 'Best quality', transcribeLang: 'Audio language', autoDetectHint: '(may confuse nb/da/sv)', speakers: 'Speakers', identifySpeakers: 'Identify speakers', speakersCount: 'Count', speakersPlaceholder: 'auto', speakersAriaLabel: 'Expected number of speakers', vocabulary: 'Vocabulary', vocabPresetChildWelfare: 'Child welfare / CPS', vocabPresetMediation: 'Mediation / legal meeting', vocabPresetGeneral: 'General Norwegian', vocabPresetCustom: 'Custom', vocabPlaceholder: 'Technical terms and names for Whisper to recognise, e.g. Barnevernet, mediation, family services…', vocabHint: 'Helps Whisper recognise technical terms. Not included in the transcript.', uploadAria: 'Audio upload', uploadDrop: 'Drop audio file(s) here, or', uploadBrowse: 'browse', uploadHint: 'max 200 MB per file', uploadAddFiles: '+ Add files', uploadClearQueue: '× Clear queue', expertSettings: 'Advanced settings', task: 'Task', taskTranscribe: 'Transcribe', taskTranslate: 'Translate to English', beamSize: 'Beam size', beamFastest: '(fastest)', beamBest: '(best)', beamSizeHint: 'Controls search breadth — higher values improve accuracy but take longer. 5 is recommended for legal recordings.', vadFilter: 'VAD filter', vadFilterLabel: 'Remove silence', vadFilterHint: 'Voice Activity Detection — skips silent passages before transcribing. Speeds up processing and prevents the model hallucinating on silence.', run: 'Run', running: 'Transcribing…', runningOther: 'Running…', readyTitle: 'Ready', readyDesc: 'Select a tool, run a request, and the result appears here.', noFileSelected: 'Select at least one audio file before transcribing.', missingOpenaiKey: 'Enter a valid OpenAI API key (sk-…) before running.', openaiFileTooLarge: (f) => `OpenAI Whisper has a 25 MB limit. Use the GPU engine for ${f}.`, missingAzureKey: 'Enter an Azure Speech API key before running.', clipLabel: (i, total) => total > 1 ? `Clip ${i}/${total}` : 'Transcribing', transcribeFailed: (s) => `Transcription failed (HTTP ${s}).`, errorLabel: (clip) => `Error – ${clip}`, filesSkipped: (names) => `Skipped: ${names}`, fileSizeExceeded: (name, mb) => `${name} (${mb} MB — max 200 MB)`, filesInQueue: (n) => `${n} file${n !== 1 ? 's' : ''} in queue.`, done: (n, dur) => n > 1 ? `Done · ${n} clips · Total audio: ${dur}` : `Done · Audio: ${dur}`, traceUploadLabel: (clip, eng) => `${clip} — uploading to ${eng}`, traceUploadDetail: (eng) => eng === 'gpu' ? 'Sending audio to cuttlefish GPU…' : `Sending audio to ${eng}…`, traceProcessingLabel: (clip, eng) => `${clip} — ${eng} transcribing`, traceProcessingDetail: (eng) => eng === 'gpu' ? 'Whisper transcribing. Large files take 1–3 minutes.' : `${eng} processing audio.`, traceStillLabel: (clip) => `${clip} — still processing…`, traceStillDetail: (e) => { const m = Math.floor(e / 60), s = e % 60; return m > 0 ? `${m}m ${s}s elapsed — working through the audio.` : `${e}s elapsed — processing.`; }, }, no: { engine: 'Motor', engineGpuLabel: 'GPU (cuttlefish RTX 3060)', engineOpenaiLabel: 'OpenAI Whisper API', engineAzureLabel: 'Azure AI Speech (nb-NO)', apiKey: 'API-nøkkel', apiKeyHint: 'Brukes kun for denne forespørselen, lagres aldri. Maks 25 MB.', region: 'Region', model: 'Modell', modelFastest: 'Raskest', modelBalanced: 'Balansert', modelBest: 'Beste kvalitet', transcribeLang: 'Språk i lydfil', autoDetectHint: '(kan forveksle nb/da/sv)', speakers: 'Talere', identifySpeakers: 'Skill ut talere', speakersCount: 'Antall', speakersPlaceholder: 'auto', speakersAriaLabel: 'Forventet antall talere', vocabulary: 'Ordliste', vocabPresetChildWelfare: 'Barnerett / CPS', vocabPresetMediation: 'Mekling / møter', vocabPresetGeneral: 'Generell norsk', vocabPresetCustom: 'Egendefinert', vocabPlaceholder: 'Fagord og navn Whisper skal gjenkjenne, f.eks. Barnevernet, Fylkesnemnda, mekling…', vocabHint: 'Hjelper Whisper gjenkjenne fagtermer. Ikke inkludert i utskriften.', uploadAria: 'Lydopplasting', uploadDrop: 'Slipp lydfil(er) her, eller', uploadBrowse: 'bla', uploadHint: 'maks 200 MB per fil', uploadAddFiles: '+ Legg til filer', uploadClearQueue: '× Tøm kø', expertSettings: 'Ekspertinnstillinger', task: 'Oppgave', taskTranscribe: 'Transkriber', taskTranslate: 'Oversett til engelsk', beamSize: 'Beam size', beamFastest: '(raskest)', beamBest: '(best)', beamSizeHint: 'Styrer søkebredde — høyere verdier gir bedre nøyaktighet men tar lengre tid. 5 anbefales for juridiske opptak.', vadFilter: 'VAD-filter', vadFilterLabel: 'Fjern stillhet', vadFilterHint: 'Taleaktivitetsdeteksjon — hopper over stille partier før transkripsjon. Raskere behandling og forhindrer hallusinasjon på stillhet.', run: 'Kjør', running: 'Transkriberer…', runningOther: 'Kjører…', readyTitle: 'Klar', readyDesc: 'Velg et verktøy, kjør en forespørsel, og svaret vises her.', noFileSelected: 'Velg minst én lydfil før transkripsjon.', missingOpenaiKey: 'Legg inn en gyldig OpenAI API-nøkkel (sk-…) før du kjører.', openaiFileTooLarge: (f) => `OpenAI Whisper har 25 MB-grense. Bruk GPU-motor for ${f}.`, missingAzureKey: 'Legg inn Azure Speech API-nøkkel før du kjører.', clipLabel: (i, total) => total > 1 ? `Klipp ${i}/${total}` : 'Transkriberer', transcribeFailed: (s) => `Transkripsjon feilet (HTTP ${s}).`, errorLabel: (clip) => `Feil – ${clip}`, filesSkipped: (names) => `Hoppet over: ${names}`, fileSizeExceeded: (name, mb) => `${name} (${mb} MB — maks 200 MB)`, filesInQueue: (n) => `${n} fil${n !== 1 ? 'er' : ''} i køen.`, done: (n, dur) => n > 1 ? `Ferdig · ${n} klipp · Total lyd: ${dur}` : `Ferdig · Lyd: ${dur}`, traceUploadLabel: (clip, eng) => `${clip} — laster opp til ${eng}`, traceUploadDetail: (eng) => eng === 'gpu' ? 'Sender lyd til cuttlefish GPU…' : `Sender lyd til ${eng}…`, traceProcessingLabel: (clip, eng) => `${clip} — ${eng} transkriberer`, traceProcessingDetail: (eng) => eng === 'gpu' ? 'Whisper transkriberer. Store filer tar 1–3 minutter.' : `${eng} behandler lyden.`, traceStillLabel: (clip) => `${clip} — behandler fortsatt…`, traceStillDetail: (e) => { const m = Math.floor(e / 60), s = e % 60; return m > 0 ? `${m} min ${s}s gått — jobber gjennom lyden.` : `${e}s gått — behandler.`; }, }, uk: { engine: 'Рушій', engineGpuLabel: 'GPU (cuttlefish RTX 3060)', engineOpenaiLabel: 'OpenAI Whisper API', engineAzureLabel: 'Azure AI Speech (nb-NO)', apiKey: 'API-ключ', apiKeyHint: 'Використовується лише для цього запиту, ніколи не зберігається. Макс 25 МБ.', region: 'Регіон', model: 'Модель', modelFastest: 'Найшвидша', modelBalanced: 'Збалансована', modelBest: 'Найкраща якість', transcribeLang: 'Мова аудіо', autoDetectHint: '(може плутати nb/da/sv)', speakers: 'Мовці', identifySpeakers: 'Визначити мовців', speakersCount: 'Кількість', speakersPlaceholder: 'auto', speakersAriaLabel: 'Очікувана кількість мовців', vocabulary: 'Словник', vocabPresetChildWelfare: 'Охорона дітей / CPS', vocabPresetMediation: 'Медіація / зустріч', vocabPresetGeneral: 'Загальна норвезька', vocabPresetCustom: 'Власний', vocabPlaceholder: 'Терміни та імена для Whisper, напр. Barnevernet, медіація…', vocabHint: 'Допомагає Whisper розпізнати терміни. Не включається до транскрипту.', uploadAria: 'Завантаження аудіо', uploadDrop: 'Перетягніть файл(u) сюди, або', uploadBrowse: 'огляд', uploadHint: 'макс 200 МБ на файл', uploadAddFiles: '+ Додати файли', uploadClearQueue: '× Очистити чергу', expertSettings: 'Розширені налаштування', task: 'Завдання', taskTranscribe: 'Транскрибувати', taskTranslate: 'Перекласти англійською', beamSize: 'Розмір пучка', beamFastest: '(найшвидший)', beamBest: '(найкращий)', beamSizeHint: 'Ширина пошуку — більше значення підвищує точність, але займає більше часу. 5 рекомендовано для юридичних записів.', vadFilter: 'VAD-фільтр', vadFilterLabel: 'Видалити тишу', vadFilterHint: 'Виявлення мовної активності — пропускає тихі ділянки перед транскрипцією. Прискорює обробку та запобігає галюцинаціям на тиші.', run: 'Запустити', running: 'Транскрибування…', runningOther: 'Виконання…', readyTitle: 'Готово', readyDesc: 'Виберіть інструмент, запустіть запит — результат з\'явиться тут.', noFileSelected: 'Виберіть хоч б один аудіофайл перед транскрибуванням.', missingOpenaiKey: 'Введіть дійсний ключ OpenAI API (sk-…) перед запуском.', openaiFileTooLarge: (f) => `OpenAI Whisper має обмеження 25 МБ. Використовуйте GPU для ${f}.`, missingAzureKey: 'Введіть ключ Azure Speech API перед запуском.', clipLabel: (i, total) => total > 1 ? `Кліп ${i}/${total}` : 'Транскрибування', transcribeFailed: (s) => `Транскрибування не вдалося (HTTP ${s}).`, errorLabel: (clip) => `Помилка – ${clip}`, filesSkipped: (names) => `Пропущено: ${names}`, fileSizeExceeded: (name, mb) => `${name} (${mb} МБ — макс 200 МБ)`, filesInQueue: (n) => `${n} файл${n !== 1 ? 'ів' : ''} у черзі.`, done: (n, dur) => n > 1 ? `Готово · ${n} кліпи · Загальне аудіо: ${dur}` : `Готово · Аудіо: ${dur}`, traceUploadLabel: (clip, eng) => `${clip} — завантаження до ${eng}`, traceUploadDetail: (eng) => eng === 'gpu' ? 'Відправка аудіо на cuttlefish GPU…' : `Відправка аудіо до ${eng}…`, traceProcessingLabel: (clip, eng) => `${clip} — ${eng} транскрибує`, traceProcessingDetail: (eng) => eng === 'gpu' ? 'Whisper транскрибує. Великі файли займають 1–3 хвилини.' : `${eng} обробляє аудіо.`, traceStillLabel: (clip) => `${clip} — ще обробляється…`, traceStillDetail: (e) => { const m = Math.floor(e / 60), s = e % 60; return m > 0 ? `Минуло ${m} хв ${s} с — обробка.` : `Минуло ${e} с — обробка.`; }, }, pl: { engine: 'Silnik', engineGpuLabel: 'GPU (cuttlefish RTX 3060)', engineOpenaiLabel: 'OpenAI Whisper API', engineAzureLabel: 'Azure AI Speech (nb-NO)', apiKey: 'Klucz API', apiKeyHint: 'Używany tylko dla tego żądania, nigdy nie przechowywany. Maks 25 MB.', region: 'Region', model: 'Model', modelFastest: 'Najszybszy', modelBalanced: 'Zrównoważony', modelBest: 'Najlepsza jakość', transcribeLang: 'Język audio', autoDetectHint: '(może mylić nb/da/sv)', speakers: 'Mówcy', identifySpeakers: 'Rozróżnij mówców', speakersCount: 'Liczba', speakersPlaceholder: 'auto', speakersAriaLabel: 'Oczekiwana liczba mówców', vocabulary: 'Słownik', vocabPresetChildWelfare: 'Opieka nad dziećmi / CPS', vocabPresetMediation: 'Mediacja / spotkanie', vocabPresetGeneral: 'Ogólny norweski', vocabPresetCustom: 'Własny', vocabPlaceholder: 'Terminy i nazwy dla Whisper, np. Barnevernet, mediacja…', vocabHint: 'Pomaga Whisper rozpoznać terminy. Nie jest uwzględniony w transkrypcji.', uploadAria: 'Prześylanie audio', uploadDrop: 'Upuść plik(i) audio tutaj lub', uploadBrowse: 'przeglądaj', uploadHint: 'maks 200 MB na plik', uploadAddFiles: '+ Dodaj pliki', uploadClearQueue: '× Wyczyść kolejkę', expertSettings: 'Ustawienia zaawansowane', task: 'Zadanie', taskTranscribe: 'Transkrybuj', taskTranslate: 'Przetłumacz na angielski', beamSize: 'Rozmiar wiązki', beamFastest: '(najszybszy)', beamBest: '(najlepszy)', beamSizeHint: 'Kontroluje szerokość wyszukiwania — wyższe wartości poprawiają dokładność, ale wydłużają czas. 5 zalecane dla nagrań prawnych.', vadFilter: 'Filtr VAD', vadFilterLabel: 'Usuń ciszę', vadFilterHint: 'Wykrywanie aktywności głosowej — pomija ciche fragmenty przed transkrypcją. Przyspiesza przetwarzanie i zapobiega halucynacjom na ciszy.', run: 'Uruchom', running: 'Transkrybowanie…', runningOther: 'Uruchamianie…', readyTitle: 'Gotowe', readyDesc: 'Wybierz narzędzie, uruchom żądanie — wynik pojawi się tutaj.', noFileSelected: 'Wybierz co najmniej jeden plik audio przed transkrypcją.', missingOpenaiKey: 'Wprowadź prawidłowy klucz API OpenAI (sk-…) przed uruchomieniem.', openaiFileTooLarge: (f) => `OpenAI Whisper ma limit 25 MB. Użyj silnika GPU dla ${f}.`, missingAzureKey: 'Wprowadź klucz Azure Speech API przed uruchomieniem.', clipLabel: (i, total) => total > 1 ? `Klip ${i}/${total}` : 'Transkrybowanie', transcribeFailed: (s) => `Transkrypcja nie powiodła się (HTTP ${s}).`, errorLabel: (clip) => `Błąd – ${clip}`, filesSkipped: (names) => `Pominięto: ${names}`, fileSizeExceeded: (name, mb) => `${name} (${mb} MB — maks 200 MB)`, filesInQueue: (n) => `${n} plik${n !== 1 ? 'i' : ''} w kolejce.`, done: (n, dur) => n > 1 ? `Gotowe · ${n} klipy · Łączne audio: ${dur}` : `Gotowe · Audio: ${dur}`, traceUploadLabel: (clip, eng) => `${clip} — przesyłanie do ${eng}`, traceUploadDetail: (eng) => eng === 'gpu' ? 'Wysyłanie audio do cuttlefish GPU…' : `Wysyłanie audio do ${eng}…`, traceProcessingLabel: (clip, eng) => `${clip} — ${eng} transkrybuje`, traceProcessingDetail: (eng) => eng === 'gpu' ? 'Whisper transkrybuje. Duże pliki zajmują 1–3 minuty.' : `${eng} przetwarza audio.`, traceStillLabel: (clip) => `${clip} — nadal przetwarza…`, traceStillDetail: (e) => { const m = Math.floor(e / 60), s = e % 60; return m > 0 ? `Minęło ${m} min ${s} s — przetwarzanie audio.` : `Minęło ${e} s — przetwarzanie.`; }, }, }; function currentUiT(key, ...args) { const t = TRANSCRIBE_I18N[uiLang] || TRANSCRIBE_I18N.en; const val = (key in t) ? t[key] : TRANSCRIBE_I18N.en[key]; if (typeof val === 'function') return val(...args); return val ?? key; } function applyTranscribeI18n(lang) { uiLang = lang; localStorage.setItem('dbn-ui-lang', lang); document.querySelectorAll('[data-i18n]').forEach((el) => { const text = currentUiT(el.dataset.i18n); if (text != null) el.textContent = text; }); document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => { const text = currentUiT(el.dataset.i18nPlaceholder); if (text != null) el.placeholder = text; }); document.querySelectorAll('[data-i18n-aria]').forEach((el) => { const text = currentUiT(el.dataset.i18nAria); if (text != null) el.setAttribute('aria-label', text); }); document.querySelectorAll('.lang-btn').forEach((btn) => { btn.classList.toggle('is-active', btn.dataset.lang === lang); }); } function currentRedactT(key) { const t = REDACT_I18N[uiLang] || REDACT_I18N.en; return (key in t) ? t[key] : (REDACT_I18N.en[key] ?? key); } function applyRedactI18n(lang) { uiLang = lang; localStorage.setItem('dbn-ui-lang', lang); document.querySelectorAll('[data-i18n]').forEach((el) => { const text = currentRedactT(el.dataset.i18n); if (text != null) el.textContent = text; }); document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => { const text = currentRedactT(el.dataset.i18nPlaceholder); if (text != null) el.placeholder = text; }); document.querySelectorAll('[data-i18n-aria]').forEach((el) => { const text = currentRedactT(el.dataset.i18nAria); if (text != null) el.setAttribute('aria-label', text); }); document.querySelectorAll('#redactLangSwitcher .lang-btn').forEach((btn) => { btn.classList.toggle('is-active', btn.dataset.lang === lang); }); } function currentRedactEngine() { return document.querySelector('input[name="redactEngine"]:checked')?.value || 'azure_mini'; } function currentOutputFormat() { return document.querySelector('input[name="outputFormat"]:checked')?.value || 'contextual'; } function currentKeepOfficials() { return document.getElementById('keepOfficialsCheck')?.checked ?? false; } function currentRedactTypes() { return { names: document.getElementById('redactNames')?.checked ?? true, orgs: document.getElementById('redactOrgs')?.checked ?? true, places: document.getElementById('redactPlaces')?.checked ?? true, dob: document.getElementById('redactDob')?.checked ?? true, }; } function setupRedactControls() { const switcher = document.getElementById('redactLangSwitcher'); if (!switcher) return; switcher.querySelectorAll('.lang-btn').forEach((btn) => { btn.addEventListener('click', () => applyRedactI18n(btn.dataset.lang)); }); applyRedactI18n(uiLang); } function setupExemptNames() { const addBtn = document.getElementById('addExemptRow'); const rows = document.getElementById('exemptRows'); if (!addBtn || !rows) return; addBtn.addEventListener('click', () => { const row = document.createElement('div'); row.className = 'exempt-row'; row.innerHTML = [ ``, '', ].join(''); rows.appendChild(row); row.querySelector('.exempt-name-input').focus(); }); rows.addEventListener('click', (e) => { const btn = e.target.closest('.alias-remove'); if (btn) btn.closest('.exempt-row').remove(); }); } function getExemptNames() { return Array.from(document.querySelectorAll('#exemptRows .exempt-name-input')) .map((el) => el.value.trim()) .filter(Boolean); } const tools = { ask: { kind: 'Source-grounded Legal Ask', title: 'Ask a legal question', label: 'Question', endpoint: 'api/ask.php', payloadKey: 'question', placeholder: 'Example: What evidence is needed before asking for changes in custody arrangements?', usesLanguage: true, badge: 'family-legal', }, search: { kind: 'Legal Source Search', title: 'Search legal sources', label: 'Search query', endpoint: 'api/search.php', payloadKey: 'query', placeholder: 'Example: barnets beste samvær foreldreansvar', usesLanguage: true, badge: 'family-legal', }, summarize: { kind: 'Document Summarizer', title: 'Summarize pasted text', label: 'Pasted text', endpoint: 'api/summarize.php', payloadKey: 'text', placeholder: 'Paste a case note, letter, or excerpt.', usesLanguage: true, badge: 'process-and-forget', }, timeline: { kind: 'Timeline Builder', title: 'Build a timeline', label: 'Pasted text', endpoint: 'api/timeline.php', payloadKey: 'text', placeholder: 'Paste case notes with dates, actors, and events.', usesLanguage: true, badge: 'process-and-forget', }, redact: { kind: 'Redaction Assistant', title: 'Redact sensitive details', label: 'Pasted text', endpoint: 'api/redact.php', payloadKey: 'text', placeholder: 'Paste text containing names, phone numbers, emails, addresses, or fødselsnummer-like values.', usesLanguage: false, badge: 'deterministic first', }, transcribe: { kind: 'Audio Transcription', title: 'Transcribe audio', label: 'Audio file', endpoint: 'api/transcribe.php', payloadKey: null, placeholder: '', usesLanguage: false, badge: 'Whisper / GPU', }, }; const els = {}; document.addEventListener('DOMContentLoaded', () => { Object.assign(els, { gate: document.querySelector('#publicLanding'), app: document.querySelector('#appShell'), passcodeForm: document.querySelector('#passcodeForm'), loginEmail: document.querySelector('#loginEmail'), loginPassword: document.querySelector('#loginPassword'), gateStatus: document.querySelector('#gateStatus'), tabs: Array.from(document.querySelectorAll('.tool-tab')), toolKind: document.querySelector('#toolKind'), toolTitle: document.querySelector('#toolTitle'), toolBadge: document.querySelector('#toolBadge'), form: document.querySelector('#toolForm'), inputLabel: document.querySelector('#inputLabel'), input: document.querySelector('#toolInput'), languageControl: document.querySelector('#languageControl'), redactionControl: document.querySelector('#redactionControl'), status: document.querySelector('#toolStatus'), results: document.querySelector('#results'), traceList: document.querySelector('#traceList'), healthButton: document.querySelector('#healthButton'), healthPill: document.querySelector('#healthPill'), uploadZone: document.querySelector('#uploadZone'), uploadInput: document.querySelector('#uploadInput'), uploadPrompt: document.querySelector('#uploadPrompt'), uploadFileInfo: document.querySelector('#uploadFileInfo'), uploadFileList: document.querySelector('#uploadFileList'), uploadClear: document.querySelector('#uploadClear'), aliasSection: document.querySelector('#aliasSection'), addAliasRow: document.querySelector('#addAliasRow'), aliasRows: document.querySelector('#aliasRows'), audioZone: document.querySelector('#audioZone'), audioInput: document.querySelector('#audioInput'), audioPrompt: document.querySelector('#audioPrompt'), audioFileInfo: document.querySelector('#audioFileInfo'), audioQueueList: document.querySelector('#audioQueueList'), audioClear: document.querySelector('#audioClear'), diarizeControl: document.querySelector('#diarizeControl'), diarizeCheck: document.querySelector('#diarizeCheck'), numSpeakersInput: document.querySelector('#numSpeakersInput'), transcribeLangControl: document.querySelector('#transcribeLangControl'), initPromptInput: document.querySelector('#initPromptInput'), vocabPresets: document.querySelector('#vocabPresets'), }); els.tabs.forEach((tab) => { if (tab.tagName !== 'A') { tab.addEventListener('click', () => setTool(tab.dataset.tool)); } }); els.form?.addEventListener('submit', runTool); els.passcodeForm?.addEventListener('submit', submitPasscode); els.healthButton.addEventListener('click', checkHealth); setupUpload(); setupAliases(); setupAudio(); setupTranscribeControls(); setupVocabPresets(); setupRedactControls(); setupExemptNames(); // Wire transcribe lang switcher (only present on transcribe page) document.querySelectorAll('#uiLangSwitcher .lang-btn').forEach((btn) => { btn.addEventListener('click', () => applyTranscribeI18n(btn.dataset.lang)); }); if (document.getElementById('uiLangSwitcher')) { applyTranscribeI18n(uiLang); } els.results.addEventListener('click', (e) => { if (e.target.closest('#exportCsvBtn')) exportTimelineCSV(lastTimelineEvents); if (e.target.closest('#dlTxt')) downloadTranscriptTxt(); if (e.target.closest('#dlSrt')) downloadTranscriptSrt(); if (e.target.closest('#dlVtt')) downloadTranscriptVtt(); }); const activeTool = document.body.dataset.activeTool || state.activeTool; setTool(activeTool); if (state.authenticated) { checkHealth(); } else { els.loginEmail?.focus(); } }); function setTool(toolName) { state.activeTool = toolName; const tool = tools[toolName]; els.tabs.forEach((button) => { const active = button.dataset.tool === toolName; button.classList.toggle('is-active', active); button.setAttribute('aria-pressed', String(active)); }); els.toolKind.textContent = tool.kind; els.toolTitle.textContent = tool.title; els.toolBadge.textContent = tool.badge; els.inputLabel.textContent = tool.label; els.input.value = ''; els.input.placeholder = tool.placeholder; els.languageControl.classList.toggle('is-hidden', !tool.usesLanguage); els.redactionControl.classList.toggle('is-hidden', toolName !== 'redact'); els.uploadZone.classList.toggle('is-hidden', toolName !== 'redact' && toolName !== 'timeline'); els.aliasSection.classList.toggle('is-hidden', toolName !== 'redact'); els.audioZone.classList.toggle('is-hidden', toolName !== 'transcribe'); els.diarizeControl.classList.toggle('is-hidden', toolName !== 'transcribe'); els.transcribeLangControl.classList.toggle('is-hidden', toolName !== 'transcribe'); els.input.classList.toggle('is-hidden', toolName === 'transcribe'); els.inputLabel.classList.toggle('is-hidden', toolName === 'transcribe'); els.input.required = toolName !== 'transcribe'; resetUpload(); resetAliases(); resetAudio(); els.status.textContent = ''; renderTrace([]); } async function submitPasscode(event) { event.preventDefault(); els.gateStatus.textContent = 'Signing in…'; try { const data = await postJson('api/session.php', { email: els.loginEmail.value.trim(), password: els.loginPassword.value, }); if (!data.ok) { throw new Error(data.error?.message || 'Credentials were not accepted.'); } state.authenticated = true; els.gate.classList.add('is-hidden'); els.app.classList.remove('is-hidden'); els.loginPassword.value = ''; els.healthPill.textContent = 'Session active'; checkHealth(); els.input.focus(); } catch (error) { els.gateStatus.textContent = error.message; } } async function runTool(event) { event.preventDefault(); if (state.activeTool === 'transcribe') { await runTranscribe(); return; } const tool = tools[state.activeTool]; const text = els.input.value.trim(); if (!text) { els.status.textContent = 'Add text before running the tool.'; els.input.focus(); return; } const payload = { [tool.payloadKey]: text }; if (tool.usesLanguage) { payload.language = currentLanguage(); } if (state.activeTool === 'search') { payload.limit = 7; } if (state.activeTool === 'redact') { payload.mode = currentRedactionMode(); payload.region = currentRedactionRegion(); payload.aliases = getAliases(); payload.engine = currentRedactEngine(); payload.output_format = currentOutputFormat(); payload.keep_officials = currentKeepOfficials(); payload.exempt_names = getExemptNames(); payload.redact_types = currentRedactTypes(); } setBusy(true); renderTrace([ { label: 'Query interpretation', detail: 'Preparing request.', status: 'running' }, ]); try { const data = await postJson(tool.endpoint, payload); if (!data.ok) { throw new Error(data.error?.message || 'Tool request failed.'); } renderResults(data); renderTrace(data.trace || []); els.status.textContent = `Done in ${data.latency_ms || 0} ms.`; } catch (error) { els.status.textContent = error.message; renderTrace([ { label: 'Tool error', detail: error.message, status: 'warning' }, ]); } finally { setBusy(false); } } function resetUpload() { if (!els.uploadInput) return; els.uploadInput.value = ''; els.uploadPrompt.classList.remove('is-hidden'); els.uploadFileInfo.classList.add('is-hidden'); els.uploadFileList.innerHTML = ''; els.uploadZone.classList.remove('is-drag-over'); } function setupUpload() { els.uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); els.uploadZone.classList.add('is-drag-over'); }); els.uploadZone.addEventListener('dragleave', (e) => { if (!els.uploadZone.contains(e.relatedTarget)) { els.uploadZone.classList.remove('is-drag-over'); } }); els.uploadZone.addEventListener('drop', (e) => { e.preventDefault(); els.uploadZone.classList.remove('is-drag-over'); if (e.dataTransfer?.files?.length) handleFiles(e.dataTransfer.files); }); els.uploadZone.addEventListener('click', (e) => { if (e.target === els.uploadClear || els.uploadClear?.contains(e.target)) return; if (e.target.tagName === 'LABEL') return; els.uploadInput.click(); }); els.uploadInput.addEventListener('change', () => { if (els.uploadInput.files?.length) handleFiles(els.uploadInput.files); }); els.uploadClear.addEventListener('click', () => { resetUpload(); els.input.value = ''; els.status.textContent = ''; }); } async function handleFiles(fileList) { const allowed = ['pdf', 'docx', 'txt']; const files = Array.from(fileList).slice(0, 5); for (const file of files) { const ext = file.name.split('.').pop().toLowerCase(); if (!allowed.includes(ext)) { els.status.textContent = `Skipped ${file.name}: unsupported type. Use .pdf, .docx, or .txt.`; return; } } els.status.textContent = files.length === 1 ? `Extracting ${files[0].name}…` : `Extracting ${files.length} files…`; setBusy(true); const parts = []; let totalChars = 0; let anyTruncated = false; try { for (const file of files) { const formData = new FormData(); formData.append('file', file); const resp = await fetch('api/extract.php', { method: 'POST', credentials: 'same-origin', body: formData, }); const data = await resp.json().catch(() => ({})); if (!resp.ok || !data.ok) { throw new Error(data.error?.message || `Extraction failed for ${file.name} (HTTP ${resp.status}).`); } parts.push({ filename: file.name, chars: data.chars, truncated: data.truncated, text: data.text }); totalChars += data.chars; if (data.truncated) anyTruncated = true; } const combined = parts.length === 1 ? parts[0].text : parts.map((p) => `--- Document: ${p.filename} ---\n\n${p.text}`).join('\n\n'); const MAX_COMBINED = 128000; const combinedTruncated = combined.length > MAX_COMBINED; els.input.value = combinedTruncated ? combined.slice(0, MAX_COMBINED) : combined; els.uploadFileList.innerHTML = parts .map((p) => `
${escapeHtml(data.next_practical_step || 'Review the evidence trail.')}
`)); if (data.disclaimer) { sections.push(`${escapeHtml(data.disclaimer)}
`); } els.results.innerHTML = sections.join(''); } function renderMainFinding(data) { if (data.tool === 'ask') { return `${escapeHtml(data.answer || data.what_we_found || '')}
`; } if (data.tool === 'redact') { return `${escapeHtml(data.redacted_text || '')}${renderEntityCounts(data.entity_counts)}`;
}
if (data.tool === 'timeline') {
lastTimelineEvents = data.events || [];
const csvBtn = lastTimelineEvents.length
? ``
: '';
return `${escapeHtml(data.what_we_found || '')}
${renderTimeline(lastTimelineEvents)}${csvBtn}`; } if (data.tool === 'summarize') { return [ `${escapeHtml(data.what_we_found || '')}
`, detailList('Key Facts', data.key_facts), detailList('Dates', data.dates), detailList('Parties', data.parties), detailList('Legal References Detected', data.legal_references_detected), ].join(''); } if (data.tool === 'search') { return `${escapeHtml(data.what_we_found || '')}
`; } return `${escapeHtml(data.what_we_found || '')}
`; } function currentTranscribeLang() { return document.querySelector('input[name="transcribeLang"]:checked')?.value || 'auto'; } function renderEvidence(data) { const items = data.evidence_trail || data.sources || data.hits || []; if (!items.length) { return 'No evidence trail was available for this request.
'; } return `${escapeHtml(chunkText)}
${escapeHtml(body)}
${chunkToggle}No events were identified.
'; } return `${escapeHtml(ev.event || '')}
${ev.source_excerpt ? `${escapeHtml(ev.source_excerpt)}` : ''}${speakerOrder.map((id, i) => { const role = speakerRoles[id] || id; return `${escapeHtml(role)}${escapeHtml(id)}`; }).join('')}
` : ''; const segmentsHtml = hasSpeakers ? `${escapeHtml(data.transcript)}No deterministic sensitive categories detected.
'; } return `No uncertainty listed.
'; } return `${escapeHtml(value || 'No uncertainty listed.')}
`; } function sectionHtml(title, content) { return `Run a tool to see interpretation, retrieval, confidence, uncertainty, and next step.
${escapeHtml(item.detail || '')}