Skip to content

Commit bbc13d6

Browse files
julien-ncelzody
authored andcommitted
move task processing provider and task type from https://github.com/nextcloud/slide_deck_generator
Signed-off-by: Julien Veyssier <julien-nc@posteo.net>
1 parent 122bf06 commit bbc13d6

File tree

6 files changed

+431
-50
lines changed

6 files changed

+431
-50
lines changed

lib/AppInfo/Application.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
use OCA\Richdocuments\Preview\OpenDocument;
3333
use OCA\Richdocuments\Preview\Pdf;
3434
use OCA\Richdocuments\Reference\OfficeTargetReferenceProvider;
35+
use OCA\Richdocuments\TaskProcessing\SlideDeckGenerationProvider;
36+
use OCA\Richdocuments\TaskProcessing\SlideDeckGenerationTaskType;
3537
use OCA\Richdocuments\TaskProcessing\TextToDocumentProvider;
3638
use OCA\Richdocuments\TaskProcessing\TextToDocumentTaskType;
3739
use OCA\Richdocuments\TaskProcessing\TextToSpreadsheetProvider;
@@ -88,10 +90,13 @@ public function register(IRegistrationContext $context): void {
8890
$context->registerPreviewProvider(Pdf::class, Pdf::MIMETYPE_REGEX);
8991
$context->registerFileConversionProvider(ConversionProvider::class);
9092
$context->registerNotifierService(Notifier::class);
93+
9194
$context->registerTaskProcessingTaskType(TextToDocumentTaskType::class);
9295
$context->registerTaskProcessingProvider(TextToDocumentProvider::class);
9396
$context->registerTaskProcessingTaskType(TextToSpreadsheetTaskType::class);
9497
$context->registerTaskProcessingProvider(TextToSpreadsheetProvider::class);
98+
$context->registerTaskProcessingProvider(SlideDeckGenerationProvider::class);
99+
$context->registerTaskProcessingTaskType(SlideDeckGenerationTaskType::class);
95100
}
96101

