true, 'modele' => isset( $args['modele'] ) ? sanitize_text_field( $args['modele'] ) : '', 'fournisseur' => isset( $args['fournisseur'] ) ? sanitize_text_field( $args['fournisseur'] ) : '', 'extension' => isset( $args['extension'] ) ? sanitize_key( $args['extension'] ) : '', 'portee' => ( isset( $args['portee'] ) && 'partiel' === $args['portee'] ) ? 'partiel' : 'integral', // 'metadonnee' = seule une métadonnée est générée ; l'objet, lui, ne l'est pas. 'cible' => ( isset( $args['cible'] ) && 'metadonnee' === $args['cible'] ) ? 'metadonnee' : 'contenu', 'horodatage' => gmdate( 'c' ), 'reglement' => 'UE 2024/1689 art. 50(2)', 'version' => self::VERSION, ); update_post_meta( $post_id, self::META_CLE, $donnees ); // Médias : on ajoute le terme IPTC pour que le marquage survive à l'export du fichier. // ⛔ JAMAIS quand seule une métadonnée est générée : déclarer une photo humaine « produite par // un algorithme entraîné » parce que son texte alternatif l'est serait une fausseté, et une // fausse déclaration de provenance décrédibilise les vraies. if ( 'contenu' === $donnees['cible'] && 'attachment' === get_post_type( $post_id ) ) { $meta = wp_get_attachment_metadata( $post_id ); if ( is_array( $meta ) ) { $meta['image_meta'] = isset( $meta['image_meta'] ) && is_array( $meta['image_meta'] ) ? $meta['image_meta'] : array(); $meta['image_meta']['digital_source_type'] = self::IPTC_SOURCE; wp_update_attachment_metadata( $post_id, $meta ); } } return true; } /** Le contenu est-il marqué ? */ public static function est_genere( $post_id ) { $d = get_post_meta( absint( $post_id ), self::META_CLE, true ); return is_array( $d ) && ! empty( $d['genere_par_ia'] ); } /** Les données de marquage, ou un tableau vide. */ public static function donnees( $post_id ) { $d = get_post_meta( absint( $post_id ), self::META_CLE, true ); return is_array( $d ) ? $d : array(); } /** * Attributs HTML lisibles par machine, à coller sur un bloc généré rendu à la volée * (cas où le contenu n'est pas persisté dans un article). */ public static function attributs( $args = array() ) { $out = ' data-ai-generated="true"'; if ( ! empty( $args['modele'] ) ) { $out .= ' data-ai-model="' . esc_attr( $args['modele'] ) . '"'; } if ( ! empty( $args['fournisseur'] ) ) { $out .= ' data-ai-provider="' . esc_attr( $args['fournisseur'] ) . '"'; } return $out; } /** Pose-t-on les filtres deux fois ? Le docbloc l interdisait sans le faire respecter. */ private static $initialise = false; /** Branchement des filtres. Idempotent : plusieurs extensions AIFORYA peuvent l appeler. */ public static function init() { if ( self::$initialise ) { return; } self::$initialise = true; add_filter( 'the_content', array( __CLASS__, 'filtrer_contenu' ), 99 ); // Capture par empreinte : c'est CE crochet qui couvre les points d'appel indirects. add_action( 'save_post', array( __CLASS__, 'marquer_si_genere' ), 20, 2 ); add_filter( 'wp_get_attachment_image_attributes', array( __CLASS__, 'filtrer_image' ), 10, 2 ); add_action( 'rest_api_init', array( __CLASS__, 'exposer_en_rest' ) ); } /** * Enveloppe le contenu marqué. Le conteneur porte l'attribut ; il n'ajoute AUCUN texte visible, * parce que le §2 demande un marquage lisible par MACHINE — la mention lisible par l'humain * relève du §4 et du déployeur, qui seul connaît son contexte éditorial. */ public static function filtrer_contenu( $contenu ) { if ( ! is_singular() || ! in_the_loop() || ! is_main_query() ) { return $contenu; } $d = self::donnees( get_the_ID() ); if ( empty( $d['genere_par_ia'] ) ) { return $contenu; } // Une métadonnée générée ne rend pas le CONTENU généré : ne pas l'envelopper. if ( isset( $d['cible'] ) && 'metadonnee' === $d['cible'] ) { return $contenu; } return ' $d['modele'], 'fournisseur' => $d['fournisseur'] ) ) . ' data-ai-scope="' . esc_attr( $d['portee'] ) . '">' . $contenu . ''; } /** Idem sur les images marquées. */ public static function filtrer_image( $attr, $attachment ) { if ( $attachment && self::est_genere( $attachment->ID ) ) { $d = self::donnees( $attachment->ID ); // L'image n'est marquée que si c'est ELLE qui a été générée, pas son texte alternatif. if ( ! isset( $d['cible'] ) || 'contenu' === $d['cible'] ) { $attr['data-ai-generated'] = 'true'; } } return $attr; } /** * Expose le marquage en REST. C'est CE point qui rend le marquage réellement lisible par machine * hors du rendu HTML : un agrégateur, un moteur ou un contrôle automatisé lit l'API, pas la page. */ public static function exposer_en_rest() { foreach ( array( 'post', 'page', 'attachment' ) as $type ) { register_rest_field( $type, 'aiforya_ia_genere', array( 'get_callback' => function ( $objet ) { return self::donnees( $objet['id'] ); }, 'schema' => array( 'description' => 'Labelling of AI-generated content — Regulation (EU) 2024/1689, art. 50(2).', 'type' => 'object', ), ) ); } } // ===================================================================== // Capture par empreinte — le chemin des générations INDIRECTES // ===================================================================== /** Clé du dépôt d'empreintes, par utilisateur : deux rédacteurs ne se marquent pas l'un l'autre. */ const DEPOT_PREFIXE = 'aiforya_ia_gen_'; /** Durée de vie d'une empreinte en attente. Au-delà, on considère que le texte n'a pas été publié. */ const DEPOT_DUREE = HOUR_IN_SECONDS; /** Nombre d'empreintes gardées par utilisateur (les plus récentes). */ const DEPOT_MAX = 20; /** * Longueur minimale d'un texte généré pour être empreint. * En dessous, deux textes différents se ressemblent trop et on marquerait à tort. */ const EMPREINTE_MIN = 120; /** Taille d'une fenêtre d'empreinte. */ const EMPREINTE_FENETRE = 100; /** * Enregistre qu'un texte vient d'être produit par un modèle. * * Appelée au SEUL point de sortie du service IA de chaque extension, donc une fois par * génération, quel que soit le point d'appel métier. * * @param string $texte Sortie brute du modèle. * @param array $args extension · modele · fournisseur. */ public static function enregistrer_generation( $texte, $args = array() ) { $texte = (string) $texte; if ( ! function_exists( 'get_current_user_id' ) ) { return; } $uid = get_current_user_id(); if ( ! $uid ) { return; // Génération hors session identifiée : rien à rapprocher d'un enregistrement. } $fenetres = self::fenetres( $texte ); if ( ! $fenetres ) { return; // Trop court pour être distingué — ne pas empreindre plutôt que marquer à tort. } $depot = get_transient( self::DEPOT_PREFIXE . $uid ); $depot = is_array( $depot ) ? $depot : array(); $depot[] = array( 'fenetres' => $fenetres, 'args' => array( 'extension' => isset( $args['extension'] ) ? sanitize_key( $args['extension'] ) : '', 'modele' => isset( $args['modele'] ) ? sanitize_text_field( $args['modele'] ) : '', 'fournisseur' => isset( $args['fournisseur'] ) ? sanitize_text_field( $args['fournisseur'] ) : '', ), ); if ( count( $depot ) > self::DEPOT_MAX ) { $depot = array_slice( $depot, -self::DEPOT_MAX ); } set_transient( self::DEPOT_PREFIXE . $uid, $depot, self::DEPOT_DUREE ); } /** * À l'enregistrement d'un article : son contenu porte-t-il une empreinte en attente ? * * ⚠ Marque l'article ENTIER en portée 'partiel' : on sait qu'une partie vient d'un modèle, on * ne sait pas laquelle ni combien. Annoncer 'integral' serait affirmer plus que ce qu'on mesure. */ public static function marquer_si_genere( $post_id, $post = null ) { if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { return; } $post_id = absint( $post_id ); if ( ! $post_id || wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { return; } if ( self::est_genere( $post_id ) ) { return; // Déjà marqué (point d'appel direct, ou enregistrement précédent). } $uid = get_current_user_id(); if ( ! $uid ) { return; } $depot = get_transient( self::DEPOT_PREFIXE . $uid ); if ( ! is_array( $depot ) || ! $depot ) { return; } $post = $post ? $post : get_post( $post_id ); if ( ! $post ) { return; } $foin = self::normaliser( $post->post_title . ' ' . $post->post_content ); if ( '' === $foin ) { return; } $restant = array(); $trouve = null; foreach ( $depot as $entree ) { if ( null === $trouve && self::porte_empreinte( $foin, $entree['fenetres'] ) ) { $trouve = $entree['args']; continue; // consommée } $restant[] = $entree; } if ( null === $trouve ) { return; } set_transient( self::DEPOT_PREFIXE . $uid, $restant, self::DEPOT_DUREE ); self::marquer( $post_id, array( 'extension' => $trouve['extension'], 'modele' => $trouve['modele'], 'fournisseur' => $trouve['fournisseur'], 'portee' => 'partiel', 'cible' => 'contenu', ) ); } /** Le contenu enregistré contient-il l'une des fenêtres ? Une seule suffit. */ private static function porte_empreinte( $foin, $fenetres ) { foreach ( (array) $fenetres as $f ) { if ( '' !== $f && false !== strpos( $foin, $f ) ) { return true; } } return false; } /** * Trois fenêtres prélevées au début, au milieu et à la fin du texte normalisé. * * Trois plutôt qu'une : un rédacteur qui coupe l'introduction ou la conclusion garde le * milieu, et une seule fenêtre au début raterait ce cas très courant. */ private static function fenetres( $texte ) { $n = self::normaliser( $texte ); $l = strlen( $n ); if ( $l < self::EMPREINTE_MIN ) { return array(); } $w = self::EMPREINTE_FENETRE; $out = array( substr( $n, 0, $w ) ); if ( $l >= $w * 3 ) { $out[] = substr( $n, (int) floor( ( $l - $w ) / 2 ), $w ); $out[] = substr( $n, $l - $w, $w ); } return array_values( array_unique( array_filter( $out ) ) ); } /** Minuscules, sans balises, espaces normalisés — pour survivre à la mise en forme de l'éditeur. */ private static function normaliser( $t ) { $t = wp_strip_all_tags( (string) $t ); $t = html_entity_decode( $t, ENT_QUOTES, 'UTF-8' ); $t = preg_replace( '/\s+/u', ' ', $t ); return trim( function_exists( 'mb_strtolower' ) ? mb_strtolower( $t, 'UTF-8' ) : strtolower( $t ) ); } }