From dc09999472d19f4504cd269e32e5c62da4dc82f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 11:35:58 +0000 Subject: [PATCH] feat(ai-act-kompass): verified evidence chain for the AI literacy test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Manual participant entry for paper tests: name + correct answers → register entry with source 'paper' (app runs are tagged 'app') - Scan upload per register entry: marked paper sheets (image/PDF, max 1 MB) stored locally as data URL and anchored with a SHA-256 checksum of the original file; view scan in-app, checksum shown - Document verification: certificate and training register printouts carry a SHA-256 checksum footer over the document content — a presented printout can be re-verified against the tool; certificate additionally references its register entry ID (evidence chain certificate ↔ register ↔ scan) - Register printout extended with source, evidence checksum and ID columns; record deletion with confirmation warning - UI labels in all nine languages; 73 tests green Claude-Session: https://claude.ai/code/session_017YjohVNx1ZGNM4MX7tHpfv --- ai-act-kompass/src/engine/quiz.test.ts | 25 +++- ai-act-kompass/src/engine/quiz.ts | 34 ++++- ai-act-kompass/src/engine/verify.ts | 53 +++++++ ai-act-kompass/src/i18n/ui.cs.ts | 13 ++ ai-act-kompass/src/i18n/ui.es.ts | 13 ++ ai-act-kompass/src/i18n/ui.fr.ts | 13 ++ ai-act-kompass/src/i18n/ui.it.ts | 13 ++ ai-act-kompass/src/i18n/ui.pl.ts | 13 ++ ai-act-kompass/src/i18n/ui.ro.ts | 13 ++ ai-act-kompass/src/i18n/ui.sk.ts | 13 ++ ai-act-kompass/src/i18n/ui.ts | 26 ++++ ai-act-kompass/src/ui/App.tsx | 4 + ai-act-kompass/src/ui/QuizView.tsx | 190 ++++++++++++++++++++++--- 13 files changed, 397 insertions(+), 26 deletions(-) create mode 100644 ai-act-kompass/src/engine/verify.ts diff --git a/ai-act-kompass/src/engine/quiz.test.ts b/ai-act-kompass/src/engine/quiz.test.ts index 83e9746..1189f1f 100644 --- a/ai-act-kompass/src/engine/quiz.test.ts +++ b/ai-act-kompass/src/engine/quiz.test.ts @@ -104,10 +104,18 @@ describe('KI-Kompetenztest', () => { expect(en).not.toBe(de); }); - it('erzeugt das Schulungsregister mit allen Durchläufen', () => { + it('erzeugt das Schulungsregister mit Quelle, Beleg-Prüfsumme und ID', () => { const md = trainingLogDoc( [ - { id: '1', name: 'Max Mustermann', date: '2026-07-04', scorePct: 92, passed: true }, + { + id: 'abcdef12-3456', + name: 'Max Mustermann', + date: '2026-07-04', + scorePct: 92, + passed: true, + source: 'paper', + attachment: { fileName: 'scan.pdf', sha256: 'f'.repeat(64), dataUrl: 'data:application/pdf;base64,x' }, + }, { id: '2', name: 'Erika Musterfrau', date: '2026-07-03', scorePct: 58, passed: false }, ], CONTENT_PACK, @@ -115,8 +123,17 @@ describe('KI-Kompetenztest', () => { profile, ); expect(md).toContain('Schulungsregister'); - expect(md).toContain('Max Mustermann'); + expect(md).toContain('| Papier |'); // Quelle Papier-Test + expect(md).toContain('| App |'); // Alt-Einträge ohne source = App + expect(md).toContain('`ffffffffffffffff…`'); // Beleg-Prüfsumme (gekürzt) + expect(md).toContain('`abcdef12`'); // Register-ID (gekürzt) expect(md).toContain('NICHT BESTANDEN'); - expect(md).toContain('70 %'); + }); + + it('Zertifikat enthält die Register-ID für die Nachweiskette', () => { + const score = scoreQuiz(QUIZ_QUESTIONS.map((q) => q.correctIndex)); + const md = certificateDoc('Max', score, CONTENT_PACK, '2026-07-04', profile, 'de', 'abcdef12-3456-7890'); + expect(md).toContain('Register-ID'); + expect(md).toContain('`abcdef12`'); }); }); diff --git a/ai-act-kompass/src/engine/quiz.ts b/ai-act-kompass/src/engine/quiz.ts index 4c672f4..ece5d3a 100644 --- a/ai-act-kompass/src/engine/quiz.ts +++ b/ai-act-kompass/src/engine/quiz.ts @@ -21,6 +21,15 @@ export interface QuizScore { readonly passed: boolean; } +/** Beleg-Anhang eines Registereintrags (z. B. eingescannter Testbogen). */ +export interface TrainingAttachment { + readonly fileName: string; + /** SHA-256-Prüfsumme der Originaldatei (Hex). */ + readonly sha256: string; + /** Dateiinhalt als Data-URL — bleibt lokal im Browser. */ + readonly dataUrl: string; +} + /** Eintrag im lokalen Schulungsregister (Art.-4-Nachweis). */ export interface TrainingRecord { readonly id: string; @@ -28,6 +37,10 @@ export interface TrainingRecord { readonly date: string; readonly scorePct: number; readonly passed: boolean; + /** Durchführung in der App oder auf Papier (Alt-Einträge: App). */ + readonly source?: 'app' | 'paper'; + /** Eingescannter, ausgewerteter Testbogen als Beleg. */ + readonly attachment?: TrainingAttachment; } /** Liefert den Fragenkatalog in der gewünschten Sprache. */ @@ -75,6 +88,8 @@ interface CertStrings { readonly logTitle: string; readonly logIntro: string; readonly logCols: string; + readonly sourceApp: string; + readonly sourcePaper: string; } const CERT: Readonly> = { @@ -95,7 +110,9 @@ const CERT: Readonly> = { signature: 'Ort, Datum: _______________ Unterschrift Beschäftigte/r: _______________ Unterschrift Arbeitgeber: _______________', logTitle: 'Schulungsregister KI-Kompetenz (Art. 4 EU-KI-VO)', logIntro: 'Fortlaufender Nachweis der durchgeführten KI-Kompetenztests. Bestehensgrenze: {pct} %.', - logCols: '| Datum | Name | Ergebnis | Status |', + logCols: '| Datum | Name | Quelle | Ergebnis | Status | Beleg (SHA-256) | ID |', + sourceApp: 'App', + sourcePaper: 'Papier', }, en: { title: 'Evidence of AI literacy (Art. 4 EU AI Act)', @@ -114,7 +131,9 @@ const CERT: Readonly> = { signature: 'Place, date: _______________ Signature employee: _______________ Signature employer: _______________', logTitle: 'AI literacy training register (Art. 4 EU AI Act)', logIntro: 'Continuous record of completed AI literacy tests. Pass threshold: {pct} %.', - logCols: '| Date | Name | Score | Status |', + logCols: '| Date | Name | Source | Score | Status | Evidence (SHA-256) | ID |', + sourceApp: 'App', + sourcePaper: 'Paper', }, }; @@ -131,6 +150,7 @@ function certStrings(locale: Locale): CertStrings { * @param today - Datum im ISO-Format * @param profile - Firmenprofil (Briefkopf) * @param locale - Dokumentsprache + * @param recordId - ID des zugehörigen Registereintrags (Nachweiskette) */ export function certificateDoc( name: string, @@ -139,6 +159,7 @@ export function certificateDoc( today: string, profile?: CompanyProfile, locale: Locale = 'de', + recordId?: string, ): string { const s = docStrings(locale); const c = certStrings(locale); @@ -157,6 +178,7 @@ export function certificateDoc( `| **${c.name}** | ${name} |`, `| **${c.date}** | ${formatDocDate(today, locale)} |`, `| **${c.result}** | ${score.correct}/${score.total} (${score.pct} %) — **${score.passed ? c.resultPassed : c.resultFailed}** |`, + ...(recordId ? [`| **${locale === 'de' ? 'Register-ID' : 'Register ID'}** | \`${recordId.slice(0, 8)}\` |`] : []), '', `## ${c.topics}`, '', @@ -349,11 +371,15 @@ export function trainingLogDoc( c.logIntro.replace('{pct}', String(QUIZ_PASS_PCT)), '', c.logCols, - '|---|---|---|---|', + '|---|---|---|---|---|---|---|', ]; for (const r of records) { + const source = (r.source ?? 'app') === 'paper' ? c.sourcePaper : c.sourceApp; + const evidence = r.attachment ? `\`${r.attachment.sha256.slice(0, 16)}…\`` : '—'; lines.push( - `| ${formatDocDate(r.date, locale)} | ${r.name} | ${r.scorePct} % | ${r.passed ? c.resultPassed : c.resultFailed} |`, + `| ${formatDocDate(r.date, locale)} | ${r.name} | ${source} | ${r.scorePct} % | ${ + r.passed ? c.resultPassed : c.resultFailed + } | ${evidence} | \`${r.id.slice(0, 8)}\` |`, ); } return lines.join('\n'); diff --git a/ai-act-kompass/src/engine/verify.ts b/ai-act-kompass/src/engine/verify.ts new file mode 100644 index 0000000..758dd4b --- /dev/null +++ b/ai-act-kompass/src/engine/verify.ts @@ -0,0 +1,53 @@ +import type { Locale } from '../i18n/types'; + +/** + * Dokument-Verifikation für Nachweise. + * + * Gedruckte Nachweise (Zertifikat, Schulungsregister) erhalten eine + * SHA-256-Prüfsumme über den Dokumentinhalt. Damit lässt sich später + * belegen, dass ein vorgelegter Ausdruck unverändert aus dem Tool + * stammt: gleiche Eingaben ⇒ gleiche Prüfsumme. Hochgeladene Scans + * werden ebenfalls gehasht und im Register verankert. + */ + +/** Berechnet den SHA-256-Hash eines Textes als Hex-String. */ +export async function sha256Hex(text: string): Promise { + const data = new TextEncoder().encode(text); + const digest = await crypto.subtle.digest('SHA-256', data); + return Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +/** Berechnet den SHA-256-Hash von Binärdaten (z. B. Scan-Datei) als Hex-String. */ +export async function sha256HexBytes(data: ArrayBuffer): Promise { + const digest = await crypto.subtle.digest('SHA-256', data); + return Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +const VERIFY_TEXT: Readonly> = { + de: { + label: 'Dokument-Prüfsumme (SHA-256)', + note: 'Verifikation: Die Prüfsumme wurde über den vorstehenden Dokumentinhalt gebildet. Ein vorgelegter Ausdruck ist unverändert, wenn die im Tool neu erzeugte Prüfsumme übereinstimmt.', + }, + en: { + label: 'Document checksum (SHA-256)', + note: 'Verification: the checksum was computed over the document content above. A presented printout is unaltered if the checksum re-generated in the tool matches.', + }, +}; + +/** + * Hängt eine Verifikations-Fußzeile (Prüfsumme über den Inhalt) an ein + * generiertes Markdown-Dokument an. + * + * @param markdown - Fertiges Dokument + * @param locale - Dokumentsprache (Fußzeile DE/EN) + * @returns Dokument mit Prüfsummen-Fußzeile + */ +export async function withVerification(markdown: string, locale: Locale): Promise { + const t = locale === 'de' ? VERIFY_TEXT.de : VERIFY_TEXT.en; + const hash = await sha256Hex(markdown); + return `${markdown}\n\n**${t.label}:** \`${hash}\`\n\n> ${t.note}`; +} diff --git a/ai-act-kompass/src/i18n/ui.cs.ts b/ai-act-kompass/src/i18n/ui.cs.ts index 4015968..87ae28f 100644 --- a/ai-act-kompass/src/i18n/ui.cs.ts +++ b/ai-act-kompass/src/i18n/ui.cs.ts @@ -66,6 +66,19 @@ export const CS: UIStrings = { quizPrintTest: 'Papírová verze testu', quizPrintKey: 'List s řešením', quizPrintHandout: 'Školicí materiál (12 pravidel)', + quizManualTitle: 'Přidat účastníka ručně (papírový test)', + quizManualHint: 'Pro testy na papíře: zadejte účastníka a výsledek, poté přiložte naskenovaný vyhodnocený arch — důkazní řetězec je tak úplný.', + quizManualScore: 'Správné odpovědi (z 12)', + quizManualAdd: 'Přidat do registru', + quizSourceApp: 'Aplikace', + quizSourcePaper: 'Papír', + quizColSource: 'Zdroj', + quizColEvidence: 'Doklad', + quizAttach: 'Přiložit sken', + quizAttachView: 'Otevřít sken', + quizAttachTooBig: 'Soubor je příliš velký — použijte sken do 1 MB (např. komprimovaný JPG/PDF).', + quizAttachDone: 'Sken uložen a ukotven v registru kontrolním součtem SHA-256.', + quizDeleteRecord: 'Opravdu smazat záznam „{name}"? Přiložené doklady budou ztraceny.', wizardTitleNew: 'Posoudit nový systém AI', wizardTitleRe: 'Nové posouzení: {name}', diff --git a/ai-act-kompass/src/i18n/ui.es.ts b/ai-act-kompass/src/i18n/ui.es.ts index ed1eaf8..544b58e 100644 --- a/ai-act-kompass/src/i18n/ui.es.ts +++ b/ai-act-kompass/src/i18n/ui.es.ts @@ -66,6 +66,19 @@ export const ES: UIStrings = { quizPrintTest: 'Versión en papel del test', quizPrintKey: 'Hoja de soluciones', quizPrintHandout: 'Material de formación (12 reglas)', + quizManualTitle: 'Registrar participante manualmente (test en papel)', + quizManualHint: 'Para tests realizados en papel: introduzca participante y resultado, y adjunte el cuestionario corregido escaneado — así la cadena de evidencia queda completa.', + quizManualScore: 'Respuestas correctas (de 12)', + quizManualAdd: 'Añadir al registro', + quizSourceApp: 'App', + quizSourcePaper: 'Papel', + quizColSource: 'Fuente', + quizColEvidence: 'Evidencia', + quizAttach: 'Adjuntar escaneo', + quizAttachView: 'Abrir escaneo', + quizAttachTooBig: 'Archivo demasiado grande — use un escaneo de menos de 1 MB (p. ej. JPG/PDF comprimido).', + quizAttachDone: 'Escaneo guardado y anclado en el registro con suma de verificación SHA-256.', + quizDeleteRecord: '¿Eliminar realmente la entrada «{name}»? Las evidencias adjuntas se perderán.', wizardTitleNew: 'Evaluar un nuevo sistema de IA', wizardTitleRe: 'Reevaluación: {name}', diff --git a/ai-act-kompass/src/i18n/ui.fr.ts b/ai-act-kompass/src/i18n/ui.fr.ts index 6c7423b..d920c41 100644 --- a/ai-act-kompass/src/i18n/ui.fr.ts +++ b/ai-act-kompass/src/i18n/ui.fr.ts @@ -66,6 +66,19 @@ export const FR: UIStrings = { quizPrintTest: 'Version papier du test', quizPrintKey: 'Corrigé', quizPrintHandout: 'Support de formation (12 règles)', + quizManualTitle: 'Saisir un participant manuellement (test papier)', + quizManualHint: 'Pour les tests réalisés sur papier : saisissez le participant et le résultat, puis joignez le questionnaire corrigé numérisé — la chaîne de preuve est ainsi complète.', + quizManualScore: 'Réponses correctes (sur 12)', + quizManualAdd: 'Ajouter au registre', + quizSourceApp: 'App', + quizSourcePaper: 'Papier', + quizColSource: 'Source', + quizColEvidence: 'Preuve', + quizAttach: 'Joindre un scan', + quizAttachView: 'Ouvrir le scan', + quizAttachTooBig: 'Fichier trop volumineux — utilisez un scan de moins de 1 Mo (p. ex. JPG/PDF compressé).', + quizAttachDone: 'Scan enregistré et ancré dans le registre avec une somme de contrôle SHA-256.', + quizDeleteRecord: 'Supprimer vraiment l’entrée « {name} » ? Les preuves jointes seront perdues.', wizardTitleNew: 'Évaluer un nouveau système d’IA', wizardTitleRe: 'Réévaluation : {name}', diff --git a/ai-act-kompass/src/i18n/ui.it.ts b/ai-act-kompass/src/i18n/ui.it.ts index c67a228..94adf1c 100644 --- a/ai-act-kompass/src/i18n/ui.it.ts +++ b/ai-act-kompass/src/i18n/ui.it.ts @@ -66,6 +66,19 @@ export const IT: UIStrings = { quizPrintTest: 'Versione cartacea del test', quizPrintKey: 'Foglio delle soluzioni', quizPrintHandout: 'Dispensa formativa (12 regole)', + quizManualTitle: 'Registrare manualmente un partecipante (test cartaceo)', + quizManualHint: 'Per i test svolti su carta: inserite partecipante e risultato, poi allegate il foglio corretto scansionato — la catena probatoria è così completa.', + quizManualScore: 'Risposte corrette (su 12)', + quizManualAdd: 'Aggiungi al registro', + quizSourceApp: 'App', + quizSourcePaper: 'Carta', + quizColSource: 'Fonte', + quizColEvidence: 'Prova', + quizAttach: 'Allega scansione', + quizAttachView: 'Apri scansione', + quizAttachTooBig: 'File troppo grande — usate una scansione sotto 1 MB (p. es. JPG/PDF compresso).', + quizAttachDone: 'Scansione salvata e ancorata nel registro con checksum SHA-256.', + quizDeleteRecord: 'Eliminare davvero la voce «{name}»? Le prove allegate andranno perse.', wizardTitleNew: 'Valutare un nuovo sistema di IA', wizardTitleRe: 'Rivalutazione: {name}', diff --git a/ai-act-kompass/src/i18n/ui.pl.ts b/ai-act-kompass/src/i18n/ui.pl.ts index 643b991..cfd72ac 100644 --- a/ai-act-kompass/src/i18n/ui.pl.ts +++ b/ai-act-kompass/src/i18n/ui.pl.ts @@ -66,6 +66,19 @@ export const PL: UIStrings = { quizPrintTest: 'Wersja papierowa testu', quizPrintKey: 'Arkusz odpowiedzi', quizPrintHandout: 'Materiał szkoleniowy (12 zasad)', + quizManualTitle: 'Dodaj uczestnika ręcznie (test papierowy)', + quizManualHint: 'Dla testów na papierze: wpisz uczestnika i wynik, a następnie załącz zeskanowany, oceniony arkusz — łańcuch dowodowy będzie kompletny.', + quizManualScore: 'Poprawne odpowiedzi (z 12)', + quizManualAdd: 'Dodaj do rejestru', + quizSourceApp: 'Aplikacja', + quizSourcePaper: 'Papier', + quizColSource: 'Źródło', + quizColEvidence: 'Dowód', + quizAttach: 'Załącz skan', + quizAttachView: 'Otwórz skan', + quizAttachTooBig: 'Plik za duży — użyj skanu poniżej 1 MB (np. skompresowany JPG/PDF).', + quizAttachDone: 'Skan zapisany i powiązany z rejestrem sumą kontrolną SHA-256.', + quizDeleteRecord: 'Czy na pewno usunąć wpis „{name}"? Załączone dowody zostaną utracone.', wizardTitleNew: 'Oceń nowy system AI', wizardTitleRe: 'Ponowna ocena: {name}', diff --git a/ai-act-kompass/src/i18n/ui.ro.ts b/ai-act-kompass/src/i18n/ui.ro.ts index 5d13fbc..519689e 100644 --- a/ai-act-kompass/src/i18n/ui.ro.ts +++ b/ai-act-kompass/src/i18n/ui.ro.ts @@ -66,6 +66,19 @@ export const RO: UIStrings = { quizPrintTest: 'Versiunea pe hârtie a testului', quizPrintKey: 'Foaia de răspunsuri', quizPrintHandout: 'Material de formare (12 reguli)', + quizManualTitle: 'Adaugă participant manual (test pe hârtie)', + quizManualHint: 'Pentru testele pe hârtie: introduceți participantul și rezultatul, apoi atașați foaia corectată scanată — lanțul probatoriu este astfel complet.', + quizManualScore: 'Răspunsuri corecte (din 12)', + quizManualAdd: 'Adaugă în registru', + quizSourceApp: 'Aplicație', + quizSourcePaper: 'Hârtie', + quizColSource: 'Sursă', + quizColEvidence: 'Dovadă', + quizAttach: 'Atașează scanarea', + quizAttachView: 'Deschide scanarea', + quizAttachTooBig: 'Fișier prea mare — folosiți o scanare sub 1 MB (de ex. JPG/PDF comprimat).', + quizAttachDone: 'Scanare salvată și ancorată în registru cu sumă de control SHA-256.', + quizDeleteRecord: 'Chiar ștergeți intrarea „{name}"? Dovezile atașate se vor pierde.', wizardTitleNew: 'Evaluează un sistem de IA nou', wizardTitleRe: 'Reevaluare: {name}', diff --git a/ai-act-kompass/src/i18n/ui.sk.ts b/ai-act-kompass/src/i18n/ui.sk.ts index 3e62823..f38cded 100644 --- a/ai-act-kompass/src/i18n/ui.sk.ts +++ b/ai-act-kompass/src/i18n/ui.sk.ts @@ -66,6 +66,19 @@ export const SK: UIStrings = { quizPrintTest: 'Papierová verzia testu', quizPrintKey: 'Hárok s riešeniami', quizPrintHandout: 'Školiaci materiál (12 pravidiel)', + quizManualTitle: 'Pridať účastníka ručne (papierový test)', + quizManualHint: 'Pre testy na papieri: zadajte účastníka a výsledok, potom priložte naskenovaný vyhodnotený hárok — dôkazová reťaz je tak úplná.', + quizManualScore: 'Správne odpovede (z 12)', + quizManualAdd: 'Pridať do registra', + quizSourceApp: 'Aplikácia', + quizSourcePaper: 'Papier', + quizColSource: 'Zdroj', + quizColEvidence: 'Doklad', + quizAttach: 'Priložiť sken', + quizAttachView: 'Otvoriť sken', + quizAttachTooBig: 'Súbor je príliš veľký — použite sken do 1 MB (napr. komprimovaný JPG/PDF).', + quizAttachDone: 'Sken uložený a ukotvený v registri kontrolným súčtom SHA-256.', + quizDeleteRecord: 'Naozaj vymazať záznam „{name}"? Priložené doklady sa stratia.', wizardTitleNew: 'Posúdiť nový systém AI', wizardTitleRe: 'Opätovné posúdenie: {name}', diff --git a/ai-act-kompass/src/i18n/ui.ts b/ai-act-kompass/src/i18n/ui.ts index 7585de9..2687eef 100644 --- a/ai-act-kompass/src/i18n/ui.ts +++ b/ai-act-kompass/src/i18n/ui.ts @@ -66,6 +66,19 @@ const DE = { quizPrintTest: 'Papierfassung des Tests', quizPrintKey: 'Lösungsbogen', quizPrintHandout: 'Schulungshandout (12 Regeln)', + quizManualTitle: 'Teilnehmer manuell erfassen (Papier-Test)', + quizManualHint: 'Für auf Papier durchgeführte Tests: Teilnehmer/in und Ergebnis eintragen, danach den ausgewerteten Bogen als Scan anhängen — so ist die Nachweiskette vollständig.', + quizManualScore: 'Richtige Antworten (von 12)', + quizManualAdd: 'In Register aufnehmen', + quizSourceApp: 'App', + quizSourcePaper: 'Papier', + quizColSource: 'Quelle', + quizColEvidence: 'Beleg', + quizAttach: 'Scan anhängen', + quizAttachView: 'Scan öffnen', + quizAttachTooBig: 'Datei zu groß — bitte Scan unter 1 MB verwenden (z. B. als komprimiertes JPG/PDF).', + quizAttachDone: 'Scan gespeichert und mit SHA-256-Prüfsumme im Register verankert.', + quizDeleteRecord: 'Eintrag „{name}" wirklich löschen? Belege gehen mit verloren.', wizardTitleNew: 'Neues KI-System bewerten', wizardTitleRe: 'Neubewertung: {name}', @@ -264,6 +277,19 @@ const EN: UIStrings = { quizPrintTest: 'Paper version of the test', quizPrintKey: 'Answer key', quizPrintHandout: 'Training handout (12 rules)', + quizManualTitle: 'Add participant manually (paper test)', + quizManualHint: 'For tests taken on paper: enter participant and score, then attach the marked sheet as a scan — completing the evidence chain.', + quizManualScore: 'Correct answers (of 12)', + quizManualAdd: 'Add to register', + quizSourceApp: 'App', + quizSourcePaper: 'Paper', + quizColSource: 'Source', + quizColEvidence: 'Evidence', + quizAttach: 'Attach scan', + quizAttachView: 'Open scan', + quizAttachTooBig: 'File too large — please use a scan under 1 MB (e.g. compressed JPG/PDF).', + quizAttachDone: 'Scan stored and anchored in the register with a SHA-256 checksum.', + quizDeleteRecord: 'Really delete entry “{name}”? Attached evidence will be lost.', wizardTitleNew: 'Assess a new AI system', wizardTitleRe: 'Reassessment: {name}', diff --git a/ai-act-kompass/src/ui/App.tsx b/ai-act-kompass/src/ui/App.tsx index 8700f80..c0c8b23 100644 --- a/ai-act-kompass/src/ui/App.tsx +++ b/ai-act-kompass/src/ui/App.tsx @@ -167,6 +167,10 @@ export function App(): JSX.Element { profile={profile} records={training} onRecord={(record) => setTraining((prev) => [record, ...prev])} + onUpdateRecord={(record) => + setTraining((prev) => prev.map((r) => (r.id === record.id ? record : r))) + } + onDeleteRecord={(id) => setTraining((prev) => prev.filter((r) => r.id !== id))} /> )} diff --git a/ai-act-kompass/src/ui/QuizView.tsx b/ai-act-kompass/src/ui/QuizView.tsx index efe6cda..aed36f7 100644 --- a/ai-act-kompass/src/ui/QuizView.tsx +++ b/ai-act-kompass/src/ui/QuizView.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { answerKeyDoc, certificateDoc, @@ -9,8 +9,9 @@ import { trainingLogDoc, } from '../engine/quiz'; import type { QuizScore, TrainingRecord } from '../engine/quiz'; -import { QUIZ_PASS_PCT } from '../engine/quizContent'; +import { QUIZ_PASS_PCT, QUIZ_QUESTIONS } from '../engine/quizContent'; import type { CompanyProfile } from '../engine/types'; +import { sha256HexBytes, withVerification } from '../engine/verify'; import { useI18n } from '../i18n/context'; import { fmt } from '../i18n/ui'; import { printDocument } from '../print/print'; @@ -20,13 +21,24 @@ interface QuizViewProps { readonly profile: CompanyProfile; readonly records: readonly TrainingRecord[]; readonly onRecord: (record: TrainingRecord) => void; + readonly onUpdateRecord: (record: TrainingRecord) => void; + readonly onDeleteRecord: (id: string) => void; } +const MAX_SCAN_BYTES = 1_000_000; + /** - * KI-Kompetenztest (Art. 4): einfacher Grundlagentest mit Auswertung, - * druckbarem Zertifikat und lokalem Schulungsregister als Nachweis. + * KI-Kompetenztest (Art. 4): Test mit Auswertung, druckbaren + * Schulungsunterlagen, manueller Teilnehmer-Erfassung (Papier-Tests), + * Scan-Belegen mit SHA-256-Prüfsumme und Schulungsregister. */ -export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Element { +export function QuizView({ + profile, + records, + onRecord, + onUpdateRecord, + onDeleteRecord, +}: QuizViewProps): JSX.Element { const { t, locale, pack } = useI18n(); const today = todayISO(); const questions = useMemo(() => getQuiz(locale), [locale]); @@ -36,24 +48,86 @@ export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Ele questions.map(() => null), ); const [result, setResult] = useState(null); + const [lastRecordId, setLastRecordId] = useState(null); + + const [manualName, setManualName] = useState(''); + const [manualCorrect, setManualCorrect] = useState(''); + const [attachTarget, setAttachTarget] = useState(null); + const [attachMessage, setAttachMessage] = useState(''); + const scanInput = useRef(null); const complete = name.trim() !== '' && answers.every((a) => a !== null); + const printVerified = (file: string, markdown: string): void => { + void withVerification(markdown, locale).then((verified) => + printDocument(file, verified, profile), + ); + }; + const submit = (): void => { const score = scoreQuiz(answers); + const id = crypto.randomUUID(); setResult(score); + setLastRecordId(id); onRecord({ - id: crypto.randomUUID(), + id, name: name.trim(), date: today, scorePct: score.pct, passed: score.passed, + source: 'app', }); }; - const reset = (): void => { - setAnswers(questions.map(() => null)); - setResult(null); + const addManual = (): void => { + const correct = Number(manualCorrect); + if (manualName.trim() === '' || !Number.isInteger(correct) || correct < 0 || correct > QUIZ_QUESTIONS.length) { + return; + } + const pct = Math.round((correct / QUIZ_QUESTIONS.length) * 100); + onRecord({ + id: crypto.randomUUID(), + name: manualName.trim(), + date: today, + scorePct: pct, + passed: pct >= QUIZ_PASS_PCT, + source: 'paper', + }); + setManualName(''); + setManualCorrect(''); + }; + + const attachScan = async (file: File): Promise => { + const record = records.find((r) => r.id === attachTarget); + if (!record) return; + if (file.size > MAX_SCAN_BYTES) { + setAttachMessage(t.quizAttachTooBig); + return; + } + const buffer = await file.arrayBuffer(); + const sha256 = await sha256HexBytes(buffer); + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : ''); + reader.onerror = () => reject(new Error('read failed')); + reader.readAsDataURL(file); + }); + onUpdateRecord({ ...record, attachment: { fileName: file.name, sha256, dataUrl } }); + setAttachMessage(t.quizAttachDone); + }; + + const openScan = (record: TrainingRecord): void => { + if (!record.attachment) return; + const win = window.open('', '_blank'); + if (!win) return; + if (record.attachment.dataUrl.startsWith('data:application/pdf')) { + win.document.write( + ``, + ); + } else { + win.document.write(``); + } + win.document.close(); }; return ( @@ -86,9 +160,7 @@ export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Ele type="radio" name={q.id} checked={answers[qi] === oi} - onChange={() => - setAnswers(answers.map((a, i) => (i === qi ? oi : a))) - } + onChange={() => setAnswers(answers.map((a, i) => (i === qi ? oi : a)))} /> {option} @@ -122,16 +194,22 @@ export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Ele - @@ -180,6 +258,35 @@ export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Ele +
+

{t.quizManualTitle}

+

{t.quizManualHint}

+
+ + +
+ +
+

{t.quizLogTitle}

@@ -187,10 +294,9 @@ export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Ele

{t.quizLogHint}

+ {attachMessage &&

{attachMessage}

} + { + const file = e.target.files?.[0]; + if (file) void attachScan(file); + e.target.value = ''; + }} + /> {records.length === 0 ? (

{t.quizLogEmpty}

) : ( @@ -208,8 +326,10 @@ export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Ele {t.colUpdated} {t.colOwner} + {t.quizColSource} {t.quizColScore} {t.quizColStatus} + {t.quizColEvidence} @@ -217,12 +337,46 @@ export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Ele {uiDate(r.date, locale)} {r.name} + {(r.source ?? 'app') === 'paper' ? t.quizSourcePaper : t.quizSourceApp} {r.scorePct} % {r.passed ? t.quizPassed : t.quizFailed} + +
+ {r.attachment ? ( + + ) : ( + + )} + +
+ ))}