From 0c248a14c663b41563062d53dff7f81785b15b02 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 27 Nov 2025 10:08:31 +0100 Subject: [PATCH] feat: Implement AI Watermarking to comply with EU AI Act Signed-off-by: Marcel Klehr --- lib/Service/DocumentGenerationService.php | 7 +++++-- lib/Service/SlideDeckService.php | 18 +++++++++++++++--- .../SlideDeckGenerationProvider.php | 9 +++++---- lib/TaskProcessing/TextToDocumentProvider.php | 7 ++++--- 4 files changed, 29 insertions(+), 12 deletions(-) diff --git a/lib/Service/DocumentGenerationService.php b/lib/Service/DocumentGenerationService.php index 6c7a31b80..ee46bf007 100644 --- a/lib/Service/DocumentGenerationService.php +++ b/lib/Service/DocumentGenerationService.php @@ -10,6 +10,7 @@ use League\CommonMark\GithubFlavoredMarkdownConverter; use OCA\Richdocuments\AppInfo\Application; use OCA\Richdocuments\TaskProcessing\TextToDocumentProvider; use OCA\Richdocuments\TaskProcessing\TextToSpreadsheetProvider; +use OCP\IL10N; use OCP\TaskProcessing\Exception\Exception; use OCP\TaskProcessing\Exception\PreConditionNotMetException; use OCP\TaskProcessing\Exception\UnauthorizedException; @@ -37,15 +38,17 @@ EOF; public function __construct( private IManager $taskProcessingManager, private RemoteService $remoteService, + private IL10N $l10n, ) { } - public function generateTextDocument(?string $userId, string $description, string $targetFormat = TextToDocumentProvider::DEFAULT_TARGET_FORMAT) { + public function generateTextDocument(?string $userId, string $description, string $targetFormat = TextToDocumentProvider::DEFAULT_TARGET_FORMAT, bool $includeWatermark = true) { $prompt = self::TEXT_PROMPT; $taskInput = $prompt . "\n\n" . $description; $markdownContent = $this->runTextToTextTask($taskInput, $userId); $converter = new GithubFlavoredMarkdownConverter(); - $htmlContent = $converter->convert($markdownContent)->getContent(); + $markdownContentWithAiNote = $includeWatermark ? $markdownContent . "\n\n" . $this->l10n->t('This document was generated using Artificial Intelligence') : $markdownContent; + $htmlContent = $converter->convert($markdownContentWithAiNote)->getContent(); $htmlStream = $this->stringToStream($htmlContent); $docxContent = $this->remoteService->convertTo('document.html', $htmlStream, $targetFormat); diff --git a/lib/Service/SlideDeckService.php b/lib/Service/SlideDeckService.php index ad1d54b68..ca00a5c8d 100644 --- a/lib/Service/SlideDeckService.php +++ b/lib/Service/SlideDeckService.php @@ -13,6 +13,7 @@ use OCA\Richdocuments\TaskProcessing\Presentation\Slides\TitleContentSlide; use OCA\Richdocuments\TaskProcessing\Presentation\Slides\TitleSlide; use OCA\Richdocuments\TemplateManager; use OCP\IConfig; +use OCP\IL10N; use OCP\TaskProcessing\Exception\Exception; use OCP\TaskProcessing\Exception\PreConditionNotMetException; use OCP\TaskProcessing\Exception\UnauthorizedException; @@ -70,17 +71,18 @@ EOF; private TemplateManager $templateManager, private RemoteService $remoteService, private IConfig $config, + private IL10N $l10n, ) { } - public function generateSlideDeck(?string $userId, string $presentationText) { + public function generateSlideDeck(?string $userId, string $presentationText, bool $includeWatermark = true) { $rawModelOutput = $this->runLLMQuery($userId, $presentationText); $ooxml = $this->config->getAppValue(Application::APPNAME, 'doc_format', 'ooxml') === 'ooxml'; $format = $ooxml ? 'pptx' : 'odp'; try { - [$presentationStyle, $parsedStructure] = $this->parseModelJSON($rawModelOutput); + [$presentationStyle, $parsedStructure] = $this->parseModelJSON($rawModelOutput, $includeWatermark); } catch (\JsonException) { throw new RuntimeException('LLM generated faulty JSON data'); } @@ -108,7 +110,7 @@ EOF; * @param string $jsonString * @return array */ - private function parseModelJSON(string $jsonString): array { + private function parseModelJSON(string $jsonString, bool $includeWatermark = true): array { $jsonString = trim($jsonString, "` \n\r\t\v\0"); $modelJSON = json_decode( $jsonString, @@ -148,9 +150,19 @@ EOF; $presentation->addSlide($slide); } + if ($includeWatermark) { + // Add a final slide with a note that this was generated by AI + $this->addAiComment(isset($index) ? $index + 1 : 0, $presentation); + } + return [$presentation->getStyle(), $presentation->getSlideCommands()]; } + private function addAiComment(int $index, Presentation $presentation) : void { + $slide = new TitleContentSlide($index, '', $this->l10n->t('Generated using Artificial Intelligence')); + $presentation->addSlide($slide); + } + /** * Creates a presentation file in memory * diff --git a/lib/TaskProcessing/SlideDeckGenerationProvider.php b/lib/TaskProcessing/SlideDeckGenerationProvider.php index b892bcaed..4c6b13daa 100644 --- a/lib/TaskProcessing/SlideDeckGenerationProvider.php +++ b/lib/TaskProcessing/SlideDeckGenerationProvider.php @@ -11,9 +11,9 @@ namespace OCA\Richdocuments\TaskProcessing; use OCA\Richdocuments\AppInfo\Application; use OCA\Richdocuments\Service\SlideDeckService; use OCP\IL10N; -use OCP\TaskProcessing\ISynchronousProvider; +use OCP\TaskProcessing\ISynchronousWatermarkingProvider; -class SlideDeckGenerationProvider implements ISynchronousProvider { +class SlideDeckGenerationProvider implements ISynchronousWatermarkingProvider { public function __construct( private SlideDeckService $slideDeckService, @@ -71,7 +71,7 @@ class SlideDeckGenerationProvider implements ISynchronousProvider { /** * @inheritDoc */ - public function process(?string $userId, array $input, callable $reportProgress): array { + public function process(?string $userId, array $input, callable $reportProgress, bool $includeWatermark = true): array { if ($userId === null) { throw new \RuntimeException('User ID is required to process the prompt.'); } @@ -80,10 +80,11 @@ class SlideDeckGenerationProvider implements ISynchronousProvider { throw new \RuntimeException('Invalid input, expected "text" key with string value'); } - $response = $this->withRetry(function () use ($userId, $input) { + $response = $this->withRetry(function () use ($userId, $input, $includeWatermark) { return $this->slideDeckService->generateSlideDeck( $userId, $input['text'], + $includeWatermark, ); }); diff --git a/lib/TaskProcessing/TextToDocumentProvider.php b/lib/TaskProcessing/TextToDocumentProvider.php index e1cb511c9..38d6d6304 100644 --- a/lib/TaskProcessing/TextToDocumentProvider.php +++ b/lib/TaskProcessing/TextToDocumentProvider.php @@ -12,11 +12,11 @@ use OCA\Richdocuments\AppInfo\Application; use OCA\Richdocuments\Service\DocumentGenerationService; use OCP\IL10N; use OCP\TaskProcessing\EShapeType; -use OCP\TaskProcessing\ISynchronousProvider; +use OCP\TaskProcessing\ISynchronousWatermarkingProvider; use OCP\TaskProcessing\ShapeDescriptor; use OCP\TaskProcessing\ShapeEnumValue; -class TextToDocumentProvider implements ISynchronousProvider { +class TextToDocumentProvider implements ISynchronousWatermarkingProvider { public const DEFAULT_TARGET_FORMAT = 'docx'; public function __construct( @@ -89,7 +89,7 @@ class TextToDocumentProvider implements ISynchronousProvider { /** * @inheritDoc */ - public function process(?string $userId, array $input, callable $reportProgress): array { + public function process(?string $userId, array $input, callable $reportProgress, bool $includeWatermark = true): array { if ($userId === null) { throw new \RuntimeException('User ID is required to process the prompt.'); } @@ -108,6 +108,7 @@ class TextToDocumentProvider implements ISynchronousProvider { $userId, $input['text'], $targetFormat, + $includeWatermark ); return [