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 ) );
}
}