Files
dobetternorge-tools/assets/js/tools.js
T
daveadmin 21c092e0d0 Legal Analysis: full language follow-through (UI + LLM)
The tool now respects the chosen UI language end-to-end — even if the
source document is Norwegian, a user on EN/UK/PL gets the analysis in
their language. Norwegian statute references (barnevernsloven § 4-25,
EMK Art. 8) and case names (Strand Lobben mot Norge 37283/13) are kept
verbatim because they are proper nouns.

LLM (LegalAnalysisAgent.php):
- extractIssues: prompt asks for question + brief_context in user's
  language; statute refs preserved
- answerIssue: Norwegian core system prompt (keeps fine-tune precision)
  + language-coercion line for non-NO; localised context/source labels
- synthesise: overall_assessment, next_steps, disclaimer in user's
  language; explicit per-language disclaimer text
- runFullAnalysis empty-case fallback also localised
- what_to_check translated per language

UI:
- 40 new la_* translation keys in i18n.php × 4 languages (NO/EN/UK/PL)
- legal-analysis.php: 4-way lang switcher, dbnToolsT() for every label,
  emits window.DBN_LA_I18N for runtime JS strings
- legal-analysis.js: t() helper reads from window.DBN_LA_I18N
- layout_footer.php: emits window.DBN_CURRENT_LANG +
  window.DBN_ADDON_I18N so the legal-analysis add-on button works in
  the page's language no matter which tool it's invoked from
- tools.js add-on: reads from DBN_ADDON_I18N, passes DBN_CURRENT_LANG
  to /api/legal-analysis.php so server responds in same language

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 08:43:15 +02:00

