Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 84 additions & 19 deletions ai-act-compass.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,7 @@ const CHECKLIST = {
{ ref: 'art. 50(3)', txt: _('Inform subjects of biometric categorisation or emotion recognition', 'Information du sujet de catégorisation biométrique ou reconnaissance émotionnelle') },
{ ref: 'art. 50(4)', txt: _('"Deepfake" label on manipulated image/audio/video content', 'Label « deepfake » sur contenus image/audio/vidéo manipulés') },
{ ref: 'art. 50(4) §2', txt: _('Synthetic public-interest text labelling (except human-edited)', 'Étiquetage du texte synthétique d\'intérêt public (sauf édition humaine)') },
{ ref: 'art. 50(5)', txt: _('Transparency information must be clear, distinguishable, and provided at the latest at first interaction/exposure; where applicable, comply with accessibility requirements under Directive 2019/882/EU (European Accessibility Act)', 'Information de transparence : claire, distinguable, fournie au plus tard à la 1ʳᵉ interaction/exposition ; lorsque applicable, conforme aux exigences d\'accessibilité de la directive 2019/882/UE (European Accessibility Act)') },
]},
{ pilier: _('AI literacy', 'AI literacy'), items: [
{ ref: 'art. 4', txt: _('AI literacy programme applicable since 2025-02-02', 'Programme AI literacy applicable depuis 2025-02-02') },
Expand Down Expand Up @@ -2172,8 +2173,8 @@ export default function App() {
const [lang, setLang] = useState('en'); // EN by default
const [step, setStep] = useState(0);
const [answers, setAnswers] = useState({
role: null, nature: null, prohibitions: null, prohibitionCarveOuts: {}, annexI: null,
annexIII: [], annexIII5Subitems: [], exceptions: null, profiling: false, art50: [], gpaiSystemic: null,
role: null, nature: null, prohibitions: null, prohibitionCarveOuts: {}, annexICoverage: null, annexI3rdPartyCA: null,
annexIII: [], annexIII5Subitems: [], exceptions: null, profiling: false, art50: [], art50TextHumanEdit: null, gpaiSystemic: null,
deployerKind: null, substantialModification: null,
});

Expand All @@ -2196,8 +2197,8 @@ export default function App() {

const restart = () => {
setAnswers({
role: null, nature: null, prohibitions: null, prohibitionCarveOuts: {}, annexI: null,
annexIII: [], annexIII5Subitems: [], exceptions: null, profiling: false, art50: [], gpaiSystemic: null,
role: null, nature: null, prohibitions: null, prohibitionCarveOuts: {}, annexICoverage: null, annexI3rdPartyCA: null,
annexIII: [], annexIII5Subitems: [], exceptions: null, profiling: false, art50: [], art50TextHumanEdit: null, gpaiSystemic: null,
deployerKind: null, substantialModification: null,
});
setStep(0);
Expand Down Expand Up @@ -2412,22 +2413,56 @@ export default function App() {
stepNum={4} totalSteps={TOTAL_STEPS}
title={t(UI.q4Title, lang)}
subtitle={t(UI.q4Sub, lang)}
canNext={answers.annexI !== null}
canNext={answers.annexICoverage !== null && answers.annexI3rdPartyCA !== null}
onNext={next} onBack={back}
>
<div className="space-y-3">
<OptionCard
selected={answers.annexI === 'oui'}
onClick={() => setAnswers({ ...answers, annexI: 'oui' })}
title={t(UI.yes, lang)}
sub={lang === 'en' ? 'art. 6(1) + Annex I' : 'art. 6(1) + Annexe I'}
desc={t(UI.q4YesDesc, lang)}
/>
<OptionCard
selected={answers.annexI === 'non'}
onClick={() => setAnswers({ ...answers, annexI: 'non' })}
title={t(UI.no, lang)} desc={t(UI.q4NoDesc, lang)}
/>
<div className="space-y-6">
<div className="space-y-3">
<div className="text-sm uppercase tracking-wider opacity-60">
{lang === 'en' ? '1. Annex I coverage' : '1. Couverture Annexe I'}
</div>
<OptionCard
selected={answers.annexICoverage === 'oui'}
onClick={() => setAnswers({ ...answers, annexICoverage: 'oui', annexI3rdPartyCA: null })}
title={t(UI.yes, lang)}
sub={lang === 'en' ? 'Annex I harmonisation legislation' : 'Législation d\'harmonisation Annexe I'}
desc={lang === 'en'
? 'My system is a safety component of (or itself) a product covered by an Annex I sectoral regulation (machinery, medical devices, toys, motor vehicles, lifts, radio equipment, …).'
: 'Mon système est un composant de sécurité (ou est lui-même) un produit couvert par une réglementation sectorielle Annexe I (machines, dispositifs médicaux, jouets, automobiles, ascenseurs, équipements radio, …).'}
/>
<OptionCard
selected={answers.annexICoverage === 'non'}
onClick={() => setAnswers({ ...answers, annexICoverage: 'non', annexI3rdPartyCA: 'non' })}
title={t(UI.no, lang)}
desc={lang === 'en' ? 'My system does not fall within Annex I scope.' : 'Mon système n\'entre pas dans le champ Annexe I.'}
/>
</div>

{answers.annexICoverage === 'oui' && (
<div className="space-y-3">
<div className="text-sm uppercase tracking-wider opacity-60">
{lang === 'en' ? '2. Third-party conformity assessment' : '2. Évaluation de conformité par tiers'}
</div>
<div className="text-xs opacity-70">
{lang === 'en'
? 'art. 6(1) is cumulative: HAUT_RISQUE_ANNEXE_I requires BOTH Annex I coverage AND a third-party CA obligation under that sectoral regime.'
: 'L\'art. 6(1) est cumulatif : HAUT_RISQUE_ANNEXE_I exige la couverture Annexe I ET une obligation d\'évaluation de conformité par tiers sous ce régime sectoriel.'}
</div>
<OptionCard
selected={answers.annexI3rdPartyCA === 'oui'}
onClick={() => setAnswers({ ...answers, annexI3rdPartyCA: 'oui' })}
title={t(UI.yes, lang)}
sub="art. 6(1)"
desc={lang === 'en' ? 'The sectoral regulation requires third-party CA for this product.' : 'La réglementation sectorielle exige une évaluation de conformité par tiers pour ce produit.'}
/>
<OptionCard
selected={answers.annexI3rdPartyCA === 'non'}
onClick={() => setAnswers({ ...answers, annexI3rdPartyCA: 'non' })}
title={t(UI.no, lang)}
desc={lang === 'en' ? 'Self-assessment route under the sectoral regulation.' : 'Voie d\'auto-évaluation sous la réglementation sectorielle.'}
/>
</div>
)}
</div>
</QuestionFrame>
)}
Expand Down Expand Up @@ -2590,13 +2625,43 @@ export default function App() {
onClick={() => {
const cur = answers.art50;
const upd = sel ? cur.filter(x => x !== tr.id) : [...cur, tr.id];
setAnswers({ ...answers, art50: upd });
setAnswers({
...answers,
art50: upd,
art50TextHumanEdit: upd.includes('genai_text') ? answers.art50TextHumanEdit : null,
});
}}
title={t(tr.label, lang)} sub={tr.ref} desc={t(tr.desc, lang)}
/>
);
})}
</div>

{(answers.art50 || []).includes('genai_text') && (
<div className="mt-6 space-y-2">
<div className="text-sm uppercase tracking-wider opacity-60">
{lang === 'en' ? 'Human review carve-out (art. 50(4) §2)' : 'Exception revue humaine (art. 50(4) §2)'}
</div>
<div className="text-xs opacity-70">
{lang === 'en'
? 'art. 50(4) second subparagraph: the labelling obligation does NOT apply where the AI-generated text has undergone human review or editorial control and a natural/legal person holds editorial responsibility.'
: 'art. 50(4) deuxième alinéa : l\'obligation d\'étiquetage NE s\'applique PAS lorsque le texte généré a fait l\'objet d\'une revue ou d\'un contrôle éditorial humain et qu\'une personne physique/morale en assume la responsabilité éditoriale.'}
</div>
<OptionCard
selected={answers.art50TextHumanEdit === 'oui'}
onClick={() => setAnswers({ ...answers, art50TextHumanEdit: 'oui' })}
title={lang === 'en' ? 'Yes — under editorial responsibility' : 'Oui — sous responsabilité éditoriale'}
sub="art. 50(4) §2"
desc={lang === 'en' ? 'Transparency obligation does not apply for this text channel.' : 'L\'obligation de transparence ne s\'applique pas pour ce canal de texte.'}
/>
<OptionCard
selected={answers.art50TextHumanEdit === 'non'}
onClick={() => setAnswers({ ...answers, art50TextHumanEdit: 'non' })}
title={lang === 'en' ? 'No — fully automated publication' : 'Non — publication entièrement automatisée'}
desc={lang === 'en' ? 'Standard art. 50(4) §2 labelling applies.' : 'L\'étiquetage standard art. 50(4) §2 s\'applique.'}
/>
</div>
)}
</QuestionFrame>
)}

