use serde::{Deserialize, Serialize};
use std::sync::LazyLock;
use regex::Regex;
use crate::airlock::AirlockMode;
use crate::decision::PolicyDecision;
use crate::reversibility::ResponseLevel;
pub const EU_AI_ACT_ART50_ANCHOR: &str = "eu-ai-act-article-50-transparency";
static DISCLOSURE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)((?:^|[^<\[\w-])ai[\s-]generated|generated by (an? )?ai\b|generated by artificial intelligence|created by (an? )?ai\b|produced by (an? )?ai\b|written by (an? )?ai\b|artificially generated|this (content|response|message|output|text) (was|is|has been) (generated|produced|created|written)( automatically)?( by)?( an?)?( ai\b| artificial intelligence)|you are (interacting|chatting|speaking) with (an? )?ai\b)",
)
.expect("valid Art.50 disclosure regex")
});
static MACHINE_READABLE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"(?i)(<ai[\s-]generated\b|</ai[\s-]generated>|data-ai-generated\s*=|["']?ai_generated["']?\s*[:=]\s*true|x-ai-generated\s*:|content-credentials|c2pa\b|synthid|\[ai-generated\]|\{ai:generated\})"#,
)
.expect("valid Art.50 machine-readable regex")
});
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Art50Config {
pub require_disclosure: bool,
pub require_machine_readable: bool,
}
impl Default for Art50Config {
fn default() -> Self {
Self {
require_disclosure: true,
require_machine_readable: true,
}
}
}
impl Art50Config {
pub fn from_json(value: &serde_json::Value) -> Self {
let d = Self::default();
Self {
require_disclosure: value
.get("require_disclosure")
.and_then(serde_json::Value::as_bool)
.unwrap_or(d.require_disclosure),
require_machine_readable: value
.get("require_machine_readable")
.and_then(serde_json::Value::as_bool)
.unwrap_or(d.require_machine_readable),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Art50Status {
Compliant,
MissingDisclosure,
MissingMachineReadable,
MissingBoth,
}
impl Art50Status {
pub fn as_str(self) -> &'static str {
match self {
Art50Status::Compliant => "compliant",
Art50Status::MissingDisclosure => "missing_disclosure",
Art50Status::MissingMachineReadable => "missing_machine_readable",
Art50Status::MissingBoth => "missing_both",
}
}
pub fn is_compliant(self) -> bool {
matches!(self, Art50Status::Compliant)
}
}
pub fn has_disclosure(response_text: &str) -> bool {
DISCLOSURE_RE.is_match(response_text)
}
pub fn has_machine_readable_marker(response_text: &str) -> bool {
MACHINE_READABLE_RE.is_match(response_text)
}
pub fn check(response_text: &str, cfg: Art50Config) -> Art50Status {
let missing_disclosure = cfg.require_disclosure && !has_disclosure(response_text);
let missing_marker =
cfg.require_machine_readable && !has_machine_readable_marker(response_text);
match (missing_disclosure, missing_marker) {
(false, false) => Art50Status::Compliant,
(true, false) => Art50Status::MissingDisclosure,
(false, true) => Art50Status::MissingMachineReadable,
(true, true) => Art50Status::MissingBoth,
}
}
pub fn response_level(status: Art50Status, mode: AirlockMode) -> ResponseLevel {
if status.is_compliant() || mode == AirlockMode::Shadow {
ResponseLevel::AllowAndLog
} else {
ResponseLevel::RequireApproval
}
}
pub fn enforce(status: Art50Status, mode: AirlockMode) -> PolicyDecision {
if status.is_compliant() {
return PolicyDecision::allow(
"art50: response carries the required AI-transparency markings",
);
}
let reason = format!(
"art50: response violates EU AI Act Article 50 transparency ({}) [{}]",
status.as_str(),
EU_AI_ACT_ART50_ANCHOR
);
match mode {
AirlockMode::Enforce => PolicyDecision::deny(reason),
AirlockMode::Shadow => PolicyDecision::allow(format!("shadow: {reason}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
const COMPLIANT: &str =
"Here is the summary. This response was generated by an AI. <ai-generated model=\"x\"/>";
#[test]
fn compliant_response_passes() {
let status = check(COMPLIANT, Art50Config::default());
assert_eq!(status, Art50Status::Compliant);
assert!(status.is_compliant());
}
#[test]
fn missing_disclosure_only() {
let text = "Here is the answer. <ai-generated/>";
assert_eq!(
check(text, Art50Config::default()),
Art50Status::MissingDisclosure
);
}
#[test]
fn missing_machine_readable_only() {
let text = "Here is the answer. Note: this content is AI-generated.";
assert_eq!(
check(text, Art50Config::default()),
Art50Status::MissingMachineReadable
);
}
#[test]
fn missing_both() {
let text = "Here is a perfectly ordinary answer with no markings at all.";
assert_eq!(
check(text, Art50Config::default()),
Art50Status::MissingBoth
);
}
#[test]
fn disclosure_phrase_variants_detected() {
for t in [
"Generated by AI.",
"This message was generated by an AI.",
"You are interacting with an AI assistant.",
"Content is artificially generated.",
"This output has been produced by AI automatically.",
] {
assert!(has_disclosure(t), "should detect disclosure in {t:?}");
}
}
#[test]
fn machine_readable_marker_variants_detected() {
for t in [
"<ai-generated/>",
"prefix data-ai-generated=\"true\" suffix",
"{\"ai_generated\": true}",
"C2PA content-credentials attached",
"[ai-generated]",
] {
assert!(
has_machine_readable_marker(t),
"should detect marker in {t:?}"
);
}
}
#[test]
fn config_can_relax_one_obligation() {
let disclosure_only = Art50Config {
require_disclosure: true,
require_machine_readable: false,
};
let text = "This content is AI-generated.";
assert_eq!(check(text, disclosure_only), Art50Status::Compliant);
}
#[test]
fn from_json_defaults_fail_closed() {
let cfg = Art50Config::from_json(&serde_json::json!({}));
assert_eq!(cfg, Art50Config::default());
assert!(cfg.require_disclosure && cfg.require_machine_readable);
let relaxed =
Art50Config::from_json(&serde_json::json!({"require_machine_readable": false}));
assert!(relaxed.require_disclosure && !relaxed.require_machine_readable);
}
#[test]
fn enforce_mode_denies_noncompliant() {
let status = Art50Status::MissingBoth;
let d = enforce(status, AirlockMode::Enforce);
assert!(d.is_denied());
assert!(d.reason.contains(EU_AI_ACT_ART50_ANCHOR));
}
#[test]
fn shadow_mode_allows_but_logs_noncompliant() {
let d = enforce(Art50Status::MissingDisclosure, AirlockMode::Shadow);
assert!(d.is_allowed());
assert!(d.reason.starts_with("shadow:"));
}
#[test]
fn compliant_always_allows() {
for mode in [AirlockMode::Enforce, AirlockMode::Shadow] {
assert!(enforce(Art50Status::Compliant, mode).is_allowed());
}
}
#[test]
fn response_level_maps_to_r_rungs() {
assert_eq!(
response_level(Art50Status::MissingBoth, AirlockMode::Enforce),
ResponseLevel::RequireApproval
);
assert_eq!(
response_level(Art50Status::MissingBoth, AirlockMode::Enforce).rung(),
"R3"
);
assert_eq!(
response_level(Art50Status::MissingBoth, AirlockMode::Shadow),
ResponseLevel::AllowAndLog
);
assert_eq!(
response_level(Art50Status::Compliant, AirlockMode::Enforce),
ResponseLevel::AllowAndLog
);
}
#[test]
fn status_wire_labels_round_trip() {
for s in [
Art50Status::Compliant,
Art50Status::MissingDisclosure,
Art50Status::MissingMachineReadable,
Art50Status::MissingBoth,
] {
let json = serde_json::to_string(&s).unwrap();
assert_eq!(json, format!("\"{}\"", s.as_str()));
let back: Art50Status = serde_json::from_str(&json).unwrap();
assert_eq!(back, s);
}
}
}