2823 lines
123 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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',
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.',
redactAdvancedToggle: 'Advanced settings',
redactDownloadTxt: 'Download .txt',
redactDownloadDocx: 'Download .docx',
redactCopy: 'Copy',
redactCopied: 'Copied!',
},
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: 'Datoer',
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.',
redactAdvancedToggle: 'Avanserte innstillinger',
redactDownloadTxt: 'Last ned .txt',
redactDownloadDocx: 'Last ned .docx',
redactCopy: 'Kopier',
redactCopied: 'Kopiert!',
},
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: 'Вставте текст або завантажте файл, налаштуйте параметри, запустіть.',
redactAdvancedToggle: 'Розширені налаштування',
redactDownloadTxt: 'Завантажити .txt',
redactDownloadDocx: 'Завантажити .docx',
redactCopy: 'Копіювати',
redactCopied: 'Скопійовано!',
},
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',
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.',
redactAdvancedToggle: 'Ustawienia zaawansowane',
redactDownloadTxt: 'Pobierz .txt',
redactDownloadDocx: 'Pobierz .docx',
redactCopy: 'Kopiuj',
redactCopied: 'Skopiowano!',
},
};
const TIMELINE_I18N = {
en: {
timelineEngine: 'Engine',
timelineEngineAzureMini: 'Azure gpt-4o-mini',
timelineEngineAzureFull: 'Azure gpt-4o',
timelineEngineGpu: 'GPU (cuttlefish)',
timelineEngineHint: 'Azure engines use your BNL Azure credits. GPU runs the local LiteLLM proxy on cuttlefish.',
timelineAdvancedToggle: 'Advanced settings',
timelineFocus: 'Focus',
timelineFocusAll: 'All events',
timelineFocusDeadlines: 'Legal deadlines',
timelineFocusHearings: 'Court hearings',
timelineFocusCps: 'CPS milestones',
timelineFocusHint: 'All events: every temporal reference. Legal deadlines: filing dates, appeal windows, statutory limits. Court hearings: tribunal and mediation sessions. CPS milestones: Barnevernet interventions and Fylkesnemnda proceedings.',
timelineConfidence: 'Confidence',
timelineConfidenceAll: 'Show all events',
timelineConfidenceHighMed: 'Hide low-confidence',
timelineConfidenceHint: 'Show all: includes uncertain events (shown in grey). Hide low-confidence: only returns events the model is reasonably sure of.',
timelineDates: 'Date types',
timelineIncludeRelative: 'Include relative / recurring dates',
timelineDatesHint: 'When checked, relative references ("three weeks later", "every Monday") are included. Uncheck to return only exact calendar dates.',
timelineUploadAria: 'File upload',
timelineUploadDrop: 'Drop up to 5 files here, or',
timelineUploadBrowse: 'browse',
timelineUploadHint: 'text extracted in memory, never stored',
timelineUploadClear: '× Clear',
timelineInputLabel: 'Pasted text',
timelineInputPlaceholder: 'Paste case notes, court decisions, or correspondence containing dates and events.',
timelineRun: 'Run',
timelineRunning: 'Building timeline…',
timelineReadyTitle: 'Ready',
timelineReadyDesc: 'Paste text or upload a file, configure options, then run.',
timelineBackground: 'Background events',
timelineIncludeBackground: 'Include narrative / background dates',
timelineBackgroundHint: 'When checked, historical context dates are included (e.g. "born 30.07.2015", "met around 2011/2012"). Uncheck to extract only operational events and deadlines.',
sortDocOrder: 'Document order',
sortChronological: 'Chronological',
timelineExportCsv: 'Download CSV',
timelineNotesLabel: 'Context notes',
timelineNotesPlaceholder: 'Add any clarifications to guide the AI — e.g. "All dates are 2024", "Focus on the mother\'s actions", "D refers to the defendant throughout".',
timelineNotesHint: 'These notes are included in the prompt to help the model interpret ambiguous dates, actors, or abbreviations. Not stored.',
},
no: {
timelineEngine: 'Motor',
timelineEngineAzureMini: 'Azure gpt-4o-mini',
timelineEngineAzureFull: 'Azure gpt-4o',
timelineEngineGpu: 'GPU (cuttlefish)',
timelineEngineHint: 'Azure-motorer bruker BNL Azure-kreditter. GPU kjører lokal LiteLLM-proxy på cuttlefish.',
timelineAdvancedToggle: 'Avanserte innstillinger',
timelineFocus: 'Fokus',
timelineFocusAll: 'Alle hendelser',
timelineFocusDeadlines: 'Juridiske frister',
timelineFocusHearings: 'Rettsmøter',
timelineFocusCps: 'Barnevernet-milepæler',
timelineFocusHint: 'Alle hendelser: alle temporale referanser. Juridiske frister: innleveringsfrister, ankefrister, lovpålagte frister. Rettsmøter: domstol- og meklingsmøter. Barnevernet: akuttvedtak og Fylkesnemnda-saker.',
timelineConfidence: 'Sikkerhet',
timelineConfidenceAll: 'Vis alle hendelser',
timelineConfidenceHighMed: 'Skjul usikre hendelser',
timelineConfidenceHint: 'Vis alle: inkluderer usikre hendelser (vist i grått). Skjul usikre: returnerer bare hendelser modellen er rimelig sikker på.',
timelineDates: 'Datotyper',
timelineIncludeRelative: 'Inkluder relative / gjentakende datoer',
timelineDatesHint: 'Når avkrysset inkluderes relative referanser ("tre uker senere", "hver mandag"). Fjern haken for å returnere kun eksakte kalenderdatoer.',
timelineUploadAria: 'Filopplasting',
timelineUploadDrop: 'Slipp opptil 5 filer her, eller',
timelineUploadBrowse: 'bla',
timelineUploadHint: 'tekst hentes i minnet, lagres aldri',
timelineUploadClear: '× Tøm',
timelineInputLabel: 'Limt inn tekst',
timelineInputPlaceholder: 'Lim inn saksdokumenter, rettsavgjørelser eller korrespondanse med datoer og hendelser.',
timelineRun: 'Kjør',
timelineRunning: 'Bygger tidslinje…',
timelineReadyTitle: 'Klar',
timelineReadyDesc: 'Lim inn tekst eller last opp en fil, konfigurer alternativene, og kjør.',
timelineBackground: 'Bakgrunnshendelser',
timelineIncludeBackground: 'Inkluder narrative / bakgrunnsdatoer',
timelineBackgroundHint: 'Når avkrysset inkluderes historiske kontekstdatoer (f.eks. "født 30.07.2015", "møttes rundt 2011/2012"). Fjern haken for å hente kun operasjonelle hendelser og frister.',
sortDocOrder: 'Dokumentrekkefølge',
sortChronological: 'Kronologisk',
timelineExportCsv: 'Last ned CSV',
timelineNotesLabel: 'Kontekstnotes',
timelineNotesPlaceholder: 'Legg til avklaringer for å veilede AI-en — f.eks. "Alle datoer er 2024", "Fokuser på morens handlinger", "D refererer til saksøkte gjennom hele dokumentet".',
timelineNotesHint: 'Disse notatene inkluderes i ledeteksten for å hjelpe modellen med å tolke uklare datoer, aktører eller forkortelser. Lagres ikke.',
},
uk: {
timelineEngine: 'Рушій',
timelineEngineAzureMini: 'Azure gpt-4o-mini',
timelineEngineAzureFull: 'Azure gpt-4o',
timelineEngineGpu: 'GPU (cuttlefish)',
timelineEngineHint: 'Рушії Azure використовують кредити BNL Azure. GPU запускає локальний проксі LiteLLM на cuttlefish.',
timelineAdvancedToggle: 'Розширені налаштування',
timelineFocus: 'Фокус',
timelineFocusAll: 'Всі події',
timelineFocusDeadlines: 'Юридичні терміни',
timelineFocusHearings: 'Судові засідання',
timelineFocusCps: 'Вехи органів опіки',
timelineFocusHint: 'Всі події: кожне часове посилання. Юридичні терміни: дати подання, вікна апеляції. Судові засідання: трибунали та медіації. Опіка: втручання Barnevernet та провадження Fylkesnemnda.',
timelineConfidence: 'Достовірність',
timelineConfidenceAll: 'Показати всі події',
timelineConfidenceHighMed: 'Приховати малодостовірні',
timelineConfidenceHint: 'Показати всі: включає непевні події (сірим). Приховати малодостовірні: повертає лише впевнено визначені події.',
timelineDates: 'Типи дат',
timelineIncludeRelative: 'Включити відносні / повторювані дати',
timelineDatesHint: 'Якщо позначено, відносні посилання ("три тижні потому", "щопонеділка") включаються. Зніміть, щоб повертати лише точні календарні дати.',
timelineUploadAria: 'Завантаження файлів',
timelineUploadDrop: 'Перетягніть до 5 файлів сюди, або',
timelineUploadBrowse: 'огляд',
timelineUploadHint: 'текст обробляється в памʼяті, ніколи не зберігається',
timelineUploadClear: '× Очистити',
timelineInputLabel: 'Вставлений текст',
timelineInputPlaceholder: 'Вставте нотатки справи, судові рішення або кореспонденцію з датами та подіями.',
timelineRun: 'Запустити',
timelineRunning: 'Будую хронологію…',
timelineReadyTitle: 'Готово',
timelineReadyDesc: 'Вставте текст або завантажте файл, налаштуйте параметри, запустіть.',
timelineBackground: 'Фонові події',
timelineIncludeBackground: 'Включити наративні / фонові дати',
timelineBackgroundHint: 'Якщо позначено, включаються дати з контексту (напр. "народився 30.07.2015", "зустрілися близько 2011/2012"). Зніміть, щоб витягувати лише операційні події.',
sortDocOrder: 'Порядок документа',
sortChronological: 'Хронологічний',
timelineExportCsv: 'Завантажити CSV',
timelineNotesLabel: 'Контекстні нотатки',
timelineNotesPlaceholder: 'Додайте пояснення для ШІ — напр. "Усі дати відносяться до 2024 року", "Зосередьтесь на діях матері", "D — відповідач по всьому документу".',
timelineNotesHint: 'Ці нотатки включаються до запиту, щоб допомогти моделі інтерпретувати неоднозначні дати, учасників або скорочення. Не зберігаються.',
},
pl: {
timelineEngine: 'Silnik',
timelineEngineAzureMini: 'Azure gpt-4o-mini',
timelineEngineAzureFull: 'Azure gpt-4o',
timelineEngineGpu: 'GPU (cuttlefish)',
timelineEngineHint: 'Silniki Azure używają kredytów Azure BNL. GPU korzysta z lokalnego proxy LiteLLM na cuttlefish.',
timelineAdvancedToggle: 'Ustawienia zaawansowane',
timelineFocus: 'Fokus',
timelineFocusAll: 'Wszystkie zdarzenia',
timelineFocusDeadlines: 'Terminy prawne',
timelineFocusHearings: 'Rozprawy sądowe',
timelineFocusCps: 'Kamienie milowe OPS',
timelineFocusHint: 'Wszystkie: każde odniesienie czasowe. Terminy: daty złożenia, okna apelacyjne. Rozprawy: trybunały i mediacje. OPS: interwencje Barnevernet i postępowania Fylkesnemnda.',
timelineConfidence: 'Pewność',
timelineConfidenceAll: 'Pokaż wszystkie zdarzenia',
timelineConfidenceHighMed: 'Ukryj mało pewne',
timelineConfidenceHint: 'Pokaż wszystkie: zawiera niepewne zdarzenia (szarym). Ukryj mało pewne: zwraca tylko zdarzenia o rozsądnej pewności.',
timelineDates: 'Typy dat',
timelineIncludeRelative: 'Uwzględnij daty względne / cykliczne',
timelineDatesHint: 'Gdy zaznaczone, odniesienia względne ("trzy tygodnie później", "co poniedziałek") są uwzględniane. Odznacz, aby zwracać tylko dokładne daty kalendarzowe.',
timelineUploadAria: 'Przesyłanie pliku',
timelineUploadDrop: 'Upuść do 5 plików tutaj lub',
timelineUploadBrowse: 'przeglądaj',
timelineUploadHint: 'tekst wyodrębniany w pamięci, nigdy nie przechowywany',
timelineUploadClear: '× Wyczyść',
timelineInputLabel: 'Wklejony tekst',
timelineInputPlaceholder: 'Wklej notatki sprawy, decyzje sądowe lub korespondencję z datami i zdarzeniami.',
timelineRun: 'Uruchom',
timelineRunning: 'Tworzę oś czasu…',
timelineReadyTitle: 'Gotowe',
timelineReadyDesc: 'Wklej tekst lub wgraj plik, skonfiguruj opcje, uruchom.',
timelineBackground: 'Zdarzenia w tle',
timelineIncludeBackground: 'Uwzględnij daty narracyjne / kontekstowe',
timelineBackgroundHint: 'Gdy zaznaczone, daty z kontekstu są uwzględniane (np. "urodzony 30.07.2015", "poznali się około 2011/2012"). Odznacz, aby wyodrębniać tylko zdarzenia operacyjne.',
sortDocOrder: 'Kolejność dokumentu',
sortChronological: 'Chronologicznie',
timelineExportCsv: 'Pobierz CSV',
timelineNotesLabel: 'Notatki kontekstowe',
timelineNotesPlaceholder: 'Dodaj wyjaśnienia dla AI — np. "Wszystkie daty dotyczą 2024", "Skup się na działaniach matki", "D odnosi się do pozwanego w całym dokumencie".',
timelineNotesHint: 'Te notatki są dołączane do zapytania, aby pomóc modelowi interpretować niejednoznaczne daty, uczestników lub skróty. Nie są przechowywane.',
},
};
let lastTimelineEvents = [];
let lastTimelineEventsOriginal = [];
let activeActorFilters = new Set();
let timelineSearchTerm = '';
let showSources = true;
let timelineSortMode = 'doc';
let audioQueue = []; // [{file, status: 'pending'|'processing'|'done'|'error', result}]
let lastTranscriptData = null;
let lastRedactedText = null;
let lastOriginalText = '';
let lastRedactPayload = null;
let lastRunEngine = null;
let lastToolPayload = 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 = window.DBN_TOOLS_LANG || localStorage.getItem('dbn-ui-lang') || 'en';
function syncOutputLanguage(lang) {
const normalized = ['en', 'no', 'uk', 'pl'].includes(lang) ? lang : 'en';
document.querySelectorAll('input[name="language"]').forEach((input) => {
if (input.value === normalized) input.checked = true;
});
}
const TRANSCRIBE_I18N = {
en: {
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 200MB per file',
uploadAddFiles: '+ Add files',
uploadClearQueue: '× Clear queue',
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.',
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) => `${clip} — uploading`,
traceUploadDetail: () => 'Sending to transcription service…',
traceProcessingLabel: (clip) => `${clip} — transcribing`,
traceProcessingDetail: () => 'Processing audio. Large files may take 13 minutes.',
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.`; },
advancedOptions: 'Advanced options',
task: 'Task',
taskTranscribe: 'Transcribe',
taskTranslate: 'Translate to English',
vadFilter: 'VAD filter',
vadFilterLabel: 'Remove silence / noise',
vadFilterHint: 'Improves accuracy on recordings with long pauses.',
whisperModel: 'Whisper model',
whisperModelHint: 'Used when Azure/GCP unavailable. large-v3 is the default.',
postModel: 'AI cleanup',
postModelNone: 'None',
postModelMini: 'GPT-4o Mini',
postModelFull: 'GPT-4o',
postModelHint: 'Fixes errors, punctuation, and domain terms after transcription.',
},
no: {
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 200MB per fil',
uploadAddFiles: '+ Legg til filer',
uploadClearQueue: '× Tøm kø',
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.',
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) => `${clip} — laster opp`,
traceUploadDetail: () => 'Sender til transkripsjonsleverandør…',
traceProcessingLabel: (clip) => `${clip} — transkriberer`,
traceProcessingDetail: () => 'Behandler lyden. Store filer tar 13 minutter.', 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.`; },
advancedOptions: 'Avanserte valg',
task: 'Oppgave',
taskTranscribe: 'Transkriber',
taskTranslate: 'Oversett til engelsk',
vadFilter: 'VAD-filter',
vadFilterLabel: 'Fjern stillhet / støy',
vadFilterHint: 'Forbedrer nøyaktigheten ved opptak med lange pauser.',
whisperModel: 'Whisper-modell',
whisperModelHint: 'Brukes når Azure/GCP ikke er tilgjengelig. large-v3 er standard.',
postModel: 'AI-opprydding',
postModelNone: 'Ingen',
postModelMini: 'GPT-4o Mini',
postModelFull: 'GPT-4o',
postModelHint: 'Retter feil, tegnsetting og fagtermer etter transkripsjon.',
},
uk: {
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: '× Очистити чергу',
run: 'Запустити',
running: 'Транскрибування…',
runningOther: 'Виконання…',
readyTitle: 'Готово',
readyDesc: 'Виберіть інструмент, запустіть запит — результат з\'явиться тут.',
noFileSelected: 'Виберіть хоч б один аудіофайл перед транскрибуванням.',
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) => `${clip} — завантаження`,
traceUploadDetail: () => 'Відправка до сервісу транскрипції…',
traceProcessingLabel: (clip) => `${clip} — транскрибування`,
traceProcessingDetail: () => 'Обробка аудіо. Великі файли займають 1–3 хвилини.', traceStillLabel: (clip) => `${clip} — ще обробляється…`,
traceStillDetail: (e) => { const m = Math.floor(e / 60), s = e % 60; return m > 0 ? `Минуло ${m} хв ${s} с — обробка.` : `Минуло ${e} с — обробка.`; },
advancedOptions: 'Розширені параметри',
task: 'Завдання',
taskTranscribe: 'Транскрибувати',
taskTranslate: 'Перекласти на англійську',
vadFilter: 'VAD-фільтр',
vadFilterLabel: 'Видалити тишу / шум',
vadFilterHint: 'Покращує точність для записів з довгими паузами.',
whisperModel: 'Модель Whisper',
whisperModelHint: 'Використовується, якщо Azure/GCP недоступні. large-v3 за замовчуванням.',
postModel: 'AI-очищення',
postModelNone: 'Без',
postModelMini: 'GPT-4o Mini',
postModelFull: 'GPT-4o',
postModelHint: 'Виправляє помилки, пунктуацію та терміни після транскрипції.',
},
pl: {
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 200MB na plik',
uploadAddFiles: '+ Dodaj pliki',
uploadClearQueue: '× Wyczyść kolejkę',
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ą.',
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) => `${clip} — przesyłanie`,
traceUploadDetail: () => 'Wysyłanie do serwisu transkrypcji…',
traceProcessingLabel: (clip) => `${clip} — transkrybowanie`,
traceProcessingDetail: () => 'Przetwarzanie audio. Duże pliki zajmują 13 minuty.', 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.`; },
advancedOptions: 'Opcje zaawansowane',
task: 'Zadanie',
taskTranscribe: 'Transkrypcja',
taskTranslate: 'Przetłumacz na angielski',
vadFilter: 'Filtr VAD',
vadFilterLabel: 'Usuń ciszę / szum',
vadFilterHint: 'Poprawia dokładność nagrań z długimi przerwami.',
whisperModel: 'Model Whisper',
whisperModelHint: 'Używany gdy Azure/GCP niedostępne. large-v3 jest domyślny.',
postModel: 'Korekta AI',
postModelNone: 'Brak',
postModelMini: 'GPT-4o Mini',
postModelFull: 'GPT-4o',
postModelHint: 'Poprawia błędy, interpunkcję i terminy po transkrypcji.',
},
};
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);
syncOutputLanguage(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);
syncOutputLanguage(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 currentTimelineT(key) {
const t = TIMELINE_I18N[uiLang] || TIMELINE_I18N.en;
return (key in t) ? t[key] : (TIMELINE_I18N.en[key] ?? key);
}
function applyTimelineI18n(lang) {
uiLang = lang;
localStorage.setItem('dbn-ui-lang', lang);
syncOutputLanguage(lang);
document.querySelectorAll('[data-i18n]').forEach((el) => {
const text = currentTimelineT(el.dataset.i18n);
if (text != null) el.textContent = text;
});
document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => {
const text = currentTimelineT(el.dataset.i18nPlaceholder);
if (text != null) el.placeholder = text;
});
document.querySelectorAll('[data-i18n-aria]').forEach((el) => {
const text = currentTimelineT(el.dataset.i18nAria);
if (text != null) el.setAttribute('aria-label', text);
});
document.querySelectorAll('#timelineLangSwitcher .lang-btn').forEach((btn) => {
btn.classList.toggle('is-active', btn.dataset.lang === lang);
});
}
function currentTimelineEngine() {
return document.querySelector('input[name="timelineEngine"]:checked')?.value || 'azure_mini';
}
function currentTimelineFocus() {
return document.querySelector('input[name="timelineFocus"]:checked')?.value || 'all';
}
function currentConfidenceFilter() {
return document.querySelector('input[name="confidenceFilter"]:checked')?.value || 'all';
}
function currentIncludeRelative() {
return document.getElementById('includeRelativeCheck')?.checked ?? true;
}
function currentIncludeBackground() {
return document.getElementById('includeBackgroundCheck')?.checked ?? true;
}
function sortChronological(events) {
const isIso = (d) => /^\d{4}-\d{2}/.test(d);
return [...events].sort((a, b) => {
const da = a.date || '', db = b.date || '';
if (isIso(da) && isIso(db)) return da.localeCompare(db);
if (isIso(da)) return -1;
if (isIso(db)) return 1;
return 0;
});
}
function setupTimelineControls() {
const switcher = document.getElementById('timelineLangSwitcher');
if (!switcher) return;
switcher.querySelectorAll('.lang-btn').forEach((btn) => {
btn.addEventListener('click', () => applyTimelineI18n(btn.dataset.lang));
});
applyTimelineI18n(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 = [
`<input type="text" class="exempt-name-input" placeholder="${escapeHtml(currentRedactT('redactExemptPlaceholder'))}" maxlength="100">`,
'<button type="button" class="alias-remove" aria-label="Remove exempt name">×</button>',
].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'),
corpusScopeControl: document.querySelector('#corpusScopeControl'),
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();
setupTimelineControls();
// 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('#txCopy')) copyTranscriptText();
if (e.target.closest('#dlTxt')) downloadTranscriptTxt();
if (e.target.closest('#dlSrt')) downloadTranscriptSrt();
if (e.target.closest('#dlVtt')) downloadTranscriptVtt();
if (e.target.closest('#rdlCopy')) copyRedactedText();
if (e.target.closest('#rdlTxt')) downloadRedactedTxt();
if (e.target.closest('#rdlDocx')) downloadRedactedDocx();
const copyBtn = e.target.closest('.timeline-copy-btn');
if (copyBtn) {
navigator.clipboard.writeText(copyBtn.dataset.copy || '').then(() => {
const orig = copyBtn.innerHTML;
copyBtn.innerHTML = '&#10003;';
setTimeout(() => { copyBtn.innerHTML = orig; }, 1200);
}).catch(() => {});
}
const chip = e.target.closest('.timeline-actor-chip');
if (chip) {
const actor = chip.dataset.actor;
if (activeActorFilters.has(actor)) {
activeActorFilters.delete(actor);
chip.classList.remove('is-active');
} else {
activeActorFilters.add(actor);
chip.classList.add('is-active');
}
applyTimelineFilters();
}
});
const activeTool = document.body.dataset.activeTool || state.activeTool;
if (els.form && tools[activeTool]) {
setTool(activeTool);
}
if (state.authenticated) {
checkHealth();
} else {
els.loginEmail?.focus();
}
});
function setTool(toolName) {
state.activeTool = toolName;
const tool = tools[toolName];
if (!tool || !els.toolKind || !els.input) return;
const serverRenderedShell = els.tabs.some((tab) => tab.tagName === 'A');
els.tabs.forEach((button) => {
const active = button.dataset.tool === toolName;
button.classList.toggle('is-active', active);
button.setAttribute('aria-pressed', String(active));
});
if (!serverRenderedShell) {
els.toolKind.textContent = tool.kind;
els.toolTitle.textContent = tool.title;
els.toolBadge.textContent = tool.badge;
els.inputLabel.textContent = tool.label;
}
els.input.value = '';
if (!serverRenderedShell) {
els.input.placeholder = tool.placeholder;
}
els.languageControl.classList.toggle('is-hidden', !tool.usesLanguage);
els.corpusScopeControl?.classList.toggle('is-hidden', toolName !== 'search');
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;
if (!els.app) {
const params = new URLSearchParams(window.location.search);
const dest = params.get('return') || '/';
window.location.href = dest.startsWith('/') && !dest.startsWith('//') ? dest : '/';
return;
}
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();
const docIds = (document.getElementById('docPickerIds')?.value || '')
.split(',').map(Number).filter(Boolean);
if (!text && !docIds.length) {
els.status.textContent = 'Add text or select a document before running the tool.';
if (!docIds.length) els.input.focus();
return;
}
const payload = { [tool.payloadKey]: text };
if (docIds.length) payload.doc_ids = docIds;
if (tool.usesLanguage) {
payload.language = currentLanguage();
}
if (state.activeTool === 'search') {
payload.limit = 7;
payload.corpus_scope = currentCorpusScope();
}
if (state.activeTool === 'redact') {
lastOriginalText = text;
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();
lastRedactPayload = { ...payload };
}
if (state.activeTool === 'timeline') {
payload.engine = currentTimelineEngine();
payload.focus = currentTimelineFocus();
payload.confidence_filter = currentConfidenceFilter();
payload.include_relative = currentIncludeRelative();
payload.include_background = currentIncludeBackground();
payload.user_notes = (document.getElementById('timelineNotes')?.value || '').trim();
payload.use_my_case = (typeof window.dbnGetUseMyCase === 'function') ? window.dbnGetUseMyCase() : false;
}
lastToolPayload = { ...payload };
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.`;
if (['ask', 'redact', 'timeline'].includes(state.activeTool)) {
showSaveResultButton(state.activeTool, lastToolPayload, data, {
model: data.trace_metadata?.deployment || null,
latency_ms: data.latency_ms || 0,
});
}
// Offer "Run deep legal analysis" on ask/redact results
if (['ask', 'redact'].includes(state.activeTool)) {
const askText = state.activeTool === 'ask'
? ((lastToolPayload && lastToolPayload.question) || (data.answer || data.what_we_found || ''))
: (lastToolPayload && lastToolPayload.text) || (data.redacted_text || '');
const resEl = els.results || document.getElementById('results');
if (askText && resEl) {
dbnInjectLegalAnalysisButton(askText, (lastToolPayload && lastToolPayload.language) || 'no', state.activeTool, resEl);
}
}
} 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() {
if (!els.uploadZone || !els.uploadInput) return;
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);
});
// Stop label-for and the input itself from bubbling into the zone click
// handler — otherwise the picker opens twice (native + programmatic).
const _uploadLabel = els.uploadZone.querySelector('label[for="' + els.uploadInput.id + '"]');
if (_uploadLabel) _uploadLabel.addEventListener('click', (e) => e.stopPropagation());
els.uploadInput.addEventListener('click', (e) => e.stopPropagation());
els.uploadZone.addEventListener('click', (e) => {
if (e.target === els.uploadClear || els.uploadClear?.contains(e.target)) return;
if (e.target === els.uploadInput) return;
const lbl = e.target.closest && e.target.closest('label');
if (lbl && lbl.getAttribute('for') === els.uploadInput.id) 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) => `<li><span class="upload-filename">${escapeHtml(p.filename)}</span><span class="upload-chars">${p.chars.toLocaleString()} chars${p.truncated ? ' • per-file limit reached' : ''}</span></li>`)
.join('');
els.uploadPrompt.classList.add('is-hidden');
els.uploadFileInfo.classList.remove('is-hidden');
const truncNote = (anyTruncated || combinedTruncated) ? ' — truncated to 128000 char limit' : '';
els.status.textContent = parts.length === 1
? `Extracted ${totalChars.toLocaleString()} chars from ${parts[0].filename}${truncNote}.`
: `Extracted ${totalChars.toLocaleString()} chars total from ${parts.length} files${truncNote}.`;
} catch (err) {
els.status.textContent = err.message;
resetUpload();
} finally {
setBusy(false);
}
}
function setupAliases() {
if (!els.addAliasRow || !els.aliasRows) return;
els.addAliasRow.addEventListener('click', () => {
const row = document.createElement('div');
row.className = 'alias-row';
row.innerHTML = [
'<input type="text" class="alias-original" placeholder="Real name" maxlength="100">',
'<span class="alias-arrow" aria-hidden="true">→</span>',
'<input type="text" class="alias-label" placeholder="Alias (without brackets)" maxlength="100">',
'<button type="button" class="alias-remove" aria-label="Remove alias">×</button>',
].join('');
els.aliasRows.appendChild(row);
row.querySelector('.alias-original').focus();
});
els.aliasRows.addEventListener('click', (e) => {
const btn = e.target.closest('.alias-remove');
if (btn) btn.closest('.alias-row').remove();
});
}
function getAliases() {
return Array.from(els.aliasRows.querySelectorAll('.alias-row')).flatMap((row) => {
const original = row.querySelector('.alias-original')?.value.trim() ?? '';
const alias = row.querySelector('.alias-label')?.value.trim() ?? '';
return original && alias ? [{ original, alias }] : [];
});
}
function resetAliases() {
if (els.aliasRows) els.aliasRows.innerHTML = '';
}
async function checkHealth() {
els.healthPill.textContent = 'Checking...';
try {
const response = await fetch('api/health.php', {
method: 'GET',
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
const data = await response.json();
els.healthPill.textContent = data.ok ? 'Healthy' : 'Needs config';
els.healthPill.classList.toggle('is-warning', !data.ok);
if (!data.ok && data.checks) {
renderHealth(data);
}
} catch (error) {
els.healthPill.textContent = 'Health failed';
els.healthPill.classList.add('is-warning');
}
}
async function postJson(url, payload) {
const response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => ({}));
if (response.status === 402 || response.status === 429) {
dbnFreeTierError(response.status, data);
throw new Error(data.error?.message || (response.status === 429 ? 'rate_limit' : 'no_credits'));
}
const remaining = response.headers.get('X-Credits-Remaining');
if (remaining !== null) { dbnUpdateCredits(parseInt(remaining, 10)); }
if (!response.ok) {
throw new Error(data.error?.message || `Request failed with HTTP ${response.status}.`);
}
return data;
}
function setBusy(isBusy) {
const button = document.querySelector('#runButton');
if (!button) return;
button.disabled = isBusy;
if (state.activeTool === 'transcribe') {
button.textContent = isBusy ? currentUiT('running') : currentUiT('run');
} else if (state.activeTool === 'redact') {
button.textContent = isBusy ? currentRedactT('redactRunning') : currentRedactT('redactRun');
} else {
button.textContent = isBusy ? currentUiT('runningOther') : currentUiT('run');
}
}
function currentLanguage() {
return document.querySelector('input[name="language"]:checked')?.value || 'en';
}
function currentCorpusScope() {
return document.querySelector('input[name="corpusScope"]:checked')?.value || 'both';
}
function currentRedactionMode() {
return document.querySelector('input[name="redactionMode"]:checked')?.value || 'standard';
}
function currentRedactionRegion() {
return document.querySelector('input[name="redactionRegion"]:checked')?.value || 'nordic';
}
function renderResults(data) {
lastRunEngine = data.trace_metadata?.deployment || null;
const sections = [];
sections.push(sectionHtml('What We Found', renderMainFinding(data)));
sections.push(sectionHtml('Evidence Trail', renderEvidence(data)));
sections.push(sectionHtml('What Remains Uncertain', renderListish(data.what_remains_uncertain)));
sections.push(sectionHtml('Next Practical Step', `<p>${escapeHtml(data.next_practical_step || 'Review the evidence trail.')}</p>`));
if (data.disclaimer) {
sections.push(`<p class="result-disclaimer">${escapeHtml(data.disclaimer)}</p>`);
}
sections.push(renderFeedbackWidget());
els.results.innerHTML = sections.join('');
setupFeedbackWidget(data.tool || state.activeTool);
if (data.tool === 'redact') setupRedactViewToggle();
const sortDoc = document.getElementById('sortDocOrder');
const sortChr = document.getElementById('sortChronological');
if (sortDoc && sortChr) {
sortDoc.addEventListener('click', () => {
timelineSortMode = 'doc';
sortDoc.classList.add('is-active');
sortChr.classList.remove('is-active');
applyTimelineFilters();
});
sortChr.addEventListener('click', () => {
timelineSortMode = 'chrono';
sortChr.classList.add('is-active');
sortDoc.classList.remove('is-active');
applyTimelineFilters();
});
}
const searchInput = document.getElementById('timelineSearch');
if (searchInput) {
searchInput.addEventListener('input', () => {
timelineSearchTerm = searchInput.value.trim().toLowerCase();
applyTimelineFilters();
});
}
const sourceToggle = document.getElementById('sourceToggle');
if (sourceToggle) {
sourceToggle.addEventListener('click', () => {
showSources = !showSources;
sourceToggle.textContent = showSources ? 'Hide sources' : 'Show sources';
document.querySelectorAll('.timeline-excerpt').forEach((el) => {
el.classList.toggle('is-hidden', !showSources);
});
});
}
}
// ── Redact: tag colour helpers ─────────────────────────────────────────────
function tagColorClass(root) {
if (/^(FATHER|MOTHER|CHILD|GRANDPARENT|SIBLING|ATTORNEY|JUDGE|CASEWORKER|EXPERT_WITNESS|PERSON)/.test(root)) return 'person';
if (root === 'ORG') return 'org';
if (root === 'PLACE') return 'place';
if (/^(DATE|DOB)/.test(root)) return 'date';
return 'id';
}
function highlightRedactedText(text) {
const escaped = escapeHtml(text);
return escaped.replace(/\[([A-Z][A-Z0-9_-]*)(?::\s*([^\]]+))?\]/g, (_m, root, suffix) => {
const cls = tagColorClass(root);
const display = suffix ? `[${root}: ${suffix}]` : `[${root}]`;
return `<span class="redact-tag redact-tag--${cls}">${display}</span>`;
});
}
function renderRedactionInventory(redactionMap, entityCounts) {
const map = redactionMap || {};
const counts = entityCounts || {};
const entries = [];
for (const [tag, info] of Object.entries(map)) {
const root = tag.replace(/^\[|\]$/g, '').split(':')[0].trim();
entries.push({ tag, originals: info.originals || [], occurrences: info.occurrences || 0, cls: tagColorClass(root) });
}
const mappedTypes = new Set(Object.values(map).map(e => e.type));
for (const [type, count] of Object.entries(counts)) {
if (Number(count) > 0 && !mappedTypes.has(type)) {
entries.push({ tag: type, originals: [], occurrences: Number(count), cls: tagColorClass(type.toUpperCase()) });
}
}
if (!entries.length) return '';
const rows = entries.map(e => {
const tagSpan = `<span class="redact-tag redact-tag--${e.cls}">${escapeHtml(e.tag)}</span>`;
const originalsHtml = e.originals.length
? `<span class="inv-originals">→ ${e.originals.map(o => `<em>${escapeHtml(o)}</em>`).join(', ')}</span> `
: '';
const countHtml = e.occurrences > 0 ? `<span class="inv-count">${e.occurrences}×</span>` : '';
return `<li>${tagSpan} ${originalsHtml}${countHtml}</li>`;
}).join('');
return `<details class="redact-inventory" open>
<summary>Redaction inventory <span class="inv-badge">${entries.length}</span></summary>
<ul class="inv-list">${rows}</ul>
</details>`;
}
async function rerunWithBetterEngine() {
if (!lastRedactPayload) return;
const btn = document.getElementById('rerunBetterBtn');
if (btn) { btn.disabled = true; btn.textContent = 'Running…'; }
const payload = { ...lastRedactPayload, engine: 'azure_full' };
lastRedactPayload = payload;
setBusy(true);
renderTrace([{ label: 'Re-run with gpt-4o', detail: 'Submitting same text with Azure gpt-4o engine.', status: 'running' }]);
try {
const data = await postJson('api/redact.php', payload);
if (!data.ok) throw new Error(data.error?.message || 'Re-run 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 setupRedactViewToggle() {
const pre = document.getElementById('redactOutputPre');
const btnRedacted = document.getElementById('viewRedacted');
const btnOriginal = document.getElementById('viewOriginal');
const rerunBtn = document.getElementById('rerunBetterBtn');
if (btnRedacted && pre) {
btnRedacted.addEventListener('click', () => {
pre.innerHTML = highlightRedactedText(lastRedactedText || '');
btnRedacted.classList.add('is-active');
if (btnOriginal) btnOriginal.classList.remove('is-active');
});
}
if (btnOriginal && pre) {
btnOriginal.addEventListener('click', () => {
pre.textContent = lastOriginalText || '';
btnOriginal.classList.add('is-active');
if (btnRedacted) btnRedacted.classList.remove('is-active');
});
}
if (rerunBtn) rerunBtn.addEventListener('click', rerunWithBetterEngine);
}
// ── Main finding renderer ───────────────────────────────────────────────────
function renderMainFinding(data) {
if (data.tool === 'ask') {
return `<p class="answer">${escapeHtml(data.answer || data.what_we_found || '')}</p>`;
}
if (data.tool === 'redact') {
lastRedactedText = data.redacted_text || '';
const t = (k) => currentRedactT(k) || k;
const viewToggle = `<div class="redact-view-toggle">
<button type="button" class="view-btn is-active" id="viewRedacted">Redacted</button>
<button type="button" class="view-btn" id="viewOriginal">Original</button>
</div>`;
const inventoryHtml = renderRedactionInventory(data.redaction_map, data.entity_counts);
const isNotBestEngine = data.engine_used && data.engine_used !== 'Azure gpt-4o' && data.engine_used !== 'gpt-4o';
const upgradeBtn = isNotBestEngine
? `<button type="button" class="upgrade-engine-btn" id="rerunBetterBtn">Re-run with gpt-4o for higher accuracy →</button>`
: '';
const dlRow = `<div class="redact-downloads">
<button type="button" class="redact-dl-btn" id="rdlCopy">${t('redactCopy')}</button>
<button type="button" class="redact-dl-btn" id="rdlTxt">${t('redactDownloadTxt')}</button>
<button type="button" class="redact-dl-btn" id="rdlDocx">${t('redactDownloadDocx')}</button>
</div>`;
return `${viewToggle}<pre class="redacted-output" id="redactOutputPre">${highlightRedactedText(lastRedactedText)}</pre>${inventoryHtml}${upgradeBtn}${dlRow}`;
}
if (data.tool === 'timeline') {
lastTimelineEventsOriginal = data.events || [];
lastTimelineEvents = [...lastTimelineEventsOriginal];
activeActorFilters = new Set();
timelineSearchTerm = '';
showSources = true;
timelineSortMode = 'doc';
const csvLabel = currentTimelineT('timelineExportCsv') || 'Download CSV';
const csvBtn = lastTimelineEventsOriginal.length
? `<div class="timeline-export"><button type="button" id="exportCsvBtn" class="export-csv-btn">${escapeHtml(csvLabel)}</button></div>`
: '';
const countBadge = buildTimelineCountBadge(lastTimelineEventsOriginal);
const actorChips = buildActorChips(lastTimelineEventsOriginal);
const toolbar = buildTimelineToolbar();
const sortBar = lastTimelineEventsOriginal.length > 1 ? `
<div class="timeline-sort-bar">
<span class="sort-label">Sort:</span>
<button type="button" class="sort-btn is-active" id="sortDocOrder">${escapeHtml(currentTimelineT('sortDocOrder') || 'Document order')}</button>
<button type="button" class="sort-btn" id="sortChronological">${escapeHtml(currentTimelineT('sortChronological') || 'Chronological')}</button>
</div>` : '';
return `<p>${escapeHtml(data.what_we_found || '')}</p>${countBadge}${actorChips}${toolbar}${sortBar}<div id="timelineListContainer">${renderTimeline(lastTimelineEvents, false)}</div>${csvBtn}`;
}
if (data.tool === 'summarize') {
return [
`<p>${escapeHtml(data.what_we_found || '')}</p>`,
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 `<p>${escapeHtml(data.what_we_found || '')}</p>`;
}
return `<p>${escapeHtml(data.what_we_found || '')}</p>`;
}
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 '<p>No evidence trail was available for this request.</p>';
}
return `<div class="source-list">${items.map(renderEvidenceItem).join('')}</div>`;
}
function renderEvidenceItem(item) {
const title = item.title || item.citation || 'Source';
const body = item.excerpt || item.why_it_matters || item.citation || '';
const chunkText = item.chunk_text || '';
const meta = [
item.package_or_corpus,
item.section,
item.score !== undefined && item.score !== null ? `score ${item.score}` : '',
].filter(Boolean).join(' · ');
const chunkToggle = (chunkText && chunkText !== body) ? `
<details class="chunk-details">
<summary class="chunk-toggle">View chunk</summary>
<pre class="chunk-text">${escapeHtml(chunkText)}</pre>
</details>
` : '';
return `
<article class="source-card">
<h4>${escapeHtml(title)}</h4>
${meta ? `<p class="source-meta">${escapeHtml(meta)}</p>` : ''}
<p>${escapeHtml(body)}</p>
${chunkToggle}
</article>
`;
}
function buildTimelineCountBadge(events) {
if (!events.length) return '';
const actors = new Set(events.map((e) => e.actor).filter((a) => a && a !== 'unknown'));
const isoDates = events.map((e) => e.date || '').filter((d) => /^\d{4}/.test(d)).sort();
let rangeStr = '';
if (isoDates.length) {
const y0 = isoDates[0].slice(0, 4);
const y1 = isoDates[isoDates.length - 1].slice(0, 4);
rangeStr = y0 === y1 ? ` · ${y0}` : ` · ${y0}${y1}`;
}
const ac = actors.size;
return `<p class="timeline-count-badge">${events.length} event${events.length !== 1 ? 's' : ''}${ac ? ` · ${ac} actor${ac !== 1 ? 's' : ''}` : ''}${rangeStr}</p>`;
}
function buildActorChips(events) {
const actors = [...new Set(events.map((e) => e.actor).filter((a) => a && a !== 'unknown'))].sort();
if (actors.length < 2) return '';
const chips = actors.map((a) =>
`<button type="button" class="timeline-actor-chip" data-actor="${escapeHtml(a)}">${escapeHtml(a)}</button>`
).join('');
return `<div class="timeline-actor-chips" id="actorChips">${chips}</div>`;
}
function buildTimelineToolbar() {
return `<div class="timeline-toolbar">
<input type="search" id="timelineSearch" class="timeline-search" placeholder="Search events…" aria-label="Filter events by keyword">
<button type="button" id="sourceToggle" class="source-toggle-btn">Hide sources</button>
</div>`;
}
function applyTimelineFilters() {
let events = timelineSortMode === 'chrono'
? sortChronological([...lastTimelineEventsOriginal])
: [...lastTimelineEventsOriginal];
if (activeActorFilters.size > 0) {
events = events.filter((e) => activeActorFilters.has(e.actor));
}
if (timelineSearchTerm) {
const q = timelineSearchTerm;
events = events.filter((e) =>
(e.event || '').toLowerCase().includes(q) ||
(e.actor || '').toLowerCase().includes(q) ||
(e.source_excerpt || '').toLowerCase().includes(q) ||
(e.date || '').toLowerCase().includes(q)
);
}
lastTimelineEvents = events;
const container = document.getElementById('timelineListContainer');
if (container) container.innerHTML = renderTimeline(events, timelineSortMode === 'chrono');
}
function renderTimeline(events, grouped = false) {
if (!events.length) {
return '<p class="timeline-empty">No matching events.</p>';
}
const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
let lastGroupKey = null;
const items = events.map((ev) => {
const conf = ev.confidence || 'medium';
let groupHeader = '';
if (grouped && /^\d{4}-\d{2}/.test(ev.date || '')) {
const year = ev.date.slice(0, 4);
const mon = ev.date.slice(5, 7);
const key = `${year}-${mon}`;
if (key !== lastGroupKey) {
const prevYear = lastGroupKey ? lastGroupKey.slice(0, 4) : null;
const label = (year !== prevYear)
? year
: `${MONTH_NAMES[parseInt(mon, 10) - 1] || mon} ${year}`;
groupHeader = `<li class="timeline-group-header" role="presentation"><span>${escapeHtml(label)}</span></li>`;
lastGroupKey = key;
}
}
const excerptHtml = ev.source_excerpt
? `<small class="timeline-excerpt${showSources ? '' : ' is-hidden'}">${escapeHtml(ev.source_excerpt)}</small>`
: '';
const copyText = [ev.date, ev.actor, ev.event].filter(Boolean).join(' · ');
return `${groupHeader}<li class="timeline-item confidence-${escapeHtml(conf)}">
<div class="timeline-header">
<strong class="timeline-date">${escapeHtml(ev.date || 'unknown')}${ev.time ? `<span class="timeline-time"> ${escapeHtml(ev.time)}</span>` : ''}</strong>
${ev.date_type ? `<span class="date-type-badge">${escapeHtml(ev.date_type)}</span>` : ''}
<span class="confidence-badge confidence-badge--${escapeHtml(conf)}">${escapeHtml(conf)}</span>
<button type="button" class="timeline-copy-btn" data-copy="${escapeHtml(copyText)}" title="Copy event" aria-label="Copy event to clipboard">&#128203;</button>
</div>
<span class="timeline-actor">${escapeHtml(ev.actor || 'unknown actor')}</span>
<p class="timeline-event">${escapeHtml(ev.event || '')}</p>
${excerptHtml}
</li>`;
}).join('');
return `<ol class="timeline-list">${items}</ol>`;
}
function renderFeedbackWidget() {
return `
<div class="feedback-widget" id="feedbackWidget">
<p class="feedback-label">Was this result useful?</p>
<div class="feedback-btns" id="feedbackBtns">
<button type="button" class="feedback-thumb" data-rating="positive" aria-label="Helpful">&#128077;</button>
<button type="button" class="feedback-thumb" data-rating="negative" aria-label="Not helpful">&#128078;</button>
</div>
<div class="feedback-detail is-hidden" id="feedbackDetail">
<label class="feedback-detail-label" for="feedbackMissed">What was missed or wrong? <span class="feedback-optional">(optional)</span></label>
<textarea id="feedbackMissed" class="feedback-textarea" rows="3" placeholder="e.g. missed dates: 18.09.25, 6.1. — or names that should have been redacted"></textarea>
<div class="feedback-detail-footer">
<button type="button" id="feedbackSubmit" class="feedback-submit-btn">Submit feedback</button>
<p id="feedbackStatus" class="feedback-status" role="status" aria-live="polite"></p>
</div>
</div>
</div>
`;
}
function setupFeedbackWidget(tool) {
const widget = document.getElementById('feedbackWidget');
const detail = document.getElementById('feedbackDetail');
const submit = document.getElementById('feedbackSubmit');
const status = document.getElementById('feedbackStatus');
const missed = document.getElementById('feedbackMissed');
if (!widget) return;
let chosenRating = null;
widget.querySelectorAll('.feedback-thumb').forEach((btn) => {
btn.addEventListener('click', () => {
chosenRating = btn.dataset.rating;
widget.querySelectorAll('.feedback-thumb').forEach((b) => b.classList.remove('is-active'));
btn.classList.add('is-active');
detail.classList.remove('is-hidden');
missed.focus();
});
});
submit.addEventListener('click', async () => {
if (!chosenRating) return;
submit.disabled = true;
status.textContent = 'Saving…';
try {
const resp = await fetch('api/feedback.php', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tool,
rating: chosenRating,
missed_or_wrong: missed.value.trim(),
engine: lastRunEngine || '',
}),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.ok) throw new Error(data.error?.message || 'Save failed');
widget.innerHTML = '<p class="feedback-thanks">Thanks for your feedback!</p>';
} catch (err) {
status.textContent = err.message;
submit.disabled = false;
}
});
}
function exportTimelineCSV(events) {
const header = ['Date', 'End Date', 'Time', 'Date Type', 'Actor', 'Event', 'Source Excerpt', 'Confidence'];
const rows = events.map((ev) => [
ev.date || '', ev.end_date || '', ev.time || '', ev.date_type || '', ev.actor || '',
ev.event || '', ev.source_excerpt || '', ev.confidence || '',
]);
const csv = [header, ...rows]
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'), { href: url, download: 'timeline.csv' });
a.click();
URL.revokeObjectURL(url);
}
function currentTask() {
const el = document.querySelector('input[name="task"]:checked');
return el ? el.value : 'transcribe';
}
function buildTranscribeError(err, file) {
const name = file?.name ?? 'file';
const sizeMB = file?.size ? (file.size / 1024 / 1024).toFixed(1) : null;
let reason = err?.message || 'Unknown error';
if (/too large|size/i.test(reason) || reason.includes('413'))
reason = `File too large${sizeMB ? ` (${sizeMB} MB)` : ''}`;
else if (/format|unsupported|415/i.test(reason))
reason = 'Unsupported audio format';
else if (/timeout|timed out|504/i.test(reason))
reason = 'Request timed out — try a shorter clip';
else if (/50[0-9]/.test(reason))
reason = 'Server error';
return `${name}: ${reason}`;
}
function showTranscribeProgress(clip, total) {
if (!els.results) return;
const pct = total > 1 ? Math.round(((clip - 1) / total) * 100) : null;
const label = total > 1 ? `Clip ${clip} / ${total}` : 'Transcribing…';
const barClass = pct !== null ? '' : ' is-indeterminate';
const barStyle = pct !== null ? ` style="width:${pct}%"` : '';
els.results.innerHTML = `
<div class="transcribe-progress-wrap">
<div class="transcribe-progress-track">
<div class="transcribe-progress-fill${barClass}"${barStyle}></div>
</div>
<p class="transcribe-progress-label">${escapeHtml(label)}</p>
</div>`;
}
async function runTranscribe() {
const storedAudioDocId = parseInt(document.getElementById('audioPickerDocId')?.value || '0', 10);
// Stored audio path — no file in the queue required
if (storedAudioDocId > 0 && !audioQueue.length) {
setBusy(true);
els.status.textContent = 'Transcribing from corpus…';
try {
const fd = new FormData();
fd.append('audio_doc_id', String(storedAudioDocId));
fd.append('language', currentTranscribeLang ? currentTranscribeLang() : 'auto');
fd.append('task', currentTask ? currentTask() : 'transcribe');
const vadFilter = document.getElementById('vadFilterCheck')?.checked ?? false;
if (vadFilter) fd.append('vad_filter', '1');
const initPrompt = (document.getElementById('initPromptInput')?.value || '').trim();
if (initPrompt) fd.append('initial_prompt', initPrompt);
const whisperModel = document.getElementById('whisperModelSelect')?.value;
if (whisperModel) fd.append('model', whisperModel);
const postModel = document.querySelector('input[name="post_model"]:checked')?.value;
if (postModel) fd.append('post_model', postModel);
const diarize = document.getElementById('diarizeCheck')?.checked ?? false;
if (diarize) {
fd.append('diarize', '1');
const ns = parseInt(document.getElementById('numSpeakersInput')?.value || '', 10);
if (ns >= 2) fd.append('num_speakers', String(ns));
}
const resp = await fetch('api/transcribe.php', { method: 'POST', credentials: 'same-origin', body: fd });
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.ok) throw new Error(data.error?.message || 'Transcription failed.');
if (typeof renderTranscribeResult === 'function') renderTranscribeResult(data);
else renderResults(data);
showSaveResultButton('transcribe', { audio_doc_id: storedAudioDocId }, data, {
model: data.model || null,
latency_ms: data.latency_ms || 0,
});
els.status.textContent = 'Done.';
} catch (err) {
els.status.textContent = err.message;
} finally {
setBusy(false);
}
return;
}
if (!audioQueue.length) {
els.status.textContent = currentUiT('noFileSelected');
return;
}
setBusy(true);
const initPrompt = els.initPromptInput?.value?.trim() || '';
const diarize = els.diarizeCheck?.checked ?? false;
const numSpeakers = parseInt(els.numSpeakersInput?.value || '', 10);
const vadFilter = document.getElementById('vadFilterCheck')?.checked ?? false;
const total = audioQueue.length;
// Reset all items to pending before starting
audioQueue.forEach((item) => { item.status = 'pending'; item.result = null; item.errorMsg = null; });
renderAudioQueue();
let cumulativeOffset = 0;
let allTranscripts = [];
let allSegments = [];
let firstSpeakerRoles = null;
let lastResult = null;
for (let i = 0; i < audioQueue.length; i++) {
const item = audioQueue[i];
item.status = 'processing';
renderAudioQueue();
// Show progress bar
showTranscribeProgress(i + 1, total);
const startTime = Date.now();
let elapsed = 0;
const clipLabel = currentUiT('clipLabel', i + 1, total);
els.status.textContent = `${clipLabel}…`;
const timer = setInterval(() => {
elapsed = Math.floor((Date.now() - startTime) / 1000);
const m = Math.floor(elapsed / 60);
const s = elapsed % 60;
const t = m > 0 ? `${m}:${pad2(s)}` : `${s}s`;
els.status.textContent = `${clipLabel}${t}`;
updateTranscribeTrace(elapsed, clipLabel);
}, 1000);
try {
const formData = new FormData();
formData.append('audio', item.file);
formData.append('language', currentTranscribeLang());
formData.append('task', currentTask());
formData.append('time_offset', String(cumulativeOffset));
if (vadFilter) formData.append('vad_filter', '1');
if (initPrompt) formData.append('initial_prompt', initPrompt);
const whisperModel = document.getElementById('whisperModelSelect')?.value;
if (whisperModel) formData.append('model', whisperModel);
const postModel = document.querySelector('input[name="post_model"]:checked')?.value;
if (postModel) formData.append('post_model', postModel);
if (diarize) {
formData.append('diarize', '1');
if (numSpeakers >= 2) formData.append('num_speakers', String(numSpeakers));
}
const resp = await fetch('api/transcribe.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 || currentUiT('transcribeFailed', resp.status));
}
clearInterval(timer);
item.status = 'done';
item.result = data;
lastResult = data;
allTranscripts.push(data.transcript || '');
allSegments.push(...(data.segments || []));
if (!firstSpeakerRoles && data.speaker_roles && Object.keys(data.speaker_roles).length) {
firstSpeakerRoles = data.speaker_roles;
}
// Advance offset by this clip's duration (fall back to file-size estimate at 128 kbps)
cumulativeOffset += data.duration_sec > 0
? data.duration_sec
: item.file.size / (128 * 1024 / 8);
} catch (err) {
clearInterval(timer);
item.status = 'error';
item.errorMsg = buildTranscribeError(err, item.file);
renderAudioQueue();
// Continue processing remaining clips rather than halting
}
renderAudioQueue();
}
// Merge results from successful clips
const errorItems = audioQueue.filter((it) => it.status === 'error');
if (errorItems.length && !lastResult) {
// All clips failed
const errList = errorItems.map((it) => it.errorMsg || it.file.name).join('; ');
els.status.textContent = `Failed: ${errList}`;
renderTrace([{ label: 'Transcription failed', detail: errList, status: 'warning' }]);
setBusy(false);
return;
}
const merged = {
...lastResult,
transcript: allTranscripts.join('\n\n'),
segments: allSegments,
speaker_roles: firstSpeakerRoles,
num_speakers: lastResult?.num_speakers ?? 0,
duration_sec: cumulativeOffset,
};
lastTranscriptData = merged;
renderTranscriptResults(merged);
showSaveResultButton('transcribe', lastToolPayload || {}, merged, {
model: merged.model || null,
latency_ms: merged.latency_ms || 0,
});
const totalSec = Math.round(cumulativeOffset);
const totalMin = Math.floor(totalSec / 60);
const remSec = totalSec % 60;
const durLabel = totalMin > 0 ? `${totalMin}m ${remSec}s` : `${totalSec}s`;
if (errorItems.length) {
const errList = errorItems.map((it) => it.errorMsg || it.file.name).join('; ');
els.status.textContent = `Done (${errorItems.length} failed: ${errList})`;
} else {
els.status.textContent = currentUiT('done', total, durLabel);
}
setBusy(false);
}
function updateTranscribeTrace(elapsed, clipLabel) {
if (!clipLabel) clipLabel = currentUiT('clipLabel', 1, 1);
let label, detail;
if (elapsed < 10) {
label = currentUiT('traceUploadLabel', clipLabel);
detail = currentUiT('traceUploadDetail');
} else if (elapsed < 60) {
label = currentUiT('traceProcessingLabel', clipLabel);
detail = currentUiT('traceProcessingDetail');
} else {
label = currentUiT('traceStillLabel', clipLabel);
detail = currentUiT('traceStillDetail', elapsed);
}
renderTrace([{ label, detail, status: 'running' }]);
}
function renderTranscriptResults(data) {
const speakerRoles = data.speaker_roles || {};
const segments = data.segments || [];
const hasSpeakers = segments.some((s) => s.speaker);
const speakerOrder = [...new Set(segments.filter((s) => s.speaker).map((s) => s.speaker))];
// Speaker role tags (shown above transcript)
const rolesHtml = speakerOrder.length
? `<p class="transcript-roles">${speakerOrder.map((id, i) => {
const role = speakerRoles[id] || id;
return `<span class="speaker-tag speaker-tag--${i % 6}">${escapeHtml(role)}<small>${escapeHtml(id)}</small></span>`;
}).join('')}</p>`
: '';
// Segments panel — includes inline speaker legend at the top
const legendHtml = speakerOrder.length
? `<div class="segment-legend">${speakerOrder.map((id, i) => {
const role = speakerRoles[id] || id;
return `<span class="speaker-tag speaker-tag--${i % 6}">${escapeHtml(role)}<small>${escapeHtml(id)}</small></span>`;
}).join('')}</div>`
: '';
const segmentsHtml = hasSpeakers
? `<details class="segment-details"><summary class="segment-summary">Segments (${segments.length})</summary>
${legendHtml}
<div class="segment-list">${segments.map((seg) => {
const idx = speakerOrder.indexOf(seg.speaker);
const roleLabel = seg.speaker && speakerRoles[seg.speaker]
? `${speakerRoles[seg.speaker]} (${seg.speaker})`
: (seg.speaker || '');
return `<div class="segment-row">
<span class="segment-time">${fmtTime(seg.start)}${fmtTime(seg.end)}</span>
${seg.speaker ? `<span class="speaker-tag speaker-tag--${idx >= 0 ? idx % 6 : 0}">${escapeHtml(roleLabel)}</span>` : ''}
<span class="segment-text">${escapeHtml(seg.text)}</span>
</div>`;
}).join('')}</div></details>`
: '';
// SRT/VTT downloads (only if segments available)
const dlSrtVtt = segments.length
? `<div class="transcript-downloads">
<button type="button" class="export-csv-btn" id="dlSrt">Download SRT</button>
<button type="button" class="export-csv-btn" id="dlVtt">Download VTT</button>
</div>`
: '';
// Meta stats row
const durStr = data.duration_sec ? `⏱ ${fmtDuration(data.duration_sec)}` : null;
const langStr = data.language ? `🌐 ${data.language.toUpperCase()}` : null;
const spkStr = data.num_speakers > 1 ? `🗣 ${data.num_speakers} speakers` : null;
const metaParts = [durStr, langStr, spkStr].filter(Boolean);
const metaRow = metaParts.length
? `<div class="transcript-meta-row">${metaParts.map((p) => `<span>${p}</span>`).join('')}</div>`
: '';
// AI cleanup badge inline with engine label
const cleanupBadge = data.cleaned_by
? ` <span class="cleanup-badge">✓ Cleaned by ${escapeHtml(data.cleaned_by)}</span>`
: '';
const engineLine = data.model
? `<p class="transcript-engine-badge">Transcribed with <strong>${escapeHtml(data.model)}</strong>${cleanupBadge}</p>`
: '';
// Action row above transcript (copy + TXT download)
const actionRow = `
<div class="transcript-actions">
<button type="button" class="export-csv-btn" id="txCopy">Copy</button>
<button type="button" class="export-csv-btn" id="dlTxt">Download TXT</button>
</div>`;
lastRunEngine = data.engine || null;
els.results.innerHTML = `
<section class="result-section">
<h3>Transcript</h3>
${engineLine}
${metaRow}
${rolesHtml}
${actionRow}
<div class="transcript-box"><pre class="transcript-text">${escapeHtml(data.transcript)}</pre></div>
${segmentsHtml}
${dlSrtVtt}
</section>
${renderFeedbackWidget()}`;
setupFeedbackWidget('transcribe');
const traceMeta = [];
if (data.duration_sec) traceMeta.push({ label: `Duration: ${fmtDuration(data.duration_sec)}`, detail: '', status: 'complete' });
if (data.language) traceMeta.push({ label: `Language: ${data.language}`, detail: '', status: 'complete' });
if (data.num_speakers > 1) traceMeta.push({ label: `Speakers: ${data.num_speakers}`, detail: Object.entries(speakerRoles).map(([id, r]) => `${id}: ${r}`).join(', ') || '', status: 'complete' });
if (data.model) traceMeta.push({ label: data.model, detail: '', status: 'complete' });
if (data.cleaned_by) traceMeta.push({ label: `Cleaned by ${data.cleaned_by}`, detail: '', status: 'complete' });
renderTrace(traceMeta.length ? traceMeta : [{ label: 'Transcribed', detail: '', status: 'complete' }]);
}
function fmtDuration(secs) {
const m = Math.floor(secs / 60);
const s = Math.round(secs % 60);
return m > 0 ? `${m}m ${s}s` : `${s}s`;
}
async function copyTranscriptText() {
if (!lastTranscriptData?.transcript) return;
const btn = document.getElementById('txCopy');
try {
await navigator.clipboard.writeText(lastTranscriptData.transcript);
if (btn) {
const orig = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = orig; }, 1800);
}
} catch {
// clipboard unavailable — silently ignore
}
}
function fmtTime(secs) {
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = Math.floor(secs % 60);
const parts = h > 0 ? [pad2(h), pad2(m), pad2(s)] : [pad2(m), pad2(s)];
return parts.join(':');
}
function pad2(n) { return String(n).padStart(2, '0'); }
function toSrtTime(secs) {
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = Math.floor(secs % 60);
const ms = Math.round((secs % 1) * 1000);
return `${pad2(h)}:${pad2(m)}:${pad2(s)},${String(ms).padStart(3, '0')}`;
}
function toVttTime(secs) {
return toSrtTime(secs).replace(',', '.');
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'), { href: url, download: filename });
a.click();
URL.revokeObjectURL(url);
}
function downloadTranscriptTxt() {
if (!lastTranscriptData) return;
downloadBlob(new Blob([lastTranscriptData.transcript], { type: 'text/plain' }), 'transcript.txt');
}
function downloadTranscriptSrt() {
if (!lastTranscriptData?.segments?.length) return;
const { segments, speaker_roles: roles = {} } = lastTranscriptData;
const lines = segments.map((seg, i) => {
const spk = seg.speaker ? `[${roles[seg.speaker] || seg.speaker}] ` : '';
return `${i + 1}\n${toSrtTime(seg.start)} --> ${toSrtTime(seg.end)}\n${spk}${seg.text}\n`;
});
downloadBlob(new Blob([lines.join('\n')], { type: 'text/srt' }), 'transcript.srt');
}
function downloadTranscriptVtt() {
if (!lastTranscriptData?.segments?.length) return;
const { segments, speaker_roles: roles = {} } = lastTranscriptData;
const lines = ['WEBVTT\n'];
segments.forEach((seg) => {
const spk = seg.speaker ? `<v ${roles[seg.speaker] || seg.speaker}>` : '';
lines.push(`${toVttTime(seg.start)} --> ${toVttTime(seg.end)}\n${spk}${seg.text}\n`);
});
downloadBlob(new Blob([lines.join('\n')], { type: 'text/vtt' }), 'transcript.vtt');
}
async function copyRedactedText() {
if (!lastRedactedText) return;
const btn = document.getElementById('rdlCopy');
await navigator.clipboard.writeText(lastRedactedText);
if (btn) {
const orig = btn.textContent;
btn.textContent = currentRedactT('redactCopied') || 'Copied!';
setTimeout(() => { btn.textContent = orig; }, 1800);
}
}
function downloadRedactedTxt() {
if (!lastRedactedText) return;
downloadBlob(new Blob([lastRedactedText], { type: 'text/plain' }), 'redacted.txt');
}
async function downloadRedactedDocx() {
if (!lastRedactedText) return;
const btn = document.getElementById('rdlDocx');
if (btn) btn.disabled = true;
try {
const resp = await fetch('/api/redact-download.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: lastRedactedText, format: 'docx' }),
});
if (!resp.ok) throw new Error('Download failed');
const blob = await resp.blob();
downloadBlob(blob, 'redacted.docx');
} catch (e) {
alert(e.message);
} finally {
if (btn) btn.disabled = false;
}
}
function resetAudio() {
audioQueue = [];
if (!els.audioInput) return;
els.audioInput.value = '';
if (els.audioPrompt) els.audioPrompt.classList.remove('is-hidden');
if (els.audioFileInfo) els.audioFileInfo.classList.add('is-hidden');
if (els.audioQueueList) els.audioQueueList.innerHTML = '';
}
function setupAudio() {
if (!els.audioZone) return;
els.audioZone.addEventListener('dragover', (e) => {
e.preventDefault();
els.audioZone.classList.add('is-drag-over');
});
els.audioZone.addEventListener('dragleave', (e) => {
if (!els.audioZone.contains(e.relatedTarget)) {
els.audioZone.classList.remove('is-drag-over');
}
});
els.audioZone.addEventListener('drop', (e) => {
e.preventDefault();
els.audioZone.classList.remove('is-drag-over');
if (e.dataTransfer?.files?.length) handleAudioFiles(e.dataTransfer.files);
});
// Stop label-for and the input itself from bubbling into the zone click
// handler — otherwise the picker opens twice (native + programmatic).
const _audioLabel = els.audioZone.querySelector('label[for="' + els.audioInput.id + '"]');
if (_audioLabel) _audioLabel.addEventListener('click', (e) => e.stopPropagation());
els.audioInput.addEventListener('click', (e) => e.stopPropagation());
els.audioZone.addEventListener('click', (e) => {
if (e.target === els.audioClear || els.audioClear?.contains(e.target)) return;
if (e.target === els.audioInput) return;
const lbl = e.target.closest && e.target.closest('label');
if (lbl && lbl.getAttribute('for') === els.audioInput.id) return;
if (e.target.closest('#audioFileInfo')) return;
els.audioInput.click();
});
els.audioInput.addEventListener('change', () => {
if (els.audioInput.files?.length) handleAudioFiles(els.audioInput.files);
els.audioInput.value = '';
});
els.audioClear.addEventListener('click', () => {
resetAudio();
els.status.textContent = '';
});
}
function setupTranscribeControls() {
// engine auto-selected server-side
}
function setupVocabPresets() {
if (!els.vocabPresets) return;
const vocabCountEl = document.getElementById('vocabCharCount');
function updateVocabCount() {
if (!vocabCountEl || !els.initPromptInput) return;
const n = els.initPromptInput.value.length;
vocabCountEl.textContent = n;
vocabCountEl.closest('small')?.classList.toggle('vocab-char-count--warn', n > 450);
}
els.initPromptInput?.addEventListener('input', updateVocabCount);
els.vocabPresets.addEventListener('click', (e) => {
const btn = e.target.closest('.vocab-btn');
if (!btn) return;
const preset = btn.dataset.preset;
if (preset && els.initPromptInput) {
els.initPromptInput.value = VOCAB_PRESETS[preset] ?? '';
els.vocabPresets.querySelectorAll('.vocab-btn').forEach((b) => b.classList.remove('is-active'));
btn.classList.add('is-active');
if (preset !== 'custom') els.initPromptInput.focus();
updateVocabCount();
}
});
}
function handleAudioFiles(fileList) {
const allowedExts = ['mp3', 'wav', 'ogg', 'oga', 'm4a', 'mp4', 'flac', 'webm', 'aac'];
let added = 0;
let skipped = [];
Array.from(fileList).forEach((file) => {
const ext = file.name.split('.').pop().toLowerCase();
if (!allowedExts.includes(ext)) {
skipped.push(file.name);
return;
}
const sizeMB = file.size / 1024 / 1024;
if (sizeMB > 200) {
skipped.push(currentUiT('fileSizeExceeded', file.name, sizeMB.toFixed(1)));
return;
}
audioQueue.push({ file, status: 'pending', result: null });
added++;
});
if (skipped.length) {
els.status.textContent = currentUiT('filesSkipped', skipped.join(', '));
} else if (added > 0) {
els.status.textContent = currentUiT('filesInQueue', audioQueue.length);
}
renderAudioQueue();
}
function renderAudioQueue() {
if (!els.audioQueueList) return;
if (!audioQueue.length) {
els.audioPrompt.classList.remove('is-hidden');
els.audioFileInfo.classList.add('is-hidden');
return;
}
els.audioPrompt.classList.add('is-hidden');
els.audioFileInfo.classList.remove('is-hidden');
els.audioQueueList.innerHTML = audioQueue.map((item, i) => {
const sizeMB = (item.file.size / 1024 / 1024).toFixed(1);
const statusIcon = item.status === 'processing' ? '⏳'
: item.status === 'done' ? '✓'
: item.status === 'error' ? '✗'
: `${i + 1}.`;
const statusClass = `queue-item queue-item--${item.status}`;
return `<li class="${statusClass}">
<span class="queue-num">${statusIcon}</span>
<span class="queue-name">${escapeHtml(item.file.name)}</span>
<span class="queue-size">${sizeMB} MB</span>
</li>`;
}).join('');
}
function renderEntityCounts(counts = {}) {
const entries = Object.entries(counts).filter(([, count]) => Number(count) > 0);
if (!entries.length) {
return '<p class="muted">No deterministic sensitive categories detected.</p>';
}
return `<ul class="pill-list">${entries.map(([name, count]) => `<li>${escapeHtml(name)} <strong>${Number(count)}</strong></li>`).join('')}</ul>`;
}
function detailList(title, values = []) {
if (!Array.isArray(values) || !values.length) {
return '';
}
return `<div class="detail-block"><h4>${escapeHtml(title)}</h4><ul>${values.map((item) => `<li>${escapeHtml(String(item))}</li>`).join('')}</ul></div>`;
}
function renderListish(value) {
if (Array.isArray(value)) {
if (!value.length) {
return '<p>No uncertainty listed.</p>';
}
return `<ul>${value.map((item) => `<li>${escapeHtml(String(item))}</li>`).join('')}</ul>`;
}
return `<p>${escapeHtml(value || 'No uncertainty listed.')}</p>`;
}
function sectionHtml(title, content) {
return `<section class="result-section"><h3>${escapeHtml(title)}</h3>${content}</section>`;
}
function renderTrace(trace) {
if (!trace.length) {
els.traceList.innerHTML = `
<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>
`;
return;
}
els.traceList.innerHTML = trace.map((item) => `
<li>
<span class="trace-status ${escapeHtml(item.status || 'complete')}"></span>
<div>
<strong>${escapeHtml(item.label || 'Step')}</strong>
<p>${escapeHtml(item.detail || '')}</p>
</div>
</li>
`).join('');
}
function renderHealth(data) {
const checks = Object.entries(data.checks || {}).map(([name, check]) => ({
label: name.replaceAll('_', ' '),
detail: check.detail || '',
status: check.ok ? 'complete' : 'warning',
}));
renderTrace(checks);
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
// ── Free-tier credit badge ────────────────────────────────────────────────
// ── Save result widget ─────────────────────────────────────────────────────
const _SAVEABLE_TOOL_LABELS = {
ask: 'Spørsmål & svar',
redact: 'Anonymisering',
timeline: 'Tidslinje',
transcribe: 'Transkripsjon',
summarize: 'Sammendrag',
barnevernet: 'BVJ-analyse',
'deep-research': 'Dyp analyse',
advocate: 'Advokatutkast',
discrepancy: 'Motstrid',
korrespond: 'Korrespondanse',
};
function _isSaveEligibleUser() {
const tier = window.DBN_USER_TIER;
return typeof tier === 'string' && !['free', 'caveau'].includes(tier);
}
function showSaveResultButton(tool, inputPayload, outputPayload, meta, containerEl) {
if (!_isSaveEligibleUser()) return;
if (!Object.prototype.hasOwnProperty.call(_SAVEABLE_TOOL_LABELS, tool)) return;
document.getElementById('saveResultWidget')?.remove();
const label = _SAVEABLE_TOOL_LABELS[tool] || tool;
const now = new Date();
const dateStr = now.toLocaleDateString('no-NO', { day: 'numeric', month: 'short', year: 'numeric' });
const timeStr = now.toLocaleTimeString('no-NO', { hour: '2-digit', minute: '2-digit' });
const dfTitle = `${label}${dateStr} ${timeStr}`;
const widget = document.createElement('div');
widget.id = 'saveResultWidget';
widget.className = 'save-result-widget';
widget.innerHTML = `
<div class="save-result-idle">
<button type="button" class="save-result-btn" id="saveResultTrigger">💾 Save result</button>
</div>
<div class="save-result-prompt is-hidden">
<input type="text" class="save-result-input" id="saveResultTitle" maxlength="200" aria-label="Result title">
<button type="button" class="save-result-confirm" id="saveResultConfirm">Save</button>
<button type="button" class="save-result-cancel" id="saveResultCancel">Cancel</button>
</div>
<div class="save-result-done is-hidden">
<a class="save-result-link" href="min-sak.php">✓ Saved — View in My Case ↗</a>
</div>
<p class="save-result-error is-hidden"></p>`;
const resultsEl = containerEl || document.getElementById('results');
if (!resultsEl) return;
resultsEl.insertBefore(widget, resultsEl.firstChild);
// Set value after inserting so escaping is handled by the DOM
widget.querySelector('#saveResultTitle').value = dfTitle;
const triggerBtn = widget.querySelector('#saveResultTrigger');
const promptDiv = widget.querySelector('.save-result-prompt');
const idleDiv = widget.querySelector('.save-result-idle');
const doneDiv = widget.querySelector('.save-result-done');
const errorP = widget.querySelector('.save-result-error');
const titleInput = widget.querySelector('#saveResultTitle');
const confirmBtn = widget.querySelector('#saveResultConfirm');
const cancelBtn = widget.querySelector('#saveResultCancel');
triggerBtn.addEventListener('click', () => {
idleDiv.classList.add('is-hidden');
promptDiv.classList.remove('is-hidden');
titleInput.focus();
titleInput.select();
});
cancelBtn.addEventListener('click', () => {
promptDiv.classList.add('is-hidden');
idleDiv.classList.remove('is-hidden');
});
titleInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') confirmBtn.click();
if (e.key === 'Escape') cancelBtn.click();
});
confirmBtn.addEventListener('click', async () => {
const title = titleInput.value.trim();
if (!title) { titleInput.focus(); return; }
confirmBtn.disabled = true;
confirmBtn.textContent = 'Saving…';
errorP.classList.add('is-hidden');
try {
const resp = await fetch('api/case/save-result.php', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tool,
title,
input_payload: inputPayload || {},
output_payload: outputPayload || {},
meta: meta || {},
}),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.ok) throw new Error(data.error?.message || 'Save failed.');
promptDiv.classList.add('is-hidden');
doneDiv.classList.remove('is-hidden');
} catch (err) {
confirmBtn.disabled = false;
confirmBtn.textContent = 'Save';
errorP.textContent = err.message;
errorP.classList.remove('is-hidden');
}
});
}
window.dbnShowSaveResultButton = showSaveResultButton;
// ── Legal Analysis add-on (Run deep legal analysis on any text/result) ──────
// Injects a button that, when clicked, streams the two-pass legal-analysis flow
// (extract issues → ask dbn-legal-agent-v3 → synthesise) into a sub-section
// below the host container.
function _laT(key, vars) {
const map = window.DBN_ADDON_I18N || {};
let s = map[key] || key;
if (vars) {
Object.keys(vars).forEach((k) => { s = s.split('{' + k + '}').join(String(vars[k])); });
}
return s;
}
function dbnRunLegalAnalysisAddon(text, lang, sourceTool, containerEl) {
if (!containerEl) containerEl = document.getElementById('results');
if (!containerEl || !text || text.length < 80) return;
// Remove any prior legal-analysis section
const prior = containerEl.parentNode.querySelector('.la-addon-section');
if (prior) prior.remove();
const section = document.createElement('section');
section.className = 'la-addon-section result-section';
section.style.marginTop = '1.2rem';
section.style.padding = '1rem 1.2rem';
section.style.border = '1px dashed var(--dbn-teal, #0f766e)';
section.style.borderRadius = '8px';
section.style.background = '#f0fdfa';
section.innerHTML =
'<h3 style="margin-top:0;color:#0e7490;">⚖️🇳🇴 ' + escapeHtml(_laT('addonSection')) + '</h3>'
+ '<div class="la-pipeline" style="margin-bottom:1rem;">'
+ '<div class="la-step running"><strong>' + escapeHtml(_laT('pass1')) + '</strong> — ' + escapeHtml(_laT('pass1Extracting')) + '</div>'
+ '</div>'
+ '<ol id="laAddonIssues" class="la-issues" style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:1rem;"></ol>';
containerEl.parentNode.appendChild(section);
const issueListEl = section.querySelector('#laAddonIssues');
const pipelineEl = section.querySelector('.la-pipeline');
const issueCards = {};
const payload = {
text: text,
language: lang || window.DBN_CURRENT_LANG || 'no',
doc_type: 'auto',
source_tool: sourceTool || 'addon',
};
fetch('api/legal-analysis.php', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(async function (resp) {
if (!resp.ok || !resp.body) throw new Error('Server returned ' + resp.status);
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const _r = await reader.read();
if (_r.done) break;
buffer += decoder.decode(_r.value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
let data;
try { data = JSON.parse(trimmed); } catch (_) { continue; }
laAddonEvent(data, issueListEl, pipelineEl, issueCards);
}
}
}).catch(function (err) {
section.innerHTML += '<p style="color:#b91c1c;margin-top:0.5rem;">' + escapeHtml(_laT('errorPrefix')) + ' ' + escapeHtml(err.message || String(err)) + '</p>';
});
}
function laAddonEvent(data, listEl, pipelineEl, cards) {
if (data.event === 'issues_extracted') {
listEl.innerHTML = '';
(data.issues || []).forEach(function (issue) {
const li = document.createElement('li');
li.className = 'la-issue pending';
li.dataset.issueId = String(issue.id);
const sev = issue.severity_hint || 'medium';
li.innerHTML =
'<div class="la-issue__head"><span class="la-issue__num">#' + issue.id + '</span>'
+ '<span class="la-severity la-severity-' + escapeHtml(sev) + '">' + escapeHtml(sev.toUpperCase()) + '</span></div>'
+ '<h4 class="la-issue__q">' + escapeHtml(issue.question || '') + '</h4>'
+ (issue.brief_context ? '<p class="la-issue__ctx"><em>' + escapeHtml(issue.brief_context) + '</em></p>' : '')
+ '<div class="la-issue__status">' + escapeHtml(_laT('waiting')) + '</div>';
listEl.appendChild(li);
cards[issue.id] = li;
});
pipelineEl.innerHTML =
'<div class="la-step done"><strong>' + escapeHtml(_laT('pass1')) + '</strong> — ' + escapeHtml(_laT('pass1Found', { n: (data.issues || []).length })) + '</div>'
+ '<div class="la-step running"><strong>' + escapeHtml(_laT('pass2')) + '</strong> — ' + escapeHtml(_laT('pass2Asking')) + '</div>';
} else if (data.event === 'progress') {
const card = cards[data.issue_id];
if (card) {
const status = card.querySelector('.la-issue__status');
if (status) {
status.textContent = data.step === 'issue_searching_corpus'
? _laT('searchingCorpus')
: _laT('askingFinetune');
}
card.classList.add('running');
}
} else if (data.event === 'issue_answered' && data.issue) {
const iss = data.issue;
const card = cards[iss.id];
if (!card) return;
card.classList.remove('pending', 'running');
card.classList.add('answered');
const sev = iss.severity || 'medium';
const sevEl = card.querySelector('.la-severity');
if (sevEl) {
sevEl.className = 'la-severity la-severity-' + escapeHtml(sev);
sevEl.textContent = escapeHtml(sev.toUpperCase());
}
const statusEl = card.querySelector('.la-issue__status');
if (statusEl) statusEl.remove();
let html = '<div class="la-issue__answer"><h5>' + escapeHtml(_laT('answerHeader')) + '</h5><p>' + escapeHtml(iss.answer || '').replace(/\n/g, '<br>') + '</p></div>';
if (iss.legal_basis) html += '<div class="la-issue__basis"><strong>' + escapeHtml(_laT('legalBasis')) + '</strong> ' + escapeHtml(iss.legal_basis) + '</div>';
if (iss.what_to_check) html += '<p class="la-issue__check"><em>' + escapeHtml(iss.what_to_check) + '</em></p>';
card.insertAdjacentHTML('beforeend', html);
} else if (data.event === 'final' && data.result) {
const res = data.result;
pipelineEl.innerHTML =
'<div class="la-step done"><strong>' + escapeHtml(_laT('pass1')) + '</strong> — ' + escapeHtml(_laT('pass1Found', { n: (res.issues || []).length })) + '</div>'
+ '<div class="la-step done"><strong>' + escapeHtml(_laT('pass2')) + '</strong> — ' + escapeHtml(_laT('pass2Answered', { n: (res.issues || []).length })) + '</div>'
+ '<div class="la-step done"><strong>' + escapeHtml(_laT('pass3')) + '</strong> — ' + escapeHtml(_laT('pass3Synthesis')) + '</div>';
if (res.overall_assessment) {
let syn = '<section class="la-synthesis" style="margin-bottom:1rem;padding:0.85rem 1.1rem;border-left:4px solid #0f766e;background:#ecfeff;border-radius:6px;">'
+ '<h3 style="margin-top:0;color:#0e7490;">' + escapeHtml(_laT('overall')) + '</h3>'
+ '<p>' + escapeHtml(res.overall_assessment) + '</p>';
if (Array.isArray(res.next_steps) && res.next_steps.length) {
syn += '<h4 style="margin:0.7rem 0 0.3rem;color:#155e75;">' + escapeHtml(_laT('nextSteps')) + '</h4><ul>'
+ res.next_steps.map(function (s) { return '<li>' + escapeHtml(s) + '</li>'; }).join('')
+ '</ul>';
}
if (res.disclaimer) {
syn += '<p style="font-size:0.85rem;color:#64748b;margin-top:0.5rem;"><em>' + escapeHtml(res.disclaimer) + '</em></p>';
}
syn += '</section>';
pipelineEl.insertAdjacentHTML('afterend', syn);
}
// Save button (legal-analysis result is itself a saveable run)
if (typeof window.dbnShowSaveResultButton === 'function') {
const containerSection = pipelineEl.parentNode;
window.dbnShowSaveResultButton(
'legal-analysis',
{ text: '', language: window.DBN_CURRENT_LANG || 'no', doc_type: 'auto', source_tool: 'addon' },
res,
{ model: res.model || 'dbn-legal-agent-v3', latency_ms: res.latency_ms || 0 },
containerSection
);
}
} else if (data.event === 'error') {
pipelineEl.innerHTML += '<div style="color:#b91c1c;margin-top:0.4rem;">' + escapeHtml(_laT('errorPrefix')) + ' ' + escapeHtml(data.message || data.error || 'unknown') + '</div>';
}
}
function dbnInjectLegalAnalysisButton(text, lang, sourceTool, containerEl, options) {
if (!containerEl || !text || text.length < 80) return;
if (containerEl.querySelector('.la-addon-btn')) return;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'la-addon-btn';
btn.textContent = (options && options.label) || _laT('addonButton');
btn.style.cssText = 'display:block;margin:1rem auto 0;padding:0.6rem 1.2rem;font-size:0.92rem;'
+ 'background:#0f766e;color:#fff;border:none;border-radius:6px;cursor:pointer;'
+ 'font-weight:600;letter-spacing:0.02em;';
btn.addEventListener('mouseenter', function () { btn.style.background = '#0d5e57'; });
btn.addEventListener('mouseleave', function () { btn.style.background = '#0f766e'; });
btn.addEventListener('click', function () {
btn.disabled = true;
btn.textContent = _laT('addonButtonBusy');
btn.style.background = '#94a3b8';
dbnRunLegalAnalysisAddon(text, lang || window.DBN_CURRENT_LANG || 'no', sourceTool, containerEl);
});
containerEl.appendChild(btn);
}
window.dbnRunLegalAnalysisAddon = dbnRunLegalAnalysisAddon;
window.dbnInjectLegalAnalysisButton = dbnInjectLegalAnalysisButton;
let _freeTierBalance = (typeof window.DBN_FREE_TIER_BALANCE === 'number') ? window.DBN_FREE_TIER_BALANCE : -1;
function dbnUpdateCredits(balance) {
if (typeof balance !== 'number' || balance < 0) return;
_freeTierBalance = balance;
const badge = document.getElementById('creditBadge');
if (!badge) return;
badge.textContent = '🪙 ' + balance + ' credit' + (balance !== 1 ? 's' : '');
badge.classList.toggle('is-low', balance > 0 && balance <= 2);
badge.classList.toggle('is-empty', balance === 0);
}
// Exposed so barnevernet.js / deep-research.js can call it
window.dbnUpdateCredits = dbnUpdateCredits;
function dbnFreeTierError(status, data) {
if (status === 429) {
const toast = document.createElement('div');
toast.className = 'credit-toast';
toast.textContent = 'Rate limit reached — you can make up to 10 requests per hour on the free tier.';
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 5000);
return;
}
// 402 — no credits
const overlay = document.createElement('div');
overlay.className = 'credit-modal-overlay';
overlay.innerHTML = `
<div class="credit-modal" role="dialog" aria-modal="true" aria-labelledby="cmTitle">
<div class="credit-modal__icon">🪙</div>
<h3 id="cmTitle">Free credits used up</h3>
<p>Your 10 free credits for this month have been used.<br>
Credits reset on the 1st of next month — or upgrade to CaveauAI for unlimited access.</p>
<div class="credit-modal__actions">
<a href="https://dobetternorge.no/tools/" class="credit-modal__cta">Learn about CaveauAI</a>
<button class="credit-modal__dismiss" onclick="this.closest('.credit-modal-overlay').remove()">Close</button>
</div>
</div>`;
document.body.appendChild(overlay);
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
}
// Exposed so barnevernet.js / deep-research.js can call it
window.dbnFreeTierError = dbnFreeTierError;
// Inject credit badge into topbar on page load
document.addEventListener('DOMContentLoaded', () => {
if (_freeTierBalance < 0) return;
const actions = document.querySelector('.topbar-actions');
if (!actions) return;
const badge = document.createElement('span');
badge.id = 'creditBadge';
badge.className = 'credit-badge';
badge.title = 'Free monthly credits remaining';
dbnUpdateCredits(_freeTierBalance); // set initial state before inserting
badge.textContent = '🪙 ' + _freeTierBalance + ' credit' + (_freeTierBalance !== 1 ? 's' : '');
badge.classList.toggle('is-low', _freeTierBalance > 0 && _freeTierBalance <= 2);
badge.classList.toggle('is-empty', _freeTierBalance === 0);
actions.insertBefore(badge, actions.firstChild);
});