feat(ai-act-kompass): verified evidence chain for the AI literacy test

- 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
This commit is contained in:
Claude 2026-07-04 11:35:58 +00:00
parent c35318a240
commit dc09999472
No known key found for this signature in database
13 changed files with 397 additions and 26 deletions

View File

@ -104,10 +104,18 @@ describe('KI-Kompetenztest', () => {
expect(en).not.toBe(de); 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( 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 }, { id: '2', name: 'Erika Musterfrau', date: '2026-07-03', scorePct: 58, passed: false },
], ],
CONTENT_PACK, CONTENT_PACK,
@ -115,8 +123,17 @@ describe('KI-Kompetenztest', () => {
profile, profile,
); );
expect(md).toContain('Schulungsregister'); 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('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`');
}); });
}); });

View File

@ -21,6 +21,15 @@ export interface QuizScore {
readonly passed: boolean; 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). */ /** Eintrag im lokalen Schulungsregister (Art.-4-Nachweis). */
export interface TrainingRecord { export interface TrainingRecord {
readonly id: string; readonly id: string;
@ -28,6 +37,10 @@ export interface TrainingRecord {
readonly date: string; readonly date: string;
readonly scorePct: number; readonly scorePct: number;
readonly passed: boolean; 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. */ /** Liefert den Fragenkatalog in der gewünschten Sprache. */
@ -75,6 +88,8 @@ interface CertStrings {
readonly logTitle: string; readonly logTitle: string;
readonly logIntro: string; readonly logIntro: string;
readonly logCols: string; readonly logCols: string;
readonly sourceApp: string;
readonly sourcePaper: string;
} }
const CERT: Readonly<Record<'de' | 'en', CertStrings>> = { const CERT: Readonly<Record<'de' | 'en', CertStrings>> = {
@ -95,7 +110,9 @@ const CERT: Readonly<Record<'de' | 'en', CertStrings>> = {
signature: 'Ort, Datum: _______________ Unterschrift Beschäftigte/r: _______________ Unterschrift Arbeitgeber: _______________', signature: 'Ort, Datum: _______________ Unterschrift Beschäftigte/r: _______________ Unterschrift Arbeitgeber: _______________',
logTitle: 'Schulungsregister KI-Kompetenz (Art. 4 EU-KI-VO)', logTitle: 'Schulungsregister KI-Kompetenz (Art. 4 EU-KI-VO)',
logIntro: 'Fortlaufender Nachweis der durchgeführten KI-Kompetenztests. Bestehensgrenze: {pct} %.', 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: { en: {
title: 'Evidence of AI literacy (Art. 4 EU AI Act)', title: 'Evidence of AI literacy (Art. 4 EU AI Act)',
@ -114,7 +131,9 @@ const CERT: Readonly<Record<'de' | 'en', CertStrings>> = {
signature: 'Place, date: _______________ Signature employee: _______________ Signature employer: _______________', signature: 'Place, date: _______________ Signature employee: _______________ Signature employer: _______________',
logTitle: 'AI literacy training register (Art. 4 EU AI Act)', logTitle: 'AI literacy training register (Art. 4 EU AI Act)',
logIntro: 'Continuous record of completed AI literacy tests. Pass threshold: {pct} %.', 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 today - Datum im ISO-Format
* @param profile - Firmenprofil (Briefkopf) * @param profile - Firmenprofil (Briefkopf)
* @param locale - Dokumentsprache * @param locale - Dokumentsprache
* @param recordId - ID des zugehörigen Registereintrags (Nachweiskette)
*/ */
export function certificateDoc( export function certificateDoc(
name: string, name: string,
@ -139,6 +159,7 @@ export function certificateDoc(
today: string, today: string,
profile?: CompanyProfile, profile?: CompanyProfile,
locale: Locale = 'de', locale: Locale = 'de',
recordId?: string,
): string { ): string {
const s = docStrings(locale); const s = docStrings(locale);
const c = certStrings(locale); const c = certStrings(locale);
@ -157,6 +178,7 @@ export function certificateDoc(
`| **${c.name}** | ${name} |`, `| **${c.name}** | ${name} |`,
`| **${c.date}** | ${formatDocDate(today, locale)} |`, `| **${c.date}** | ${formatDocDate(today, locale)} |`,
`| **${c.result}** | ${score.correct}/${score.total} (${score.pct} %) — **${score.passed ? c.resultPassed : c.resultFailed}** |`, `| **${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}`, `## ${c.topics}`,
'', '',
@ -349,11 +371,15 @@ export function trainingLogDoc(
c.logIntro.replace('{pct}', String(QUIZ_PASS_PCT)), c.logIntro.replace('{pct}', String(QUIZ_PASS_PCT)),
'', '',
c.logCols, c.logCols,
'|---|---|---|---|', '|---|---|---|---|---|---|---|',
]; ];
for (const r of records) { 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( 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'); return lines.join('\n');

View File

@ -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<string> {
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<string> {
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<Record<'de' | 'en', { label: string; note: string }>> = {
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<string> {
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}`;
}

View File

@ -66,6 +66,19 @@ export const CS: UIStrings = {
quizPrintTest: 'Papírová verze testu', quizPrintTest: 'Papírová verze testu',
quizPrintKey: 'List s řešením', quizPrintKey: 'List s řešením',
quizPrintHandout: 'Školicí materiál (12 pravidel)', 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', wizardTitleNew: 'Posoudit nový systém AI',
wizardTitleRe: 'Nové posouzení: {name}', wizardTitleRe: 'Nové posouzení: {name}',

View File

@ -66,6 +66,19 @@ export const ES: UIStrings = {
quizPrintTest: 'Versión en papel del test', quizPrintTest: 'Versión en papel del test',
quizPrintKey: 'Hoja de soluciones', quizPrintKey: 'Hoja de soluciones',
quizPrintHandout: 'Material de formación (12 reglas)', 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', wizardTitleNew: 'Evaluar un nuevo sistema de IA',
wizardTitleRe: 'Reevaluación: {name}', wizardTitleRe: 'Reevaluación: {name}',

View File

@ -66,6 +66,19 @@ export const FR: UIStrings = {
quizPrintTest: 'Version papier du test', quizPrintTest: 'Version papier du test',
quizPrintKey: 'Corrigé', quizPrintKey: 'Corrigé',
quizPrintHandout: 'Support de formation (12 règles)', 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 lentrée « {name} » ? Les preuves jointes seront perdues.',
wizardTitleNew: 'Évaluer un nouveau système dIA', wizardTitleNew: 'Évaluer un nouveau système dIA',
wizardTitleRe: 'Réévaluation : {name}', wizardTitleRe: 'Réévaluation : {name}',

View File

@ -66,6 +66,19 @@ export const IT: UIStrings = {
quizPrintTest: 'Versione cartacea del test', quizPrintTest: 'Versione cartacea del test',
quizPrintKey: 'Foglio delle soluzioni', quizPrintKey: 'Foglio delle soluzioni',
quizPrintHandout: 'Dispensa formativa (12 regole)', 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', wizardTitleNew: 'Valutare un nuovo sistema di IA',
wizardTitleRe: 'Rivalutazione: {name}', wizardTitleRe: 'Rivalutazione: {name}',

View File

@ -66,6 +66,19 @@ export const PL: UIStrings = {
quizPrintTest: 'Wersja papierowa testu', quizPrintTest: 'Wersja papierowa testu',
quizPrintKey: 'Arkusz odpowiedzi', quizPrintKey: 'Arkusz odpowiedzi',
quizPrintHandout: 'Materiał szkoleniowy (12 zasad)', 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', wizardTitleNew: 'Oceń nowy system AI',
wizardTitleRe: 'Ponowna ocena: {name}', wizardTitleRe: 'Ponowna ocena: {name}',

View File

@ -66,6 +66,19 @@ export const RO: UIStrings = {
quizPrintTest: 'Versiunea pe hârtie a testului', quizPrintTest: 'Versiunea pe hârtie a testului',
quizPrintKey: 'Foaia de răspunsuri', quizPrintKey: 'Foaia de răspunsuri',
quizPrintHandout: 'Material de formare (12 reguli)', 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', wizardTitleNew: 'Evaluează un sistem de IA nou',
wizardTitleRe: 'Reevaluare: {name}', wizardTitleRe: 'Reevaluare: {name}',

View File

@ -66,6 +66,19 @@ export const SK: UIStrings = {
quizPrintTest: 'Papierová verzia testu', quizPrintTest: 'Papierová verzia testu',
quizPrintKey: 'Hárok s riešeniami', quizPrintKey: 'Hárok s riešeniami',
quizPrintHandout: 'Školiaci materiál (12 pravidiel)', 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', wizardTitleNew: 'Posúdiť nový systém AI',
wizardTitleRe: 'Opätovné posúdenie: {name}', wizardTitleRe: 'Opätovné posúdenie: {name}',

View File

@ -66,6 +66,19 @@ const DE = {
quizPrintTest: 'Papierfassung des Tests', quizPrintTest: 'Papierfassung des Tests',
quizPrintKey: 'Lösungsbogen', quizPrintKey: 'Lösungsbogen',
quizPrintHandout: 'Schulungshandout (12 Regeln)', 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', wizardTitleNew: 'Neues KI-System bewerten',
wizardTitleRe: 'Neubewertung: {name}', wizardTitleRe: 'Neubewertung: {name}',
@ -264,6 +277,19 @@ const EN: UIStrings = {
quizPrintTest: 'Paper version of the test', quizPrintTest: 'Paper version of the test',
quizPrintKey: 'Answer key', quizPrintKey: 'Answer key',
quizPrintHandout: 'Training handout (12 rules)', 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', wizardTitleNew: 'Assess a new AI system',
wizardTitleRe: 'Reassessment: {name}', wizardTitleRe: 'Reassessment: {name}',

View File

@ -167,6 +167,10 @@ export function App(): JSX.Element {
profile={profile} profile={profile}
records={training} records={training}
onRecord={(record) => setTraining((prev) => [record, ...prev])} 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))}
/> />
)} )}

View File

@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'; import { useMemo, useRef, useState } from 'react';
import { import {
answerKeyDoc, answerKeyDoc,
certificateDoc, certificateDoc,
@ -9,8 +9,9 @@ import {
trainingLogDoc, trainingLogDoc,
} from '../engine/quiz'; } from '../engine/quiz';
import type { QuizScore, TrainingRecord } 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 type { CompanyProfile } from '../engine/types';
import { sha256HexBytes, withVerification } from '../engine/verify';
import { useI18n } from '../i18n/context'; import { useI18n } from '../i18n/context';
import { fmt } from '../i18n/ui'; import { fmt } from '../i18n/ui';
import { printDocument } from '../print/print'; import { printDocument } from '../print/print';
@ -20,13 +21,24 @@ interface QuizViewProps {
readonly profile: CompanyProfile; readonly profile: CompanyProfile;
readonly records: readonly TrainingRecord[]; readonly records: readonly TrainingRecord[];
readonly onRecord: (record: TrainingRecord) => void; 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, * KI-Kompetenztest (Art. 4): Test mit Auswertung, druckbaren
* druckbarem Zertifikat und lokalem Schulungsregister als Nachweis. * 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 { t, locale, pack } = useI18n();
const today = todayISO(); const today = todayISO();
const questions = useMemo(() => getQuiz(locale), [locale]); const questions = useMemo(() => getQuiz(locale), [locale]);
@ -36,24 +48,86 @@ export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Ele
questions.map(() => null), questions.map(() => null),
); );
const [result, setResult] = useState<QuizScore | null>(null); const [result, setResult] = useState<QuizScore | null>(null);
const [lastRecordId, setLastRecordId] = useState<string | null>(null);
const [manualName, setManualName] = useState('');
const [manualCorrect, setManualCorrect] = useState('');
const [attachTarget, setAttachTarget] = useState<string | null>(null);
const [attachMessage, setAttachMessage] = useState('');
const scanInput = useRef<HTMLInputElement>(null);
const complete = name.trim() !== '' && answers.every((a) => a !== 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 submit = (): void => {
const score = scoreQuiz(answers); const score = scoreQuiz(answers);
const id = crypto.randomUUID();
setResult(score); setResult(score);
setLastRecordId(id);
onRecord({ onRecord({
id: crypto.randomUUID(), id,
name: name.trim(), name: name.trim(),
date: today, date: today,
scorePct: score.pct, scorePct: score.pct,
passed: score.passed, passed: score.passed,
source: 'app',
}); });
}; };
const reset = (): void => { const addManual = (): void => {
setAnswers(questions.map(() => null)); const correct = Number(manualCorrect);
setResult(null); 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<void> => {
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<string>((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(
`<iframe src="${record.attachment.dataUrl}" style="border:0;width:100vw;height:100vh"></iframe>`,
);
} else {
win.document.write(`<img src="${record.attachment.dataUrl}" style="max-width:100%">`);
}
win.document.close();
}; };
return ( return (
@ -86,9 +160,7 @@ export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Ele
type="radio" type="radio"
name={q.id} name={q.id}
checked={answers[qi] === oi} checked={answers[qi] === oi}
onChange={() => onChange={() => setAnswers(answers.map((a, i) => (i === qi ? oi : a)))}
setAnswers(answers.map((a, i) => (i === qi ? oi : a)))
}
/> />
<span>{option}</span> <span>{option}</span>
</label> </label>
@ -122,16 +194,22 @@ export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Ele
<button <button
className="primary" className="primary"
onClick={() => onClick={() =>
printDocument( printVerified(
`ki-kompetenz-${name.trim()}-${today}`, `ki-kompetenz-${name.trim()}-${today}`,
certificateDoc(name.trim(), result, pack, today, profile, locale), certificateDoc(name.trim(), result, pack, today, profile, locale, lastRecordId ?? undefined),
profile,
) )
} }
> >
{t.quizCert} {t.quizCert}
</button> </button>
<button className="ghost" onClick={reset}> <button
className="ghost"
onClick={() => {
setAnswers(questions.map(() => null));
setResult(null);
setLastRecordId(null);
}}
>
{t.quizRetry} {t.quizRetry}
</button> </button>
</div> </div>
@ -180,6 +258,35 @@ export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Ele
</div> </div>
</div> </div>
<div className="panel">
<h2 style={{ marginTop: 0 }}>{t.quizManualTitle}</h2>
<p className="hint">{t.quizManualHint}</p>
<div className="profile-grid">
<label className="field">
<span>{t.quizName}</span>
<input
type="text"
value={manualName}
placeholder={t.quizNamePh}
onChange={(e) => setManualName(e.target.value)}
/>
</label>
<label className="field">
<span>{t.quizManualScore}</span>
<input
type="text"
inputMode="numeric"
value={manualCorrect}
placeholder={`0${QUIZ_QUESTIONS.length}`}
onChange={(e) => setManualCorrect(e.target.value)}
/>
</label>
</div>
<button className="primary" onClick={addManual}>
{t.quizManualAdd}
</button>
</div>
<div className="panel"> <div className="panel">
<div className="panel-head"> <div className="panel-head">
<h2 style={{ margin: 0 }}>{t.quizLogTitle}</h2> <h2 style={{ margin: 0 }}>{t.quizLogTitle}</h2>
@ -187,10 +294,9 @@ export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Ele
<button <button
className="ghost" className="ghost"
onClick={() => onClick={() =>
printDocument( printVerified(
`schulungsregister-${today}`, `schulungsregister-${today}`,
trainingLogDoc(records, pack, today, profile, locale), trainingLogDoc(records, pack, today, profile, locale),
profile,
) )
} }
> >
@ -199,6 +305,18 @@ export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Ele
)} )}
</div> </div>
<p className="hint">{t.quizLogHint}</p> <p className="hint">{t.quizLogHint}</p>
{attachMessage && <p className="hint">{attachMessage}</p>}
<input
ref={scanInput}
type="file"
accept="image/*,application/pdf"
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void attachScan(file);
e.target.value = '';
}}
/>
{records.length === 0 ? ( {records.length === 0 ? (
<p className="hint">{t.quizLogEmpty}</p> <p className="hint">{t.quizLogEmpty}</p>
) : ( ) : (
@ -208,8 +326,10 @@ export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Ele
<tr> <tr>
<th>{t.colUpdated}</th> <th>{t.colUpdated}</th>
<th>{t.colOwner}</th> <th>{t.colOwner}</th>
<th>{t.quizColSource}</th>
<th>{t.quizColScore}</th> <th>{t.quizColScore}</th>
<th>{t.quizColStatus}</th> <th>{t.quizColStatus}</th>
<th>{t.quizColEvidence}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -217,12 +337,46 @@ export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Ele
<tr key={r.id}> <tr key={r.id}>
<td>{uiDate(r.date, locale)}</td> <td>{uiDate(r.date, locale)}</td>
<td>{r.name}</td> <td>{r.name}</td>
<td>{(r.source ?? 'app') === 'paper' ? t.quizSourcePaper : t.quizSourceApp}</td>
<td>{r.scorePct} %</td> <td>{r.scorePct} %</td>
<td> <td>
<span className={`badge ${r.passed ? 'minimal' : 'high'}`}> <span className={`badge ${r.passed ? 'minimal' : 'high'}`}>
{r.passed ? t.quizPassed : t.quizFailed} {r.passed ? t.quizPassed : t.quizFailed}
</span> </span>
</td> </td>
<td>
<div className="toolbar" style={{ margin: 0 }}>
{r.attachment ? (
<button
className="ghost"
title={`SHA-256: ${r.attachment.sha256}`}
onClick={() => openScan(r)}
>
{t.quizAttachView}
</button>
) : (
<button
className="ghost"
onClick={() => {
setAttachTarget(r.id);
scanInput.current?.click();
}}
>
{t.quizAttach}
</button>
)}
<button
className="danger"
onClick={() => {
if (window.confirm(fmt(t.quizDeleteRecord, { name: r.name }))) {
onDeleteRecord(r.id);
}
}}
>
</button>
</div>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>