feat(AI): generate presentations with AI

Signed-off-by: Elizabeth Danzberger <elizabeth@elzody.dev>
This commit is contained in:
Elizabeth Danzberger 2025-08-05 17:59:56 -04:00
parent 4c15cfefd6
commit 53376d304e
No known key found for this signature in database
GPG key ID: 6B466A21DF5E753C
13 changed files with 493 additions and 29 deletions

BIN
emptyTemplates/ai/pitch.odp Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -7,6 +7,10 @@
namespace OCA\Richdocuments\Service; namespace OCA\Richdocuments\Service;
use OCA\Richdocuments\AppInfo\Application; 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 OCA\Richdocuments\TemplateManager;
use OCP\IConfig; use OCP\IConfig;
use OCP\TaskProcessing\Exception\Exception; use OCP\TaskProcessing\Exception\Exception;
@ -20,11 +24,40 @@ use RuntimeException;
class SlideDeckService { class SlideDeckService {
public const PROMPT = <<<EOF public const PROMPT = <<<EOF
Draft a presentation slide deck with headlines and a maximum of 5 bullet points per headline. Draft a presentation with slides based on the following JSON.
Use the following JSON structure for your whole output and output only the JSON array: Replace the title, subtitle, and content with your own.
If the content is an array of bullet point strings, replace them as necessary and always use at least four of them. Do not place any dot (.) or hyphen (-) before the bullet points.
Choose one of the following three presentation styles and replace the "presentationStyle" field with your choice: security, pitch, triangle
Security is dark and serious, pitch is light and playful, and triangle is light and geometric.
The slide titles should not contain more than three words.
Use the following JSON structure for your entire output.
Output 5 or more of the JSON objects, and use each of them at least once.
Output only the JSON array:
``` ```
[{"headline": "Headline 1", "points": ["Bullet point 1", "Bullet point 2"]}, {"headline": "Headline 2", "points": ["Bullet point 1", "Bullet point 2"]}] [
{
"layout": 0,
"title": "Presentation title",
"subtitle": "Presentation subtitle"
},
{
"layout": 1,
"title": "Slide title",
"content": "Paragraph or other longer text"
},
{
"layout": 1,
"title": "Slide title",
"content": [
"Bullet point one",
"Bullet point two",
"Bullet point three",
"Bullet point four"
]
},
{ "presentationStyle": "" },
]
``` ```
Only output the JSON array. Do not wrap it with spaces, new lines or backticks (`). Only output the JSON array. Do not wrap it with spaces, new lines or backticks (`).
@ -45,19 +78,21 @@ EOF;
$ooxml = $this->config->getAppValue(Application::APPNAME, 'doc_format', 'ooxml') === 'ooxml'; $ooxml = $this->config->getAppValue(Application::APPNAME, 'doc_format', 'ooxml') === 'ooxml';
$format = $ooxml ? 'pptx' : 'odp'; $format = $ooxml ? 'pptx' : 'odp';
$emptyPresentation = $this->getBlankPresentation($format);
try { try {
$parsedStructure = $this->parseModelJSON($rawModelOutput); [$presentationStyle, $parsedStructure] = $this->parseModelJSON($rawModelOutput);
} catch (\JsonException) { } catch (\JsonException) {
throw new RuntimeException('LLM generated faulty JSON data'); throw new RuntimeException('LLM generated faulty JSON data');
} }
$emptyPresentation = $this->getPresentationTemplate($presentationStyle);
try { try {
$transformedPresentation = $this->remoteService->transformDocumentStructure( $transformedPresentation = $this->remoteService->transformDocumentStructure(
'presentation.' . $format, 'presentation.' . $format,
$emptyPresentation, $emptyPresentation,
$parsedStructure $parsedStructure,
$format
); );
return $transformedPresentation; return $transformedPresentation;
@ -81,37 +116,49 @@ EOF;
flags: JSON_THROW_ON_ERROR flags: JSON_THROW_ON_ERROR
); );
$slideCommands = []; $layoutTypes = array_column(LayoutType::cases(), 'value');
foreach ($modelJSON as $index => $slide) { $presentation = new Presentation();
if (count($slideCommands) > 0) {
$slideCommands[] = [ 'JumpToSlide' => 'last' ]; foreach ($modelJSON as $index => $slideJSON) {
$slideCommands[] = [ 'InsertMasterSlide' => 0 ]; if ($slideJSON['presentationStyle']) {
} else { $presentation->setStyle($slideJSON['presentationStyle']);
$slideCommands[] = [ 'JumpToSlide' => $index]; continue;
} }
$slideCommands[] = [ 'ChangeLayoutByName' => 'AUTOLAYOUT_TITLE_CONTENT' ]; $validLayout = array_key_exists($slideJSON['layout'], $layoutTypes);
$slideCommands[] = [ 'SetText.0' => $slide['headline'] ];
$editTextObjectCommands = [ if (!$validLayout) {
[ 'SelectParagraph' => 0 ], continue;
[ 'InsertText' => implode(PHP_EOL, $slide['points']) ],
];
$slideCommands[] = [ 'EditTextObject.1' => $editTextObjectCommands ];
} }
return [ 'SlideCommands' => $slideCommands ]; $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 [$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 * @return resource
*/ */
private function getBlankPresentation(string $format) { private function getPresentationTemplate(string $name = '') {
$emptyPresentationContent = $this->templateManager->getEmptyFileContent($format); $emptyPresentationContent = $this->templateManager->getAITemplate($name);
$memoryStream = fopen('php://memory', 'r+'); $memoryStream = fopen('php://memory', 'r+');
if (!$memoryStream) { if (!$memoryStream) {

View file

@ -0,0 +1,12 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Richdocuments\TaskProcessing\Presentation;
interface ISlide {
public function getPosition(): int;
public function getSlideCommands(): array;
}

View file

@ -0,0 +1,13 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Richdocuments\TaskProcessing\Presentation;
enum LayoutType: string {
case Title = 'AUTOLAYOUT_TITLE';
case TitleContent = 'AUTOLAYOUT_TITLE_CONTENT';
case Title2Content = 'AUTOLAYOUT_TITLE_2CONTENT';
}

View file

@ -0,0 +1,56 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Richdocuments\TaskProcessing\Presentation;
class Presentation {
/** @var ISlide[] $slides */
private $slides = [];
/** @var string $style */
private $style = 'security';
public function __construct() {
}
/**
* @param ISlide $slide Slide to be inserted into the presentation
*/
public function addSlide(ISlide $slide): void {
$this->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 ];
}
}

View file

@ -0,0 +1,75 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Richdocuments\TaskProcessing\Presentation\Slides;
use OCA\Richdocuments\TaskProcessing\Presentation\ISlide;
class TitleContentSlide implements ISlide {
private int $position;
private string $title;
private string|array $content;
public function __construct(
int $position,
string $title,
string|array $content) {
$this->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;
}
}

View file

@ -0,0 +1,56 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Richdocuments\TaskProcessing\Presentation\Slides;
use OCA\Richdocuments\TaskProcessing\Presentation\ISlide;
class TitleSlide implements ISlide {
private int $position;
private string $title;
private string $subtitle;
public function __construct(
int $position,
string $title,
string $subtitle) {
$this->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;
}
}

View file

@ -80,11 +80,28 @@ class SlideDeckGenerationProvider implements ISynchronousProvider {
throw new \RuntimeException('Invalid input, expected "text" key with string value'); throw new \RuntimeException('Invalid input, expected "text" key with string value');
} }
$response = $this->slideDeckService->generateSlideDeck( $response = $this->withRetry(function () use ($userId, $input) {
return $this->slideDeckService->generateSlideDeck(
$userId, $userId,
$input['text'], $input['text'],
); );
});
return ['slide_deck' => $response]; 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;
}
}
}
}
} }

View file

@ -471,6 +471,21 @@ class TemplateManager {
return true; 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 * Return default content for empty files of a given filename by file extension
*/ */

View file

@ -0,0 +1,123 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Tests\Richdocuments;
use OCA\Richdocuments\TaskProcessing\Presentation\Slides\TitleContentSlide;
use PHPUnit\Framework\TestCase;
class TitleContentSlideTest extends TestCase {
public function setUp(): void {
parent::setUp();
}
public function testCreateWithStringContent(): void {
$slide = new TitleContentSlide(0, 'Title', 'Content');
$this->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()),
);
}
}

View file

@ -0,0 +1,50 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Tests\Richdocuments;
use OCA\Richdocuments\TaskProcessing\Presentation\Slides\TitleSlide;
use PHPUnit\Framework\TestCase;
class TitleSlideTest extends TestCase {
public function setUp(): void {
parent::setUp();
}
public function testCreateTitleSlide(): void {
$slide = new TitleSlide(0, 'Title', 'Subtitle');
$this->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()),
);
}
}