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; 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 200 MB 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 1–3 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 200 MB 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 1–3 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 200 MB 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ą 1–3 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 = [ ``, '', ].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 = '✓'; 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(); if (!text) { els.status.textContent = 'Add text before running the tool.'; els.input.focus(); return; } const payload = { [tool.payloadKey]: text }; if (tool.usesLanguage) { payload.language = currentLanguage(); } if (state.activeTool === 'search') { payload.limit = 7; 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; } setBusy(true); renderTrace([ { label: 'Query interpretation', detail: 'Preparing request.', status: 'running' }, ]); try { const data = await postJson(tool.endpoint, payload); if (!data.ok) { throw new Error(data.error?.message || 'Tool request failed.'); } renderResults(data); renderTrace(data.trace || []); els.status.textContent = `Done in ${data.latency_ms || 0} ms.`; } catch (error) { els.status.textContent = error.message; renderTrace([ { label: 'Tool error', detail: error.message, status: 'warning' }, ]); } finally { setBusy(false); } } function resetUpload() { if (!els.uploadInput) return; els.uploadInput.value = ''; els.uploadPrompt.classList.remove('is-hidden'); els.uploadFileInfo.classList.add('is-hidden'); els.uploadFileList.innerHTML = ''; els.uploadZone.classList.remove('is-drag-over'); } function setupUpload() { 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); }); els.uploadZone.addEventListener('click', (e) => { if (e.target === els.uploadClear || els.uploadClear?.contains(e.target)) return; if (e.target.tagName === 'LABEL') return; els.uploadInput.click(); }); els.uploadInput.addEventListener('change', () => { if (els.uploadInput.files?.length) handleFiles(els.uploadInput.files); }); els.uploadClear.addEventListener('click', () => { resetUpload(); els.input.value = ''; els.status.textContent = ''; }); } async function handleFiles(fileList) { const allowed = ['pdf', 'docx', 'txt']; const files = Array.from(fileList).slice(0, 5); for (const file of files) { const ext = file.name.split('.').pop().toLowerCase(); if (!allowed.includes(ext)) { els.status.textContent = `Skipped ${file.name}: unsupported type. Use .pdf, .docx, or .txt.`; return; } } els.status.textContent = files.length === 1 ? `Extracting ${files[0].name}…` : `Extracting ${files.length} files…`; setBusy(true); const parts = []; let totalChars = 0; let anyTruncated = false; try { for (const file of files) { const formData = new FormData(); formData.append('file', file); const resp = await fetch('api/extract.php', { method: 'POST', credentials: 'same-origin', body: formData, }); const data = await resp.json().catch(() => ({})); if (!resp.ok || !data.ok) { throw new Error(data.error?.message || `Extraction failed for ${file.name} (HTTP ${resp.status}).`); } parts.push({ filename: file.name, chars: data.chars, truncated: data.truncated, text: data.text }); totalChars += data.chars; if (data.truncated) anyTruncated = true; } const combined = parts.length === 1 ? parts[0].text : parts.map((p) => `--- Document: ${p.filename} ---\n\n${p.text}`).join('\n\n'); const MAX_COMBINED = 128000; const combinedTruncated = combined.length > MAX_COMBINED; els.input.value = combinedTruncated ? combined.slice(0, MAX_COMBINED) : combined; els.uploadFileList.innerHTML = parts .map((p) => `
  • ${escapeHtml(p.filename)}${p.chars.toLocaleString()} chars${p.truncated ? ' • per-file limit reached' : ''}
  • `) .join(''); els.uploadPrompt.classList.add('is-hidden'); els.uploadFileInfo.classList.remove('is-hidden'); const truncNote = (anyTruncated || combinedTruncated) ? ' — truncated to 128 000 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 = [ '', '', '', '', ].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', `

    ${escapeHtml(data.next_practical_step || 'Review the evidence trail.')}

    `)); if (data.disclaimer) { sections.push(`

    ${escapeHtml(data.disclaimer)}

    `); } 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 `${display}`; }); } 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 = `${escapeHtml(e.tag)}`; const originalsHtml = e.originals.length ? `→ ${e.originals.map(o => `${escapeHtml(o)}`).join(', ')} ` : ''; const countHtml = e.occurrences > 0 ? `${e.occurrences}×` : ''; return `
  • ${tagSpan} ${originalsHtml}${countHtml}
  • `; }).join(''); return `
    Redaction inventory ${entries.length}
    `; } 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 `

    ${escapeHtml(data.answer || data.what_we_found || '')}

    `; } if (data.tool === 'redact') { lastRedactedText = data.redacted_text || ''; const t = (k) => currentRedactT(k) || k; const viewToggle = `
    `; 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 ? `` : ''; const dlRow = `
    `; return `${viewToggle}
    ${highlightRedactedText(lastRedactedText)}
    ${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 ? `
    ` : ''; const countBadge = buildTimelineCountBadge(lastTimelineEventsOriginal); const actorChips = buildActorChips(lastTimelineEventsOriginal); const toolbar = buildTimelineToolbar(); const sortBar = lastTimelineEventsOriginal.length > 1 ? `
    Sort:
    ` : ''; return `

    ${escapeHtml(data.what_we_found || '')}

    ${countBadge}${actorChips}${toolbar}${sortBar}
    ${renderTimeline(lastTimelineEvents, false)}
    ${csvBtn}`; } if (data.tool === 'summarize') { return [ `

    ${escapeHtml(data.what_we_found || '')}

    `, detailList('Key Facts', data.key_facts), detailList('Dates', data.dates), detailList('Parties', data.parties), detailList('Legal References Detected', data.legal_references_detected), ].join(''); } if (data.tool === 'search') { return `

    ${escapeHtml(data.what_we_found || '')}

    `; } return `

    ${escapeHtml(data.what_we_found || '')}

    `; } function currentTranscribeLang() { return document.querySelector('input[name="transcribeLang"]:checked')?.value || 'auto'; } function renderEvidence(data) { const items = data.evidence_trail || data.sources || data.hits || []; if (!items.length) { return '

    No evidence trail was available for this request.

    '; } return `
    ${items.map(renderEvidenceItem).join('')}
    `; } 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) ? `
    View chunk
    ${escapeHtml(chunkText)}
    ` : ''; return `

    ${escapeHtml(title)}

    ${meta ? `

    ${escapeHtml(meta)}

    ` : ''}

    ${escapeHtml(body)}

    ${chunkToggle}
    `; } 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 `

    ${events.length} event${events.length !== 1 ? 's' : ''}${ac ? ` · ${ac} actor${ac !== 1 ? 's' : ''}` : ''}${rangeStr}

    `; } 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) => `` ).join(''); return `
    ${chips}
    `; } function buildTimelineToolbar() { return `
    `; } 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 '

    No matching events.

    '; } 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 = ``; lastGroupKey = key; } } const excerptHtml = ev.source_excerpt ? `${escapeHtml(ev.source_excerpt)}` : ''; const copyText = [ev.date, ev.actor, ev.event].filter(Boolean).join(' · '); return `${groupHeader}
  • ${escapeHtml(ev.date || 'unknown')}${ev.time ? ` ${escapeHtml(ev.time)}` : ''} ${ev.date_type ? `${escapeHtml(ev.date_type)}` : ''} ${escapeHtml(conf)}
    ${escapeHtml(ev.actor || 'unknown actor')}

    ${escapeHtml(ev.event || '')}

    ${excerptHtml}
  • `; }).join(''); return `
      ${items}
    `; } function renderFeedbackWidget() { return `

    Was this result useful?

    `; } 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 = '

    Thanks for your feedback!

    '; } 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 = `

    ${escapeHtml(label)}

    `; } async function runTranscribe() { 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); 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 ? `

    ${speakerOrder.map((id, i) => { const role = speakerRoles[id] || id; return `${escapeHtml(role)}${escapeHtml(id)}`; }).join('')}

    ` : ''; // Segments panel — includes inline speaker legend at the top const legendHtml = speakerOrder.length ? `
    ${speakerOrder.map((id, i) => { const role = speakerRoles[id] || id; return `${escapeHtml(role)}${escapeHtml(id)}`; }).join('')}
    ` : ''; const segmentsHtml = hasSpeakers ? `
    Segments (${segments.length}) ${legendHtml}
    ${segments.map((seg) => { const idx = speakerOrder.indexOf(seg.speaker); const roleLabel = seg.speaker && speakerRoles[seg.speaker] ? `${speakerRoles[seg.speaker]} (${seg.speaker})` : (seg.speaker || ''); return `
    ${fmtTime(seg.start)}–${fmtTime(seg.end)} ${seg.speaker ? `${escapeHtml(roleLabel)}` : ''} ${escapeHtml(seg.text)}
    `; }).join('')}
    ` : ''; // SRT/VTT downloads (only if segments available) const dlSrtVtt = segments.length ? `
    ` : ''; // 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 ? `
    ${metaParts.map((p) => `${p}`).join('')}
    ` : ''; // AI cleanup badge inline with engine label const cleanupBadge = data.cleaned_by ? ` ✓ Cleaned by ${escapeHtml(data.cleaned_by)}` : ''; const engineLine = data.model ? `

    Transcribed with ${escapeHtml(data.model)}${cleanupBadge}

    ` : ''; // Action row above transcript (copy + TXT download) const actionRow = `
    `; lastRunEngine = data.engine || null; els.results.innerHTML = `

    Transcript

    ${engineLine} ${metaRow} ${rolesHtml} ${actionRow}
    ${escapeHtml(data.transcript)}
    ${segmentsHtml} ${dlSrtVtt}
    ${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 ? `` : ''; 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); }); els.audioZone.addEventListener('click', (e) => { if (e.target === els.audioClear || els.audioClear?.contains(e.target)) return; if (e.target === els.audioInput) return; if (e.target.tagName === 'LABEL') return; if (e.target.closest('#audioFileInfo') && e.target.tagName !== 'LABEL') 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 `
  • ${statusIcon} ${escapeHtml(item.file.name)} ${sizeMB} MB
  • `; }).join(''); } function renderEntityCounts(counts = {}) { const entries = Object.entries(counts).filter(([, count]) => Number(count) > 0); if (!entries.length) { return '

    No deterministic sensitive categories detected.

    '; } return ``; } function detailList(title, values = []) { if (!Array.isArray(values) || !values.length) { return ''; } return `

    ${escapeHtml(title)}

    `; } function renderListish(value) { if (Array.isArray(value)) { if (!value.length) { return '

    No uncertainty listed.

    '; } return ``; } return `

    ${escapeHtml(value || 'No uncertainty listed.')}

    `; } function sectionHtml(title, content) { return `

    ${escapeHtml(title)}

    ${content}
    `; } function renderTrace(trace) { if (!trace.length) { els.traceList.innerHTML = `
  • Waiting

    Run a tool to see interpretation, retrieval, confidence, uncertainty, and next step.

  • `; return; } els.traceList.innerHTML = trace.map((item) => `
  • ${escapeHtml(item.label || 'Step')}

    ${escapeHtml(item.detail || '')}

  • `).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('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } // ── Free-tier credit badge ──────────────────────────────────────────────── 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 = ` `; 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); });