2026-04-28 23:35:15 +00:00
/ * A v a — A w a r e n e s s & a m p ; C l u b - M a n a g e r - C o a c h W i d g e t
* Vanilla JS , no build , no framework , keine externen Fonts / Analytics .
* Chat | Quiz | Flashcards | Fortschritt | Policy - Bibliothek — localStorage only .
* /
( ( ) => {
'use strict' ;
// ==== Config ====
const API = 'https://llm.qognio.com/api/bots/awareness-coach/chat' ;
const RAW _KEY = window . _ _AVA _KEY _ _ || '' ;
const KEY = /^qb_[a-zA-Z0-9]{6,}$/ . test ( RAW _KEY ) ? RAW _KEY : '' ;
const LS _KEY = 'ava.state.v1' ;
const LS _CHAT = 'ava.chat.v1' ;
const LS _FLASH = 'ava.flash.v1' ;
// ==== State ====
let CURRICULA = null ;
let state = loadState ( ) ;
let chatHistory = loadChatHistory ( ) ;
let flashCards = loadFlashCards ( ) ; // { [topicId]: [{front, back, hint, ef, interval, due, reps}] }
// ==== Utils ====
const $ = ( s , r = document ) => r . querySelector ( s ) ;
const $$ = ( s , r = document ) => Array . from ( r . querySelectorAll ( s ) ) ;
const now = ( ) => Date . now ( ) ;
const today = ( ) => {
const d = new Date ( ) ;
return ` ${ d . getFullYear ( ) } - ${ String ( d . getMonth ( ) + 1 ) . padStart ( 2 , '0' ) } - ${ String ( d . getDate ( ) ) . padStart ( 2 , '0' ) } ` ;
} ;
function daysBetween ( dateA , dateB ) {
const a = new Date ( dateA ) ; const b = new Date ( dateB ) ;
return Math . round ( ( b - a ) / 86400000 ) ;
}
// ==== Persistence ====
function loadState ( ) {
try {
const s = JSON . parse ( localStorage . getItem ( LS _KEY ) || '{}' ) ;
return {
xp : s . xp || 0 ,
totalAnswers : s . totalAnswers || 0 ,
correctAnswers : s . correctAnswers || 0 ,
currentStreak : s . currentStreak || 0 ,
maxStreak : s . maxStreak || 0 ,
lastActive : s . lastActive || null ,
quizStreak : s . quizStreak || 0 ,
maxQuizStreak : s . maxQuizStreak || 0 ,
mastery : s . mastery || { } , // { [curriculumId]: { correct, total } }
moduleCorrect : s . moduleCorrect || { } , // { [moduleId]: correctCount }
moduleTotal : s . moduleTotal || { } , // { [moduleId]: totalAnswered }
modulePassedFlash : s . modulePassedFlash || { } , // { [moduleId]: true when all cards rated >= "gut"}
completedQuizzes : s . completedQuizzes || 0 ,
flashCardsRated : s . flashCardsRated || 0 ,
badges : s . badges || { } ,
seenWelcome : s . seenWelcome || false ,
2026-04-29 02:51:28 +00:00
seenWelcomeVersion : s . seenWelcomeVersion || 0 ,
2026-04-28 23:35:15 +00:00
completedCurricula : s . completedCurricula || [ ]
} ;
} catch ( e ) {
return {
xp : 0 , totalAnswers : 0 , correctAnswers : 0 ,
currentStreak : 0 , maxStreak : 0 , lastActive : null ,
quizStreak : 0 , maxQuizStreak : 0 , mastery : { } ,
moduleCorrect : { } , moduleTotal : { } , modulePassedFlash : { } ,
completedQuizzes : 0 , flashCardsRated : 0 ,
2026-04-29 02:51:28 +00:00
badges : { } , seenWelcome : false , seenWelcomeVersion : 0 , completedCurricula : [ ]
2026-04-28 23:35:15 +00:00
} ;
}
}
2026-04-29 02:51:28 +00:00
// Welcome-version bump: bump this number whenever a bot's welcome.html gets a
// non-trivial visual refresh, so returning users see the new intro once.
// Per-bot value injected at render time from config.yaml welcome_version
// (default 1). After dismiss, state.seenWelcomeVersion catches up.
const WELCOME _VERSION = Number ( window . _ _WELCOME _VERSION _ _ || 1 ) ;
2026-04-28 23:35:15 +00:00
function saveState ( ) { localStorage . setItem ( LS _KEY , JSON . stringify ( state ) ) ; }
function loadChatHistory ( ) {
try { return JSON . parse ( localStorage . getItem ( LS _CHAT ) || '[]' ) ; } catch ( e ) { return [ ] ; }
}
function saveChatHistory ( ) {
// Cap at last 40 exchanges to keep API payload small
const trimmed = chatHistory . slice ( - 40 ) ;
localStorage . setItem ( LS _CHAT , JSON . stringify ( trimmed ) ) ;
}
function loadFlashCards ( ) {
try { return JSON . parse ( localStorage . getItem ( LS _FLASH ) || '{}' ) ; } catch ( e ) { return { } ; }
}
function saveFlashCards ( ) { localStorage . setItem ( LS _FLASH , JSON . stringify ( flashCards ) ) ; }
// ==== Streak / activity tracking ====
function touchActivity ( ) {
const t = today ( ) ;
if ( state . lastActive === t ) return ;
if ( state . lastActive ) {
const diff = daysBetween ( state . lastActive , t ) ;
if ( diff === 1 ) state . currentStreak += 1 ;
else if ( diff > 1 ) state . currentStreak = 1 ;
else state . currentStreak = Math . max ( 1 , state . currentStreak ) ;
} else {
state . currentStreak = 1 ;
}
state . lastActive = t ;
if ( state . currentStreak > state . maxStreak ) state . maxStreak = state . currentStreak ;
checkBadges ( ) ;
saveState ( ) ;
}
// ==== XP / Level ====
function addXP ( n , reason = '' ) {
state . xp += n ;
saveState ( ) ;
showXPGain ( ` + ${ n } XP ${ reason ? ' · ' + reason : '' } ` ) ;
}
function levelInfo ( ) {
const levels = ( CURRICULA && CURRICULA . levels ) || [
{ min : 0 , title : 'Einsteiger:in' } , { min : 50 , title : 'Schicht-Leiter:in' } ,
{ min : 200 , title : 'Bar-/Club-Manager:in' } , { min : 500 , title : 'Betriebsleiter:in' } ,
{ min : 1250 , title : 'Awareness-Beauftragte:r' } , { min : 2500 , title : 'Geschäftsführung Gastro/Events' } ,
{ min : 5000 , title : 'Branchen-Expert:in' }
] ;
let cur = levels [ 0 ] ;
for ( const l of levels ) if ( state . xp >= l . min ) cur = l ;
const idx = levels . indexOf ( cur ) ;
const next = levels [ idx + 1 ] || null ;
const pct = next ? Math . min ( 100 , ( ( state . xp - cur . min ) / ( next . min - cur . min ) ) * 100 ) : 100 ;
return { levelNum : idx + 1 , title : cur . title , pct , next } ;
}
// ==== Badges ====
function unlockBadge ( id ) {
if ( state . badges [ id ] ) return false ;
state . badges [ id ] = today ( ) ;
saveState ( ) ;
const badge = ( CURRICULA && CURRICULA . badges || [ ] ) . find ( b => b . id === id ) ;
if ( badge ) toast ( '🏆 Neues Abzeichen: ' + badge . title , 'success' , 4500 ) ;
return true ;
}
function checkBadges ( ) {
const done = state . completedCurricula || [ ] ;
// Policy-Architekt:in — erste Quiz-Frage zu Policy-Struktur richtig
if ( ( state . completedQuizzes || 0 ) >= 1 ) unlockBadge ( 'erste_policy' ) ;
// AGG-Schutzmauer — Diskriminierungsschutz-Modul komplett
if ( done . includes ( 'agg_diskriminierung' ) ) unlockBadge ( 'agg_shield' ) ;
// Incident-Profi — Vorfall-Dokumentation komplett
if ( done . includes ( 'dokumentation' ) || done . includes ( 'save_protokoll' ) ) unlockBadge ( 'incident_keeper' ) ;
// Krisenkommunikations-Profi — beide Krise-Module
if ( done . includes ( 'statement' ) && done . includes ( 'medien' ) ) unlockBadge ( 'crisis_comm_pro' ) ;
// Harm-Reduction-Advocate — beide Harm-Module
if ( done . includes ( 'notfaelle' ) && done . includes ( 'drogenhilfe' ) ) unlockBadge ( 'harm_reducer' ) ;
// Safer-Space-Architekt:in — Fundament + Policy + Team komplett
const foundations = [ 'awareness_grundlagen' , 'parteilichkeit' ] ;
const policy = [ 'policy_struktur' , 'eskalationskette' ] ;
const team = [ 'team_aufbau' , 'konflikte_fuehrung' ] ;
if ( foundations . every ( m => done . includes ( m ) ) && policy . every ( m => done . includes ( m ) ) && team . every ( m => done . includes ( m ) ) ) {
unlockBadge ( 'safer_space_builder' ) ;
}
// Awareness-Master — alle 16 Module
if ( done . length >= 16 ) unlockBadge ( 'policy_master' ) ;
// Monats-Disziplin — 30-Tage-Streak
if ( state . maxStreak >= 30 ) unlockBadge ( 'streak_30' ) ;
// Night Owl (late-night Lerner) — Nightlife-Flavour
const h = new Date ( ) . getHours ( ) ;
if ( h >= 22 ) unlockBadge ( 'night_owl' ) ;
}
// ==== Toast ====
function toast ( msg , kind = '' , ms = 3200 ) {
const stack = $ ( '#toast-stack' ) ;
const t = document . createElement ( 'div' ) ;
t . className = 'toast ' + kind ;
t . textContent = msg ;
stack . appendChild ( t ) ;
setTimeout ( ( ) => {
t . style . opacity = '0' ;
t . style . transition = 'opacity .25s' ;
setTimeout ( ( ) => t . remove ( ) , 260 ) ;
} , ms ) ;
}
function showXPGain ( txt ) {
const el = document . createElement ( 'div' ) ;
el . className = 'xp-gain' ;
el . textContent = txt ;
document . body . appendChild ( el ) ;
setTimeout ( ( ) => el . remove ( ) , 1600 ) ;
}
// ==== Simple markdown renderer ====
function renderMD ( md ) {
if ( ! md ) return '' ;
let s = md ;
// Escape HTML first
s = s . replace ( /&/g , '&' ) . replace ( /</g , '<' ) . replace ( />/g , '>' ) ;
// Code fences
s = s . replace ( /```(\w+)?\n([\s\S]*?)```/g , ( _ , lang , code ) =>
` <pre><code> ${ code } </code></pre> ` ) ;
// Inline code
s = s . replace ( /`([^`\n]+)`/g , '<code>$1</code>' ) ;
// GFM tables: header / separator / body
s = s . replace ( /(?:^|\n)((?:\|[^\n]*\|[ \t]*\n){2,})/g , ( block , content ) => {
const lines = content . trim ( ) . split ( '\n' ) ;
if ( lines . length < 2 ) return block ;
const sep = lines [ 1 ] ;
if ( ! /^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/ . test ( sep ) ) return block ;
const parseRow = ( ln ) => ln . replace ( /^\|/ , '' ) . replace ( /\|\s*$/ , '' ) . split ( '|' ) . map ( c => c . trim ( ) ) ;
const header = parseRow ( lines [ 0 ] ) ;
const aligns = parseRow ( sep ) . map ( s => / ^ : - + : $ / . test ( s ) ? 'center' : /-+:$/ . test ( s ) ? 'right' : 'left' ) ;
const rows = lines . slice ( 2 ) . map ( parseRow ) ;
let html = '\n<table class="md-table"><thead><tr>' ;
header . forEach ( ( h , i ) => { html += ` <th style="text-align: ${ aligns [ i ] || 'left' } "> ${ h } </th> ` ; } ) ;
html += '</tr></thead><tbody>' ;
rows . forEach ( r => {
html += '<tr>' ;
for ( let i = 0 ; i < Math . max ( r . length , header . length ) ; i ++ ) {
html += ` <td style="text-align: ${ aligns [ i ] || 'left' } "> ${ r [ i ] || '' } </td> ` ;
}
html += '</tr>' ;
} ) ;
html += '</tbody></table>\n' ;
return html ;
} ) ;
// Bold
s = s . replace ( /\*\*([^*\n]+)\*\*/g , '<strong>$1</strong>' ) ;
s = s . replace ( /__([^_\n]+)__/g , '<strong>$1</strong>' ) ;
// Italic
s = s . replace ( /(^|[^*])\*([^*\n]+)\*/g , '$1<em>$2</em>' ) ;
// Headings
s = s . replace ( /^### (.+)$/gm , '<h3>$1</h3>' ) ;
s = s . replace ( /^## (.+)$/gm , '<h2>$1</h2>' ) ;
s = s . replace ( /^# (.+)$/gm , '<h1>$1</h1>' ) ;
// Links
s = s . replace ( /\[([^\]]+)\]\(([^)\s]+)\)/g , '<a href="$2" target="_blank" rel="noopener">$1</a>' ) ;
// Unordered lists
s = s . replace ( /(?:^|\n)((?:[*\-] .+\n?)+)/g , ( m ) => {
const items = m . trim ( ) . split ( '\n' ) . map ( ln => ln . replace ( /^[*\-] / , '' ) ) . map ( li => ` <li> ${ li } </li> ` ) . join ( '' ) ;
return '\n<ul>' + items + '</ul>' ;
} ) ;
// Ordered lists
s = s . replace ( /(?:^|\n)((?:\d+\. .+\n?)+)/g , ( m ) => {
const items = m . trim ( ) . split ( '\n' ) . map ( ln => ln . replace ( /^\d+\. / , '' ) ) . map ( li => ` <li> ${ li } </li> ` ) . join ( '' ) ;
return '\n<ol>' + items + '</ol>' ;
} ) ;
// Paragraphs: split on blank lines
s = s . split ( /\n{2,}/ ) . map ( block => {
if ( /^<(h\d|ul|ol|pre|blockquote|table)/ . test ( block . trim ( ) ) ) return block ;
return '<p>' + block . replace ( /\n/g , '<br>' ) + '</p>' ;
} ) . join ( '\n' ) ;
return s ;
}
// ==== Chat API ====
async function chatAPI ( message , history , attachments ) {
const body = { message , history } ;
if ( Array . isArray ( attachments ) && attachments . length > 0 ) body . attachments = attachments ;
const r = await fetch ( API , {
method : 'POST' ,
headers : {
'Content-Type' : 'application/json' ,
'Authorization' : 'Bearer ' + KEY
} ,
body : JSON . stringify ( body )
} ) ;
let data ;
try { data = await r . json ( ) ; } catch ( e ) { throw new Error ( 'Server-Antwort nicht lesbar' ) ; }
if ( ! r . ok ) throw new Error ( data . error || ( 'HTTP ' + r . status ) ) ;
return data ;
}
// ==== Attachments (file upload) ====
const ATTACH _MAX _COUNT = 5 ;
const ATTACH _MAX _BYTES = 8 * 1024 * 1024 ;
const ATTACH _ACCEPTED _RE = /\.(pdf|txt|md|csv|json|xml|yaml|yml|log|png|jpg|jpeg|webp|gif)$/i ;
let pendingAttachments = [ ] ; // [{ name, mimeType, dataUrl, size }]
function fmtSize ( bytes ) {
if ( bytes < 1024 ) return bytes + ' B' ;
if ( bytes < 1024 * 1024 ) return ( bytes / 1024 ) . toFixed ( 1 ) + ' KB' ;
return ( bytes / 1024 / 1024 ) . toFixed ( 1 ) + ' MB' ;
}
function fileToDataUrl ( file ) {
return new Promise ( ( resolve , reject ) => {
const fr = new FileReader ( ) ;
fr . onload = ( ) => resolve ( fr . result ) ;
fr . onerror = ( ) => reject ( new Error ( 'Datei konnte nicht gelesen werden' ) ) ;
fr . readAsDataURL ( file ) ;
} ) ;
}
function renderAttachStrip ( ) {
const strip = $ ( '#attach-strip' ) ;
if ( ! strip ) return ;
strip . innerHTML = '' ;
pendingAttachments . forEach ( ( a , idx ) => {
const chip = document . createElement ( 'span' ) ;
chip . className = 'attach-chip' ;
const ico = a . mimeType . startsWith ( 'image/' ) ? '🖼' : ( a . mimeType === 'application/pdf' || /\.pdf$/i . test ( a . name ) ) ? '📄' : '📝' ;
chip . innerHTML = ` <span> ${ ico } </span><span class="attach-chip-name" title=" ${ a . name . replace ( /[<>"']/g , '' ) } "> ${ a . name . replace ( /[<>"']/g , '' ) } </span><span class="attach-chip-size"> ${ fmtSize ( a . size ) } </span> ` ;
const rm = document . createElement ( 'button' ) ;
rm . type = 'button' ;
rm . className = 'attach-chip-remove' ;
rm . setAttribute ( 'aria-label' , 'Anhang entfernen' ) ;
rm . textContent = '× ' ;
rm . addEventListener ( 'click' , ( ) => {
pendingAttachments . splice ( idx , 1 ) ;
renderAttachStrip ( ) ;
} ) ;
chip . appendChild ( rm ) ;
strip . appendChild ( chip ) ;
} ) ;
}
async function addFiles ( fileList ) {
const files = Array . from ( fileList || [ ] ) ;
for ( const f of files ) {
if ( pendingAttachments . length >= ATTACH _MAX _COUNT ) {
toast ( ` Max ${ ATTACH _MAX _COUNT } Anhänge — weitere ignoriert ` , 'warn' ) ;
break ;
}
if ( ! ATTACH _ACCEPTED _RE . test ( f . name ) && ! f . type . startsWith ( 'image/' ) && ! /text|json|xml|pdf/i . test ( f . type ) ) {
toast ( ` ${ f . name } : Format nicht unterstützt ` , 'error' ) ;
continue ;
}
if ( f . size > ATTACH _MAX _BYTES ) {
toast ( ` ${ f . name } : ${ fmtSize ( f . size ) } > Limit ${ fmtSize ( ATTACH _MAX _BYTES ) } ` , 'error' ) ;
continue ;
}
try {
const dataUrl = await fileToDataUrl ( f ) ;
pendingAttachments . push ( {
name : f . name ,
mimeType : f . type || 'application/octet-stream' ,
dataUrl ,
size : f . size ,
} ) ;
} catch ( e ) {
toast ( ` ${ f . name } : ${ e . message } ` , 'error' ) ;
}
}
renderAttachStrip ( ) ;
}
// ==== Chat UI ====
function addMsg ( role , content , { markdown = false , pending = false , attachments = null } = { } ) {
const box = $ ( '#chat-box' ) ;
const el = document . createElement ( 'div' ) ;
el . className = 'msg ' + role ;
if ( pending ) {
el . innerHTML = '<span class="dots"><span></span><span></span><span></span></span>' ;
} else if ( markdown ) {
el . innerHTML = renderMD ( content ) ;
} else {
el . textContent = content ;
}
if ( Array . isArray ( attachments ) && attachments . length > 0 ) {
const wrap = document . createElement ( 'div' ) ;
wrap . className = 'msg-attachments' ;
attachments . forEach ( a => {
const ico = a . mimeType && a . mimeType . startsWith ( 'image/' ) ? '🖼' : ( a . mimeType === 'application/pdf' || /\.pdf$/i . test ( a . name ) ) ? '📄' : '📝' ;
const span = document . createElement ( 'span' ) ;
span . className = 'att-name' ;
span . textContent = ` ${ ico } ${ a . name } ( ${ fmtSize ( a . size ) } ) ` ;
wrap . appendChild ( span ) ;
} ) ;
el . appendChild ( wrap ) ;
}
box . appendChild ( el ) ;
box . scrollTop = box . scrollHeight ;
setTimeout ( ( ) => { $ ( '.main' ) . scrollTop = $ ( '.main' ) . scrollHeight ; } , 20 ) ;
return el ;
}
function clearChatUI ( ) {
$ ( '#chat-box' ) . innerHTML = '' ;
}
function renderWelcome ( ) {
2026-04-29 02:51:28 +00:00
const newWelcomeAvailable = ( state . seenWelcomeVersion || 0 ) < WELCOME _VERSION ;
if ( ( state . xp === 0 && chatHistory . length === 0 && ! state . seenWelcome ) || newWelcomeAvailable ) {
2026-04-28 23:35:15 +00:00
$ ( '#welcome-screen' ) . classList . remove ( 'hidden' ) ;
$ ( '#welcome-screen' ) . setAttribute ( 'aria-hidden' , 'false' ) ;
} else {
$ ( '#welcome-screen' ) . classList . add ( 'hidden' ) ;
$ ( '#welcome-screen' ) . setAttribute ( 'aria-hidden' , 'true' ) ;
}
}
function restoreChat ( ) {
clearChatUI ( ) ;
for ( const m of chatHistory ) {
addMsg ( m . role === 'assistant' ? 'bot' : 'user' , m . content , { markdown : m . role === 'assistant' } ) ;
}
renderWelcome ( ) ;
}
async function sendChat ( text ) {
const attaches = pendingAttachments . slice ( ) ;
if ( ! text . trim ( ) && attaches . length === 0 ) return ;
$ ( '#welcome-screen' ) . classList . add ( 'hidden' ) ;
state . seenWelcome = true ;
2026-04-29 02:51:28 +00:00
state . seenWelcomeVersion = WELCOME _VERSION ;
2026-04-28 23:35:15 +00:00
addMsg ( 'user' , text || '(nur Anhang)' , { attachments : attaches } ) ;
chatHistory . push ( {
role : 'user' ,
content : text + ( attaches . length ? '\n[Anhänge: ' + attaches . map ( a => a . name ) . join ( ', ' ) + ']' : '' ) ,
} ) ;
pendingAttachments = [ ] ;
renderAttachStrip ( ) ;
const pend = addMsg ( 'bot' , '' , { pending : true } ) ;
$ ( '#composer-send' ) . disabled = true ;
try {
const hist = chatHistory . slice ( - 20 , - 1 ) ;
const data = await chatAPI ( text , hist , attaches . length ? attaches . map ( a => ( { name : a . name , mimeType : a . mimeType , dataUrl : a . dataUrl } ) ) : null ) ;
pend . classList . remove ( 'pending' ) ;
const structured = _tryParseStructuredReply ( data . reply || '' ) ;
if ( structured ) {
pend . innerHTML = _renderStructuredInChat ( structured ) ;
} else {
pend . innerHTML = renderMD ( data . reply || '' ) ;
}
if ( Array . isArray ( data . attachment _notes ) && data . attachment _notes . length ) {
const notice = document . createElement ( 'div' ) ;
notice . className = 'attachment-notice' ;
notice . textContent = '📎 ' + data . attachment _notes . join ( ' · ' ) ;
pend . appendChild ( notice ) ;
}
chatHistory . push ( { role : 'assistant' , content : data . reply } ) ;
saveChatHistory ( ) ;
touchActivity ( ) ;
// Soft-Gate: postMessage to parent window after N user→assistant
// exchanges (configurable via ?showcase=1 query param). The outer
// qognio.com/showcase page listens for this event and triggers the
// lead-form modal. Sent only when running in an iframe.
try {
const isShowcase = new URLSearchParams ( window . location . search ) . get ( 'showcase' ) === '1' ;
if ( isShowcase && window . parent && window . parent !== window ) {
const userMessages = chatHistory . filter ( ( m ) => m . role === 'user' ) . length ;
const SOFT _GATE _AT = 3 ;
if ( userMessages === SOFT _GATE _AT ) {
window . parent . postMessage (
{ type : 'qognio:soft-gate' , afterMessages : userMessages , botSlug : window . location . hostname . split ( '.' ) [ 0 ] } ,
'*'
) ;
}
}
} catch ( sgErr ) {
// non-fatal — swallow
}
} catch ( e ) {
pend . className = 'msg sys' ;
pend . textContent = '⚠ Fehler: ' + ( e . message || 'unbekannt' ) ;
toast ( 'Verbindung fehlgeschlagen' , 'error' ) ;
} finally {
$ ( '#composer-send' ) . disabled = false ;
$ ( '#composer' ) . focus ( ) ;
}
}
// ==== Structured request helper ====
function _extractJSON ( reply ) {
let s = ( reply || '' ) . trim ( ) ;
const fence = s . match ( /```(?:json)?\s*([\s\S]*?)\s*```/ ) ;
if ( fence ) s = fence [ 1 ] . trim ( ) ;
const b = s . indexOf ( '{' ) ; const e = s . lastIndexOf ( '}' ) ;
if ( b >= 0 && e > b ) s = s . slice ( b , e + 1 ) ;
return s ;
}
function _repairJSON ( s ) {
s = s . replace ( /,(\s*[}\]])/g , '$1' ) ;
s = s . replace ( /,\s*,/g , ',' ) ;
s = s . replace ( /(\})\s*\n\s*(\{)/g , '$1,\n$2' ) ;
s = s . replace ( /(")(\s*\n\s*)(")/g , '$1,$2$3' ) ;
return s ;
}
// --- Structured-Reply-Fallback fuer Chat (2026-04-25) ---
// Wenn der Bot versehentlich QUIZ/FLASHCARD/CASE/EXAM-JSON liefert statt Markdown,
// parse + render hier lesbar statt Raw-JSON im Chat-Bubble anzuzeigen.
function _tryParseStructuredReply ( reply ) {
let s = ( reply || '' ) . trim ( ) ;
const fence = s . match ( /```(?:json)?\s*([\s\S]*?)\s*```/ ) ;
if ( fence ) s = fence [ 1 ] . trim ( ) ;
if ( ! s . startsWith ( '{' ) ) return null ;
const b = s . indexOf ( '{' ) , e = s . lastIndexOf ( '}' ) ;
if ( b < 0 || e <= b ) return null ;
const raw = s . slice ( b , e + 1 ) ;
let obj ;
try { obj = JSON . parse ( raw ) ; } catch {
try { obj = JSON . parse ( _repairJSON ( raw ) ) ; } catch { return null ; }
}
if ( ! obj || typeof obj !== 'object' ) return null ;
const KNOWN = [
'case' , 'quiz' , 'flashcards' , 'exam' , 'lesson' , 'presentation' ,
// Bot-spezifische Strukturen (KURT/VESTIGIA/PAUL/Pia/Otto/Eli/LIMEN/Zita/LIBRA/IDA)
'audit' , 'privacy_check' , 'mail_check' , 'plan' , 'validate' , 'interview' , 'decode' , 'write' , 'calc' , 'unterweisung' ,
'compliance_report'
] ;
if ( ! KNOWN . includes ( obj . type ) ) return null ;
return obj ;
}
function _renderStructuredInChat ( obj ) {
const esc = ( s ) => String ( s == null ? '' : s ) . replace ( /[&<>"']/g , c => ( { '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' } [ c ] ) ) ;
const label = [ 'A' , 'B' , 'C' , 'D' , 'E' , 'F' , 'G' , 'H' ] ;
const TYPE _BADGE = {
case : '📖 Fallbeispiel' , quiz : '🧠 Quiz-Fragen' , flashcards : '📇 Karteikarten' ,
exam : '📝 Prüfung' , lesson : '🎓 Lektion' , presentation : '🖼 Präsentation' ,
audit : '🛡 AI-Act Audit-Trail' , privacy _check : '🩺 Pflege-Datenschutz-Check' ,
mail _check : '📧 Mail-Check' , plan : '🗓 Plan' , validate : '✓ Validierung' ,
interview : '🎙 Wissens-Interview' , decode : '🔍 Zeugnis-Decoder' ,
write : '✍ Zeugnis-Entwurf' , calc : '🧮 Kalkulation' ,
unterweisung : '🛠 AdA-Unterweisung' ,
compliance _report : '🔍 Website-Compliance-Check' ,
} ;
let html = ` <div class="structured-chat"><div class="struct-badge"> ${ TYPE _BADGE [ obj . type ] || '📄 Strukturierte Antwort' } </div> ` ;
if ( obj . topic ) html += ` <div class="struct-topic"> ${ esc ( obj . topic ) } </div> ` ;
if ( obj . type === 'case' ) {
if ( obj . scenario ) html += ` <div class="struct-scenario"><strong>Szenario:</strong><br> ${ esc ( obj . scenario ) } </div> ` ;
const fragen = obj . fragen || obj . questions || [ ] ;
fragen . forEach ( ( f , i ) => {
html += ` <div class="struct-question"><strong>Frage ${ i + 1 } :</strong> ${ esc ( f . frage || f . q || '' ) } ` ;
const opts = f . options || [ ] ;
if ( opts . length ) {
html += '<ul class="struct-options">' ;
opts . forEach ( ( o , j ) => {
const correct = ( f . correct === j ) ;
html += ` <li ${ correct ? ' class="correct"' : '' } ><strong> ${ label [ j ] || ( j + 1 ) } )</strong> ${ esc ( o ) } ${ correct ? ' ✓' : '' } </li> ` ;
} ) ;
html += '</ul>' ;
}
const ex = f . explain || f . explanation ;
if ( ex ) html += ` <div class="struct-explain"><em>Erklärung:</em> ${ esc ( ex ) } </div> ` ;
html += '</div>' ;
} ) ;
const lessons = obj . lessons || [ ] ;
if ( lessons . length ) {
html += '<div class="struct-lessons"><strong>Lessons:</strong><ul>' ;
lessons . forEach ( l => { html += ` <li> ${ esc ( l ) } </li> ` ; } ) ;
html += '</ul></div>' ;
}
const norm = obj . paragraphen || obj . normen || obj . artikel || [ ] ;
if ( norm . length ) {
html += '<div class="struct-norms"><strong>Rechtsnormen:</strong> ' + norm . map ( n => ` <code> ${ esc ( n ) } </code> ` ) . join ( ' · ' ) + '</div>' ;
}
} else if ( obj . type === 'quiz' || obj . type === 'exam' ) {
// Exam-spezifisch: Dauer-Anzeige + HF-Tag pro Frage (IDA AdA-Schein)
if ( obj . type === 'exam' && obj . duration _min ) {
html += ` <div class="struct-row"><strong>Dauer:</strong> ${ esc ( obj . duration _min ) } Min · <strong>Fragen:</strong> ${ ( obj . questions || [ ] ) . length } </div> ` ;
}
const qs = obj . questions || [ ] ;
qs . forEach ( ( q , i ) => {
const hfTag = ( obj . type === 'exam' && q . hf != null ) ? ` <span style="font-size:.8em;color:var(--accent,#7c3aed);font-weight:600">[HF ${ esc ( q . hf ) } ]</span> ` : '' ;
html += ` <div class="struct-question"><strong>Frage ${ i + 1 } :</strong> ${ hfTag } ${ esc ( q . q || q . frage || '' ) } ` ;
const opts = q . options || [ ] ;
if ( opts . length ) {
html += '<ul class="struct-options">' ;
opts . forEach ( ( o , j ) => {
const correct = ( q . correct === j ) ;
html += ` <li ${ correct ? ' class="correct"' : '' } ><strong> ${ label [ j ] || ( j + 1 ) } )</strong> ${ esc ( o ) } ${ correct ? ' ✓' : '' } </li> ` ;
} ) ;
html += '</ul>' ;
}
const ex = q . explain || q . explanation ;
if ( ex ) html += ` <div class="struct-explain"><em>Erklärung:</em> ${ esc ( ex ) } </div> ` ;
html += '</div>' ;
} ) ;
} else if ( obj . type === 'flashcards' ) {
( obj . cards || [ ] ) . forEach ( ( c , i ) => {
html += ` <div class="struct-flashcard"><div class="fc-front"><strong>Karte ${ i + 1 } :</strong> ${ esc ( c . front || '' ) } </div><div class="fc-back"> ${ esc ( c . back || '' ) } </div> ` ;
if ( c . hint ) html += ` <div class="fc-hint"><em>Hinweis:</em> ${ esc ( c . hint ) } </div> ` ;
html += '</div>' ;
} ) ;
} else if ( obj . type === 'lesson' || obj . type === 'presentation' ) {
if ( obj . objectives || obj . learning _objectives ) {
const objs = obj . objectives || obj . learning _objectives ;
html += '<div class="struct-objectives"><strong>Lernziele:</strong><ul>' ;
objs . forEach ( o => { html += ` <li> ${ esc ( o ) } </li> ` ; } ) ;
html += '</ul></div>' ;
}
( obj . slides || [ ] ) . forEach ( ( s , i ) => {
html += ` <div class="struct-slide"><strong> ${ i + 1 } . ${ esc ( s . title || '' ) } </strong> ` ;
if ( s . content _md || s . content ) html += ` <div class="slide-content"> ${ renderMD ( s . content _md || s . content || '' ) } </div> ` ;
if ( s . key _point ) html += ` <div class="slide-key">💡 ${ esc ( s . key _point ) } </div> ` ;
html += '</div>' ;
} ) ;
} else if ( obj . type === 'audit' ) {
// KURT / VESTIGIA — AI-Act Audit-Trail
if ( obj . system ) html += ` <div class="struct-row"><strong>System:</strong> ${ esc ( obj . system ) } </div> ` ;
const cls = obj . ai _act _risk _class || obj . risk _class ;
if ( cls ) {
const clsColor = { prohibited : '#dc2626' , high : '#dc2626' , limited : '#eab308' , minimal : '#22c55e' } [ cls ] || '#8b8a99' ;
html += ` <div class="struct-row"><strong>Risiko-Klasse:</strong> <span style="color: ${ clsColor } ;font-weight:600;text-transform:uppercase"> ${ esc ( cls ) } </span></div> ` ;
}
if ( obj . role ) html += ` <div class="struct-row"><strong>Rolle:</strong> ${ esc ( obj . role ) } </div> ` ;
if ( obj . dsgvo _relevant != null ) html += ` <div class="struct-row"><strong>DSGVO-relevant:</strong> ${ obj . dsgvo _relevant ? 'ja' : 'nein' } </div> ` ;
if ( obj . art22 _check ) html += ` <div class="struct-row"><strong>Art. 22 DSGVO:</strong> ${ esc ( obj . art22 _check ) } </div> ` ;
const arts = obj . required _artifacts || [ ] ;
if ( arts . length ) {
html += '<div class="struct-section"><strong>Erforderliche Artefakte:</strong><table class="struct-table"><thead><tr><th>Artefakt</th><th>Status</th><th>Basis</th></tr></thead><tbody>' ;
arts . forEach ( a => {
const sColor = { required : '#dc2626' , optional : '#eab308' , 'not-required' : '#22c55e' } [ a . status ] || '#8b8a99' ;
html += ` <tr><td> ${ esc ( a . name || '' ) } </td><td style="color: ${ sColor } ;font-weight:600"> ${ esc ( a . status || '' ) } </td><td><code> ${ esc ( a . based _on || '' ) } </code></td></tr> ` ;
} ) ;
html += '</tbody></table></div>' ;
}
const cw = obj . crosswalk _savings || [ ] ;
if ( cw . length ) {
html += '<div class="struct-section"><strong>Crosswalk-Einsparung:</strong><ul>' ;
cw . forEach ( c => { html += ` <li> ${ esc ( c ) } </li> ` ; } ) ;
html += '</ul></div>' ;
}
const dl = obj . deadlines || [ ] ;
if ( dl . length ) {
html += '<div class="struct-section"><strong>Fristen:</strong><ul>' ;
dl . forEach ( d => { html += ` <li><code> ${ esc ( d . date || '' ) } </code> — ${ esc ( d . what || '' ) } </li> ` ; } ) ;
html += '</ul></div>' ;
}
const ws = obj . warnings || obj . warnung || [ ] ;
if ( ws . length ) {
html += '<div class="struct-section" style="color:#dc2626"><strong>⚠ Warnungen:</strong><ul>' ;
ws . forEach ( w => { html += ` <li> ${ esc ( w ) } </li> ` ; } ) ;
html += '</ul></div>' ;
}
} else if ( obj . type === 'privacy_check' ) {
// PAUL — Pflege-Datenschutz-Ampel
const ampel = obj . ampel || 'yellow' ;
const ampelColor = { green : '#22c55e' , yellow : '#eab308' , red : '#dc2626' } [ ampel ] || '#8b8a99' ;
const ampelLabel = { green : '🟢 GRÜN — OK' , yellow : '🟡 GELB — Vorsicht' , red : '🔴 ROT — Akute Gefahr' } [ ampel ] || ampel ;
html += ` <div class="struct-row" style="font-size:1.1rem;font-weight:700;color: ${ ampelColor } "> ${ ampelLabel } </div> ` ;
if ( obj . situation ) html += ` <div class="struct-row"><strong>Situation:</strong> ${ esc ( obj . situation ) } </div> ` ;
if ( obj . schweigepflicht ) html += ` <div class="struct-row"><strong>Schweigepflicht (§203 StGB):</strong> ${ esc ( obj . schweigepflicht ) } </div> ` ;
if ( obj . dsgvo _basis ) html += ` <div class="struct-row"><strong>DSGVO-Basis:</strong> <code> ${ esc ( obj . dsgvo _basis ) } </code></div> ` ;
const acts = obj . handlung || obj . handlungen || [ ] ;
if ( acts . length ) {
html += '<div class="struct-section"><strong>Handlung:</strong><ol>' ;
acts . forEach ( a => { html += ` <li> ${ esc ( a ) } </li> ` ; } ) ;
html += '</ol></div>' ;
}
const wns = obj . warnung || obj . warnungen || [ ] ;
if ( wns . length ) {
html += ` <div class="struct-section" style="color: ${ ampelColor } "><strong>⚠ Achtung:</strong><ul> ` ;
wns . forEach ( w => { html += ` <li> ${ esc ( w ) } </li> ` ; } ) ;
html += '</ul></div>' ;
}
} else if ( obj . type === 'mail_check' ) {
// Pia — Phishing-Prüferin Mail-Bewertung
const ampel = obj . ampel || 'yellow' ;
const ampelColor = { green : '#22c55e' , yellow : '#eab308' , red : '#dc2626' } [ ampel ] || '#8b8a99' ;
const ampelLabel = { green : '🟢 GRÜN — vermutlich legitim' , yellow : '🟡 GELB — verdächtig, zweite Meinung holen' , red : '🔴 ROT — sehr wahrscheinlich Phishing/BEC' } [ ampel ] || ampel ;
html += ` <div class="struct-row" style="font-size:1.1rem;font-weight:700;color: ${ ampelColor } "> ${ ampelLabel } </div> ` ;
if ( obj . kurz _begruendung ) html += ` <div class="struct-row"><strong>Kurz:</strong> ${ esc ( obj . kurz _begruendung ) } </div> ` ;
if ( obj . pattern ) html += ` <div class="struct-row"><strong>Pattern:</strong> <code> ${ esc ( obj . pattern ) } </code></div> ` ;
const flags = obj . red _flags || [ ] ;
if ( flags . length ) {
html += '<div class="struct-section"><strong>Red Flags:</strong><ul>' ;
flags . forEach ( f => { html += ` <li> ${ esc ( f ) } </li> ` ; } ) ;
html += '</ul></div>' ;
}
const recs = obj . empfohlene _aktion || obj . aktionen || [ ] ;
if ( recs . length ) {
html += '<div class="struct-section"><strong>Empfohlene Aktion:</strong><ol>' ;
recs . forEach ( a => { html += ` <li> ${ esc ( a ) } </li> ` ; } ) ;
html += '</ol></div>' ;
}
if ( obj . weiterleiten _an ) html += ` <div class="struct-row"><strong>Weiterleiten an:</strong> ${ esc ( obj . weiterleiten _an ) } </div> ` ;
} else if ( obj . type === 'plan' ) {
// Otto — 90-Tage-Onboarding-Plan
if ( obj . role ) html += ` <div class="struct-row"><strong>Rolle:</strong> ${ esc ( obj . role ) } </div> ` ;
const items = obj . weeks || obj . days || obj . steps || obj . phases || [ ] ;
if ( items . length ) {
items . forEach ( ( it , i ) => {
const label = it . day || it . week || it . phase || ` Phase ${ i + 1 } ` ;
const focus = it . focus || it . title || '' ;
html += ` <div class="struct-step"><div class="step-head"><strong> ${ esc ( label ) } </strong> ${ focus ? ` — ${ esc ( focus ) } ` : '' } </div> ` ;
const tasks = it . tasks || it . aufgaben || [ ] ;
if ( tasks . length ) {
html += '<ul>' ;
tasks . forEach ( t => { html += ` <li> ${ esc ( typeof t === 'string' ? t : ( t . task || t . text || JSON . stringify ( t ) ) ) } </li> ` ; } ) ;
html += '</ul>' ;
}
if ( it . success _signal || it . success ) html += ` <div class="step-success">✓ ${ esc ( it . success _signal || it . success ) } </div> ` ;
html += '</div>' ;
} ) ;
}
} else if ( obj . type === 'validate' ) {
// Eli — E-Rechnungs-Validator
const status = obj . status || 'ok' ;
const statusColor = { ok : '#22c55e' , warnings : '#eab308' , errors : '#dc2626' } [ status ] || '#8b8a99' ;
const statusLabel = { ok : '✅ Validierung OK' , warnings : '⚠ Warnungen' , errors : '❌ Fehler — nicht konform' } [ status ] || status ;
html += ` <div class="struct-row" style="font-weight:700;color: ${ statusColor } "> ${ statusLabel } </div> ` ;
if ( obj . file || obj . dateiname ) html += ` <div class="struct-row"><strong>Datei:</strong> <code> ${ esc ( obj . file || obj . dateiname ) } </code></div> ` ;
if ( obj . format ) html += ` <div class="struct-row"><strong>Format:</strong> ${ esc ( obj . format ) } </div> ` ;
const issues = obj . issues || obj . findings || [ ] ;
if ( issues . length ) {
html += '<div class="struct-section"><strong>Befunde:</strong><table class="struct-table"><thead><tr><th>Severity</th><th>Meldung</th><th>Fix</th></tr></thead><tbody>' ;
issues . forEach ( i => {
const sev = i . severity || 'info' ;
const sevColor = { error : '#dc2626' , warning : '#eab308' , info : '#06b6d4' } [ sev ] || '#8b8a99' ;
html += ` <tr><td style="color: ${ sevColor } ;font-weight:600"> ${ esc ( sev ) } </td><td> ${ esc ( i . message || i . msg || '' ) } </td><td> ${ esc ( i . fix || '' ) } </td></tr> ` ;
} ) ;
html += '</tbody></table></div>' ;
}
} else if ( obj . type === 'interview' ) {
// LIMEN — Wissens-Interview entlang Achse
if ( obj . achse ) html += ` <div class="struct-row"><strong>Wissens-Achse:</strong> <code> ${ esc ( obj . achse ) } </code></div> ` ;
const fs = obj . fragen || obj . questions || [ ] ;
fs . forEach ( ( f , i ) => {
html += ` <div class="struct-question"><strong>Frage ${ i + 1 } :</strong> ${ esc ( f . f || f . frage || f . q || '' ) } ` ;
if ( f . tipp _aktiv _zuhören || f . tipp _zuhoeren ) html += ` <div style="font-size:.85em;color:#8b8a99;margin-top:.25rem"><em>👂 Tipp Aktiv-Zuhören:</em> ${ esc ( f . tipp _aktiv _zuhören || f . tipp _zuhoeren ) } </div> ` ;
if ( f . tipp _nachfass ) html += ` <div style="font-size:.85em;color:#8b8a99;margin-top:.25rem"><em>↳ Tipp Nachfass:</em> ${ esc ( f . tipp _nachfass ) } </div> ` ;
html += '</div>' ;
} ) ;
} else if ( obj . type === 'decode' ) {
// Zita — Zeugnis-Decoder
if ( obj . zeugnis _text ) html += ` <div class="struct-row"><strong>Zeugnis-Text:</strong><blockquote style="border-left:3px solid var(--accent);padding-left:.75rem;margin:.5rem 0;color:#cfcedb"> ${ esc ( obj . zeugnis _text ) } </blockquote></div> ` ;
const gradeNum = ( g ) => {
const n = typeof g === 'number' ? g : parseFloat ( String ( g ) . replace ( ',' , '.' ) ) ;
return isNaN ( n ) ? null : n ;
} ;
const gradeColor = ( g ) => {
const n = gradeNum ( g ) ;
if ( n != null ) return n <= 2 ? '#22c55e' : n <= 3 ? '#eab308' : '#dc2626' ;
return ( { sehr _gut : '#22c55e' , gut : '#22c55e' , befriedigend : '#eab308' , ausreichend : '#eab308' , mangelhaft : '#dc2626' , ungenuegend : '#dc2626' } [ String ( g || '' ) ] || '#8b8a99' ) ;
} ;
if ( obj . overall _grade != null && obj . overall _grade !== '' ) {
const ogColor = gradeColor ( obj . overall _grade ) ;
html += ` <div class="struct-row" style="font-size:1.1rem;font-weight:700;color: ${ ogColor } ">Gesamt-Note: ${ esc ( obj . overall _grade ) } ${ obj . grade _label ? ' — ' + esc ( obj . grade _label ) : '' } </div> ` ;
}
// Sub-Noten (verhalten/schlussformel)
if ( obj . verhalten _grade != null || obj . schlussformel _grade != null ) {
html += '<div class="struct-row" style="font-size:.9em">' ;
if ( obj . verhalten _grade != null ) html += ` <strong>Verhalten:</strong> <span style="color: ${ gradeColor ( obj . verhalten _grade ) } ;font-weight:600"> ${ esc ( obj . verhalten _grade ) } </span> ` ;
if ( obj . schlussformel _grade != null ) html += ` · <strong>Schlussformel:</strong> <span style="color: ${ gradeColor ( obj . schlussformel _grade ) } ;font-weight:600"> ${ esc ( obj . schlussformel _grade ) } </span> ` ;
html += '</div>' ;
}
// Akzeptiere mehrere Schema-Varianten: decodierung/codes/findings + passage/quote, klartext/bedeutung/meaning, note/grade, code/section
const dec = obj . decodierung || obj . codes || obj . findings || [ ] ;
if ( dec . length ) {
html += '<div class="struct-section"><strong>Code-Decodierung:</strong>' ;
dec . forEach ( d => {
const passage = d . passage || d . quote || d . text || '' ;
const klartext = d . klartext || d . bedeutung || d . meaning || '' ;
const code = d . code || d . section || '' ;
const note = d . note || d . grade || '' ;
const risk = d . risk || '' ;
const noteColor = note !== '' ? gradeColor ( note ) : '#8b8a99' ;
html += ` <div class="struct-step"><div><strong>" ${ esc ( passage ) } "</strong></div> ` ;
if ( code ) html += ` <div style="font-size:.85em;color:#8b8a99"> ${ esc ( code ) } </div> ` ;
if ( klartext ) html += ` <div>↳ ${ esc ( klartext ) } </div> ` ;
if ( note !== '' ) html += ` <div style="color: ${ noteColor } ;font-weight:600">Note: ${ esc ( note ) } ${ risk ? ` · Risiko: ${ esc ( risk ) } ` : '' } </div> ` ;
html += '</div>' ;
} ) ;
html += '</div>' ;
}
const redFlags = obj . red _flags || [ ] ;
if ( redFlags . length ) {
html += '<div class="struct-section" style="color:#dc2626"><strong>🚩 Red Flags:</strong><ul>' ;
redFlags . forEach ( f => { html += ` <li> ${ esc ( f ) } </li> ` ; } ) ;
html += '</ul></div>' ;
}
const missing = obj . missing _elements || [ ] ;
if ( missing . length ) {
html += '<div class="struct-section" style="color:#eab308"><strong>Fehlende Pflicht-Elemente:</strong><ul>' ;
missing . forEach ( m => { html += ` <li> ${ esc ( m ) } </li> ` ; } ) ;
html += '</ul></div>' ;
}
const rewrites = obj . rewrite _suggestions || [ ] ;
if ( rewrites . length ) {
html += '<div class="struct-section"><strong>Umschreib-Vorschläge:</strong><table class="struct-table"><thead><tr><th>Original</th><th>Vorschlag</th><th>Warum</th></tr></thead><tbody>' ;
rewrites . forEach ( r => {
html += ` <tr><td> ${ esc ( r . original || '' ) } </td><td> ${ esc ( r . rewrite || '' ) } </td><td> ${ esc ( r . why || '' ) } </td></tr> ` ;
} ) ;
html += '</tbody></table></div>' ;
}
const dsources = obj . sources || obj . quellen || [ ] ;
if ( dsources . length ) {
html += '<div class="struct-row" style="font-size:.85em;color:#8b8a99;margin-top:.4rem"><strong>Quellen:</strong> ' + dsources . map ( s => ` <code> ${ esc ( s ) } </code> ` ) . join ( ' · ' ) + '</div>' ;
}
} else if ( obj . type === 'write' ) {
// Zita — Zeugnis-Schreiber
if ( obj . role ) html += ` <div class="struct-row"><strong>Rolle:</strong> ${ esc ( obj . role ) } </div> ` ;
if ( obj . grade != null ) {
const gColor = obj . grade <= 2 ? '#22c55e' : obj . grade <= 3 ? '#eab308' : '#dc2626' ;
html += ` <div class="struct-row"><strong>Note:</strong> <span style="color: ${ gColor } ;font-weight:600"> ${ esc ( obj . grade ) } </span></div> ` ;
}
const zeugnisText = obj . zeugnis || obj . zeugnis _text || obj . markdown || obj . text ;
if ( zeugnisText ) {
html += '<div class="struct-section"><strong>Zeugnis-Entwurf:</strong>' ;
html += ` <div class="struct-doc" style="background:rgba(255,255,255,.04);padding:.75rem;border-radius:.5rem;border:1px solid rgba(255,255,255,.1);margin-top:.5rem;white-space:pre-wrap;font-family:Georgia,serif"> ${ esc ( zeugnisText ) } </div> ` ;
html += '</div>' ;
}
const notenSignale = obj . noten _signale || [ ] ;
if ( notenSignale . length ) {
html += '<div class="struct-section"><strong>Noten-Signale:</strong><table class="struct-table"><thead><tr><th>Satz</th><th>Codiert</th></tr></thead><tbody>' ;
notenSignale . forEach ( s => {
html += ` <tr><td> ${ esc ( s . satz || '' ) } </td><td> ${ esc ( s . codiert || '' ) } </td></tr> ` ;
} ) ;
html += '</tbody></table></div>' ;
}
const paragraphen = obj . verwendete _paragraphen || obj . paragraphen || [ ] ;
if ( paragraphen . length ) {
html += '<div class="struct-row"><strong>Verwendete Paragraphen:</strong> ' + paragraphen . map ( p => ` <code> ${ esc ( p ) } </code> ` ) . join ( ' · ' ) + '</div>' ;
}
const warnings = obj . warnings || obj . warnungen || [ ] ;
if ( warnings . length ) {
html += '<div class="struct-section" style="color:#dc2626"><strong>⚠ Warnungen:</strong><ul>' ;
warnings . forEach ( w => { html += ` <li> ${ esc ( w ) } </li> ` ; } ) ;
html += '</ul></div>' ;
}
const notes = obj . notes || obj . hinweise || [ ] ;
if ( notes . length ) {
html += '<div class="struct-section"><strong>Hinweise:</strong><ul>' ;
notes . forEach ( n => { html += ` <li> ${ esc ( n ) } </li> ` ; } ) ;
html += '</ul></div>' ;
}
const wsources = obj . sources || obj . quellen || [ ] ;
if ( wsources . length ) {
html += '<div class="struct-row" style="font-size:.85em;color:#8b8a99"><strong>Quellen:</strong> ' + wsources . map ( s => ` <code> ${ esc ( s ) } </code> ` ) . join ( ' · ' ) + '</div>' ;
}
} else if ( obj . type === 'calc' ) {
// LIBRA — Kalkulations-Rechner
if ( obj . formel ) html += ` <div class="struct-row"><strong>Formel:</strong> <code> ${ esc ( obj . formel ) } </code></div> ` ;
if ( obj . inputs && typeof obj . inputs === 'object' ) {
html += '<div class="struct-section"><strong>Eingaben:</strong><table class="struct-table"><tbody>' ;
Object . entries ( obj . inputs ) . forEach ( ( [ k , v ] ) => { html += ` <tr><td><code> ${ esc ( k ) } </code></td><td> ${ esc ( v ) } </td></tr> ` ; } ) ;
html += '</tbody></table></div>' ;
}
const steps = obj . schritte || obj . steps || [ ] ;
if ( steps . length ) {
html += '<div class="struct-section"><strong>Rechenweg:</strong><ol>' ;
steps . forEach ( s => { html += ` <li> ${ esc ( typeof s === 'string' ? s : ( s . text || JSON . stringify ( s ) ) ) } </li> ` ; } ) ;
html += '</ol></div>' ;
}
if ( obj . ergebnis != null ) html += ` <div class="struct-row" style="font-size:1.1rem;font-weight:700;color:var(--accent);margin-top:.5rem"><strong>Ergebnis:</strong> ${ esc ( obj . ergebnis ) } </div> ` ;
} else if ( obj . type === 'unterweisung' ) {
// IDA — AdA-Unterweisung (4-Stufen / Lehrgespraech / Leittext)
if ( obj . methode ) html += ` <div class="struct-row"><strong>Methode:</strong> <code> ${ esc ( obj . methode ) } </code></div> ` ;
const lz = obj . lernzielanalyse || obj . lernziele || null ;
if ( lz && typeof lz === 'object' ) {
html += '<div class="struct-section"><strong>Lernzielanalyse:</strong><table class="struct-table"><tbody>' ;
if ( lz . richtlernziel ) html += ` <tr><td><strong>Richtlernziel</strong></td><td> ${ esc ( lz . richtlernziel ) } </td></tr> ` ;
if ( lz . groblernziel ) html += ` <tr><td><strong>Groblernziel</strong></td><td> ${ esc ( lz . groblernziel ) } </td></tr> ` ;
if ( lz . feinlernziel ) html += ` <tr><td><strong>Feinlernziel</strong></td><td> ${ esc ( lz . feinlernziel ) } </td></tr> ` ;
if ( lz . bereich ) html += ` <tr><td><strong>Bereich</strong></td><td><code> ${ esc ( lz . bereich ) } </code></td></tr> ` ;
html += '</tbody></table></div>' ;
}
const phasen = obj . phasen || obj . stufen || obj . phases || [ ] ;
if ( phasen . length ) {
let totalMin = 0 ;
phasen . forEach ( p => { if ( typeof p . minuten === 'number' ) totalMin += p . minuten ; } ) ;
html += ` <div class="struct-section"><strong>Phasen ${ totalMin ? ` ( ${ totalMin } Min gesamt) ` : '' } :</strong> ` ;
phasen . forEach ( ( p , i ) => {
html += ` <div class="struct-step"><div class="step-head"><strong> ${ i + 1 } . ${ esc ( p . name || p . stufe || '' ) } </strong> ${ p . minuten ? ` <span style="color:#8b8a99">· ${ esc ( p . minuten ) } Min</span> ` : '' } </div> ` ;
if ( p . ausbilder _tut ) html += ` <div><strong>Ausbilder:in:</strong> ${ esc ( p . ausbilder _tut ) } </div> ` ;
if ( p . azubi _tut ) html += ` <div><strong>Azubi:</strong> ${ esc ( p . azubi _tut ) } </div> ` ;
if ( p . feedback _check ) html += ` <div style="font-size:.9em;color:#8b8a99"><em>Check:</em> ${ esc ( p . feedback _check ) } </div> ` ;
html += '</div>' ;
} ) ;
html += '</div>' ;
}
if ( obj . erfolgskontrolle ) {
html += ` <div class="struct-row"><strong>Erfolgskontrolle:</strong> ${ esc ( obj . erfolgskontrolle ) } </div> ` ;
}
const alt = obj . handlungsalternativen || obj . alternativen || [ ] ;
if ( alt . length ) {
html += '<div class="struct-section"><strong>Handlungs-Alternativen:</strong><ul>' ;
alt . forEach ( a => { html += ` <li> ${ esc ( a ) } </li> ` ; } ) ;
html += '</ul></div>' ;
}
} else if ( obj . type === 'compliance_report' ) {
// Vera — Website-Compliance-Check (TMG/DSGVO/TTDSG)
const ampelColor = { green : '#10b981' , yellow : '#f59e0b' , red : '#ef4444' } [ obj . ampel ] || '#8b8a99' ;
const ampelEmoji = { green : '🟢' , yellow : '🟡' , red : '🔴' } [ obj . ampel ] || '⚪' ;
if ( obj . url ) html += ` <div class="struct-row"><strong>Geprüfte URL:</strong> <a href=" ${ esc ( obj . url ) } " target="_blank" rel="noopener"> ${ esc ( obj . url ) } </a></div> ` ;
if ( typeof obj . overall _score === 'number' ) {
html += ` <div class="struct-row" style="font-size:1.4rem;font-weight:700;color: ${ ampelColor } ;margin:.5rem 0"> ${ ampelEmoji } Score: ${ obj . overall _score } %</div> ` ;
}
if ( obj . summary ) html += ` <div class="struct-row" style="font-style:italic;margin-bottom:.5rem"> ${ esc ( obj . summary ) } </div> ` ;
// Pro Section: Impressum, Datenschutz, Cookies
const sections = [
{ key : 'impressum' , label : '📄 Impressum (TMG § 5 + § 18 MStV + ODR + VSBG)' } ,
{ key : 'datenschutz' , label : '🛡 Datenschutzerklärung (DSGVO Art. 13)' } ,
] ;
for ( const s of sections ) {
const sec = obj [ s . key ] ;
if ( ! sec ) continue ;
const sScore = typeof sec . score === 'number' ? ` — ${ sec . score } % ` : '' ;
const sUrl = sec . url ? ` <a href=" ${ esc ( sec . url ) } " target="_blank" rel="noopener" style="font-size:.85em">↗ Quelle</a> ` : '' ;
html += ` <div class="struct-section"><strong> ${ s . label } ${ sScore } ${ sUrl } </strong> ` ;
if ( Array . isArray ( sec . ok ) && sec . ok . length ) {
html += '<ul style="list-style:none;padding-left:0">' ;
sec . ok . forEach ( o => { html += ` <li style="color:#10b981">✓ ${ esc ( o ) } </li> ` ; } ) ;
html += '</ul>' ;
}
if ( Array . isArray ( sec . missing ) && sec . missing . length ) {
html += '<ul style="list-style:none;padding-left:0">' ;
sec . missing . forEach ( m => { html += ` <li style="color:#ef4444">✗ ${ esc ( m ) } </li> ` ; } ) ;
html += '</ul>' ;
}
if ( Array . isArray ( sec . warnings ) && sec . warnings . length ) {
html += '<ul style="list-style:none;padding-left:0">' ;
sec . warnings . forEach ( w => { html += ` <li style="color:#f59e0b">⚠ ${ esc ( w ) } </li> ` ; } ) ;
html += '</ul>' ;
}
html += '</div>' ;
}
// Cookies
const c = obj . cookies ;
if ( c && typeof c === 'object' ) {
const bannerEmoji = c . banner _present ? '✓' : '✗' ;
const bannerColor = c . banner _present ? '#10b981' : '#ef4444' ;
html += ` <div class="struct-section"><strong>🍪 Cookies (TTDSG § 25)</strong> ` ;
html += ` <div style="color: ${ bannerColor } "> ${ bannerEmoji } Cookie-Banner: ${ c . banner _present ? 'erkannt' : 'NICHT erkannt' } ${ c . banner _signal ? ` <code style="font-size:.8em"> ${ esc ( c . banner _signal ) } </code> ` : '' } </div> ` ;
if ( Array . isArray ( c . third _party _loaders _pre _consent ) && c . third _party _loaders _pre _consent . length ) {
html += ` <div style="color:#f59e0b;margin-top:.3rem">⚠ ${ c . third _party _loaders _pre _consent . length } Drittanbieter im HTML — prüfen ob hinter Consent-Gate:</div><ul> ` ;
c . third _party _loaders _pre _consent . forEach ( t => { html += ` <li> ${ esc ( t ) } </li> ` ; } ) ;
html += '</ul>' ;
}
if ( Array . isArray ( c . notes ) && c . notes . length ) {
html += '<ul style="margin-top:.3rem;font-size:.9em;color:var(--text-mute)">' ;
c . notes . forEach ( n => { html += ` <li> ${ esc ( n ) } </li> ` ; } ) ;
html += '</ul>' ;
}
html += '</div>' ;
}
// Next steps
if ( Array . isArray ( obj . next _steps ) && obj . next _steps . length ) {
html += '<div class="struct-section"><strong>Nächste Schritte:</strong><ol>' ;
obj . next _steps . forEach ( n => { html += ` <li> ${ esc ( n ) } </li> ` ; } ) ;
html += '</ol></div>' ;
}
if ( obj . scanned _at ) html += ` <div style="font-size:.75em;color:var(--text-mute);margin-top:.5rem">Geprüft am ${ esc ( obj . scanned _at . replace ( 'T' , ' ' ) . slice ( 0 , 19 ) ) } UTC</div> ` ;
}
const HINT _BY _TYPE = {
audit : '↳ Crosswalk gespeichert. Brauchst du Tech-Doku Anhang IV (VESTIGIA) oder die DPIA-Tiefe (Cora)?' ,
privacy _check : '↳ Bei Unsicherheit: PDL/EL informieren, Schweigepflicht §203 StGB hat Vorrang vor DSGVO.' ,
mail _check : '↳ Im Zweifel: nicht klicken, an IT/SOC weiterleiten. Eskalation > Schaden.' ,
plan : '↳ Tipp: 1. Tag-Erlebnis ist der wichtigste Baustein — ohne Wow-Moment bröckelt die Probezeit.' ,
validate : '↳ Bei "errors": Rechnung nicht buchen, Lieferanten kontaktieren, Validator-Output anhängen.' ,
interview : '↳ Story-First: lass die Person erzählen, hör 3 Sekunden nach der Antwort weiter zu.' ,
decode : '↳ Bei kritischen Codes: Anwält:in für Arbeitsrecht konsultieren, Zeugnis-Anfechtung prüfen.' ,
write : '↳ Vor Übergabe: 4-Augen-Review durch HR-Lead, Datum prüfen, korrekt unterschreiben.' ,
calc : '↳ Stichproben mit echten Aufträgen testen — Theorie-Marge ≠ Praxis-Marge.' ,
unterweisung : '↳ Methode folgt Lernziel: psychomotorisch → 4-Stufen, kognitiv → Lehrgespräch, komplex → Leittext.' ,
} ;
const hint = HINT _BY _TYPE [ obj . type ] || '↳ Nutze den <strong>Quiz</strong>-, <strong>Karteikarten</strong>- oder <strong>Chat</strong>-Tab für die interaktive Version.' ;
html += ` <div class="struct-hint"> ${ hint } </div> ` ;
html += '</div>' ;
return html ;
}
async function requestStructured ( prompt ) {
const data = await chatAPI ( prompt , [ ] ) ;
let raw = _extractJSON ( data . reply || '' ) ;
try { return JSON . parse ( raw ) ; } catch ( e1 ) {
try { return JSON . parse ( _repairJSON ( raw ) ) ; } catch ( e2 ) {
const heal = await chatAPI ( prompt + "\n\nHINWEIS: Dein letzter JSON-Output war invalid (Parse-Fehler). Antworte JETZT NUR mit sauberem JSON. Keine Kommentare, kein Markdown-Fence, keine trailing commas, alle String-Werte in doppelten Anf\u00fchrungszeichen." , [ ] ) ;
const healRaw = _extractJSON ( heal . reply || '' ) ;
try { return JSON . parse ( healRaw ) ; } catch ( e3 ) {
try { return JSON . parse ( _repairJSON ( healRaw ) ) ; } catch ( e4 ) {
const err = new Error ( 'Konnte JSON nicht parsen \u2014 Modell liefert kaputtes Format. Versuch es noch mal.' ) ;
err . original = e1 . message ; err . raw = raw . slice ( 0 , 300 ) ;
throw err ;
}
}
}
}
}
// ==== Quiz ====
const quizState = { set : null , idx : 0 , correct : 0 , topic : null } ;
function renderQuizIntro ( ) {
const host = $ ( '#quiz-host' ) ;
const topics = CURRICULA . curricula . flatMap ( c =>
c . modules . map ( m => ( { id : m . id , curr : c . title , title : m . title , color : c . color } ) )
) ;
host . innerHTML = `
< div class = "quiz-intro" >
< div class = "topic-select" >
< h3 > 🎯 Quiz - Thema wählen < / h 3 >
< p style = "color:var(--text-dim);font-size:.85rem" > Wähle ein Modul — Ava generiert realistische Management - Szenario - Fragen . < / p >
< div class = "topic-btn-row" id = "quiz-topic-pills" > < / d i v >
< div style = "margin-top:1rem;display:flex;gap:.5rem;align-items:center" >
< label for = "quiz-count" style = "color:var(--text-dim);font-size:.85rem" > Anzahl : < / l a b e l >
< select id = "quiz-count" class = "btn-sec" style = "padding:.4rem .7rem" >
< option value = "5" > 5 < / o p t i o n >
< option value = "10" selected > 10 < / o p t i o n >
< option value = "15" > 15 < / o p t i o n >
< / s e l e c t >
< div class = "spacer" style = "flex:1" > < / d i v >
< button class = "btn-primary" id = "quiz-start" > Quiz starten < / b u t t o n >
< / d i v >
< / d i v >
< / d i v >
` ;
const pillHost = $ ( '#quiz-topic-pills' ) ;
CURRICULA . curricula . forEach ( c => {
c . modules . forEach ( m => {
const btn = document . createElement ( 'button' ) ;
btn . className = 'topic-pill' ;
btn . dataset . curr = c . id ;
btn . dataset . mod = m . id ;
btn . textContent = m . title ;
btn . title = c . title ;
btn . addEventListener ( 'click' , ( ) => {
$$ ( '#quiz-topic-pills .topic-pill' ) . forEach ( p => p . setAttribute ( 'aria-selected' , 'false' ) ) ;
btn . setAttribute ( 'aria-selected' , 'true' ) ;
quizState . topic = { curr : c , mod : m } ;
} ) ;
pillHost . appendChild ( btn ) ;
} ) ;
} ) ;
$ ( '#quiz-start' ) . addEventListener ( 'click' , ( ) => {
if ( ! quizState . topic ) { toast ( 'Bitte ein Thema wählen.' , 'warn' ) ; return ; }
const count = parseInt ( $ ( '#quiz-count' ) . value , 10 ) ;
startQuiz ( quizState . topic , count ) ;
} ) ;
}
async function startQuiz ( topic , count ) {
const host = $ ( '#quiz-host' ) ;
host . innerHTML = ` <div class="quiz-card"><div class="dots" style="justify-content:center"><span></span><span></span><span></span></div><p style="text-align:center;color:var(--text-dim);margin-top:1rem">Ava erstellt ${ count } Szenario-Fragen zu „ ${ topic . mod . title } " …</p></div> ` ;
try {
const topicText = ` ${ topic . curr . title } — ${ topic . mod . title } ` ;
const data = await requestStructured ( ` QUIZ_REQUEST topic=" ${ topicText } " count= ${ count } ` ) ;
if ( ! data . questions || ! Array . isArray ( data . questions ) || data . questions . length === 0 ) {
throw new Error ( 'Keine Fragen erhalten' ) ;
}
quizState . set = data . questions ;
quizState . idx = 0 ;
quizState . correct = 0 ;
renderQuizQuestion ( ) ;
} catch ( e ) {
host . innerHTML = ` <div class="quiz-card"><p style="color:var(--danger)">⚠ Konnte Quiz nicht erstellen: ${ e . message } </p><div style="margin-top:1rem"><button class="btn-sec" onclick="location.reload()">Neu laden</button></div></div> ` ;
toast ( 'Quiz-Generierung fehlgeschlagen' , 'error' ) ;
}
}
function renderQuizQuestion ( ) {
const host = $ ( '#quiz-host' ) ;
const q = quizState . set [ quizState . idx ] ;
if ( ! q ) return renderQuizDone ( ) ;
const letters = [ 'A' , 'B' , 'C' , 'D' , 'E' , 'F' ] ;
host . innerHTML = `
< div class = "quiz-card" >
< div class = "quiz-progress" >
< span > Frage $ { quizState . idx + 1 } / $ { quizState . set . length } < / s p a n >
< span > ✓ $ { quizState . correct } < / s p a n >
< / d i v >
< div class = "quiz-q" > $ { escapeHTML ( q . q ) } < / d i v >
< div class = "quiz-options" id = "quiz-opts" >
$ { q . options . map ( ( opt , i ) => `
< button class = "quiz-option" data - idx = "${i}" aria - label = "Option ${letters[i]}" >
< span class = "opt-letter" > $ { letters [ i ] } < / s p a n >
< span > $ { escapeHTML ( opt ) } < / s p a n >
< / b u t t o n >
` ).join('')}
< / d i v >
< div class = "quiz-explain hidden" id = "quiz-explain" > < / d i v >
< div class = "quiz-next hidden" id = "quiz-next" >
< button class = "btn-primary" id = "quiz-next-btn" > Weiter → < / b u t t o n >
< / d i v >
< / d i v >
` ;
$$ ( '#quiz-opts .quiz-option' ) . forEach ( btn => {
btn . addEventListener ( 'click' , ( ) => handleQuizAnswer ( btn , q ) ) ;
} ) ;
}
function handleQuizAnswer ( btn , q ) {
const chosen = parseInt ( btn . dataset . idx , 10 ) ;
const correct = q . correct ;
state . totalAnswers += 1 ;
const curId = quizState . topic . curr . id ;
const modId = quizState . topic . mod . id ;
if ( ! state . mastery [ curId ] ) state . mastery [ curId ] = { correct : 0 , total : 0 } ;
state . mastery [ curId ] . total += 1 ;
state . moduleTotal [ modId ] = ( state . moduleTotal [ modId ] || 0 ) + 1 ;
$$ ( '#quiz-opts .quiz-option' ) . forEach ( ( b , i ) => {
b . disabled = true ;
if ( i === correct ) b . classList . add ( 'correct' ) ;
else if ( i === chosen ) b . classList . add ( 'wrong' ) ;
} ) ;
const ex = $ ( '#quiz-explain' ) ;
ex . classList . remove ( 'hidden' ) ;
if ( chosen === correct ) {
quizState . correct += 1 ;
state . correctAnswers += 1 ;
state . quizStreak += 1 ;
if ( state . quizStreak > state . maxQuizStreak ) state . maxQuizStreak = state . quizStreak ;
state . mastery [ curId ] . correct += 1 ;
state . moduleCorrect [ modId ] = ( state . moduleCorrect [ modId ] || 0 ) + 1 ;
addXP ( 10 , 'Richtig!' ) ;
ex . innerHTML = ` <strong style="color:var(--success)">✓ Richtig!</strong><br> ${ escapeHTML ( q . explain || '' ) }
< div class = "deepdive-bar" data - inquiz = "1" >
< button class = "deepdive-btn" data - kind = "more" > 💡 Mehr dazu < / b u t t o n >
< button class = "deepdive-btn" data - kind = "sources" > 📚 Quellen < / b u t t o n >
< button class = "deepdive-btn" data - kind = "why" > 🤔 Warum ? < / b u t t o n >
< button class = "deepdive-btn" data - kind = "ask" > ❓ Frag dazu < / b u t t o n >
< / d i v >
< div id = "quiz-deepdive" class = "deepdive-panel hidden" > < / d i v > ` ;
$$ ( '#quiz-explain .deepdive-btn' ) . forEach ( b => b . addEventListener ( 'click' , ( ) => quizDeepdive ( b . dataset . kind , q , chosen , correct ) ) ) ;
} else {
state . quizStreak = 0 ;
ex . innerHTML = ` <strong style="color:var(--danger)">✗ Falsch.</strong> Richtig wäre <strong> ${ [ 'A' , 'B' , 'C' , 'D' , 'E' , 'F' ] [ correct ] } </strong>. ${ escapeHTML ( q . explain || '' ) }
< div class = "deepdive-bar" data - inquiz = "1" >
< button class = "deepdive-btn" data - kind = "more" > 💡 Mehr dazu < / b u t t o n >
< button class = "deepdive-btn" data - kind = "sources" > 📚 Quellen < / b u t t o n >
< button class = "deepdive-btn" data - kind = "why" > 🤔 Warum ? < / b u t t o n >
< button class = "deepdive-btn" data - kind = "ask" > ❓ Frag dazu < / b u t t o n >
< / d i v >
< div id = "quiz-deepdive" class = "deepdive-panel hidden" > < / d i v > ` ;
$$ ( '#quiz-explain .deepdive-btn' ) . forEach ( b => b . addEventListener ( 'click' , ( ) => quizDeepdive ( b . dataset . kind , q , chosen , correct ) ) ) ;
}
saveState ( ) ;
touchActivity ( ) ;
checkBadges ( ) ;
$ ( '#quiz-next' ) . classList . remove ( 'hidden' ) ;
$ ( '#quiz-next-btn' ) . addEventListener ( 'click' , ( ) => {
quizState . idx += 1 ;
if ( quizState . idx >= quizState . set . length ) renderQuizDone ( ) ;
else renderQuizQuestion ( ) ;
} ) ;
}
function renderQuizDone ( ) {
const host = $ ( '#quiz-host' ) ;
const pct = Math . round ( quizState . correct / quizState . set . length * 100 ) ;
state . completedQuizzes += 1 ;
// Module complete (>=80% correct in this quiz) — bonus XP + track for DSGVO-Master
if ( pct >= 80 && quizState . topic ) {
const modId = quizState . topic . mod . id ;
if ( ! state . completedCurricula . includes ( modId ) ) {
state . completedCurricula . push ( modId ) ;
addXP ( 20 , 'Modul abgeschlossen: ' + quizState . topic . mod . title ) ;
}
// Lernreise: Quiz mit ≥80% beendet → Step kann auto-completed werden.
// Portal-Listener entscheidet, ob er das übernimmt.
sendJourneyBeacon ( 'quiz_passed' , { module : modId , score _pct : pct } ) ;
}
saveState ( ) ;
checkBadges ( ) ;
host . innerHTML = `
< div class = "quiz-card quiz-done" >
< h3 > Quiz beendet ! < / h 3 >
< div class = "score" > $ { quizState . correct } / $ { quizState . set . length } < / d i v >
< p style = "color:var(--text-dim)" > $ { pct } % richtig — $ { pct >= 80 ? 'Ausgezeichnet!' : pct >= 60 ? 'Solide!' : 'Probier es noch mal.' } < / p >
< div class = "actions" >
< button class = "btn-sec" id = "quiz-again" > Gleiches Thema nochmal < / b u t t o n >
< button class = "btn-primary" id = "quiz-new" > Anderes Thema < / b u t t o n >
< / d i v >
< / d i v >
` ;
$ ( '#quiz-again' ) . addEventListener ( 'click' , ( ) => startQuiz ( quizState . topic , quizState . set . length ) ) ;
$ ( '#quiz-new' ) . addEventListener ( 'click' , ( ) => renderQuizIntro ( ) ) ;
}
// ==== Flashcards (SM-2 Spaced Repetition) ====
const flashState = { topic : null , deck : null , cur : null , showBack : false } ;
function renderFlashIntro ( ) {
const host = $ ( '#flash-host' ) ;
host . innerHTML = `
< div class = "flash-intro" >
< div class = "topic-select" >
< h3 > 🃏 Flashcards < / h 3 >
< p style = "color:var(--text-dim);font-size:.85rem" > Ava erstellt Karteikarten zu einem Thema . Bewerte dein Erinnerungsvermögen — das System wiederholt schwere Karten öfter ( SM - 2 ) . < / p >
< div class = "topic-btn-row" id = "flash-topic-pills" > < / d i v >
< div style = "margin-top:1rem;display:flex;gap:.5rem;align-items:center" >
< label for = "flash-count" style = "color:var(--text-dim);font-size:.85rem" > Neue Karten : < / l a b e l >
< select id = "flash-count" class = "btn-sec" style = "padding:.4rem .7rem" >
< option value = "5" > 5 < / o p t i o n >
< option value = "10" selected > 10 < / o p t i o n >
< option value = "20" > 20 < / o p t i o n >
< / s e l e c t >
< div class = "spacer" style = "flex:1" > < / d i v >
< button class = "btn-sec" id = "flash-review" > Fällige üben < / b u t t o n >
< button class = "btn-primary" id = "flash-start" > Neu laden < / b u t t o n >
< / d i v >
< div id = "flash-stats" style = "margin-top:.75rem;font-size:.8rem;color:var(--text-mute)" > < / d i v >
< / d i v >
< / d i v >
` ;
const pillHost = $ ( '#flash-topic-pills' ) ;
CURRICULA . curricula . forEach ( c => {
c . modules . forEach ( m => {
const btn = document . createElement ( 'button' ) ;
btn . className = 'topic-pill' ;
btn . textContent = m . title ;
btn . title = c . title ;
btn . addEventListener ( 'click' , ( ) => {
$$ ( '#flash-topic-pills .topic-pill' ) . forEach ( p => p . setAttribute ( 'aria-selected' , 'false' ) ) ;
btn . setAttribute ( 'aria-selected' , 'true' ) ;
flashState . topic = { curr : c , mod : m } ;
updateFlashStats ( ) ;
} ) ;
pillHost . appendChild ( btn ) ;
} ) ;
} ) ;
$ ( '#flash-start' ) . addEventListener ( 'click' , ( ) => {
if ( ! flashState . topic ) { toast ( 'Bitte ein Thema wählen.' , 'warn' ) ; return ; }
loadNewFlashCards ( flashState . topic , parseInt ( $ ( '#flash-count' ) . value , 10 ) ) ;
} ) ;
$ ( '#flash-review' ) . addEventListener ( 'click' , ( ) => {
if ( ! flashState . topic ) { toast ( 'Bitte ein Thema wählen.' , 'warn' ) ; return ; }
startReview ( flashState . topic ) ;
} ) ;
updateFlashStats ( ) ;
}
function updateFlashStats ( ) {
const el = $ ( '#flash-stats' ) ;
if ( ! el ) return ;
if ( ! flashState . topic ) { el . textContent = '' ; return ; }
const tid = flashState . topic . mod . id ;
const cards = flashCards [ tid ] || [ ] ;
const due = cards . filter ( c => ! c . due || c . due <= now ( ) ) . length ;
el . textContent = cards . length === 0
? 'Noch keine Karten in diesem Thema.'
: ` ${ cards . length } Karten gespeichert · ${ due } fällig heute ` ;
}
async function loadNewFlashCards ( topic , count ) {
const host = $ ( '#flash-host' ) ;
host . innerHTML = ` <div class="flashcard"><div class="dots" style="justify-content:center"><span></span><span></span><span></span></div><p style="text-align:center;color:var(--text-dim);margin-top:1rem">Ava erstellt Karten zu „ ${ topic . mod . title } " …</p></div> ` ;
try {
const topicText = ` ${ topic . curr . title } — ${ topic . mod . title } ` ;
const data = await requestStructured ( ` FLASHCARD_REQUEST topic=" ${ topicText } " count= ${ count } ` ) ;
if ( ! data . cards || ! data . cards . length ) throw new Error ( 'Keine Karten erhalten' ) ;
const tid = topic . mod . id ;
if ( ! flashCards [ tid ] ) flashCards [ tid ] = [ ] ;
for ( const c of data . cards ) {
flashCards [ tid ] . push ( {
front : c . front , back : c . back , hint : c . hint || '' ,
ef : 2.5 , interval : 0 , reps : 0 , due : now ( )
} ) ;
}
saveFlashCards ( ) ;
startReview ( topic ) ;
} catch ( e ) {
host . innerHTML = ` <div class="flashcard"><p style="color:var(--danger)">⚠ Fehler: ${ e . message } </p><div style="margin-top:1rem"><button class="btn-sec" onclick="document.querySelector('[data-mode=flash]').click()">Zurück</button></div></div> ` ;
toast ( 'Karten-Generierung fehlgeschlagen' , 'error' ) ;
}
}
function startReview ( topic ) {
flashState . topic = topic ;
const tid = topic . mod . id ;
flashState . deck = ( flashCards [ tid ] || [ ] ) . filter ( c => ! c . due || c . due <= now ( ) ) ;
if ( flashState . deck . length === 0 ) {
const host = $ ( '#flash-host' ) ;
host . innerHTML = ` <div class="flashcard"><h3>Keine fälligen Karten in „ ${ escapeHTML ( topic . mod . title ) } "</h3><p style="color:var(--text-dim);margin-top:.5rem">Lege neue Karten an oder komm später wieder.</p><div style="margin-top:1rem"><button class="btn-primary" id="back-to-flash">Zurück</button></div></div> ` ;
$ ( '#back-to-flash' ) . addEventListener ( 'click' , renderFlashIntro ) ;
return ;
}
flashState . cur = 0 ;
flashState . showBack = false ;
renderFlashCard ( ) ;
}
function renderFlashCard ( ) {
const host = $ ( '#flash-host' ) ;
const card = flashState . deck [ flashState . cur ] ;
if ( ! card ) {
host . innerHTML = ` <div class="flashcard"><h3>Review beendet 🎉</h3><p style="color:var(--text-dim);margin-top:.5rem">Alle fälligen Karten in „ ${ escapeHTML ( flashState . topic . mod . title ) } " durch.</p><div style="margin-top:1rem"><button class="btn-primary" id="back-to-flash">Weiter</button></div></div> ` ;
$ ( '#back-to-flash' ) . addEventListener ( 'click' , renderFlashIntro ) ;
return ;
}
host . innerHTML = `
< div class = "flash-meta" >
< span > Karte $ { flashState . cur + 1 } / $ { flashState . deck . length } < / s p a n >
< span > $ { escapeHTML ( flashState . topic . mod . title ) } < / s p a n >
< / d i v >
< div class = "flashcard" id = "flash-card" tabindex = "0" role = "button" aria - label = "Karte umdrehen" >
$ { flashState . showBack
? ` <div class="flashcard-back"><strong> ${ escapeHTML ( card . front ) } </strong> ${ escapeHTML ( card . back ) } </div> `
: ` <div class="flashcard-front"> ${ escapeHTML ( card . front ) } </div> ${ card . hint ? ` <div class="flashcard-hint">Hinweis: ${ escapeHTML ( card . hint ) } </div> ` : '' } <div style="margin-top:1rem;font-size:.75rem;color:var(--text-mute)">Klicken oder Leertaste drücken zum Umdrehen</div> ` }
< / d i v >
$ { flashState . showBack ? `
< div class = "flash-controls" >
< button class = "flash-btn" data - rating = "0" > Wieder < span class = "label" > & lt ; 1 Min < / s p a n > < / b u t t o n >
< button class = "flash-btn" data - rating = "1" > Schwer < span class = "label" > ~ 1 Tag < / s p a n > < / b u t t o n >
< button class = "flash-btn" data - rating = "2" > Gut < span class = "label" > ~ 3 Tage < / s p a n > < / b u t t o n >
< button class = "flash-btn" data - rating = "3" > Leicht < span class = "label" > ~ 7 Tage < / s p a n > < / b u t t o n >
< / d i v >
< div class = "deepdive-bar" >
< button class = "deepdive-btn" data - kind = "more" title = "Vertiefende Erklärung" > 💡 Mehr dazu < / b u t t o n >
< button class = "deepdive-btn" data - kind = "sources" title = "Konkrete Quellen" > 📚 Quellen < / b u t t o n >
< button class = "deepdive-btn" data - kind = "example" title = "Praxis-Beispiel" > 🎯 Beispiel < / b u t t o n >
< button class = "deepdive-btn" data - kind = "ask" title = "Eigene Frage stellen" > ❓ Frag dazu < / b u t t o n >
< / d i v >
< div id = "flash-deepdive" class = "deepdive-panel hidden" > < / d i v >
` : ''}
` ;
$ ( '#flash-card' ) . addEventListener ( 'click' , flipCard ) ;
$ ( '#flash-card' ) . addEventListener ( 'keydown' , ( ev ) => {
if ( ev . key === ' ' || ev . key === 'Enter' ) { ev . preventDefault ( ) ; flipCard ( ) ; }
} ) ;
$ ( '#flash-card' ) . focus ( ) ;
$$ ( '.flash-btn' ) . forEach ( btn => btn . addEventListener ( 'click' , ( ) => rateCard ( parseInt ( btn . dataset . rating , 10 ) ) ) ) ;
$$ ( '.deepdive-btn' ) . forEach ( btn => btn . addEventListener ( 'click' , ( ) => cardDeepdive ( btn . dataset . kind , card ) ) ) ;
}
async function cardDeepdive ( kind , card ) {
const prompts = {
more : ` Zu dieser Lernkarte — gib mir 2-3 vertiefende Informationen, die über die Karte hinausgehen. Keine Wiederholung. \n \n Frage: ${ card . front } \n Antwort: ${ card . back } ${ card . hint ? '\nHinweis: ' + card . hint : '' } \n \n Markdown erlaubt. Kurz halten (≤180 Wörter). ` ,
sources : ` Für diese Lernkarte — wo kann der Lerner das vertiefen? Nenne 3-5 konkrete öffentliche Quellen (Gesetzestext + Paragraph, Norm-Nummer, Fachbuch mit Autor, Paper, offizielle Webseite, IHK-Leitfaden). Je Quelle EINE Zeile. Keine Allgemeinplätze wie „Fachliteratur". \n \n Frage: ${ card . front } \n Antwort: ${ card . back } \n \n Format: Markdown-Liste. ` ,
example : ` Praxis-Beispiel zu dieser Lernkarte — ein konkretes Szenario aus dem Berufsalltag in 3-5 Sätzen, kein Theorie-Kram. \n \n Frage: ${ card . front } \n Antwort: ${ card . back } ` ,
ask : null ,
} ;
const host = $ ( '#flash-deepdive' ) ;
host . classList . remove ( 'hidden' ) ;
if ( kind === 'ask' ) {
const q = window . prompt ( 'Deine Frage zu dieser Karte:' , '' ) ;
if ( ! q || ! q . trim ( ) ) { host . classList . add ( 'hidden' ) ; return ; }
host . innerHTML = ` <div class="dd-loading"><span class="dots"><span></span><span></span><span></span></span> <span>denkt …</span></div> ` ;
try {
const data = await chatAPI ( ` Zu dieser Lernkarte: \n Frage: ${ card . front } \n Antwort: ${ card . back } \n \n Meine Zusatzfrage: ${ q . trim ( ) } \n \n Beantworte präzise, Markdown erlaubt, ≤200 Wörter. ` , [ ] ) ;
host . innerHTML = '<div class="dd-body">' + renderMD ( data . reply || '' ) + '</div><button class="btn-secondary close-dd">Schließen</button>' ;
} catch ( e ) { host . innerHTML = ` <div class="msg sys">Fehler: ${ e . message || 'unbekannt' } </div><button class="btn-secondary close-dd">Schließen</button> ` ; }
} else {
host . innerHTML = ` <div class="dd-loading"><span class="dots"><span></span><span></span><span></span></span> <span> ${ kind === 'sources' ? 'sucht Quellen' : kind === 'example' ? 'baut Beispiel' : 'vertieft' } …</span></div> ` ;
try {
const data = await chatAPI ( prompts [ kind ] , [ ] ) ;
host . innerHTML = '<div class="dd-body">' + renderMD ( data . reply || '' ) + '</div><button class="btn-secondary close-dd">Schließen</button>' ;
} catch ( e ) { host . innerHTML = ` <div class="msg sys">Fehler: ${ e . message || 'unbekannt' } </div><button class="btn-secondary close-dd">Schließen</button> ` ; }
}
const cb = $ ( '#flash-deepdive .close-dd' ) ;
if ( cb ) cb . addEventListener ( 'click' , ( ) => host . classList . add ( 'hidden' ) ) ;
}
async function quizDeepdive ( kind , q , chosenIdx , correctIdx ) {
const host = $ ( '#quiz-deepdive' ) ;
host . classList . remove ( 'hidden' ) ;
const chosenTxt = q . options [ chosenIdx ] , correctTxt = q . options [ correctIdx ] ;
const prompts = {
more : ` Gib mir zu dieser Quiz-Frage 2-3 weiterführende Infos, die über die knappe Erklärung hinausgehen. Kein Wiederholen. \n \n Frage: ${ q . q } \n Richtig: ${ correctTxt } \n Erklärung: ${ q . explain || '(—)' } \n \n Markdown erlaubt, ≤180 Wörter. ` ,
sources : ` Für diese Quiz-Frage — wo kann der Lerner das vertiefen? 3-5 konkrete öffentliche Quellen (Gesetzestext+Paragraph, Norm-Nummer, Fachbuch mit Autor, Paper, offizielle Webseite). Je Quelle EINE Zeile, keine Allgemeinplätze. \n \n Frage: ${ q . q } \n Richtig: ${ correctTxt } \n \n Markdown-Liste. ` ,
why : chosenIdx === correctIdx
? ` Ich hab richtig geantwortet — erkläre mir NOCH tiefer warum das die richtige Wahl ist. Was hätte falsch gelegen bei den anderen Optionen? Je Option 1 Satz. \n \n Frage: ${ q . q } \n Optionen: ${ q . options . map ( ( o , i ) => ( i + 1 ) + '. ' + o ) . join ( ' | ' ) } \n Richtig: ${ correctTxt } `
: ` Ich hab „ ${ chosenTxt } " gewählt, richtig wäre „ ${ correctTxt } ". Erkläre mir präzise wo mein Denkfehler lag und wie ich das beim nächsten Mal anders angehe. Sokratisch wenn möglich. \n \n Frage: ${ q . q } \n Meine Antwort: ${ chosenTxt } \n Richtig: ${ correctTxt } ` ,
ask : null ,
} ;
if ( kind === 'ask' ) {
const userQ = window . prompt ( 'Deine Frage zu dieser Quiz-Frage:' , '' ) ;
if ( ! userQ || ! userQ . trim ( ) ) { host . classList . add ( 'hidden' ) ; return ; }
host . innerHTML = ` <div class="dd-loading"><span class="dots"><span></span><span></span><span></span></span> <span>denkt …</span></div> ` ;
try {
const data = await chatAPI ( ` Zur Quiz-Frage: " ${ q . q } " \n Richtige Antwort: ${ correctTxt } \n Meine Antwort: ${ chosenTxt } \n \n Zusatzfrage: ${ userQ . trim ( ) } \n \n Beantworte präzise, Markdown, ≤200 Wörter. ` , [ ] ) ;
host . innerHTML = '<div class="dd-body">' + renderMD ( data . reply || '' ) + '</div><button class="btn-secondary close-dd">Schließen</button>' ;
} catch ( e ) { host . innerHTML = ` <div class="msg sys">Fehler: ${ e . message || 'unbekannt' } </div><button class="btn-secondary close-dd">Schließen</button> ` ; }
} else {
host . innerHTML = ` <div class="dd-loading"><span class="dots"><span></span><span></span><span></span></span> <span> ${ kind === 'sources' ? 'sucht Quellen' : kind === 'why' ? 'analysiert' : 'vertieft' } …</span></div> ` ;
try {
const data = await chatAPI ( prompts [ kind ] , [ ] ) ;
host . innerHTML = '<div class="dd-body">' + renderMD ( data . reply || '' ) + '</div><button class="btn-secondary close-dd">Schließen</button>' ;
} catch ( e ) { host . innerHTML = ` <div class="msg sys">Fehler: ${ e . message || 'unbekannt' } </div><button class="btn-secondary close-dd">Schließen</button> ` ; }
}
const cb = $ ( '#quiz-deepdive .close-dd' ) ;
if ( cb ) cb . addEventListener ( 'click' , ( ) => host . classList . add ( 'hidden' ) ) ;
}
function flipCard ( ) {
if ( flashState . showBack ) return ;
const el = $ ( '#flash-card' ) ;
if ( el ) { el . classList . add ( 'flipping' ) ; setTimeout ( ( ) => { flashState . showBack = true ; renderFlashCard ( ) ; } , 120 ) ; }
}
function rateCard ( rating ) {
const card = flashState . deck [ flashState . cur ] ;
// SM-2 algorithm
const q = rating ; // 0..3 (we map: 0→ again, 1→hard, 2→good, 3→easy)
const qMap = [ 0 , 3 , 4 , 5 ] ; // SM-2 uses 0..5, we map our 0..3 to subset
const sm2q = qMap [ q ] ;
if ( sm2q < 3 ) {
card . reps = 0 ;
card . interval = 0 ;
card . due = now ( ) + 60 * 1000 ; // 1 min
} else {
card . reps += 1 ;
if ( card . reps === 1 ) card . interval = 1 ;
else if ( card . reps === 2 ) card . interval = 3 ;
else card . interval = Math . round ( card . interval * card . ef ) ;
card . due = now ( ) + card . interval * 86400 * 1000 ;
}
card . ef = Math . max ( 1.3 , card . ef + ( 0.1 - ( 5 - sm2q ) * ( 0.08 + ( 5 - sm2q ) * 0.02 ) ) ) ;
saveFlashCards ( ) ;
// XP
state . flashCardsRated += 1 ;
state . totalAnswers += 1 ;
if ( q === 2 ) { addXP ( 5 , 'Flashcard Gut' ) ; state . correctAnswers += 1 ; }
else if ( q === 3 ) { addXP ( 2 , 'Flashcard Leicht' ) ; state . correctAnswers += 1 ; }
// Track per-run ratings for modulePassedFlash (all cards in deck rated >= Gut)
flashState . runRatings = flashState . runRatings || [ ] ;
flashState . runRatings . push ( q ) ;
saveState ( ) ;
touchActivity ( ) ;
checkBadges ( ) ;
flashState . showBack = false ;
flashState . cur += 1 ;
// If end of deck and topic set, check if all cards rated Gut/Leicht
if ( flashState . cur >= flashState . deck . length && flashState . topic ) {
const allGood = flashState . runRatings . length >= 3 && flashState . runRatings . every ( r => r >= 2 ) ;
if ( allGood ) {
const modId = flashState . topic . mod . id ;
state . modulePassedFlash [ modId ] = true ;
saveState ( ) ;
checkBadges ( ) ;
}
flashState . runRatings = [ ] ;
}
renderFlashCard ( ) ;
}
// ==== Progress ====
function renderProgress ( ) {
const host = $ ( '#progress-host' ) ;
const li = levelInfo ( ) ;
const masteryRows = Object . entries ( state . mastery )
. map ( ( [ cid , m ] ) => {
const c = CURRICULA . curricula . find ( x => x . id === cid ) ;
if ( ! c || m . total === 0 ) return null ;
const pct = Math . round ( m . correct / m . total * 100 ) ;
return { title : c . title , color : c . color , pct , correct : m . correct , total : m . total } ;
} )
. filter ( Boolean )
. sort ( ( a , b ) => b . pct - a . pct ) ;
const badges = ( CURRICULA . badges || [ ] ) ;
host . innerHTML = `
< div class = "progress-grid" >
< div class = "stat-row" >
< div class = "stat-card accent" >
< div class = "val" > $ { state . xp } < / d i v >
< div class = "lbl" > XP gesamt < / d i v >
< / d i v >
< div class = "stat-card level" >
< div class = "val" > Lvl $ { li . levelNum } · $ { li . title } < / d i v >
< div class = "lbl" > $ { li . next ? li . next . min - state . xp + ' XP bis ' + li . next . title : 'Top-Level erreicht' } < / d i v >
< / d i v >
< div class = "stat-card streak" >
< div class = "val" > 🔥 $ { state . currentStreak } < / d i v >
< div class = "lbl" > Tage - Streak ( max . $ { state . maxStreak } ) < / d i v >
< / d i v >
< / d i v >
< div class = "stat-row" >
< div class = "stat-card" >
< div class = "val" > $ { state . totalAnswers } < / d i v >
< div class = "lbl" > Antworten gesamt < / d i v >
< / d i v >
< div class = "stat-card" >
< div class = "val" > $ { state . totalAnswers === 0 ? '0%' : Math . round ( state . correctAnswers / state . totalAnswers * 100 ) + '%' } < / d i v >
< div class = "lbl" > Trefferquote < / d i v >
< / d i v >
< div class = "stat-card" >
< div class = "val" > $ { state . completedQuizzes } < / d i v >
< div class = "lbl" > Quizze < / d i v >
< / d i v >
< / d i v >
$ { li . next ? `
< div class = "section-card" >
< h3 > Fortschritt zu „ $ { li . next . title } " < / h 3 >
< div class = "bar-bg" > < div class = "bar-fg" style = "width:${li.pct}%" > < / d i v > < / d i v >
< div style = "margin-top:.4rem;font-size:.78rem;color:var(--text-dim)" > $ { Math . round ( li . pct ) } % · $ { state . xp } / $ { li . next . min } XP < / d i v >
< / d i v > ` : ' ' }
< div class = "section-card" >
< h3 > Mastery pro Curriculum < / h 3 >
$ { masteryRows . length === 0
? '<p style="color:var(--text-dim);font-size:.88rem">Noch keine Daten. Mach ein Quiz, um Mastery aufzubauen.</p>'
: '<div class="mastery-row">' + masteryRows . map ( r => `
< div class = "mastery-bar" >
< div class = "mastery-head" >
< span > $ { escapeHTML ( r . title ) } < / s p a n >
< span class = "pct" > $ { r . pct } % < span style = "color:var(--text-mute);font-weight:400;margin-left:.4rem" > ( $ { r . correct } / $ { r . total } ) < / s p a n > < / s p a n >
< / d i v >
< div class = "bar-bg" > < div class = "bar-fg" style = "width:${r.pct}%" > < / d i v > < / d i v >
< / d i v >
` ).join('') + '</div>'
}
< / d i v >
< div class = "section-card" >
< h3 > Abzeichen ( $ { Object . keys ( state . badges ) . length } / $ { badges . length } ) < / h 3 >
< div class = "badge-grid" >
$ { badges . map ( b => {
const earned = ! ! state . badges [ b . id ] ;
const icons = { award : '🏆' , flame : '🔥' , star : '⭐' , calendar : '🗓' , crown : '👑' , moon : '🌙' , sun : '☀️' , shield : '🛡️' , detective : '🕵️' , clock : '⏱️' , handshake : '🤝' , medal : '🎖️' , diamond : '💎' } ;
return ` <div class="badge ${ earned ? 'earned' : 'locked' } ">
< span class = "icon" > $ { icons [ b . icon ] || '🎖' } < / s p a n >
< div class = "title" > $ { escapeHTML ( b . title ) } < / d i v >
< div class = "desc" > $ { escapeHTML ( b . description ) } < / d i v >
< / d i v > ` ;
} ) . join ( '' ) }
< / d i v >
< / d i v >
< div class = "section-card" >
< h3 > Daten zurücksetzen < / h 3 >
< p style = "color:var(--text-dim);font-size:.85rem;margin-bottom:.75rem" > Lokal gespeichert ( kein Server - Tracking ) . < / p >
< button class = "btn-sec" id = "reset-data" > Alle Fortschritte löschen < / b u t t o n >
< / d i v >
< / d i v >
` ;
$ ( '#reset-data' ) . addEventListener ( 'click' , ( ) => {
if ( confirm ( 'Alle lokalen Daten (XP, Streaks, Flashcards, Chat) wirklich löschen?' ) ) {
localStorage . removeItem ( LS _KEY ) ;
localStorage . removeItem ( LS _CHAT ) ;
localStorage . removeItem ( LS _FLASH ) ;
state = loadState ( ) ;
chatHistory = [ ] ;
flashCards = { } ;
restoreChat ( ) ;
renderProgress ( ) ;
toast ( 'Daten zurückgesetzt.' , 'success' ) ;
}
} ) ;
}
// ==== Curriculum tree ====
function renderCurriculum ( ) {
const host = $ ( '#curr-host' ) ;
host . innerHTML = '<div class="curr-tree" id="curr-tree-root"></div>' ;
const root = $ ( '#curr-tree-root' ) ;
CURRICULA . curricula . forEach ( c => {
const rootEl = document . createElement ( 'details' ) ;
rootEl . className = 'curr-root' ;
rootEl . innerHTML = `
< summary class = "curr-root-head" style = "list-style:none" >
< span class = "ic" style = "background:${c.color}" > $ { c . title . charAt ( 0 ) } < / s p a n >
< span class = "txt" >
< strong > $ { escapeHTML ( c . title ) } < / s t r o n g >
< small > $ { escapeHTML ( c . short ) } · $ { c . modules . length } Module < / s m a l l >
< / s p a n >
< span class = "chev" > ▾ < / s p a n >
< / s u m m a r y >
< div class = "curr-mods" >
$ { c . modules . map ( m => `
< div class = "curr-mod" data - curr = "${c.id}" data - mod = "${m.id}" >
< span class = "m-title" > $ { escapeHTML ( m . title ) } < / s p a n >
< span class = "m-arrow" > → < / s p a n >
< / d i v >
` ).join('')}
< / d i v >
` ;
root . appendChild ( rootEl ) ;
} ) ;
$$ ( '.curr-mod' ) . forEach ( el => el . addEventListener ( 'click' , ( ) => {
const c = CURRICULA . curricula . find ( x => x . id === el . dataset . curr ) ;
const m = c . modules . find ( x => x . id === el . dataset . mod ) ;
renderModuleDetail ( c , m ) ;
} ) ) ;
}
function renderModuleDetail ( c , m ) {
const host = $ ( '#curr-host' ) ;
host . innerHTML = `
< div class = "mod-detail" >
< div class = "breadcrumb" >
< button id = "breadcrumb-back" > ← Policy - Bibliothek < /button> / $ { escapeHTML ( c . title ) }
< / d i v >
< h3 > $ { escapeHTML ( m . title ) } < / h 3 >
< h4 > Lernziele < / h 4 >
< ul > $ { m . objectives . map ( o => ` <li> ${ escapeHTML ( o ) } </li> ` ) . join ( '' ) } < / u l >
< h4 > Kernthemen < / h 4 >
< ul > $ { ( m . topics || [ ] ) . map ( t => ` <li> ${ escapeHTML ( t ) } </li> ` ) . join ( '' ) } < / u l >
$ { m . hours ? ` <p style="margin-top:.5rem;color:var(--text-dim);font-size:.85rem">Umfang: ~ ${ m . hours } h</p> ` : '' }
< div class = "mod-actions" >
< button class = "btn-primary" data - action = "quiz" > Quiz zu diesem Thema < / b u t t o n >
< button class = "btn-sec" data - action = "flash" > Flashcards < / b u t t o n >
< button class = "btn-sec" data - action = "ask" > Ava fragen < / b u t t o n >
< / d i v >
< / d i v >
` ;
$ ( '#breadcrumb-back' ) . addEventListener ( 'click' , renderCurriculum ) ;
$$ ( '.mod-actions button' ) . forEach ( b => b . addEventListener ( 'click' , ( ) => {
const act = b . dataset . action ;
if ( act === 'quiz' ) {
quizState . topic = { curr : c , mod : m } ;
switchMode ( 'quiz' ) ;
setTimeout ( ( ) => startQuiz ( { curr : c , mod : m } , 10 ) , 50 ) ;
} else if ( act === 'flash' ) {
flashState . topic = { curr : c , mod : m } ;
switchMode ( 'flash' ) ;
setTimeout ( ( ) => {
const tid = m . id ;
if ( flashCards [ tid ] && flashCards [ tid ] . length ) startReview ( { curr : c , mod : m } ) ;
else loadNewFlashCards ( { curr : c , mod : m } , 10 ) ;
} , 50 ) ;
} else if ( act === 'ask' ) {
switchMode ( 'chat' ) ;
$ ( '#composer' ) . value = ` Erklär mir kurz und klar: ${ m . title } (aus ${ c . title } ). Fokus auf ${ m . topics . slice ( 0 , 3 ) . join ( ', ' ) } . ` ;
$ ( '#composer' ) . focus ( ) ;
}
} ) ) ;
}
// ==== Mode switching ====
function switchMode ( mode ) {
$$ ( '.tab' ) . forEach ( t => {
const isActive = t . dataset . mode === mode ;
t . setAttribute ( 'aria-selected' , isActive ? 'true' : 'false' ) ;
} ) ;
$$ ( '.view' ) . forEach ( v => {
v . dataset . active = ( v . id === 'view-' + mode ) ? 'true' : 'false' ;
} ) ;
// The composer is shown only in chat mode
$ ( '#composer-form' ) . classList . toggle ( 'hidden' , mode !== 'chat' ) ;
if ( mode === 'quiz' ) renderQuizIntro ( ) ;
if ( mode === 'flash' ) renderFlashIntro ( ) ;
if ( mode === 'progress' ) renderProgress ( ) ;
if ( mode === 'curriculum' ) renderCurriculum ( ) ;
if ( mode === 'chat' ) setTimeout ( ( ) => $ ( '#composer' ) . focus ( ) , 50 ) ;
}
// ==== Escape HTML ====
function escapeHTML ( s ) {
return String ( s ) . replace ( /[&<>"']/g , ch => ( {
'&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : '''
} [ ch ] ) ) ;
}
// ==== Boot ====
// Lernreise-State (URL-Params), einmal beim Boot ausgelesen.
// Wird von renderJourneyBanner() für die UI und von sendJourneyBeacon()
// für die postMessage-Kommunikation zurück zum Portal-Tab benutzt.
const JOURNEY = ( ( ) => {
try {
const p = new URLSearchParams ( window . location . search ) ;
const slug = p . get ( 'journey' ) ;
if ( ! slug ) return null ;
const step = parseInt ( p . get ( 'step' ) || '0' , 10 ) ;
const total = parseInt ( p . get ( 'total' ) || '0' , 10 ) ;
let returnOrigin = '' ;
try {
if ( p . get ( 'return' ) ) returnOrigin = new URL ( p . get ( 'return' ) ) . origin ;
} catch { }
return { slug , step , total , returnOrigin } ;
} catch { return null ; }
} ) ( ) ;
// Beacon zurück an den Portal-Tab. Funktioniert nur wenn das Widget über
// `target="_blank" rel="opener"` aus der Lernreise-Seite geöffnet wurde
// (window.opener erreichbar). Bei direktem Aufruf der Bot-URL no-op.
function sendJourneyBeacon ( event , extra = { } ) {
if ( ! JOURNEY || ! window . opener ) return ;
if ( ! JOURNEY . returnOrigin ) return ;
try {
window . opener . postMessage ( {
type : 'qognio:journey-step-event' ,
journey : JOURNEY . slug ,
step : JOURNEY . step ,
event , // 'quiz_passed' | 'curriculum_completed' | 'manual'
... extra ,
} , JOURNEY . returnOrigin ) ;
} catch ( e ) {
console . warn ( 'journey beacon failed' , e ) ;
}
}
// Lernreise-Banner: gerendert wenn der Bot via Portal-Lernreise geöffnet wurde
// (`?journey=<slug>&step=<n>&total=<m>&return=<url>`). Nicht-invasiv, weglinkbar.
function renderJourneyBanner ( ) {
try {
const params = new URLSearchParams ( window . location . search ) ;
const journey = params . get ( 'journey' ) ;
const step = parseInt ( params . get ( 'step' ) || '0' , 10 ) ;
const total = parseInt ( params . get ( 'total' ) || '0' , 10 ) ;
const ret = params . get ( 'return' ) || '' ;
if ( ! journey || step <= 0 ) return ;
// Dismissed for this journey/step combo? (LS sticky)
const dismissKey = 'qognio.journey-banner.dismiss.v1' ;
const dismissed = JSON . parse ( localStorage . getItem ( dismissKey ) || '{}' ) ;
const dKey = journey + ':' + step ;
if ( dismissed [ dKey ] ) return ;
const bar = document . createElement ( 'div' ) ;
bar . id = 'qognio-journey-banner' ;
bar . setAttribute ( 'role' , 'note' ) ;
bar . style . cssText = [
'position:sticky' , 'top:0' , 'z-index:100' ,
'display:flex' , 'align-items:center' , 'gap:.75rem' ,
'padding:.55rem .9rem' ,
'background:linear-gradient(90deg, rgba(124,58,237,.18), rgba(34,211,238,.10))' ,
'border-bottom:1px solid rgba(124,58,237,.35)' ,
'color:var(--text, #e5e7eb)' , 'font-size:.875rem' ,
'box-shadow:0 1px 0 rgba(0,0,0,.05)' ,
] . join ( ';' ) ;
const human = journey . replace ( /-/g , ' ' ) . replace ( /\b\w/g , c => c . toUpperCase ( ) ) ;
bar . innerHTML = [
'<span aria-hidden="true" style="font-size:1.05em;line-height:1">🧭</span>' ,
'<span style="font-weight:600">Lernreise: ' + human + '</span>' ,
'<span style="opacity:.7">Schritt ' + step + ( total > 0 ? ' von ' + total : '' ) + '</span>' ,
'<span style="flex:1"></span>' ,
ret ? '<a href="' + ret . replace ( /"/g , '"' ) + '" style="text-decoration:none;padding:.3rem .65rem;border-radius:.4rem;background:rgba(124,58,237,.25);color:inherit;font-weight:500">↩ Zurück zur Reise</a>' : '' ,
'<button type="button" id="qognio-journey-dismiss" aria-label="Banner ausblenden" title="Banner ausblenden" style="background:transparent;border:0;color:inherit;font-size:1.1em;line-height:1;cursor:pointer;opacity:.7;padding:.25rem .5rem">× </button>' ,
] . join ( '' ) ;
document . body . prepend ( bar ) ;
const btn = bar . querySelector ( '#qognio-journey-dismiss' ) ;
if ( btn ) btn . addEventListener ( 'click' , ( ) => {
dismissed [ dKey ] = Date . now ( ) ;
localStorage . setItem ( dismissKey , JSON . stringify ( dismissed ) ) ;
bar . remove ( ) ;
} ) ;
} catch ( e ) {
console . warn ( 'journey banner failed' , e ) ;
}
}
async function boot ( ) {
renderJourneyBanner ( ) ;
try {
const r = await fetch ( 'curricula.json?v=2026-04-21' ) ;
if ( ! r . ok ) throw new Error ( 'curricula.json HTTP ' + r . status ) ;
CURRICULA = await r . json ( ) ;
} catch ( e ) {
toast ( 'Curriculum konnte nicht geladen werden.' , 'error' , 6000 ) ;
console . error ( e ) ;
return ;
}
restoreChat ( ) ;
touchActivity ( ) ;
// Tabs
$$ ( '.tab' ) . forEach ( tab => tab . addEventListener ( 'click' , ( ) => switchMode ( tab . dataset . mode ) ) ) ;
// Welcome-card shortcuts
$$ ( '[data-goto]' ) . forEach ( b => b . addEventListener ( 'click' , ( ) => {
2026-04-29 02:51:28 +00:00
state . seenWelcome = true ; state . seenWelcomeVersion = WELCOME _VERSION ; saveState ( ) ;
2026-04-28 23:35:15 +00:00
switchMode ( b . dataset . goto ) ;
} ) ) ;
// Welcome-card prompt-fillers (special *_REQUEST modes)
$$ ( '[data-prompt]' ) . forEach ( b => b . addEventListener ( 'click' , ( ) => {
2026-04-29 02:51:28 +00:00
state . seenWelcome = true ; state . seenWelcomeVersion = WELCOME _VERSION ; saveState ( ) ;
2026-04-28 23:35:15 +00:00
switchMode ( 'chat' ) ;
const composer = $ ( '#composer' ) ;
if ( composer ) {
composer . value = b . dataset . prompt ;
autogrow ( composer ) ;
setTimeout ( ( ) => composer . focus ( ) , 50 ) ;
}
} ) ) ;
// Composer
const form = $ ( '#composer-form' ) ;
const ta = $ ( '#composer' ) ;
form . addEventListener ( 'submit' , ( ev ) => {
ev . preventDefault ( ) ;
const text = ta . value . trim ( ) ;
if ( ! text && pendingAttachments . length === 0 ) return ;
ta . value = '' ;
autogrow ( ta ) ;
sendChat ( text ) ;
} ) ;
ta . addEventListener ( 'keydown' , ( ev ) => {
if ( ev . key === 'Enter' && ! ev . shiftKey ) {
ev . preventDefault ( ) ;
form . requestSubmit ( ) ;
}
} ) ;
// Attachments
const fileInput = $ ( '#composer-file' ) ;
const attachBtn = $ ( '#composer-attach' ) ;
if ( attachBtn && fileInput ) {
attachBtn . addEventListener ( 'click' , ( ) => fileInput . click ( ) ) ;
fileInput . addEventListener ( 'change' , async ( ev ) => {
await addFiles ( ev . target . files ) ;
ev . target . value = '' ;
} ) ;
}
// Drag & Drop on composer
[ 'dragenter' , 'dragover' ] . forEach ( evt => form . addEventListener ( evt , ( ev ) => {
if ( ev . dataTransfer && Array . from ( ev . dataTransfer . types || [ ] ) . includes ( 'Files' ) ) {
ev . preventDefault ( ) ;
form . classList . add ( 'dragover' ) ;
}
} ) ) ;
[ 'dragleave' , 'drop' ] . forEach ( evt => form . addEventListener ( evt , ( ev ) => {
if ( evt === 'dragleave' && ev . target !== form ) return ;
form . classList . remove ( 'dragover' ) ;
} ) ) ;
form . addEventListener ( 'drop' , async ( ev ) => {
if ( ev . dataTransfer && ev . dataTransfer . files && ev . dataTransfer . files . length ) {
ev . preventDefault ( ) ;
await addFiles ( ev . dataTransfer . files ) ;
}
} ) ;
// Paste image / file
ta . addEventListener ( 'paste' , async ( ev ) => {
const items = ev . clipboardData && ev . clipboardData . files ;
if ( items && items . length ) {
ev . preventDefault ( ) ;
await addFiles ( items ) ;
}
} ) ;
ta . addEventListener ( 'input' , ( ) => autogrow ( ta ) ) ;
function autogrow ( el ) {
el . style . height = 'auto' ;
el . style . height = Math . min ( el . scrollHeight , 140 ) + 'px' ;
}
// Keyboard shortcuts Ctrl+1..5
document . addEventListener ( 'keydown' , ( ev ) => {
if ( ! ( ev . ctrlKey || ev . metaKey ) ) return ;
const map = { '1' : 'chat' , '2' : 'quiz' , '3' : 'flash' , '4' : 'progress' , '5' : 'curriculum' } ;
if ( map [ ev . key ] ) { ev . preventDefault ( ) ; switchMode ( map [ ev . key ] ) ; }
} ) ;
// Default focus
ta . focus ( ) ;
// Update status once per min (visual cue)
setInterval ( ( ) => { /* placeholder for future heartbeat */ } , 60000 ) ;
2026-04-29 02:51:28 +00:00
console . log ( 'Ava v2026-04-29 ready. XP:' , state . xp , 'Streak:' , state . currentStreak ) ;
2026-04-28 23:35:15 +00:00
}
if ( document . readyState === 'loading' ) document . addEventListener ( 'DOMContentLoaded' , boot ) ;
else boot ( ) ;
} ) ( ) ;