diff --git a/emptyTemplates/ai/pitch.odp b/emptyTemplates/ai/pitch.odp new file mode 100644 index 000000000..7cd8270dd Binary files /dev/null and b/emptyTemplates/ai/pitch.odp differ diff --git a/emptyTemplates/ai/security.odp b/emptyTemplates/ai/security.odp new file mode 100644 index 000000000..43bea9254 Binary files /dev/null and b/emptyTemplates/ai/security.odp differ diff --git a/emptyTemplates/ai/triangle.odp b/emptyTemplates/ai/triangle.odp new file mode 100644 index 000000000..69df3e001 Binary files /dev/null and b/emptyTemplates/ai/triangle.odp differ diff --git a/lib/Service/SlideDeckService.php b/lib/Service/SlideDeckService.php index 420480993..ad1d54b68 100644 --- a/lib/Service/SlideDeckService.php +++ b/lib/Service/SlideDeckService.php @@ -7,6 +7,10 @@ namespace OCA\Richdocuments\Service; use OCA\Richdocuments\AppInfo\Application; +use OCA\Richdocuments\TaskProcessing\Presentation\LayoutType; +use OCA\Richdocuments\TaskProcessing\Presentation\Presentation; +use OCA\Richdocuments\TaskProcessing\Presentation\Slides\TitleContentSlide; +use OCA\Richdocuments\TaskProcessing\Presentation\Slides\TitleSlide; use OCA\Richdocuments\TemplateManager; use OCP\IConfig; use OCP\TaskProcessing\Exception\Exception; @@ -20,11 +24,40 @@ use RuntimeException; class SlideDeckService { public const PROMPT = <<config->getAppValue(Application::APPNAME, 'doc_format', 'ooxml') === 'ooxml'; $format = $ooxml ? 'pptx' : 'odp'; - $emptyPresentation = $this->getBlankPresentation($format); try { - $parsedStructure = $this->parseModelJSON($rawModelOutput); + [$presentationStyle, $parsedStructure] = $this->parseModelJSON($rawModelOutput); } catch (\JsonException) { throw new RuntimeException('LLM generated faulty JSON data'); } + $emptyPresentation = $this->getPresentationTemplate($presentationStyle); + try { $transformedPresentation = $this->remoteService->transformDocumentStructure( 'presentation.' . $format, $emptyPresentation, - $parsedStructure + $parsedStructure, + $format ); return $transformedPresentation; @@ -81,37 +116,49 @@ EOF; flags: JSON_THROW_ON_ERROR ); - $slideCommands = []; - foreach ($modelJSON as $index => $slide) { - if (count($slideCommands) > 0) { - $slideCommands[] = [ 'JumpToSlide' => 'last' ]; - $slideCommands[] = [ 'InsertMasterSlide' => 0 ]; - } else { - $slideCommands[] = [ 'JumpToSlide' => $index]; + $layoutTypes = array_column(LayoutType::cases(), 'value'); + $presentation = new Presentation(); + + foreach ($modelJSON as $index => $slideJSON) { + if ($slideJSON['presentationStyle']) { + $presentation->setStyle($slideJSON['presentationStyle']); + continue; } - $slideCommands[] = [ 'ChangeLayoutByName' => 'AUTOLAYOUT_TITLE_CONTENT' ]; - $slideCommands[] = [ 'SetText.0' => $slide['headline'] ]; + $validLayout = array_key_exists($slideJSON['layout'], $layoutTypes); - $editTextObjectCommands = [ - [ 'SelectParagraph' => 0 ], - [ 'InsertText' => implode(PHP_EOL, $slide['points']) ], - ]; + if (!$validLayout) { + continue; + } - $slideCommands[] = [ 'EditTextObject.1' => $editTextObjectCommands ]; + $slideLayout = LayoutType::from($layoutTypes[$slideJSON['layout']]); + + $slide = match ($slideLayout) { + LayoutType::Title => new TitleSlide($index, $slideJSON['title'], $slideJSON['subtitle']), + + LayoutType::TitleContent => new TitleContentSlide($index, $slideJSON['title'], $slideJSON['content']), + + default => null, + }; + + if (is_null($slide)) { + continue; + } + + $presentation->addSlide($slide); } - return [ 'SlideCommands' => $slideCommands ]; + return [$presentation->getStyle(), $presentation->getSlideCommands()]; } /** - * Creates a blank presentation file in memory + * Creates a presentation file in memory * - * @param string $format + * @param string $name * @return resource */ - private function getBlankPresentation(string $format) { - $emptyPresentationContent = $this->templateManager->getEmptyFileContent($format); + private function getPresentationTemplate(string $name = '') { + $emptyPresentationContent = $this->templateManager->getAITemplate($name); $memoryStream = fopen('php://memory', 'r+'); if (!$memoryStream) { diff --git a/lib/TaskProcessing/Presentation/ISlide.php b/lib/TaskProcessing/Presentation/ISlide.php new file mode 100644 index 000000000..c7684d809 --- /dev/null +++ b/lib/TaskProcessing/Presentation/ISlide.php @@ -0,0 +1,12 @@ +slides[] = $slide; + } + + /** + * @return ISlide[] Array of slides in the presentation + */ + public function getSlides(): array { + return $this->slides; + } + + public function setStyle(string $style): void { + $this->style = $style; + } + + public function getStyle(): string { + return $this->style; + } + + /** + * @return array Slide commands to be passed to an external API + */ + public function getSlideCommands(): array { + $slideCommands = array_map( + function (ISlide $slide) { + return $slide->getSlideCommands(); + }, + $this->getSlides(), + ); + + $slideCommands = array_merge([], ...$slideCommands); + + return [ 'SlideCommands' => $slideCommands ]; + } +} diff --git a/lib/TaskProcessing/Presentation/Slides/TitleContentSlide.php b/lib/TaskProcessing/Presentation/Slides/TitleContentSlide.php new file mode 100644 index 000000000..a766d8185 --- /dev/null +++ b/lib/TaskProcessing/Presentation/Slides/TitleContentSlide.php @@ -0,0 +1,75 @@ +position = $position; + $this->title = $title; + $this->content = $content; + } + + public function getTitle(): string { + return $this->title; + } + + public function getContent(): string|array { + return $this->content; + } + + public function getPosition(): int { + return $this->position; + } + + public function getSlideCommands(): array { + $slideCommands = []; + + if ($this->getPosition() > 1) { + $slideCommands[] = [ 'DuplicateSlide' => $this->getPosition() - 1 ]; + } + + $slideCommands[] = [ 'JumpToSlide' => $this->getPosition() ]; + + $slideCommands[] = [ + 'EditTextObject.0' => [ + 'SelectParagraph' => 0, + 'InsertText' => $this->getTitle(), + ] + ]; + + if (is_array($this->getContent())) { + $slideCommands[] = [ + 'EditTextObject.1' => [ + 'SelectText' => [], + 'UnoCommand' => '.uno:Cut', + 'InsertText' => implode(PHP_EOL, array_map(function ($bulletPoint) { + return '• ' . $bulletPoint; + }, $this->getContent())), + ] + ]; + } else { + $slideCommands[] = [ + 'EditTextObject.1' => [ + 'SelectText' => [], + 'UnoCommand' => '.uno:Cut', + 'InsertText' => $this->getContent(), + ] + ]; + } + + return $slideCommands; + } +} diff --git a/lib/TaskProcessing/Presentation/Slides/TitleSlide.php b/lib/TaskProcessing/Presentation/Slides/TitleSlide.php new file mode 100644 index 000000000..82ff3defc --- /dev/null +++ b/lib/TaskProcessing/Presentation/Slides/TitleSlide.php @@ -0,0 +1,56 @@ +position = $position; + $this->title = $title; + $this->subtitle = $subtitle; + } + + public function getTitle(): string { + return $this->title; + } + + public function getSubtitle(): string { + return $this->subtitle; + } + + public function getPosition(): int { + return $this->position; + } + + public function getSlideCommands(): array { + $slideCommands = []; + + $slideCommands[] = [ + 'EditTextObject.0' => [ + 'SelectParagraph' => 0, + 'InsertText' => $this->getTitle(), + ] + ]; + + $slideCommands[] = [ + 'EditTextObject.1' => [ + 'SelectParagraph' => 0, + 'InsertText' => $this->getSubtitle(), + ] + ]; + + return $slideCommands; + } +} diff --git a/lib/TaskProcessing/SlideDeckGenerationProvider.php b/lib/TaskProcessing/SlideDeckGenerationProvider.php index 6f6979967..b892bcaed 100644 --- a/lib/TaskProcessing/SlideDeckGenerationProvider.php +++ b/lib/TaskProcessing/SlideDeckGenerationProvider.php @@ -80,11 +80,28 @@ class SlideDeckGenerationProvider implements ISynchronousProvider { throw new \RuntimeException('Invalid input, expected "text" key with string value'); } - $response = $this->slideDeckService->generateSlideDeck( - $userId, - $input['text'], - ); + $response = $this->withRetry(function () use ($userId, $input) { + return $this->slideDeckService->generateSlideDeck( + $userId, + $input['text'], + ); + }); return ['slide_deck' => $response]; } + + private function withRetry(callable $action, $maxAttempts = 2) { + $attempt = 0; + + while ($attempt < $maxAttempts) { + try { + $attempt += 1; + return $action(); + } catch (\Exception $e) { + if ($attempt === $maxAttempts) { + throw $e; + } + } + } + } } diff --git a/lib/TemplateManager.php b/lib/TemplateManager.php index 5a1bf441b..de9133dd7 100644 --- a/lib/TemplateManager.php +++ b/lib/TemplateManager.php @@ -471,6 +471,21 @@ class TemplateManager { return true; } + public function getAITemplate(?string $templateName = 'security'): string { + $emptyAITemplates = __DIR__ . '/../emptyTemplates/ai/'; + $fullTemplatePath = $emptyAITemplates . $templateName . '.odp'; + + if (file_exists($fullTemplatePath)) { + $emptyFileContent = file_get_contents($fullTemplatePath); + + if ($emptyFileContent !== false) { + return $emptyFileContent; + } + } + + return ''; + } + /** * Return default content for empty files of a given filename by file extension */ diff --git a/tests/lib/TaskProcessing/Presentation/Slides/TitleContentSlideTest.php b/tests/lib/TaskProcessing/Presentation/Slides/TitleContentSlideTest.php new file mode 100644 index 000000000..ce21f5aa0 --- /dev/null +++ b/tests/lib/TaskProcessing/Presentation/Slides/TitleContentSlideTest.php @@ -0,0 +1,123 @@ +assertInstanceOf(TitleContentSlide::class, $slide); + + $this->assertEquals($slide->getPosition(), 0); + $this->assertEquals($slide->getTitle(), 'Title'); + + $this->assertIsString($slide->getContent()); + $this->assertEquals($slide->getContent(), 'Content'); + } + + public function testCreateWithArrayContent(): void { + $slide = new TitleContentSlide(0, 'Title', ['Content', 'Content']); + + $this->assertInstanceOf(TitleContentSlide::class, $slide); + + $this->assertEquals($slide->getPosition(), 0); + $this->assertEquals($slide->getTitle(), 'Title'); + + $this->assertIsArray($slide->getContent()); + $this->assertEquals($slide->getContent(), ['Content', 'Content']); + } + + public function testSlideCommandsWithStringContent(): void { + $slide = new TitleContentSlide(0, 'Title', 'Content'); + + $expectedSlideCommands = [ + [ 'JumpToSlide' => 0 ], + + [ + 'EditTextObject.0' => [ + 'SelectParagraph' => 0, + 'InsertText' => 'Title', + ] + ], + [ + 'EditTextObject.1' => [ + 'SelectParagraph' => 0, + 'InsertText' => 'Content', + ] + ] + ]; + + $this->assertJsonStringEqualsJsonString( + json_encode($expectedSlideCommands), + json_encode($slide->getSlideCommands()), + ); + } + + public function testSlideCommandsWithArrayContent(): void { + $slide = new TitleContentSlide(0, 'Title', ['Content', 'Content']); + + $expectedSlideCommands = [ + // Jump to slide at $this->getPosition() + [ 'JumpToSlide' => 0 ], + + [ + 'EditTextObject.0' => [ + 'SelectParagraph' => 0, + 'InsertText' => 'Title', + ] + ], + [ + 'EditTextObject.1' => [ + 'SelectParagraph' => 0, + 'InsertText' => 'Content' . PHP_EOL . 'Content', + ] + ] + ]; + + $this->assertJsonStringEqualsJsonString( + json_encode($expectedSlideCommands), + json_encode($slide->getSlideCommands()), + ); + } + + public function testSlideCommandsWithPosition(): void { + $slide = new TitleContentSlide(2, 'Title', 'Content'); + + $expectedSlideCommands = [ + // Duplicates slide at index $this->getPosition() - 1 + [ 'DuplicateSlide' => 1 ], + + // Jump to slide at $this->getPosition() + [ 'JumpToSlide' => 2 ], + + [ + 'EditTextObject.0' => [ + 'SelectParagraph' => 0, + 'InsertText' => 'Title', + ] + ], + [ + 'EditTextObject.1' => [ + 'SelectParagraph' => 0, + 'InsertText' => 'Content', + ] + ] + ]; + + $this->assertJsonStringEqualsJsonString( + json_encode($expectedSlideCommands), + json_encode($slide->getSlideCommands()), + ); + } +} diff --git a/tests/lib/TaskProcessing/Presentation/Slides/TitleSlideTest.php b/tests/lib/TaskProcessing/Presentation/Slides/TitleSlideTest.php new file mode 100644 index 000000000..eda79e0bc --- /dev/null +++ b/tests/lib/TaskProcessing/Presentation/Slides/TitleSlideTest.php @@ -0,0 +1,50 @@ +assertInstanceOf(TitleSlide::class, $slide); + + $this->assertEquals($slide->getPosition(), 0); + $this->assertEquals($slide->getTitle(), 'Title'); + $this->assertEquals($slide->getSubtitle(), 'Subtitle'); + } + + public function testSlideCommands(): void { + $slide = new TitleSlide(0, 'Title', 'Subtitle'); + + $expectedSlideCommands = [ + [ + 'EditTextObject.0' => [ + 'SelectParagraph' => 0, + 'InsertText' => 'Title', + ], + ], + [ + 'EditTextObject.1' => [ + 'SelectParagraph' => 0, + 'InsertText' => 'Subtitle', + ] + ], + ]; + + $this->assertJsonStringEqualsJsonString( + json_encode($expectedSlideCommands), + json_encode($slide->getSlideCommands()), + ); + } +}