feat(ai-act-kompass): printable training materials (paper test, answer key, handout)

- Paper version of the AI literacy test: name/date/department fields,
  12 questions with tick boxes (A/B/C), marking line and signature
  block — for employees without computer access; contains no answers
- Answer key for markers: correct letter plus explanation per question,
  pass threshold noted
- Training handout: the 12 core rules (question + explanation) as
  Art. 4 training material to distribute before the test
- All printable with company letterhead via the existing print/PDF
  pipeline and downloadable; buttons in the quiz view under 'Print
  training materials'; UI labels in all nine languages (document body
  DE/EN with EN fallback, full localization follows the content pack)
- 3 new tests (72 total)

Claude-Session: https://claude.ai/code/session_017YjohVNx1ZGNM4MX7tHpfv
This commit is contained in:
Claude 2026-07-04 11:29:14 +00:00
parent efdc60fe84
commit ca58f1738f
No known key found for this signature in database
11 changed files with 258 additions and 2 deletions

View File

@ -1,6 +1,14 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { CONTENT_PACK } from '../content/pack'; import { CONTENT_PACK } from '../content/pack';
import { certificateDoc, getQuiz, scoreQuiz, trainingLogDoc } from './quiz'; import {
answerKeyDoc,
certificateDoc,
getQuiz,
handoutDoc,
printableTestDoc,
scoreQuiz,
trainingLogDoc,
} from './quiz';
import { QUIZ_PASS_PCT, QUIZ_QUESTIONS } from './quizContent'; import { QUIZ_PASS_PCT, QUIZ_QUESTIONS } from './quizContent';
import type { CompanyProfile } from './types'; import type { CompanyProfile } from './types';
@ -65,6 +73,30 @@ describe('KI-Kompetenztest', () => {
expect(md).toContain('keine Rechtsberatung'); expect(md).toContain('keine Rechtsberatung');
}); });
it('Papierfassung enthält alle Fragen, Ankreuzfelder und Auswertungszeile', () => {
const md = printableTestDoc(CONTENT_PACK, '2026-07-04', profile);
expect(md).toContain('Papierfassung');
expect((md.match(/☐/g) ?? []).length).toBe(36); // 12 Fragen × 3 Optionen
expect(md).toContain('**Name:**');
expect(md).toContain('Ergebnis: ______ / 12');
expect(md).not.toContain('richtige Antwort'); // keine Lösungen im Testbogen
});
it('Lösungsbogen nennt je Frage den richtigen Buchstaben', () => {
const md = answerKeyDoc(CONTENT_PACK, '2026-07-04', profile);
expect(md).toContain('Lösungsbogen');
expect((md.match(/\| \d+ \| \*\*[ABC]\*\* \|/g) ?? []).length).toBe(12);
});
it('Schulungshandout enthält die 12 Erklärungen und ist lokalisiert', () => {
const de = handoutDoc(CONTENT_PACK, '2026-07-04', profile, 'de');
expect(de).toContain('Schulungshandout');
expect((de.match(/^## \d+\./gm) ?? []).length).toBe(12);
const en = handoutDoc(CONTENT_PACK, '2026-07-04', profile, 'en');
expect(en).toContain('Training handout');
expect(en).not.toBe(de);
});
it('erzeugt das Schulungsregister mit allen Durchläufen', () => { it('erzeugt das Schulungsregister mit allen Durchläufen', () => {
const md = trainingLogDoc( const md = trainingLogDoc(
[ [

View File

@ -168,6 +168,156 @@ export function certificateDoc(
].join('\n'); ].join('\n');
} }
interface PrintStrings {
readonly testTitle: string;
readonly testIntro: string;
readonly nameDateLine: string;
readonly testFooter: string;
readonly keyTitle: string;
readonly keyIntro: string;
readonly keyCols: string;
readonly handoutTitle: string;
readonly handoutIntro: string;
readonly handoutFooter: string;
}
const PRINT: Readonly<Record<'de' | 'en', PrintStrings>> = {
de: {
testTitle: 'KI-Kompetenztest (Art. 4 EU-KI-VO) — Papierfassung',
testIntro:
'Bitte kreuzen Sie je Frage genau eine Antwort an. Bestehensgrenze: {pct} % ({min} von {total} Fragen). Die Auswertung erfolgt mit dem Lösungsbogen; das Ergebnis wird im Schulungsregister dokumentiert.',
nameDateLine: '**Name:** ______________________________ **Datum:** ______________ **Abteilung:** ______________________________',
testFooter: 'Unterschrift Beschäftigte/r: _______________ Ausgewertet durch (Name, Unterschrift): _______________ Ergebnis: ______ / {total}',
keyTitle: 'KI-Kompetenztest — Lösungsbogen (nur für Auswertende)',
keyIntro: 'Richtige Antworten und Erklärungen. Bestehensgrenze: {pct} % ({min} von {total} Fragen).',
keyCols: '| Nr. | Richtig | Erklärung |',
handoutTitle: 'Schulungshandout: Die 12 wichtigsten Regeln im Umgang mit KI',
handoutIntro:
'Dieses Handout fasst die Kernregeln für den Arbeitsalltag zusammen und dient als Schulungsunterlage nach Art. 4 EU-KI-VO. Es kann vor dem Kompetenztest ausgeteilt werden.',
handoutFooter:
'Bei Fragen oder Vorfällen: an die/den KI-Beauftragte/n wenden (siehe interne Melde- und Eskalationsrichtlinie).',
},
en: {
testTitle: 'AI literacy test (Art. 4 EU AI Act) — paper version',
testIntro:
'Please tick exactly one answer per question. Pass threshold: {pct} % ({min} of {total} questions). Marking is done with the answer key; the result is documented in the training register.',
nameDateLine: '**Name:** ______________________________ **Date:** ______________ **Department:** ______________________________',
testFooter: 'Signature employee: _______________ Marked by (name, signature): _______________ Score: ______ / {total}',
keyTitle: 'AI literacy test — answer key (for markers only)',
keyIntro: 'Correct answers and explanations. Pass threshold: {pct} % ({min} of {total} questions).',
keyCols: '| No. | Correct | Explanation |',
handoutTitle: 'Training handout: the 12 most important rules for working with AI',
handoutIntro:
'This handout summarises the core rules for everyday work and serves as training material under Art. 4 EU AI Act. It can be handed out before the literacy test.',
handoutFooter:
'For questions or incidents: contact the AI officer (see the internal reporting and escalation policy).',
},
};
function printStrings(locale: Locale): PrintStrings {
return locale === 'de' ? PRINT.de : PRINT.en;
}
const OPTION_LETTERS = ['A', 'B', 'C'] as const;
function minCorrect(): number {
return Math.ceil((QUIZ_PASS_PCT / 100) * QUIZ_QUESTIONS.length);
}
function fillIn(template: string): string {
return template
.replace('{pct}', String(QUIZ_PASS_PCT))
.replace('{min}', String(minCorrect()))
.replaceAll('{total}', String(QUIZ_QUESTIONS.length));
}
/**
* Erzeugt die Papierfassung des Kompetenztests (zum Ausdrucken und
* handschriftlichen Ausfüllen) als Markdown.
*/
export function printableTestDoc(
pack: ContentPack,
today: string,
profile?: CompanyProfile,
locale: Locale = 'de',
): string {
const s = docStrings(locale);
const p = printStrings(locale);
const company = profile && profile.name.trim() !== '' ? profile.name : '_______________';
const lines = [
`# ${p.testTitle}`,
'',
`**${s.meta.company}:** ${company} · ${s.inventory.asOf}: ${formatDocDate(today, locale)} · Content-Pack v${pack.version}`,
'',
p.nameDateLine,
'',
fillIn(p.testIntro),
'',
];
getQuiz(locale).forEach((q, i) => {
lines.push(`## ${i + 1}. ${q.question}`, '');
q.options.forEach((option, oi) => {
lines.push(`- ☐ **${OPTION_LETTERS[oi]})** ${option}`);
});
lines.push('');
});
lines.push(fillIn(p.testFooter));
return lines.join('\n');
}
/** Erzeugt den Lösungsbogen (richtige Antworten + Erklärungen) als Markdown. */
export function answerKeyDoc(
pack: ContentPack,
today: string,
profile?: CompanyProfile,
locale: Locale = 'de',
): string {
const s = docStrings(locale);
const p = printStrings(locale);
const company = profile && profile.name.trim() !== '' ? profile.name : '_______________';
const lines = [
`# ${p.keyTitle}`,
'',
`**${s.meta.company}:** ${company} · ${s.inventory.asOf}: ${formatDocDate(today, locale)} · Content-Pack v${pack.version}`,
'',
fillIn(p.keyIntro),
'',
p.keyCols,
'|---|---|---|',
];
getQuiz(locale).forEach((q, i) => {
lines.push(`| ${i + 1} | **${OPTION_LETTERS[q.correctIndex]}** | ${q.explanation} |`);
});
return lines.join('\n');
}
/** Erzeugt das Schulungshandout (Kernregeln aus den Erklärungen) als Markdown. */
export function handoutDoc(
pack: ContentPack,
today: string,
profile?: CompanyProfile,
locale: Locale = 'de',
): string {
const s = docStrings(locale);
const p = printStrings(locale);
const company = profile && profile.name.trim() !== '' ? profile.name : '_______________';
const lines = [
`# ${p.handoutTitle}`,
'',
`**${s.meta.company}:** ${company} · ${s.inventory.asOf}: ${formatDocDate(today, locale)} · Content-Pack v${pack.version}`,
'',
s.disclaimer,
'',
p.handoutIntro,
'',
];
getQuiz(locale).forEach((q, i) => {
lines.push(`## ${i + 1}. ${q.question}`, '', q.explanation, '');
});
lines.push(p.handoutFooter);
return lines.join('\n');
}
/** /**
* Erzeugt das druckbare Schulungsregister (alle Testdurchläufe) als Markdown. * Erzeugt das druckbare Schulungsregister (alle Testdurchläufe) als Markdown.
*/ */

View File

@ -62,6 +62,10 @@ export const CS: UIStrings = {
quizLogPrint: 'Vytisknout registr / PDF', quizLogPrint: 'Vytisknout registr / PDF',
quizColScore: 'Výsledek', quizColScore: 'Výsledek',
quizColStatus: 'Stav', quizColStatus: 'Stav',
quizDocsTitle: 'Tisk školicích materiálů',
quizPrintTest: 'Papírová verze testu',
quizPrintKey: 'List s řešením',
quizPrintHandout: 'Školicí materiál (12 pravidel)',
wizardTitleNew: 'Posoudit nový systém AI', wizardTitleNew: 'Posoudit nový systém AI',
wizardTitleRe: 'Nové posouzení: {name}', wizardTitleRe: 'Nové posouzení: {name}',

View File

@ -62,6 +62,10 @@ export const ES: UIStrings = {
quizLogPrint: 'Imprimir registro / PDF', quizLogPrint: 'Imprimir registro / PDF',
quizColScore: 'Resultado', quizColScore: 'Resultado',
quizColStatus: 'Estado', quizColStatus: 'Estado',
quizDocsTitle: 'Imprimir materiales de formación',
quizPrintTest: 'Versión en papel del test',
quizPrintKey: 'Hoja de soluciones',
quizPrintHandout: 'Material de formación (12 reglas)',
wizardTitleNew: 'Evaluar un nuevo sistema de IA', wizardTitleNew: 'Evaluar un nuevo sistema de IA',
wizardTitleRe: 'Reevaluación: {name}', wizardTitleRe: 'Reevaluación: {name}',

View File

@ -62,6 +62,10 @@ export const FR: UIStrings = {
quizLogPrint: 'Imprimer le registre / PDF', quizLogPrint: 'Imprimer le registre / PDF',
quizColScore: 'Résultat', quizColScore: 'Résultat',
quizColStatus: 'Statut', quizColStatus: 'Statut',
quizDocsTitle: 'Imprimer les supports de formation',
quizPrintTest: 'Version papier du test',
quizPrintKey: 'Corrigé',
quizPrintHandout: 'Support de formation (12 règles)',
wizardTitleNew: 'Évaluer un nouveau système dIA', wizardTitleNew: 'Évaluer un nouveau système dIA',
wizardTitleRe: 'Réévaluation : {name}', wizardTitleRe: 'Réévaluation : {name}',

View File

@ -62,6 +62,10 @@ export const IT: UIStrings = {
quizLogPrint: 'Stampa registro / PDF', quizLogPrint: 'Stampa registro / PDF',
quizColScore: 'Risultato', quizColScore: 'Risultato',
quizColStatus: 'Stato', quizColStatus: 'Stato',
quizDocsTitle: 'Stampa materiali formativi',
quizPrintTest: 'Versione cartacea del test',
quizPrintKey: 'Foglio delle soluzioni',
quizPrintHandout: 'Dispensa formativa (12 regole)',
wizardTitleNew: 'Valutare un nuovo sistema di IA', wizardTitleNew: 'Valutare un nuovo sistema di IA',
wizardTitleRe: 'Rivalutazione: {name}', wizardTitleRe: 'Rivalutazione: {name}',

View File

@ -62,6 +62,10 @@ export const PL: UIStrings = {
quizLogPrint: 'Drukuj rejestr / PDF', quizLogPrint: 'Drukuj rejestr / PDF',
quizColScore: 'Wynik', quizColScore: 'Wynik',
quizColStatus: 'Status', quizColStatus: 'Status',
quizDocsTitle: 'Drukuj materiały szkoleniowe',
quizPrintTest: 'Wersja papierowa testu',
quizPrintKey: 'Arkusz odpowiedzi',
quizPrintHandout: 'Materiał szkoleniowy (12 zasad)',
wizardTitleNew: 'Oceń nowy system AI', wizardTitleNew: 'Oceń nowy system AI',
wizardTitleRe: 'Ponowna ocena: {name}', wizardTitleRe: 'Ponowna ocena: {name}',

View File

@ -62,6 +62,10 @@ export const RO: UIStrings = {
quizLogPrint: 'Tipărește registrul / PDF', quizLogPrint: 'Tipărește registrul / PDF',
quizColScore: 'Rezultat', quizColScore: 'Rezultat',
quizColStatus: 'Stare', quizColStatus: 'Stare',
quizDocsTitle: 'Tipărește materialele de formare',
quizPrintTest: 'Versiunea pe hârtie a testului',
quizPrintKey: 'Foaia de răspunsuri',
quizPrintHandout: 'Material de formare (12 reguli)',
wizardTitleNew: 'Evaluează un sistem de IA nou', wizardTitleNew: 'Evaluează un sistem de IA nou',
wizardTitleRe: 'Reevaluare: {name}', wizardTitleRe: 'Reevaluare: {name}',

View File

@ -62,6 +62,10 @@ export const SK: UIStrings = {
quizLogPrint: 'Vytlačiť register / PDF', quizLogPrint: 'Vytlačiť register / PDF',
quizColScore: 'Výsledok', quizColScore: 'Výsledok',
quizColStatus: 'Stav', quizColStatus: 'Stav',
quizDocsTitle: 'Tlač školiacich materiálov',
quizPrintTest: 'Papierová verzia testu',
quizPrintKey: 'Hárok s riešeniami',
quizPrintHandout: 'Školiaci materiál (12 pravidiel)',
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

@ -62,6 +62,10 @@ const DE = {
quizLogPrint: 'Register drucken / PDF', quizLogPrint: 'Register drucken / PDF',
quizColScore: 'Ergebnis', quizColScore: 'Ergebnis',
quizColStatus: 'Status', quizColStatus: 'Status',
quizDocsTitle: 'Schulungsunterlagen drucken',
quizPrintTest: 'Papierfassung des Tests',
quizPrintKey: 'Lösungsbogen',
quizPrintHandout: 'Schulungshandout (12 Regeln)',
wizardTitleNew: 'Neues KI-System bewerten', wizardTitleNew: 'Neues KI-System bewerten',
wizardTitleRe: 'Neubewertung: {name}', wizardTitleRe: 'Neubewertung: {name}',
@ -256,6 +260,10 @@ const EN: UIStrings = {
quizLogPrint: 'Print register / PDF', quizLogPrint: 'Print register / PDF',
quizColScore: 'Score', quizColScore: 'Score',
quizColStatus: 'Status', quizColStatus: 'Status',
quizDocsTitle: 'Print training materials',
quizPrintTest: 'Paper version of the test',
quizPrintKey: 'Answer key',
quizPrintHandout: 'Training handout (12 rules)',
wizardTitleNew: 'Assess a new AI system', wizardTitleNew: 'Assess a new AI system',
wizardTitleRe: 'Reassessment: {name}', wizardTitleRe: 'Reassessment: {name}',

View File

@ -1,5 +1,13 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { certificateDoc, getQuiz, scoreQuiz, trainingLogDoc } from '../engine/quiz'; import {
answerKeyDoc,
certificateDoc,
getQuiz,
handoutDoc,
printableTestDoc,
scoreQuiz,
trainingLogDoc,
} 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 } from '../engine/quizContent';
import type { CompanyProfile } from '../engine/types'; import type { CompanyProfile } from '../engine/types';
@ -142,6 +150,36 @@ export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Ele
</div> </div>
)} )}
<div className="panel">
<h2 style={{ marginTop: 0 }}>{t.quizDocsTitle}</h2>
<div className="toolbar" style={{ marginBottom: 0 }}>
<button
className="ghost"
onClick={() =>
printDocument(`ki-test-papier-${today}`, printableTestDoc(pack, today, profile, locale), profile)
}
>
{t.quizPrintTest}
</button>
<button
className="ghost"
onClick={() =>
printDocument(`ki-test-loesungen-${today}`, answerKeyDoc(pack, today, profile, locale), profile)
}
>
{t.quizPrintKey}
</button>
<button
className="ghost"
onClick={() =>
printDocument(`ki-schulungshandout-${today}`, handoutDoc(pack, today, profile, locale), profile)
}
>
{t.quizPrintHandout}
</button>
</div>
</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>