Files
daveadmin 662fbf7d6d feat(tools): persona-driven multi-domain corpus + model routing
Generalize the family-locked legal tools into caveauAI persona profiles
(client 57 chat profiles, resolved in-process via the chat_profiles bridge).
Each tool accepts an optional `profile` slug that scopes the corpus package(s),
search method, system prompt and synthesis model; omitting it falls back to the
family-legal package so existing behaviour is unchanged.

- dbnToolsResolvePersona / dbnToolsListPersonas / dbnToolsBootChatProfiles in
  bootstrap.php; new api/personas.php + dbn.list_personas MCP tool.
- LegalTools search/ask/corpusContextForSummarize and the BvjAnalyzer /
  LegalAnalysis / translate paths take the persona's packages + prompt + model.
- Persona <select> on ask/search/summarize (populated from api/personas.php).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 20:49:58 +02:00

3140 lines
138 KiB
JavaScript
Raw Permalink 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',
redactEngineHint: 'gpt-4o-mini: 1 credit — fast, handles most documents well. gpt-4o: 2 credits — higher accuracy for complex or multi-person cases.',
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 one file 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!',
docPickerBtn: 'Select from My Docs',
},
no: {
redactEngine: 'Motor',
redactEngineAzureMini: 'Azure gpt-4o-mini',
redactEngineAzureFull: 'Azure gpt-4o',
redactEngineHint: 'gpt-4o-mini: 1 kreditt — rask, håndterer de fleste dokumenter godt. gpt-4o: 2 kreditter — høyere nøyaktighet for komplekse eller flerpersonssaker.',
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 én fil 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!',
docPickerBtn: 'Velg fra Mine dokumenter',
},
uk: {
redactEngine: 'Рушій',
redactEngineAzureMini: 'Azure gpt-4o-mini',
redactEngineAzureFull: 'Azure gpt-4o',
redactEngineHint: 'gpt-4o-mini: 1 кредит — швидко, добре обробляє більшість документів. gpt-4o: 2 кредити — вища точність для складних або багатоособових справ.',
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: 'Перетягніть один файл сюди, або',
redactUploadBrowse: 'огляд',
redactUploadHint: 'текст обробляється в пам\'яті, ніколи не зберігається',
redactUploadClear: '× Очистити',
redactInputLabel: 'Вставлений текст',
redactInputPlaceholder: 'Вставте текст з іменами, телефонами, адресами або ідентифікаційними номерами.',
redactRun: 'Запустити',
redactRunning: 'Редагування…',
redactReadyTitle: 'Готово',
redactReadyDesc: 'Вставте текст або завантажте файл, налаштуйте параметри, запустіть.',
redactAdvancedToggle: 'Розширені налаштування',
redactDownloadTxt: 'Завантажити .txt',
redactDownloadDocx: 'Завантажити .docx',
redactCopy: 'Копіювати',
redactCopied: 'Скопійовано!',
docPickerBtn: 'Вибрати з Моїх документів',
},
pl: {
redactEngine: 'Silnik',
redactEngineAzureMini: 'Azure gpt-4o-mini',
redactEngineAzureFull: 'Azure gpt-4o',
redactEngineHint: 'gpt-4o-mini: 1 kredyt — szybko, dobrze radzi sobie z większością dokumentów. gpt-4o: 2 kredyty — wyższa dokładność dla złożonych lub wieloosobowych spraw.',
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ść jeden plik 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!',
docPickerBtn: 'Wybierz z Moich dokumentów',
},
};
const TIMELINE_I18N = {
en: {
timelineEngine: 'Engine',
timelineEngineAzureMini: 'Standard',
timelineEngineAzureFull: 'Deep',
timelineEngineHint: 'Standard uses Claude Haiku 4.5 — fast and accurate for most documents (1 credit). Deep uses Claude Sonnet 4.6 — better for complex multi-actor cases (2 credits).',
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 a file 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.',
docPickerBtn: 'Select from My Docs',
},
no: {
timelineEngine: 'Motor',
timelineEngineAzureMini: 'Standard',
timelineEngineAzureFull: 'Dyp',
timelineEngineHint: 'Standard bruker Claude Haiku 4.5 — rask og nøyaktig for de fleste dokumenter (1 kreditt). Dyp bruker Claude Sonnet 4.6 — bedre for komplekse saker med mange aktører (2 kreditter).',
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 en fil 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.',
docPickerBtn: 'Velg fra Mine dokumenter',
},
uk: {
timelineEngine: 'Рушій',
timelineEngineAzureMini: 'Стандарт',
timelineEngineAzureFull: 'Глибокий',
timelineEngineHint: 'Стандарт використовує Claude Haiku 4.5 — швидко і точно для більшості документів (1 кредит). Глибокий використовує Claude Sonnet 4.6 — краще для складних справ (2 кредити).',
timelineAdvancedToggle: 'Розширені налаштування',
timelineFocus: 'Фокус',
timelineFocusAll: 'Всі події',
timelineFocusDeadlines: 'Юридичні терміни',
timelineFocusHearings: 'Судові засідання',
timelineFocusCps: 'Вехи органів опіки',
timelineFocusHint: 'Всі події: кожне часове посилання. Юридичні терміни: дати подання, вікна апеляції. Судові засідання: трибунали та медіації. Опіка: втручання Barnevernet та провадження Fylkesnemnda.',
timelineConfidence: 'Достовірність',
timelineConfidenceAll: 'Показати всі події',
timelineConfidenceHighMed: 'Приховати малодостовірні',
timelineConfidenceHint: 'Показати всі: включає непевні події (сірим). Приховати малодостовірні: повертає лише впевнено визначені події.',
timelineDates: 'Типи дат',
timelineIncludeRelative: 'Включити відносні / повторювані дати',
timelineDatesHint: 'Якщо позначено, відносні посилання ("три тижні потому", "щопонеділка") включаються. Зніміть, щоб повертати лише точні календарні дати.',
timelineUploadAria: 'Завантаження файлів',
timelineUploadDrop: 'Перетягніть один файл сюди, або',
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: 'Ці нотатки включаються до запиту, щоб допомогти моделі інтерпретувати неоднозначні дати, учасників або скорочення. Не зберігаються.',
docPickerBtn: 'Вибрати з Моїх документів',
},
pl: {
timelineEngine: 'Silnik',
timelineEngineAzureMini: 'Standard',
timelineEngineAzureFull: 'Zaawansowany',
timelineEngineHint: 'Standard używa Claude Haiku 4.5 — szybki i dokładny dla większości dokumentów (1 kredyt). Zaawansowany używa Claude Sonnet 4.6 — lepszy dla złożonych spraw z wieloma uczestnikami (2 kredyty).',
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ść jeden plik 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.',
docPickerBtn: 'Wybierz z Moich dokumentów',
},
};
let lastTimelineEvents = [];
let lastTimelineEventsOriginal = [];
let lastTimelineWhatWeFound = '';
let lastTimelineInputDateHintCount = null;
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;
let pendingTimelineQuote = 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.',
audioPickerBtn: 'Select from My Audio',
},
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.',
audioPickerBtn: 'Velg fra Mine lydfiler',
},
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: 'Виправляє помилки, пунктуацію та терміни після транскрипції.',
audioPickerBtn: 'Вибрати з Мого аудіо',
},
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.',
audioPickerBtn: 'Wybierz z Mojego audio',
},
};
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 timelineEngineLabel(engine) {
return {
nova_lite: 'Quick',
azure_mini: 'Standard',
azure_full: 'Deep',
}[engine] || 'Timeline';
}
function timelineClientRoute(engine, charCount) {
return timelineClientQuote(engine, charCount);
}
function timelineClientQuote(engine, charCount) {
const valid = ['nova_lite', 'azure_mini', 'azure_full'];
const requested = valid.includes(engine) ? engine : 'azure_mini';
const singleLimits = { nova_lite: 25000, azure_mini: 55000, azure_full: 128000 };
const maxLimits = { nova_lite: 100000, azure_mini: 300000, azure_full: 600000 };
const chunkSizes = { nova_lite: 10000, azure_mini: 16000, azure_full: 30000 };
const ranks = { nova_lite: 1, azure_mini: 2, azure_full: 3 };
const baseCredits = requested === 'azure_full' ? 2 : 1;
let effective = requested;
if (charCount > 600000) {
return {
error: true,
message: `This timeline input is ${charCount.toLocaleString()} characters. Split the file or use fewer selected documents; the current maximum is 600,000 characters.`,
};
}
if (charCount > maxLimits[effective]) {
effective = charCount <= maxLimits.azure_mini ? 'azure_mini' : 'azure_full';
}
if (charCount > maxLimits[effective]) effective = 'azure_full';
let credits = 1;
if (effective === 'nova_lite') {
credits = charCount <= singleLimits.nova_lite ? 1 : 2;
} else if (effective === 'azure_mini') {
credits = charCount <= singleLimits.azure_mini ? 1 : (charCount <= 180000 ? 2 : 3);
} else {
credits = charCount <= singleLimits.azure_full ? 2 : (charCount <= 350000 ? 4 : 6);
}
const chunked = charCount > singleLimits[effective];
return {
requested,
effective,
upgraded: ranks[effective] > ranks[requested],
charCount,
credits,
baseCredits,
chunked,
chunkCount: chunked ? Math.ceil(charCount / chunkSizes[effective]) : 1,
requiresConfirmation: credits > baseCredits || ranks[effective] > ranks[requested],
};
}
function timelineQuoteMessage(quote) {
return [
`Timeline will use ${timelineEngineLabel(quote.effective)} for ${Number(quote.charCount || 0).toLocaleString()} characters.`,
quote.chunked ? `It will process about ${quote.chunkCount} chunks.` : 'It can run in a single pass.',
`Cost: ${quote.credits} credit${quote.credits === 1 ? '' : 's'}.`,
'Continue?'
].join('\n');
}
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,
usesPersona: 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,
usesPersona: 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,
usesPersona: 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'),
personaControl: document.querySelector('#personaControl'),
personaSelect: document.querySelector('#personaSelect'),
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('#exportDocxBtn')) downloadTimelineDocx();
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();
if (els.personaSelect) loadPersonas();
} 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.personaControl?.classList.toggle('is-hidden', !tool.usesPersona || !personaState.options.length);
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 (tool.usesPersona) {
const profile = currentPersona();
if (profile) payload.profile = profile;
}
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 };
}
let timelineRouteNotice = '';
if (state.activeTool === 'timeline') {
payload.engine = currentTimelineEngine();
const clientRoute = timelineClientQuote(payload.engine, text.length);
if (clientRoute.error) {
els.status.textContent = clientRoute.message;
return;
}
const pendingQuoteApplies = pendingTimelineQuote
&& pendingTimelineQuote.text === text
&& pendingTimelineQuote.requested === payload.engine;
if (pendingQuoteApplies) {
payload.accepted_timeline_quote = true;
payload.accepted_credits = pendingTimelineQuote.credits;
payload.accepted_effective_engine = pendingTimelineQuote.effective;
pendingTimelineQuote = null;
} else if (clientRoute.requiresConfirmation) {
if (!window.confirm(timelineQuoteMessage(clientRoute))) {
els.status.textContent = 'Timeline run cancelled before any credits were charged.';
return;
}
payload.accepted_timeline_quote = true;
payload.accepted_credits = clientRoute.credits;
payload.accepted_effective_engine = clientRoute.effective;
}
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;
timelineRouteNotice = clientRoute.upgraded || clientRoute.chunked
? `This input is ${text.length.toLocaleString()} characters, so Timeline will use ${timelineEngineLabel(clientRoute.effective)}${clientRoute.chunked ? ` across about ${clientRoute.chunkCount} chunks` : ''}.`
: '';
}
lastToolPayload = { ...payload };
setBusy(true);
if (state.activeTool === 'redact') {
els.results.innerHTML = '<div class="redact-working" role="status" aria-live="polite"><span class="redact-working__spinner" aria-hidden="true"></span><p>Redacting document…</p></div>';
}
if (state.activeTool === 'timeline') {
const routeNotice = timelineRouteNotice ? `<p class="upload-hint">${escapeHtml(timelineRouteNotice)}</p>` : '';
els.results.innerHTML = `<div class="redact-working" id="timelineWorkingState" role="status" aria-live="polite"><span class="redact-working__spinner" aria-hidden="true"></span><p id="timelineStatusMsg">Building timeline…</p>${routeNotice}</div>`;
}
renderTrace([
{ label: 'Query interpretation', detail: 'Preparing request.', status: 'running' },
]);
try {
let data;
if (state.activeTool === 'timeline') {
const resp = await fetch('api/timeline-stream.php', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const errData = await resp.json().catch(() => ({}));
const quote = errData.timeline_quote;
if (errData.error?.code === 'timeline_quote_required' && quote) {
const confirmQuote = {
effective: quote.effective_engine,
charCount: quote.input_char_count,
credits: quote.credits || quote.estimated_credits,
chunked: Boolean(quote.chunked_timeline),
chunkCount: quote.timeline_chunk_count || 1,
};
if (window.confirm(timelineQuoteMessage(confirmQuote))) {
pendingTimelineQuote = {
text,
requested: payload.engine,
effective: confirmQuote.effective,
credits: Number(confirmQuote.credits || 0),
};
return runTool(event);
}
throw new Error('Timeline run cancelled before any credits were charged.');
}
throw new Error(errData.error?.message || `HTTP ${resp.status}`);
}
const reader = resp.body.getReader();
const dec = new TextDecoder();
let buf = '', event = '';
outer: while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop();
for (const line of lines) {
if (line.startsWith('event: ')) { event = line.slice(7).trim(); continue; }
if (line.startsWith('data: ')) {
let parsed;
try { parsed = JSON.parse(line.slice(6)); } catch (_) { continue; }
if (event === 'status') {
const el = document.getElementById('timelineStatusMsg');
if (el) el.textContent = parsed.msg;
} else if (event === 'result') {
data = parsed;
} else if (event === 'error') {
throw new Error(parsed.message || 'Timeline failed');
}
event = '';
}
}
}
if (!data) throw new Error('No result received from timeline.');
} else {
data = await postJson(tool.endpoint, payload);
}
if (!data.ok) {
throw new Error(data.error?.message || 'Tool request failed.');
}
renderResults(data);
renderTrace(data.trace || []);
const routeMeta = data.trace_metadata || {};
const serverRouteNotice = state.activeTool === 'timeline' && (routeMeta.auto_upgraded_engine || routeMeta.chunked_timeline || routeMeta.credits_charged)
? ` Used ${timelineEngineLabel(routeMeta.effective_engine)} for ${Number(routeMeta.input_char_count || 0).toLocaleString()} characters${routeMeta.chunked_timeline ? ` across ${routeMeta.timeline_chunk_count || 1} chunks` : ''}; charged ${routeMeta.credits_charged || routeMeta.estimated_credits || 1} credit(s).`
: '';
els.status.textContent = `Done in ${data.latency_ms || 0} ms.${serverRouteNotice}`;
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 results
if (state.activeTool === 'ask') {
const askText = (lastToolPayload && lastToolPayload.question) || (data.answer || data.what_we_found || '');
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, 1);
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 = `Extracting ${files[0].name}…`;
setBusy(true);
const parts = [];
let totalChars = 0;
let anyTruncated = false;
try {
for (const file of files) {
const formData = new FormData();
formData.append('file', file);
formData.append('tool', state.activeTool);
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[0].text;
const MAX_COMBINED = state.activeTool === 'timeline' ? 600000 : 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 ${MAX_COMBINED.toLocaleString()} char limit` : '';
els.status.textContent = `Extracted ${totalChars.toLocaleString()} chars from ${parts[0].filename}${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() {
if (!els.healthPill) return;
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';
}
const personaState = { options: [], default: 'family' };
function currentPersona() {
return els.personaSelect?.value || personaState.default || '';
}
async function loadPersonas() {
if (!els.personaSelect) return;
try {
const response = await fetch('api/personas.php', {
method: 'GET',
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
const data = await response.json().catch(() => ({}));
if (!response.ok || !data || data.ok !== true || !Array.isArray(data.personas) || !data.personas.length) return;
personaState.options = data.personas;
personaState.default = data.default_persona || 'family';
const saved = sessionStorage.getItem('dbnPersona');
els.personaSelect.innerHTML = '';
for (const p of data.personas) {
const opt = document.createElement('option');
opt.value = p.slug;
opt.textContent = p.name || p.slug;
els.personaSelect.appendChild(opt);
}
const initial = (saved && data.personas.some((p) => p.slug === saved))
? saved
: (data.personas.some((p) => p.slug === personaState.default) ? personaState.default : data.personas[0].slug);
els.personaSelect.value = initial;
els.personaSelect.addEventListener('change', () => {
sessionStorage.setItem('dbnPersona', els.personaSelect.value);
});
if (tools[state.activeTool]?.usesPersona) {
els.personaControl?.classList.remove('is-hidden');
}
} catch (_) {
// Personas are optional UI sugar; ignore failures (e.g. pre-seed environments).
}
}
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];
lastTimelineWhatWeFound = data.what_we_found || '';
lastTimelineInputDateHintCount = data.trace_metadata?.input_date_hint_count ?? null;
activeActorFilters = new Set();
timelineSearchTerm = '';
showSources = true;
timelineSortMode = 'doc';
const csvLabel = currentTimelineT('timelineExportCsv') || 'Download CSV';
const exportRow = lastTimelineEventsOriginal.length
? `<div class="timeline-export"><button type="button" id="exportCsvBtn" class="export-csv-btn">${escapeHtml(csvLabel)}</button><button type="button" id="exportDocxBtn" class="export-csv-btn">Export to Word</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(lastTimelineWhatWeFound)}</p>${countBadge}${actorChips}${toolbar}${sortBar}<div id="timelineListContainer">${renderTimeline(lastTimelineEvents, false)}</div>${exportRow}`;
}
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) {
if (lastTimelineInputDateHintCount === 0) {
return '<p class="timeline-empty">No recognizable dates were found in the extracted text. Check that the upload is text-searchable, or paste the relevant dated section and run again.</p>';
}
return '<p class="timeline-empty">No matching events.</p>';
}
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function fmtDate(d) {
if (!d || !/^\d{4}-\d{2}-\d{2}/.test(d)) return d || 'unknown';
const [y, m, day] = d.split('-');
return `${parseInt(day, 10)} ${MONTHS[parseInt(m, 10) - 1] || m} ${y}`;
}
// Group consecutive events with the same date + actor into one card
const groups = [];
for (const ev of events) {
const key = `${ev.date || ''}||${ev.actor || ''}`;
const last = groups[groups.length - 1];
if (last && last.key === key) {
last.events.push(ev);
} else {
groups.push({ key, date: ev.date || '', actor: ev.actor || '', events: [ev] });
}
}
const CONF_RANK = { high: 0, medium: 1, low: 2 };
let lastGroupKey = null;
const items = groups.map((grp) => {
let monthHeader = '';
if (grouped && /^\d{4}-\d{2}/.test(grp.date)) {
const year = grp.date.slice(0, 4);
const mon = grp.date.slice(5, 7);
const key = `${year}-${mon}`;
if (key !== lastGroupKey) {
const prevYear = lastGroupKey ? lastGroupKey.slice(0, 4) : null;
const label = year !== prevYear
? year
: `${MONTHS[parseInt(mon, 10) - 1] || mon} ${year}`;
monthHeader = `<li class="timeline-group-header" role="presentation"><span>${escapeHtml(label)}</span></li>`;
lastGroupKey = key;
}
}
// Worst confidence in the group (drives border colour)
const worstConf = grp.events.reduce(
(w, ev) => (CONF_RANK[ev.confidence || 'medium'] ?? 1) > (CONF_RANK[w] ?? 1) ? (ev.confidence || 'medium') : w,
'high'
);
const lowFlag = worstConf === 'low'
? `<span class="confidence-low-flag" title="Low confidence — date or event may be approximate">&#9888;</span>`
: '';
// Collect unique times across the group
const times = [...new Set(grp.events.map((ev) => ev.time).filter(Boolean))];
const timeHtml = times.length ? `<span class="timeline-time">${escapeHtml(times.join(', '))}</span>` : '';
const displayDate = fmtDate(grp.date);
if (grp.events.length === 1) {
const ev = grp.events[0];
const excerptHtml = ev.source_excerpt
? `<small class="timeline-excerpt${showSources ? '' : ' is-hidden'}">${escapeHtml(ev.source_excerpt)}</small>`
: '';
const copyText = [grp.date, grp.actor, ev.event].filter(Boolean).join(' · ');
return `${monthHeader}<li class="timeline-item confidence-${escapeHtml(worstConf)}">
<div class="timeline-header">
<strong class="timeline-date">${escapeHtml(displayDate)}${timeHtml}</strong>
${lowFlag}
<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(grp.actor || 'unknown actor')}</span>
<p class="timeline-event">${escapeHtml(ev.event || '')}</p>
${excerptHtml}
</li>`;
}
// Multi-event group: render as bullet list
const bullets = grp.events.map((ev) => {
const excerptHtml = ev.source_excerpt
? `<small class="timeline-excerpt${showSources ? '' : ' is-hidden'}">${escapeHtml(ev.source_excerpt)}</small>`
: '';
const evLow = ev.confidence === 'low'
? `<span class="confidence-low-flag" title="Low confidence">&#9888;</span>`
: '';
return `<li class="timeline-bullet">${escapeHtml(ev.event || '')}${evLow}${excerptHtml}</li>`;
}).join('');
const copyText = [grp.date, grp.actor, ...grp.events.map((ev) => ev.event)].filter(Boolean).join('\n');
return `${monthHeader}<li class="timeline-item timeline-item--grouped confidence-${escapeHtml(worstConf)}">
<div class="timeline-header">
<strong class="timeline-date">${escapeHtml(displayDate)}${timeHtml}</strong>
${lowFlag}
<button type="button" class="timeline-copy-btn" data-copy="${escapeHtml(copyText)}" title="Copy events" aria-label="Copy events to clipboard">&#128203;</button>
</div>
<span class="timeline-actor">${escapeHtml(grp.actor || 'unknown actor')}</span>
<ul class="timeline-bullets">${bullets}</ul>
</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);
}
async function downloadTimelineDocx() {
const btn = document.getElementById('exportDocxBtn');
if (!btn || !lastTimelineEventsOriginal.length) return;
const origText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Generating…';
try {
const resp = await fetch('api/timeline-download.php', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ events: lastTimelineEventsOriginal, what_we_found: lastTimelineWhatWeFound, format: 'docx' }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error?.message || 'Download failed');
}
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'), { href: url, download: 'timeline.docx' });
a.click();
URL.revokeObjectURL(url);
} catch (err) {
alert('Could not export Word file: ' + err.message);
} finally {
btn.disabled = false;
btn.textContent = origText;
}
}
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 data.balance === 'number' && typeof window.dbnUpdateCredits === 'function') {
window.dbnUpdateCredits(data.balance);
}
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));
}
if (typeof data.balance === 'number' && typeof window.dbnUpdateCredits === 'function') {
window.dbnUpdateCredits(data.balance);
}
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 to My Docs</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 to My Docs ↗</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;
if (typeof res.balance === 'number' && typeof window.dbnUpdateCredits === 'function') {
window.dbnUpdateCredits(res.balance);
}
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.';
toast.textContent = data?.error?.message || 'Rate limit reached. Try again shortly or see pricing for higher caps.';
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>`;
overlay.querySelector('.credit-modal__icon').textContent = 'DBN';
overlay.querySelector('#cmTitle').textContent = 'No credits remaining';
overlay.querySelector('p').innerHTML = 'Your monthly and prepaid credits have been used.<br>Monthly credits reset next month, and prepaid top-ups never expire.';
const pricingLink = overlay.querySelector('.credit-modal__cta');
pricingLink.setAttribute('href', '/pricing.php');
pricingLink.textContent = 'See plans and top-ups';
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);
});