Skip to content
Merged
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
128 changes: 115 additions & 13 deletions ai-act-compass.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import {
import { t } from './src/lib/i18n.js';
import {
computeCategory,
computeRoleNotes,
PROHIBITED_PRACTICES,
ANNEX_III_AREAS,
ART50_TRIGGERS,
ART5_CARVEOUTS,
} from './src/lib/classify.js';

/* ============================================================================
Expand Down Expand Up @@ -491,6 +493,11 @@ const QUICKWINS = {
],
};

// Identifies the art. 27 FRIA quickwin so it can be gated by computeRoleNotes.
// Keep this in sync with QUICKWINS.HAUT_RISQUE_ANNEXE_III; if other quickwins
// ever cite art. 27 for unrelated reasons, this predicate needs tightening.
const isFriaItem = (item) => (item.refs || []).some(r => r === 'art. 27');

/* ---------------------------------------------------------------------------
* CHECKLIST by category
* ------------------------------------------------------------------------- */
Expand Down Expand Up @@ -1304,7 +1311,7 @@ function PrintSection({ icon: Icon, title, breakBefore = true, children }) {
);
}

function generateReport(answers, result, lang) {
function generateReport(answers, result, lang, friaRequired = false) {
const meta = CATEGORIES_META[result.primary];
const role = ROLES.find(r => r.id === answers.role);
const lines = [];
Expand All @@ -1323,7 +1330,9 @@ function generateReport(answers, result, lang) {
result.justifications.forEach(j => lines.push(` - [${j.ref}] ${j.label}`));
lines.push('');
lines.push(t(UI.reportQuickwins, lang));
(QUICKWINS[result.primary] || []).forEach((q, i) => {
(QUICKWINS[result.primary] || [])
.filter(q => !isFriaItem(q) || friaRequired)
.forEach((q, i) => {
lines.push(` ${i + 1}. ${t(q.titre, lang)} [${t(q.delai, lang)}]`);
lines.push(` ${t(q.action, lang)}`);
lines.push(` ${t(UI.reportRefs, lang)} : ${q.refs.map(r => t(r, lang)).join(' | ')}`);
Expand Down Expand Up @@ -1366,11 +1375,11 @@ const htmlEscape = (s) => String(s ?? '').replace(/[&<>"']/g, c => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));

function buildPrintHTML({ result, answers, lang, today, checked }) {
function buildPrintHTML({ result, answers, lang, today, checked, friaRequired = false }) {
const meta = CATEGORIES_META[result.primary];
const role = ROLES.find(r => r.id === answers.role);
const nature = NATURES.find(n => n.id === answers.nature);
const QW = QUICKWINS[result.primary] || [];
const QW = (QUICKWINS[result.primary] || []).filter(q => !isFriaItem(q) || friaRequired);
const CL = CHECKLIST[result.primary] || [];
const extraCL = (result.secondary || []).map(s => ({ cat: s, items: CHECKLIST[s] || [] }));
const applicableTimeline = TIMELINE.filter(m =>
Expand Down Expand Up @@ -1641,7 +1650,12 @@ function Result({ answers, result, onRestart }) {
useEffect(() => { window.scrollTo({ top: 0, behavior: 'auto' }); }, []);

const meta = CATEGORIES_META[result.primary];
const roleNotes = useMemo(
() => computeRoleNotes(answers, answers.role, lang),
[answers, lang],
);
const QW = QUICKWINS[result.primary] || [];
const gatedQW = QW.filter(item => !isFriaItem(item) || roleNotes.friaRequired);
const CL = CHECKLIST[result.primary] || [];
const role = ROLES.find(r => r.id === answers.role);
const nature = NATURES.find(n => n.id === answers.nature);
Expand All @@ -1654,7 +1668,7 @@ function Result({ answers, result, onRestart }) {
);

const handleCopy = async () => {
const txt = generateReport(answers, result, lang);
const txt = generateReport(answers, result, lang, roleNotes.friaRequired);
// Tentative API moderne (nécessite un contexte sécurisé HTTPS)
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
Expand Down Expand Up @@ -1709,7 +1723,7 @@ function Result({ answers, result, onRestart }) {
setPdfBusy(true);

// 1. Build print CSS + HTML
const { css, html } = buildPrintHTML({ result, answers, lang, today, checked });
const { css, html } = buildPrintHTML({ result, answers, lang, today, checked, friaRequired: roleNotes.friaRequired });

// 2. Inject the HTML into a hidden sandbox AND inject the <style> inside
// .aac-print itself (as the LAST child, after content) so html2canvas
Expand Down Expand Up @@ -2052,7 +2066,12 @@ ${reportHTML}
>
{t(UI.quickwinsIntro, lang)}
</p>
<QuickwinsList items={QW} />
{roleNotes.friaRequired && roleNotes.friaReason?.label && (
<p className="text-[13px] text-ink-muted mb-5 italic opacity-70">
{lang === 'en' ? 'FRIA note: ' : 'Note FRIA : '}{roleNotes.friaReason.label}
</p>
)}
<QuickwinsList items={gatedQW} />
</div>
)}
{tab === 'checklist' && (
Expand All @@ -2078,7 +2097,7 @@ ${reportHTML}
* Cachée à l'écran, déployée uniquement à l'impression.
* ========================================================== */}
<PrintSection icon={Zap} title={t(UI.printSectionQW, lang)}>
<QuickwinsList items={QW} />
<QuickwinsList items={gatedQW} />
</PrintSection>

<PrintSection icon={ListChecks} title={t(UI.printSectionCL, lang)}>
Expand Down Expand Up @@ -2152,8 +2171,9 @@ 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, annexI: null,
role: null, nature: null, prohibitions: null, prohibitionCarveOuts: {}, annexI: null,
annexIII: [], exceptions: null, profiling: false, art50: [], gpaiSystemic: null,
deployerKind: null, substantialModification: null,
});

// Fonts (Fraunces variable + JetBrains Mono) are loaded via @import in
Expand All @@ -2175,8 +2195,9 @@ export default function App() {

const restart = () => {
setAnswers({
role: null, nature: null, prohibitions: null, annexI: null,
role: null, nature: null, prohibitions: null, prohibitionCarveOuts: {}, annexI: null,
annexIII: [], exceptions: null, profiling: false, art50: [], gpaiSystemic: null,
deployerKind: null, substantialModification: null,
});
setStep(0);
};
Expand Down Expand Up @@ -2234,14 +2255,39 @@ export default function App() {
<OptionCard
key={r.id}
selected={answers.role === r.id}
onClick={() => setAnswers({ ...answers, role: r.id })}
onClick={() => setAnswers({ ...answers, role: r.id, deployerKind: null })}
icon={r.icon}
title={t(r.label, lang)}
sub={t(r.sub, lang)}
desc={t(r.desc, lang)}
/>
))}
</div>
{answers.role === 'deployeur' && (
<div className="mt-6 space-y-2">
<div className="text-sm uppercase tracking-wider opacity-60">
{lang === 'en' ? 'Deployer kind (art. 27 gating)' : 'Type de déployeur (gating art. 27)'}
</div>
<OptionCard
selected={answers.deployerKind === 'public_body'}
onClick={() => setAnswers({ ...answers, deployerKind: 'public_body' })}
title={lang === 'en' ? 'Body governed by public law' : 'Organisme de droit public'}
desc={lang === 'en' ? 'Public administration, public agency, state-owned entity.' : 'Administration publique, agence publique, entité étatique.'}
/>
<OptionCard
selected={answers.deployerKind === 'private_public_service'}
onClick={() => setAnswers({ ...answers, deployerKind: 'private_public_service' })}
title={lang === 'en' ? 'Private entity providing public services' : 'Entité privée fournissant un service public'}
desc={lang === 'en' ? 'Operator of public service under delegation/concession.' : 'Opérateur de service public en délégation/concession.'}
/>
<OptionCard
selected={answers.deployerKind === 'private_other'}
onClick={() => setAnswers({ ...answers, deployerKind: 'private_other' })}
title={lang === 'en' ? 'Other private deployer' : 'Autre déployeur privé'}
desc={lang === 'en' ? 'No public-service mandate.' : 'Sans mission de service public.'}
/>
</div>
)}
</QuestionFrame>
)}

Expand All @@ -2258,13 +2304,42 @@ export default function App() {
<OptionCard
key={n.id}
selected={answers.nature === n.id}
onClick={() => setAnswers({ ...answers, nature: n.id })}
onClick={() => setAnswers({ ...answers, nature: n.id, substantialModification: null })}
title={t(n.label, lang)}
sub={t(n.sub, lang)}
desc={t(n.desc, lang)}
/>
))}
</div>
{answers.nature === 'systeme_sur_gpai' && (
<div className="mt-6 space-y-2">
<div className="text-sm uppercase tracking-wider opacity-60">
{lang === 'en' ? 'Substantial modification (art. 25)' : 'Modification substantielle (art. 25)'}
</div>
<div className="text-xs opacity-70">
{lang === 'en'
? 'Have you fine-tuned, retrained, or repurposed the third-party model in a way that materially changes its intended purpose or affects its compliance? (See recitals 84, 109.)'
: 'Avez-vous fine-tuné, réentraîné ou repurposé le modèle tiers d\'une manière qui modifie matériellement sa finalité ou affecte sa conformité ? (Cf. considérants 84, 109.)'}
</div>
<OptionCard
selected={answers.substantialModification === 'oui'}
onClick={() => setAnswers({ ...answers, substantialModification: 'oui' })}
title={lang === 'en' ? 'Yes — substantial modification' : 'Oui — modification substantielle'}
sub="art. 25"
desc={lang === 'en'
? 'You are flipped to GPAI provider for the modified model — art. 53–55 obligations apply.'
: 'Vous êtes requalifié en fournisseur GPAI pour le modèle modifié — obligations art. 53–55 applicables.'}
/>
<OptionCard
selected={answers.substantialModification === 'non'}
onClick={() => setAnswers({ ...answers, substantialModification: 'non' })}
title={lang === 'en' ? 'No — pure integration' : 'Non — intégration pure'}
desc={lang === 'en'
? 'GPAI obligations remain with the upstream model provider; you operate under the AI-system regime only.'
: 'Les obligations GPAI restent sur le fournisseur amont ; vous opérez sous le régime système IA uniquement.'}
/>
</div>
)}
</QuestionFrame>
)}

Expand All @@ -2286,7 +2361,9 @@ export default function App() {
onClick={() => {
const cur = answers.prohibitions || [];
const upd = sel ? cur.filter(x => x !== p.id) : [...cur, p.id];
setAnswers({ ...answers, prohibitions: upd });
const carveOuts = { ...(answers.prohibitionCarveOuts || {}) };
if (sel) delete carveOuts[p.id];
setAnswers({ ...answers, prohibitions: upd, prohibitionCarveOuts: carveOuts });
}}
title={t(p.label, lang)} sub={p.ref} desc={t(p.desc, lang)}
/>
Expand All @@ -2301,6 +2378,31 @@ export default function App() {
/>
</div>
</div>
{(answers.prohibitions || []).some(id => ART5_CARVEOUTS.some(c => c.appliesTo === id)) && (
<div className="mt-6 space-y-2">
<div className="text-sm uppercase tracking-wider opacity-60">
{lang === 'en' ? 'Article 5 carve-outs (optional)' : 'Exceptions article 5 (facultatives)'}
</div>
{ART5_CARVEOUTS
.filter(c => (answers.prohibitions || []).includes(c.appliesTo))
.map(c => (
<OptionCard
key={`carveout-${c.id}`}
selected={!!(answers.prohibitionCarveOuts || {})[c.id]}
onClick={() => setAnswers({
...answers,
prohibitionCarveOuts: {
...(answers.prohibitionCarveOuts || {}),
[c.id]: !((answers.prohibitionCarveOuts || {})[c.id]),
},
})}
title={t(c.label, lang)}
sub={c.ref}
desc={t(c.desc, lang)}
/>
))}
</div>
)}
</QuestionFrame>
)}

Expand Down
Loading
Loading