From 4a5223cd867dba869ae7372f5d0fb0166424036b Mon Sep 17 00:00:00 2001 From: abk1969 Date: Fri, 15 May 2026 15:34:34 +0200 Subject: [PATCH 1/4] feat(classify): split Annex I trigger into cumulative coverage + 3rd-party CA tests art. 6(1) requires BOTH (i) Annex I harmonisation coverage AND (ii) third-party conformity assessment obligation. The single annexI yes/no allowed false-positive HAUT_RISQUE_ANNEXE_I when only (i) was true. Replace with two cumulative answer fields and a Step 4 two-part question. Co-Authored-By: Claude Opus 4.7 (1M context) --- ai-act-compass.jsx | 66 ++++++++++++++++++++++++++++++---------- src/lib/classify.js | 4 +-- src/lib/classify.test.js | 39 ++++++++++++++++++++---- 3 files changed, 85 insertions(+), 24 deletions(-) diff --git a/ai-act-compass.jsx b/ai-act-compass.jsx index 88001ac..93fb8eb 100644 --- a/ai-act-compass.jsx +++ b/ai-act-compass.jsx @@ -2172,7 +2172,7 @@ 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, + role: null, nature: null, prohibitions: null, prohibitionCarveOuts: {}, annexICoverage: null, annexI3rdPartyCA: null, annexIII: [], annexIII5Subitems: [], exceptions: null, profiling: false, art50: [], gpaiSystemic: null, deployerKind: null, substantialModification: null, }); @@ -2196,7 +2196,7 @@ export default function App() { const restart = () => { setAnswers({ - role: null, nature: null, prohibitions: null, prohibitionCarveOuts: {}, annexI: null, + role: null, nature: null, prohibitions: null, prohibitionCarveOuts: {}, annexICoverage: null, annexI3rdPartyCA: null, annexIII: [], annexIII5Subitems: [], exceptions: null, profiling: false, art50: [], gpaiSystemic: null, deployerKind: null, substantialModification: null, }); @@ -2412,22 +2412,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} > -
- 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)} - /> - setAnswers({ ...answers, annexI: 'non' })} - title={t(UI.no, lang)} desc={t(UI.q4NoDesc, lang)} - /> +
+
+
+ {lang === 'en' ? '1. Annex I coverage' : '1. Couverture Annexe I'} +
+ setAnswers({ ...answers, annexICoverage: 'oui' })} + 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, …).'} + /> + 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.'} + /> +
+ + {answers.annexICoverage === 'oui' && ( +
+
+ {lang === 'en' ? '2. Third-party conformity assessment' : '2. Évaluation de conformité par tiers'} +
+
+ {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.'} +
+ 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.'} + /> + 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.'} + /> +
+ )}
)} diff --git a/src/lib/classify.js b/src/lib/classify.js index 2437858..d46c308 100644 --- a/src/lib/classify.js +++ b/src/lib/classify.js @@ -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'); } diff --git a/src/lib/classify.test.js b/src/lib/classify.test.js index 890f739..bf31ebd 100644 --- a/src/lib/classify.test.js +++ b/src/lib/classify.test.js @@ -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'); @@ -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)', () => { @@ -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', @@ -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', From 6e0fcb7e6e34b075e961b27623fb093e4c3bfb72 Mon Sep 17 00:00:00 2001 From: abk1969 Date: Fri, 15 May 2026 15:36:43 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat(classify):=20model=20art.=2050(4)=20?= =?UTF-8?q?=C2=A72=20human-edit=20exemption=20for=20AI-generated=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user declares the AI-generated public-interest text is under human review / editorial control (art. 50(4) second subparagraph), the genai_text trigger no longer contributes to RISQUE_LIMITE. Other art. 50 triggers (chatbot, biocat_emotion, genai_media) remain active. Co-Authored-By: Claude Opus 4.7 (1M context) --- ai-act-compass.jsx | 36 ++++++++++++++++++++++++++++++++--- src/lib/classify.js | 17 ++++++++++++++--- src/lib/classify.test.js | 41 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 6 deletions(-) diff --git a/ai-act-compass.jsx b/ai-act-compass.jsx index 93fb8eb..dfa5ae5 100644 --- a/ai-act-compass.jsx +++ b/ai-act-compass.jsx @@ -2173,7 +2173,7 @@ export default function App() { const [step, setStep] = useState(0); const [answers, setAnswers] = useState({ role: null, nature: null, prohibitions: null, prohibitionCarveOuts: {}, annexICoverage: null, annexI3rdPartyCA: null, - annexIII: [], annexIII5Subitems: [], exceptions: null, profiling: false, art50: [], gpaiSystemic: null, + annexIII: [], annexIII5Subitems: [], exceptions: null, profiling: false, art50: [], art50TextHumanEdit: null, gpaiSystemic: null, deployerKind: null, substantialModification: null, }); @@ -2197,7 +2197,7 @@ export default function App() { const restart = () => { setAnswers({ role: null, nature: null, prohibitions: null, prohibitionCarveOuts: {}, annexICoverage: null, annexI3rdPartyCA: null, - annexIII: [], annexIII5Subitems: [], exceptions: null, profiling: false, art50: [], gpaiSystemic: null, + annexIII: [], annexIII5Subitems: [], exceptions: null, profiling: false, art50: [], art50TextHumanEdit: null, gpaiSystemic: null, deployerKind: null, substantialModification: null, }); setStep(0); @@ -2624,13 +2624,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)} /> ); })}
+ + {(answers.art50 || []).includes('genai_text') && ( +
+
+ {lang === 'en' ? 'Human review carve-out (art. 50(4) §2)' : 'Exception revue humaine (art. 50(4) §2)'} +
+
+ {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.'} +
+ 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.'} + /> + 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.'} + /> +
+ )} )} diff --git a/src/lib/classify.js b/src/lib/classify.js index d46c308..bc41f9e 100644 --- a/src/lib/classify.js +++ b/src/lib/classify.js @@ -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'); } diff --git a/src/lib/classify.test.js b/src/lib/classify.test.js index bf31ebd..8335f3c 100644 --- a/src/lib/classify.test.js +++ b/src/lib/classify.test.js @@ -551,3 +551,44 @@ 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); + }); +}); From 29b642000152bb0acb99aac8a10d453649551796 Mon Sep 17 00:00:00 2001 From: abk1969 Date: Fri, 15 May 2026 15:37:18 +0200 Subject: [PATCH 3/4] feat(checklist): surface art. 50(5) accessibility caveat in RISQUE_LIMITE checklist art. 50(5) imposes that transparency information be clear, distinguishable, provided at the latest at the first interaction/exposure, and accessible to persons with disabilities. Add the corresponding compliance-deliverable entry. Co-Authored-By: Claude Opus 4.7 (1M context) --- ai-act-compass.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/ai-act-compass.jsx b/ai-act-compass.jsx index dfa5ae5..cf4705a 100644 --- a/ai-act-compass.jsx +++ b/ai-act-compass.jsx @@ -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, provided at the latest at first interaction/exposure, and accessible (incl. WCAG-level accessibility for persons with disabilities where applicable)', 'Information de transparence : claire, distinguable, fournie au plus tard à la 1ʳᵉ interaction/exposition, et accessible (incl. niveau WCAG pour personnes en situation de handicap si applicable)') }, ]}, { 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') }, From a698dd39cf021f5feb765fa8fab38e7ab85524f7 Mon Sep 17 00:00:00 2001 From: abk1969 Date: Fri, 15 May 2026 16:04:42 +0200 Subject: [PATCH 4/4] refactor: polish B+E+F per code review - Item B: reset annexI3rdPartyCA when coverage flips to 'oui' (avoid stale answer carrying over from a previous 'non' click on coverage) - Item E: add orphan-claim test (art50TextHumanEdit='oui' without genai_text selected is a graceful no-op) - Item F: replace speculative 'WCAG-level accessibility' citation in the art. 50(5) checklist line with the regulation's actual reference (Directive 2019/882/EU European Accessibility Act) Co-Authored-By: Claude Opus 4.7 (1M context) --- ai-act-compass.jsx | 4 ++-- src/lib/classify.test.js | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/ai-act-compass.jsx b/ai-act-compass.jsx index cf4705a..ca1cb74 100644 --- a/ai-act-compass.jsx +++ b/ai-act-compass.jsx @@ -604,7 +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, provided at the latest at first interaction/exposure, and accessible (incl. WCAG-level accessibility for persons with disabilities where applicable)', 'Information de transparence : claire, distinguable, fournie au plus tard à la 1ʳᵉ interaction/exposition, et accessible (incl. niveau WCAG pour personnes en situation de handicap si applicable)') }, + { 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') }, @@ -2423,7 +2423,7 @@ export default function App() { setAnswers({ ...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' diff --git a/src/lib/classify.test.js b/src/lib/classify.test.js index 8335f3c..867235f 100644 --- a/src/lib/classify.test.js +++ b/src/lib/classify.test.js @@ -591,4 +591,14 @@ describe('art. 50(4) §2 — human-edit exemption for AI-generated public-intere 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); + }); });