Expand Down
21 changes: 16 additions & 5 deletions src/lib/classify.js
Original file line number Diff line number Diff line change
Expand Up @@ -325,10 +325,10 @@ export function computeCategory(answers, lang) {
const flipsViaArt25 = isOnGPAI && answers.substantialModification === 'oui';
const isGPAI_RS = (isGPAIProvider || flipsViaArt25) && answers.gpaiSystemic === 'oui';

if (answers.annexI === 'oui') {
if (answers.annexICoverage === 'oui' && answers.annexI3rdPartyCA === 'oui') {
justifications.push({
ref: lang === 'en' ? 'art. 6(1) + Annex I' : 'art. 6(1) + Annexe I',
label: lang === 'en' ? 'Safety component of a harmonised product' : 'Composant de sécurité de produit harmonisé',
label: lang === 'en' ? 'Safety component of a harmonised product subject to third-party conformity assessment' : 'Composant de sécurité de produit harmonisé soumis à évaluation de conformité par tiers',
});
categories.push('HAUT_RISQUE_ANNEXE_I');
}
Expand Down Expand Up @@ -369,11 +369,22 @@ export function computeCategory(answers, lang) {
}

const triggers = answers.art50 || [];
if (triggers.length > 0) {
triggers.forEach(id => {
const textHumanEdit = answers.art50TextHumanEdit === 'oui';
const effectiveTriggers = textHumanEdit ? triggers.filter(id => id !== 'genai_text') : triggers;
triggers.forEach(id => {
if (id === 'genai_text' && textHumanEdit) {
justifications.push({
ref: 'art. 50(4) §2 carve-out',
label: lang === 'en'
? 'AI-generated public-interest text is under human review / editorial control — art. 50(4) §2 transparency obligation does not apply (counsel verification required).'
: 'Texte généré d\'intérêt public sous revue / édition humaine — l\'obligation de transparence art. 50(4) §2 ne s\'applique pas (vérification juridique requise).',
});
} else {
const tr = ART50_TRIGGERS.find(x => x.id === id);
if (tr) justifications.push({ ref: tr.ref, label: t(tr.label, lang) });
});
}
});
if (effectiveTriggers.length > 0) {
categories.push('RISQUE_LIMITE');
}

Expand Down
90 changes: 84 additions & 6 deletions src/lib/classify.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ describe('art. 5 — prohibited practices', () => {
prohibitions: ['a'],
annexIII: [3, 4], // education + employment
art50: ['interaction'], // chatbot trigger
annexI: 'oui',
annexICoverage: 'oui',
annexI3rdPartyCA: 'oui',
nature: 'gpai',
gpaiSystemic: 'oui',
}, 'en');
Expand All @@ -66,12 +67,36 @@ describe('art. 5 — prohibited practices', () => {
});
});

describe('art. 6(1) — Annex I product safety pathway', () => {
it('returns HAUT_RISQUE_ANNEXE_I when annexI === "oui"', () => {
const result = computeCategory({ annexI: 'oui' }, 'en');
describe('art. 6(1) — Annex I product safety pathway (cumulative test)', () => {
it('returns HAUT_RISQUE_ANNEXE_I when BOTH annexICoverage AND annexI3rdPartyCA are "oui"', () => {
const result = computeCategory({
annexICoverage: 'oui',
annexI3rdPartyCA: 'oui',
}, 'en');
expect(result.primary).toBe('HAUT_RISQUE_ANNEXE_I');
expect(result.justifications.some(j => /Annex I|Annexe I/.test(j.ref))).toBe(true);
});

it('does NOT trigger HAUT_RISQUE_ANNEXE_I when only coverage is "oui" (no 3rd-party CA)', () => {
const result = computeCategory({
annexICoverage: 'oui',
annexI3rdPartyCA: 'non',
}, 'en');
expect(result.primary).not.toBe('HAUT_RISQUE_ANNEXE_I');
});

it('does NOT trigger HAUT_RISQUE_ANNEXE_I when only 3rd-party CA is "oui" (no Annex I coverage)', () => {
const result = computeCategory({
annexICoverage: 'non',
annexI3rdPartyCA: 'oui',
}, 'en');
expect(result.primary).not.toBe('HAUT_RISQUE_ANNEXE_I');
});

it('does NOT trigger HAUT_RISQUE_ANNEXE_I when neither is set (defaults to null)', () => {
const result = computeCategory({}, 'en');
expect(result.primary).not.toBe('HAUT_RISQUE_ANNEXE_I');
});
});

describe('art. 6(2) — Annex III pathway (no exception)', () => {
Expand Down Expand Up @@ -162,7 +187,8 @@ describe('art. 51-55 + art. 25 — GPAI', () => {
describe('priority ordering across multiple triggers', () => {
it('orders categories: Annex I > Annex III > GPAI_RS > GPAI > RISQUE_LIMITE', () => {
const result = computeCategory({
annexI: 'oui',
annexICoverage: 'oui',
annexI3rdPartyCA: 'oui',
annexIII: [3],
art50: ['interaction'],
nature: 'gpai',
Expand Down Expand Up @@ -208,7 +234,8 @@ describe('i18n parity', () => {
it('produces identical primary, secondary, and justification refs across en and fr (language-independent refs only)', () => {
const answers = {
prohibitions: [],
annexI: 'non',
annexICoverage: 'non',
annexI3rdPartyCA: 'non',
annexIII: [],
art50: ['interaction', 'genai_media'],
nature: 'gpai',
Expand Down Expand Up @@ -524,3 +551,54 @@ describe('art. 25 — substantial-modification provider flip', () => {
expect(result.justifications.some(j => j.ref === 'art. 25')).toBe(false);
});
});

describe('art. 50(4) §2 — human-edit exemption for AI-generated public-interest text', () => {
it('still triggers RISQUE_LIMITE for genai_text when no human-edit claim is made', () => {
const result = computeCategory({ art50: ['genai_text'] }, 'en');
expect(result.primary).toBe('RISQUE_LIMITE');
expect(result.justifications.some(j => j.ref === 'art. 50(4) §2')).toBe(true);
});

it('removes RISQUE_LIMITE contribution of genai_text when art50TextHumanEdit === "oui"', () => {
const result = computeCategory({
art50: ['genai_text'],
art50TextHumanEdit: 'oui',
}, 'en');
expect(result.primary).not.toBe('RISQUE_LIMITE');
expect(result.justifications.some(j => j.ref === 'art. 50(4) §2 carve-out')).toBe(true);
expect(result.justifications.some(j => j.ref === 'art. 50(4) §2')).toBe(false);
});

it('keeps other art50 triggers active when only genai_text is carved out', () => {
const result = computeCategory({
art50: ['genai_text', 'interaction'],
art50TextHumanEdit: 'oui',
}, 'en');
expect(result.primary).toBe('RISQUE_LIMITE');
// chatbot trigger still contributes
expect(result.justifications.some(j => j.ref === 'art. 50(1)')).toBe(true);
// genai_text is carved out
expect(result.justifications.some(j => j.ref === 'art. 50(4) §2')).toBe(false);
expect(result.justifications.some(j => j.ref === 'art. 50(4) §2 carve-out')).toBe(true);
});

it('emits a French label for the carve-out when lang === "fr"', () => {
const result = computeCategory({
art50: ['genai_text'],
art50TextHumanEdit: 'oui',
}, 'fr');
const carveOut = result.justifications.find(j => j.ref === 'art. 50(4) §2 carve-out');
expect(carveOut).toBeDefined();
expect(carveOut.label).toMatch(/édition humaine|revue éditoriale/i);
});

it('is a no-op when art50TextHumanEdit is set but genai_text is not selected', () => {
const result = computeCategory({
art50: ['interaction'],
art50TextHumanEdit: 'oui',
}, 'en');
expect(result.primary).toBe('RISQUE_LIMITE');
expect(result.justifications.some(j => j.ref === 'art. 50(4) §2 carve-out')).toBe(false);
expect(result.justifications.some(j => j.ref === 'art. 50(1)')).toBe(true);
});
});