97102
public function boot(IBootContext $context): void {

lib/Service/RemoteService.php

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class RemoteService {
1616
public function __construct(
1717
private AppConfig $appConfig,
1818
private IClientService $clientService,
19+
private CapabilitiesService $capabilitiesService,
1920
private LoggerInterface $logger,
2021
) {
2122
}
@@ -96,6 +97,104 @@ public function convertTo(string $filename, $stream, string $format, bool $sendF
9697
}
9798
}
9899

100+
/**
101+
* @param string $filename
102+
* @param resource $stream
103+
* @return array
104+
*/
105+
public function extractDocumentStructure(string $filename, $stream, string $filter): array {
106+
if (!$this->capabilitiesService->hasFormFilling()) {
107+
return [];
108+
}
109+
110+
$collaboraUrl = $this->appConfig->getCollaboraUrlInternal();
111+
$client = $this->clientService->newClient();
112+
113+
$options = RemoteOptionsService::getDefaultOptions();
114+
$options['expect'] = false;
115+
116+
if ($this->appConfig->getDisableCertificateValidation()) {
117+
$options['verify'] = false;
118+
}
119+
120+
$options['query'] = ['filter' => $filter];
121+
$options['multipart'] = [
122+
[
123+
'name' => 'data',
124+
'filename' => $filename,
125+
'contents' => $stream,
126+
'headers' => [ 'Content-Type' => 'multipart/form-data' ],
127+
],
128+
];
129+
130+
try {
131+
$response = $client->post(
132+
$collaboraUrl . '/cool/extract-document-structure',
133+
$options
134+
);
135+
136+
return json_decode($response->getBody(), true)['DocStructure'] ?? [];
137+
} catch (\Exception $e) {
138+
$this->logger->error($e->getMessage());
139+
return [];
140+
}
141+
}
142+
143+
/**
144+
* @param string $filename
145+
* @param resource $stream
146+
* @return string|resource
147+
*/
148+
public function transformDocumentStructure(string $filename, $stream, array $values, ?string $format = null) {
149+
if (!$this->capabilitiesService->hasFormFilling()) {
150+
throw new \RuntimeException('Form filling not supported by the Collabora server');
151+
}
152+
153+
$collaboraUrl = $this->appConfig->getCollaboraUrlInternal();
154+
$client = $this->clientService->newClient();
155+
156+
$options = RemoteOptionsService::getDefaultOptions();
157+
$options['expect'] = false;
158+
159+
if ($this->appConfig->getDisableCertificateValidation()) {
160+
$options['verify'] = false;
161+
}
162+
163+
$data = [
164+
'name' => 'data',
165+
'filename' => $filename,
166+
'contents' => $stream,
167+
'headers' => [ 'Content-Type' => 'multipart/form-data' ],
168+
];
169+
170+
$transform = [
171+
'name' => 'transform',
172+
'contents' => '{"Transforms": ' . json_encode($values) . '}',
173+
'headers' => [ 'Content-Type' => 'application/json' ],
174+
];
175+
176+
$options['multipart'] = [$data, $transform];
177+
178+
if ($format !== null) {
179+
$options['multipart'][] = [
180+
'name' => 'format',
181+
'contents' => $format,
182+
];
183+
}
184+
185+
try {
186+
$response = $client->post(
187+
$collaboraUrl . '/cool/transform-document-structure',
188+
$options
189+
);
190+
191+
return $response->getBody();
192+
} catch (\Exception $e) {
193+
$this->logger->error($e->getMessage());
194+
throw $e;
195+
}
196+
}
197+
99198
private function getRequestOptionsForFile(File $file, ?string $target = null): array {
100199
$localFile = $file->getStorage()->getLocalFile($file->getInternalPath());
101200
if (!is_string($localFile)) {

lib/Service/SlideDeckService.php

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
<?php
2+
/**
3+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
4+
* SPDX-License-Identifier: AGPL-3.0-or-later
5+
*/
6+
7+
namespace OCA\Richdocuments\Service;
8+
9+
use OCA\Richdocuments\AppInfo\Application;
10+
use OCA\Richdocuments\TemplateManager;
11+
use OCP\IConfig;
12+
use OCP\TaskProcessing\Exception\Exception;
13+
use OCP\TaskProcessing\Exception\NotFoundException;
14+
use OCP\TaskProcessing\Exception\PreConditionNotMetException;
15+
use OCP\TaskProcessing\Exception\UnauthorizedException;
16+
use OCP\TaskProcessing\Exception\ValidationException;
17+
use OCP\TaskProcessing\IManager;
18+
use OCP\TaskProcessing\Task;
19+
use OCP\TaskProcessing\TaskTypes\TextToText;
20+
use RuntimeException;
21+
22+
class SlideDeckService {
23+
public const PROMPT = <<<EOF
24+
Draft a presentation slide deck with headlines and a maximum of 5 bullet points per headline. Use the following JSON structure for your whole output and output only the JSON array, no introductory text:
25+
26+
```
27+
[{"headline": "Headline 1", points: ["Bullet point 1", "Bullet point 2"]}, {"headline": "Headline 2", points: ["Bullet point 1", "Bullet point 2"]}]
28+
```
29+
30+
Here is the presentation text:
31+
EOF;
32+
33+
public function __construct(
34+
private IManager $taskProcessingManager,
35+
private TemplateManager $templateManager,
36+
private RemoteService $remoteService,
37+
private IConfig $config,
38+
) {
39+
}
40+
41+
public function generateSlideDeck(?string $userId, string $presentationText) {
42+
$rawModelOutput = $this->runLLMQuery($userId, $presentationText);
43+
44+
$ooxml = $this->config->getAppValue(Application::APPNAME, 'doc_format', '') === 'ooxml';
45+
$format = $ooxml ? 'pptx' : 'odp';
46+
$emptyPresentation = $this->getBlankPresentation($format);
47+
48+
try {
49+
$parsedStructure = $this->parseModelJSON($rawModelOutput);
50+
} catch (\JsonException) {
51+
throw new RuntimeException('LLM generated faulty JSON data');
52+
}
53+
54+
try {
55+
$transformedPresentation = $this->remoteService->transformDocumentStructure(
56+
'presentation.' . $format,
57+
$emptyPresentation,
58+
$parsedStructure
59+
);
60+
61+
return $transformedPresentation;
62+
} catch (\Exception) {
63+
throw new RuntimeException('Unable to apply transformations to presentation file');
64+
}
65+
}
66+
67+
/**
68+
* Parses the JSON output from the LLM into
69+
* JSON that Collabora expects
70+
*
71+
* @param string $jsonString
72+
* @return array
73+
*/
74+
private function parseModelJSON(string $jsonString): array {
75+
$modelJSON = json_decode(
76+
$jsonString,
77+
associative: true,
78+
flags: JSON_THROW_ON_ERROR
79+
);
80+
81+
$slideCommands = [];
82+
foreach ($modelJSON as $index => $slide) {
83+
if (count($slideCommands) > 0) {
84+
$slideCommands[] = [ 'JumpToSlide' => 'last' ];
85+
$slideCommands[] = [ 'InsertMasterSlide' => 0 ];
86+
} else {
87+
$slideCommands[] = [ 'JumpToSlide' => $index];
88+
}
89+
90+
$slideCommands[] = [ 'ChangeLayoutByName' => 'AUTOLAYOUT_TITLE_CONTENT' ];
91+
$slideCommands[] = [ 'SetText.0' => $slide['headline'] ];
92+
93+
$editTextObjectCommands = [
94+
[ 'SelectParagraph' => 0 ],
95+
[ 'InsertText' => implode(PHP_EOL, $slide['points']) ],
96+
];
97+
98+
$slideCommands[] = [ 'EditTextObject.1' => $editTextObjectCommands ];
99+
}
100+
101+
return [ 'SlideCommands' => $slideCommands ];
102+
}
103+
104+
/**
105+
* Creates a blank presentation file in memory
106+
*
107+
* @param string $format
108+
* @return resource
109+
*/
110+
private function getBlankPresentation(string $format) {
111+
$emptyPresentationContent = $this->templateManager->getEmptyFileContent($format);
112+
$memoryStream = fopen('php://memory', 'r+');
113+
114+
if (!$memoryStream) {
115+
throw new RuntimeException('Unable to open file stream');
116+
}
117+
118+
fwrite($memoryStream, $emptyPresentationContent);
119+
rewind($memoryStream);
120+
121+
return $memoryStream;
122+
}
123+
124+
private function runLLMQuery(?string $userId, string $presentationText) {
125+
$prompt = self::PROMPT;
126+
$task = new Task(
127+
TextToText::ID,
128+
['input' => $prompt . "\n\n" . $presentationText],
129+
Application::APPNAME,
130+
$userId
131+
);
132+
133+
try {
134+
$this->taskProcessingManager->scheduleTask($task);
135+
} catch (PreConditionNotMetException|UnauthorizedException|ValidationException|Exception $e) {
136+
throw new RuntimeException($e->getMessage(), $e->getCode(), $e);
137+
}
138+
139+
while (true) {
140+
try {
141+
$task = $this->taskProcessingManager->getTask($task->getId());
142+
} catch (NotFoundException|Exception $e) {
143+
throw new RuntimeException($e->getMessage(), $e->getCode(), $e);
144+
}
145+
if (in_array($task->getStatus(), [Task::STATUS_SUCCESSFUL, Task::STATUS_FAILED, Task::STATUS_CANCELLED])) {
146+
break;
147+
}
148+
}
149+
150+
if ($task->getStatus() !== Task::STATUS_SUCCESSFUL) {
151+
throw new RuntimeException('LLM backend Task with id ' . $task->getId() . ' failed or was cancelled');
152+
}
153+
154+
return $task->getOutput()['output'];
155+
}
156+
}

lib/Service/TemplateFieldService.php

Lines changed: 10 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,6 @@ public function __construct(
4343
* @throws NotFoundException
4444
*/
4545
public function extractFields(Node|int $file): array {
46-
if (!$this->capabilitiesService->hasFormFilling()) {
47-
return [];
48-
}
49-
5046
if (is_int($file)) {
5147
$file = $this->rootFolder->getFirstNodeById($file);
5248
}
@@ -74,23 +70,12 @@ public function extractFields(Node|int $file): array {
7470
return [];
7571
}
7672

77-
$collaboraUrl = $this->appConfig->getCollaboraUrlInternal();
78-
$httpClient = $this->clientService->newClient();
79-
80-
$form = RemoteOptionsService::getDefaultOptions();
81-
$form['query'] = ['filter' => 'contentcontrol'];
82-
$form['multipart'] = [[
83-
'name' => 'data',
84-
'contents' => $file->getStorage()->fopen($file->getInternalPath(), 'r'),
85-
'headers' => ['Content-Type' => 'multipart/form-data'],
86-
]];
87-
88-
$response = $httpClient->post(
89-
$collaboraUrl . '/cool/extract-document-structure',
90-
$form
73+
$documentStructure = $this->remoteService->extractDocumentStructure(
74+
$file->getName(),
75+
$file->getStorage()->fopen($file->getInternalPath(), 'r'),
76+
'contentcontrol'
9177
);
9278

93-
$documentStructure = json_decode($response->getBody(), true)['DocStructure'] ?? [];
9479
$fields = [];
9580

9681
foreach ($documentStructure as $index => $attr) {
@@ -138,10 +123,6 @@ public function extractFields(Node|int $file): array {
138123
* @return string|resource
139124
*/
140125
public function fillFields(Node|int $file, array $fields = [], ?string $destination = null, ?string $format = null) {
141-
if (!$this->capabilitiesService->hasFormFilling()) {
142-
throw new \RuntimeException('Form filling not supported by the Collabora server');
143-
}
144-
145126
if (is_int($file)) {
146127
$file = $this->rootFolder->getFirstNodeById($file);
147128
}
@@ -161,39 +142,18 @@ public function fillFields(Node|int $file, array $fields = [], ?string $destinat
161142
return $content;
162143
}
163144

164-
$collaboraUrl = $this->appConfig->getCollaboraUrlInternal();
165-
$httpClient = $this->clientService->newClient();
166-
167-
$formData = [
168-
'name' => 'data',
169-
'contents' => $file->getStorage()->fopen($file->getInternalPath(), 'r'),
170-
'headers' => ['Content-Type' => 'multipart/form-data'],
171-
];
172-
173-
$formTransform = [
174-
'name' => 'transform',
175-
'contents' => '{"Transforms": ' . json_encode($fields) . '}',
176-
];
177-
178-
$formFormat = [
179-
'name' => 'format',
180-
'contents' => $format === null ? $file->getExtension() : $format,
181-
];
182-
183-
$form = RemoteOptionsService::getDefaultOptions();
184-
$form['multipart'] = [$formData, $formTransform, $formFormat];
185-
186145
try {
187-
$response = $httpClient->post(
188-
$collaboraUrl . '/cool/transform-document-structure',
189-
$form
146+
$content = $this->remoteService->transformDocumentStructure(
147+
$file->getName(),
148+
$file->getStorage()->fopen($file->getInternalPath(), 'r'),
149+
$fields,
150+
$format === null ? $file->getExtension() : $format
190151
);
191152

192-
$content = $response->getBody();
193-
194153
if ($destination !== null) {
195154
$this->writeToDestination($destination, $content);
196155
}
156+
197157
return $content;
198158
} catch (\Exception $e) {
199159
$this->logger->error($e->getMessage());

0 commit comments

Comments
 (0)