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:
parent
c35318a240
commit
dc09999472
@ -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`');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<Record<'de' | 'en', CertStrings>> = {
|
||||
@ -95,7 +110,9 @@ const CERT: Readonly<Record<'de' | 'en', CertStrings>> = {
|
||||
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<Record<'de' | 'en', CertStrings>> = {
|
||||
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');
|
||||
|
||||
53
ai-act-kompass/src/engine/verify.ts
Normal file
53
ai-act-kompass/src/engine/verify.ts
Normal 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}`;
|
||||
}
|
||||
@ -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}',
|
||||
|
||||
@ -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}',
|
||||
|
||||
@ -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}',
|
||||
|
||||
@ -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}',
|
||||
|
||||
@ -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}',
|
||||
|
||||
@ -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}',
|
||||
|
||||
@ -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}',
|
||||
|
||||
@ -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}',
|
||||
|
||||
@ -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))}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -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<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 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<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 (
|
||||
@ -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)))}
|
||||
/>
|
||||
<span>{option}</span>
|
||||
</label>
|
||||
@ -122,16 +194,22 @@ export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Ele
|
||||
<button
|
||||
className="primary"
|
||||
onClick={() =>
|
||||
printDocument(
|
||||
printVerified(
|
||||
`ki-kompetenz-${name.trim()}-${today}`,
|
||||
certificateDoc(name.trim(), result, pack, today, profile, locale),
|
||||
profile,
|
||||
certificateDoc(name.trim(), result, pack, today, profile, locale, lastRecordId ?? undefined),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t.quizCert}
|
||||
</button>
|
||||
<button className="ghost" onClick={reset}>
|
||||
<button
|
||||
className="ghost"
|
||||
onClick={() => {
|
||||
setAnswers(questions.map(() => null));
|
||||
setResult(null);
|
||||
setLastRecordId(null);
|
||||
}}
|
||||
>
|
||||
{t.quizRetry}
|
||||
</button>
|
||||
</div>
|
||||
@ -180,6 +258,35 @@ export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Ele
|
||||
</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-head">
|
||||
<h2 style={{ margin: 0 }}>{t.quizLogTitle}</h2>
|
||||
@ -187,10 +294,9 @@ export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Ele
|
||||
<button
|
||||
className="ghost"
|
||||
onClick={() =>
|
||||
printDocument(
|
||||
printVerified(
|
||||
`schulungsregister-${today}`,
|
||||
trainingLogDoc(records, pack, today, profile, locale),
|
||||
profile,
|
||||
)
|
||||
}
|
||||
>
|
||||
@ -199,6 +305,18 @@ export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Ele
|
||||
)}
|
||||
</div>
|
||||
<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 ? (
|
||||
<p className="hint">{t.quizLogEmpty}</p>
|
||||
) : (
|
||||
@ -208,8 +326,10 @@ export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Ele
|
||||
<tr>
|
||||
<th>{t.colUpdated}</th>
|
||||
<th>{t.colOwner}</th>
|
||||
<th>{t.quizColSource}</th>
|
||||
<th>{t.quizColScore}</th>
|
||||
<th>{t.quizColStatus}</th>
|
||||
<th>{t.quizColEvidence}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -217,12 +337,46 @@ export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Ele
|
||||
<tr key={r.id}>
|
||||
<td>{uiDate(r.date, locale)}</td>
|
||||
<td>{r.name}</td>
|
||||
<td>{(r.source ?? 'app') === 'paper' ? t.quizSourcePaper : t.quizSourceApp}</td>
|
||||
<td>{r.scorePct} %</td>
|
||||
<td>
|
||||
<span className={`badge ${r.passed ? 'minimal' : 'high'}`}>
|
||||
{r.passed ? t.quizPassed : t.quizFailed}
|
||||
</span>
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user