From 7045804d7a0597f88cfde39a0fb5ca3424404ff7 Mon Sep 17 00:00:00 2001 From: Lukas Schaefer Date: Wed, 6 May 2026 12:35:23 -0400 Subject: [PATCH 1/5] feat(TaskProcessing): add TextToTextReformatParagraphs task processing handler Signed-off-by: Lukas Schaefer --- lib/AppInfo/Application.php | 3 + .../ReformatParagraphsProvider.php | 203 ++++++++++++++++++ psalm.xml | 1 + tests/unit/Providers/OpenAiProviderTest.php | 78 +++++++ 4 files changed, 285 insertions(+) create mode 100644 lib/TaskProcessing/ReformatParagraphsProvider.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index e6078dc8..91544200 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -131,6 +131,9 @@ public function register(IRegistrationContext $context): void { if (class_exists('OCP\\TaskProcessing\\TaskTypes\\TextToTextProofread')) { $context->registerTaskProcessingProvider(\OCA\OpenAi\TaskProcessing\ProofreadProvider::class); } + if (class_exists('OCP\\TaskProcessing\\TaskTypes\\TextToTextReformatParagraphs')) { + $context->registerTaskProcessingProvider(\OCA\OpenAi\TaskProcessing\ReformatParagraphsProvider::class); + } if ($isUsingOpenAI || $this->appConfig->getValueString(Application::APP_ID, 'analyze_image_provider_enabled') === '1') { if (!class_exists('OCP\\TaskProcessing\\TaskTypes\\AnalyzeImages')) { $context->registerTaskProcessingTaskType(\OCA\OpenAi\TaskProcessing\AnalyzeImagesTaskType::class); diff --git a/lib/TaskProcessing/ReformatParagraphsProvider.php b/lib/TaskProcessing/ReformatParagraphsProvider.php new file mode 100644 index 00000000..22c1bd69 --- /dev/null +++ b/lib/TaskProcessing/ReformatParagraphsProvider.php @@ -0,0 +1,203 @@ +openAiAPIService->getServiceName(); + } + + public function getTaskTypeId(): string { + return self::TASK_TYPE_ID; + } + + public function getExpectedRuntime(): int { + return $this->openAiAPIService->getExpTextProcessingTime(); + } + + public function getInputShapeEnumValues(): array { + return []; + } + + public function getInputShapeDefaults(): array { + return []; + } + + public function getOptionalInputShape(): array { + return [ + 'max_tokens' => new ShapeDescriptor( + $this->l->t('Maximum output words'), + $this->l->t('The maximum number of words/tokens that can be generated in the completion.'), + EShapeType::Number + ), + 'model' => new ShapeDescriptor( + $this->l->t('Model'), + $this->l->t('The model used to generate the completion'), + EShapeType::Enum + ), + ]; + } + + public function getOptionalInputShapeEnumValues(): array { + return [ + 'model' => $this->openAiAPIService->getModelEnumValues($this->userId), + ]; + } + + public function getOptionalInputShapeDefaults(): array { + $adminModel = $this->openAiAPIService->isUsingOpenAi() + ? ($this->appConfig->getValueString(Application::APP_ID, 'default_completion_model_id', Application::DEFAULT_MODEL_ID, lazy: true) ?: Application::DEFAULT_MODEL_ID) + : $this->appConfig->getValueString(Application::APP_ID, 'default_completion_model_id', lazy: true); + return [ + 'max_tokens' => $this->openAiSettingsService->getMaxTokens(), + 'model' => $adminModel, + ]; + } + + public function getOutputShapeEnumValues(): array { + return []; + } + + public function getOptionalOutputShape(): array { + return []; + } + + public function getOptionalOutputShapeEnumValues(): array { + return []; + } + + public function process(?string $userId, array $input, callable $reportProgress): array { + $startTime = time(); + + if (!isset($input['input']) || !is_string($input['input'])) { + throw new RuntimeException('Invalid prompt'); + } + $prompt = $input['input']; + + $maxTokens = null; + if (isset($input['max_tokens']) && is_int($input['max_tokens'])) { + $maxTokens = $input['max_tokens']; + } + + if (isset($input['model']) && is_string($input['model'])) { + $model = $input['model']; + } else { + $model = $this->appConfig->getValueString(Application::APP_ID, 'default_completion_model_id', Application::DEFAULT_MODEL_ID, lazy: true) ?: Application::DEFAULT_MODEL_ID; + } + $chunks = $this->chunkService->chunkSplitPrompt($prompt, false); + $result = ''; + $increase = 1.0 / (float)count($chunks); + $progress = 0.0; + + foreach ($chunks as $chunk) { + $systemPrompt = 'Analyze the provided text and split it into paragraphs based exclusively on thematic shifts. ' + . 'Follow these strict constraints: ' + . 'Thematic breaks only: Do not create a new paragraph for rhythm, style, or sentence flow. ' + . 'A break is allowed only when the subject matter changes significantly. ' + . 'Output format: For each identified paragraph, return only the first 8 to 12 words verbatim from the input. ' + . 'Structure: Return exactly one anchor per line. Do not include bullets, numbering, summaries, quotes, or any additional text. ' + . 'Single topic: If the text covers only one topic, return exactly one line.'; + try { + if ($this->openAiAPIService->isUsingOpenAi() || $this->openAiSettingsService->getChatEndpointEnabled()) { + $completion = $this->openAiAPIService->createChatCompletion($userId, $model, $chunk, $systemPrompt, null, 1, $maxTokens); + $completion = $completion['messages']; + } else { + $instruction = $systemPrompt . ' Here is the text:' . "\n\n" . $chunk; + $completion = $this->openAiAPIService->createCompletion($userId, $instruction, 1, $model, $maxTokens); + } + } catch (Exception $e) { + throw new RuntimeException('OpenAI/LocalAI request failed: ' . $e->getMessage()); + } + if (count($completion) > 0) { + // The llm only needs to generate the first sentence of each paragraph, and we get the rest of the output from the orginal input. + $raw = (string)array_pop($completion); + $anchors = $this->parseAnchorsFromModelOutput($raw); + $result .= $this->insertParagraphBreaksByAnchors($chunk, $anchors); + $progress += $increase; + $reportProgress($progress); + continue; + } + + throw new RuntimeException('No result in OpenAI/LocalAI response.'); + } + + $endTime = time(); + $this->openAiAPIService->updateExpTextProcessingTime($endTime - $startTime); + return ['output' => $result]; + } +} diff --git a/psalm.xml b/psalm.xml index 891905b2..361dc8b7 100644 --- a/psalm.xml +++ b/psalm.xml @@ -38,6 +38,7 @@ + diff --git a/tests/unit/Providers/OpenAiProviderTest.php b/tests/unit/Providers/OpenAiProviderTest.php index 322f22f7..93ed3165 100644 --- a/tests/unit/Providers/OpenAiProviderTest.php +++ b/tests/unit/Providers/OpenAiProviderTest.php @@ -23,6 +23,7 @@ use OCA\OpenAi\TaskProcessing\EmojiProvider; use OCA\OpenAi\TaskProcessing\HeadlineProvider; use OCA\OpenAi\TaskProcessing\ProofreadProvider; +use OCA\OpenAi\TaskProcessing\ReformatParagraphsProvider; use OCA\OpenAi\TaskProcessing\SummaryProvider; use OCA\OpenAi\TaskProcessing\TextToImageProvider; use OCA\OpenAi\TaskProcessing\TextToSpeechProvider; @@ -32,6 +33,7 @@ use OCP\Http\Client\IClientService; use OCP\IAppConfig; use OCP\ICacheFactory; +use OCP\TaskProcessing\TaskTypes\TextToTextReformatParagraphs; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; use Test\TestCase; @@ -690,4 +692,80 @@ public function testTextToImageProvider(): void { $this->quotaUsageMapper->deleteUserQuotaUsages(self::TEST_USER1); } + public function testReformatParagraphsProvider(): void { + if (!class_exists(TextToTextReformatParagraphs::class)) { + $this->markTestSkipped('TextToTextReformatParagraphs task type is not available in this Nextcloud version.'); + } + + $provider = new ReformatParagraphsProvider( + $this->openAiApiService, + \OCP\Server::get(IAppConfig::class), + $this->openAiSettingsService, + $this->createMock(\OCP\IL10N::class), + $this->chunkService, + self::TEST_USER1, + ); + + $inputText = 'Alpha part. Beta part.'; + $n = 1; + + $response = '{ + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1677652288, + "model": "gpt-4.1-mini", + "system_fingerprint": "fp_44709d6fcb", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Alpha part.\nBeta part." + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 9, + "completion_tokens": 12, + "total_tokens": 21 + } + }'; + + $url = self::OPENAI_API_BASE . 'chat/completions'; + $systemPrompt = 'Analyze the provided text and split it into paragraphs based exclusively on thematic shifts. ' + . 'Follow these strict constraints: ' + . 'Thematic breaks only: Do not create a new paragraph for rhythm, style, or sentence flow. ' + . 'A break is allowed only when the subject matter changes significantly. ' + . 'Output format: For each identified paragraph, return only the first 8 to 12 words verbatim from the input. ' + . 'Structure: Return exactly one anchor per line. Do not include bullets, numbering, summaries, quotes, or any additional text. ' + . 'Single topic: If the text covers only one topic, return exactly one line.'; + + $options = ['timeout' => Application::OPENAI_DEFAULT_REQUEST_TIMEOUT, 'headers' => ['User-Agent' => Application::USER_AGENT, 'Authorization' => self::AUTHORIZATION_HEADER, 'Content-Type' => 'application/json']]; + $options['body'] = json_encode([ + 'model' => Application::DEFAULT_COMPLETION_MODEL_ID, + 'messages' => [ + ['role' => 'system', 'content' => $systemPrompt], + ['role' => 'user', 'content' => $inputText], + ], + 'n' => $n, + 'max_completion_tokens' => Application::DEFAULT_MAX_NUM_OF_TOKENS, + 'user' => self::TEST_USER1, + ]); + + $iResponse = $this->createMock(\OCP\Http\Client\IResponse::class); + $iResponse->method('getBody')->willReturn($response); + $iResponse->method('getStatusCode')->willReturn(200); + $iResponse->method('getHeader')->with('Content-Type')->willReturn('application/json'); + + $this->iClient->expects($this->once())->method('post')->with($url, $options)->willReturn($iResponse); + + $result = $provider->process(self::TEST_USER1, ['input' => $inputText], fn () => null); + $this->assertEquals("Alpha part.\n\nBeta part.", $result['output']); + + $usage = $this->quotaUsageMapper->getQuotaUnitsOfUser(self::TEST_USER1, Application::QUOTA_TYPE_TEXT); + $this->assertEquals(21, $usage); + $this->quotaUsageMapper->deleteUserQuotaUsages(self::TEST_USER1); + } + } From 4d03993810dd708db3539aff56d277db8f3aba32 Mon Sep 17 00:00:00 2001 From: Lukas Schaefer Date: Fri, 8 May 2026 08:27:51 +0200 Subject: [PATCH 2/5] Resolve feedback Signed-off-by: Lukas Schaefer --- .../ReformatParagraphsProvider.php | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/lib/TaskProcessing/ReformatParagraphsProvider.php b/lib/TaskProcessing/ReformatParagraphsProvider.php index 22c1bd69..4bf185f5 100644 --- a/lib/TaskProcessing/ReformatParagraphsProvider.php +++ b/lib/TaskProcessing/ReformatParagraphsProvider.php @@ -10,6 +10,7 @@ namespace OCA\OpenAi\TaskProcessing; use Exception; +use InvalidArgumentException; use OCA\OpenAi\AppInfo\Application; use OCA\OpenAi\Service\ChunkService; use OCA\OpenAi\Service\OpenAiAPIService; @@ -57,8 +58,14 @@ private function insertParagraphBreaksByAnchors(string $text, array $anchors): s } $insertAt = $pos + $delta; - $result = substr($result, 0, $insertAt) . "\n\n" . substr($result, $insertAt); - $delta += 2; + // Makes sure to replace newlines and whitespace that already exists at the split + $replaceFrom = $insertAt; + while ($replaceFrom > 0 && preg_match('/\s/u', $result[$replaceFrom - 1]) === 1) { + $replaceFrom--; + } + $result = substr($result, 0, $replaceFrom) . "\n\n" . substr($result, $insertAt); + $delta += 2 - ($insertAt - $replaceFrom); + $searchOffset = $pos + strlen($anchor); } return $result; @@ -120,9 +127,7 @@ public function getOptionalInputShapeEnumValues(): array { } public function getOptionalInputShapeDefaults(): array { - $adminModel = $this->openAiAPIService->isUsingOpenAi() - ? ($this->appConfig->getValueString(Application::APP_ID, 'default_completion_model_id', Application::DEFAULT_MODEL_ID, lazy: true) ?: Application::DEFAULT_MODEL_ID) - : $this->appConfig->getValueString(Application::APP_ID, 'default_completion_model_id', lazy: true); + $adminModel = $this->openAiSettingsService->getAdminDefaultCompletionModelId(); return [ 'max_tokens' => $this->openAiSettingsService->getMaxTokens(), 'model' => $adminModel, @@ -145,7 +150,7 @@ public function process(?string $userId, array $input, callable $reportProgress) $startTime = time(); if (!isset($input['input']) || !is_string($input['input'])) { - throw new RuntimeException('Invalid prompt'); + throw new InvalidArgumentException('Invalid prompt'); } $prompt = $input['input']; @@ -157,7 +162,7 @@ public function process(?string $userId, array $input, callable $reportProgress) if (isset($input['model']) && is_string($input['model'])) { $model = $input['model']; } else { - $model = $this->appConfig->getValueString(Application::APP_ID, 'default_completion_model_id', Application::DEFAULT_MODEL_ID, lazy: true) ?: Application::DEFAULT_MODEL_ID; + $model = $this->openAiSettingsService->getAdminDefaultCompletionModelId(); } $chunks = $this->chunkService->chunkSplitPrompt($prompt, false); $result = ''; @@ -170,7 +175,7 @@ public function process(?string $userId, array $input, callable $reportProgress) . 'Thematic breaks only: Do not create a new paragraph for rhythm, style, or sentence flow. ' . 'A break is allowed only when the subject matter changes significantly. ' . 'Output format: For each identified paragraph, return only the first 8 to 12 words verbatim from the input. ' - . 'Structure: Return exactly one anchor per line. Do not include bullets, numbering, summaries, quotes, or any additional text. ' + . 'Structure: Return exactly one anchor per line. Do not include bullets, html tags, numbering, summaries, quotes, or any additional text. ' . 'Single topic: If the text covers only one topic, return exactly one line.'; try { if ($this->openAiAPIService->isUsingOpenAi() || $this->openAiSettingsService->getChatEndpointEnabled()) { @@ -184,7 +189,7 @@ public function process(?string $userId, array $input, callable $reportProgress) throw new RuntimeException('OpenAI/LocalAI request failed: ' . $e->getMessage()); } if (count($completion) > 0) { - // The llm only needs to generate the first sentence of each paragraph, and we get the rest of the output from the orginal input. + // The llm only needs to generate the first 8 to 12 words of each paragraph, and we get the rest of the output from the original input. $raw = (string)array_pop($completion); $anchors = $this->parseAnchorsFromModelOutput($raw); $result .= $this->insertParagraphBreaksByAnchors($chunk, $anchors); From bb8b27f252fbed897fa1bba94df93bfdd90fbff3 Mon Sep 17 00:00:00 2001 From: Lukas Schaefer Date: Tue, 12 May 2026 10:41:19 +0200 Subject: [PATCH 3/5] Use Marcel's improved prompt Signed-off-by: Lukas Schaefer --- .../ReformatParagraphsProvider.php | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/lib/TaskProcessing/ReformatParagraphsProvider.php b/lib/TaskProcessing/ReformatParagraphsProvider.php index 4bf185f5..115a5e32 100644 --- a/lib/TaskProcessing/ReformatParagraphsProvider.php +++ b/lib/TaskProcessing/ReformatParagraphsProvider.php @@ -170,13 +170,15 @@ public function process(?string $userId, array $input, callable $reportProgress) $progress = 0.0; foreach ($chunks as $chunk) { - $systemPrompt = 'Analyze the provided text and split it into paragraphs based exclusively on thematic shifts. ' - . 'Follow these strict constraints: ' - . 'Thematic breaks only: Do not create a new paragraph for rhythm, style, or sentence flow. ' - . 'A break is allowed only when the subject matter changes significantly. ' - . 'Output format: For each identified paragraph, return only the first 8 to 12 words verbatim from the input. ' - . 'Structure: Return exactly one anchor per line. Do not include bullets, html tags, numbering, summaries, quotes, or any additional text. ' - . 'Single topic: If the text covers only one topic, return exactly one line.'; + $systemPrompt = 'You will receive a continuous block of text without line breaks. Your task is to identify points in the text where the subject or topic changes (e.g., a shift to a new person, place, concept, or thematic focus) and insert a line break at that specific transition. ' . + 'Do NOT break lines based on sentence length or grammar unless the subject actually changes. ' . + 'Once you have identified these segments, do NOT output the full text. Instead, for each new line created by a subject change, output ONLY the first 3-5 words of that line. These serve as anchors for programmatic retrieval. ' . + 'Format your output as a plain list of these anchor words, one per line. Do not include numbers, bullet points, or any additional commentary. ' . + 'Example input: "The market for electric vehicles is expanding rapidly. In contrast, traditional motorcycle sales are declining globally. Aside from transportation, the price of copper remains volatile." ' . + 'Example output:\n' . + 'The market for electric vehicles\n' . + 'In contrast, traditional motorcycle\n' . + 'Aside from transportation, the price\n'; try { if ($this->openAiAPIService->isUsingOpenAi() || $this->openAiSettingsService->getChatEndpointEnabled()) { $completion = $this->openAiAPIService->createChatCompletion($userId, $model, $chunk, $systemPrompt, null, 1, $maxTokens); From 56e17d8ebd1038a3844e415116ea5469ae3d89d9 Mon Sep 17 00:00:00 2001 From: Lukas Schaefer Date: Tue, 12 May 2026 10:49:46 +0200 Subject: [PATCH 4/5] Use HEREDOC for prompt Signed-off-by: Lukas Schaefer --- .../ReformatParagraphsProvider.php | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/lib/TaskProcessing/ReformatParagraphsProvider.php b/lib/TaskProcessing/ReformatParagraphsProvider.php index 115a5e32..e7d3ac37 100644 --- a/lib/TaskProcessing/ReformatParagraphsProvider.php +++ b/lib/TaskProcessing/ReformatParagraphsProvider.php @@ -170,15 +170,19 @@ public function process(?string $userId, array $input, callable $reportProgress) $progress = 0.0; foreach ($chunks as $chunk) { - $systemPrompt = 'You will receive a continuous block of text without line breaks. Your task is to identify points in the text where the subject or topic changes (e.g., a shift to a new person, place, concept, or thematic focus) and insert a line break at that specific transition. ' . - 'Do NOT break lines based on sentence length or grammar unless the subject actually changes. ' . - 'Once you have identified these segments, do NOT output the full text. Instead, for each new line created by a subject change, output ONLY the first 3-5 words of that line. These serve as anchors for programmatic retrieval. ' . - 'Format your output as a plain list of these anchor words, one per line. Do not include numbers, bullet points, or any additional commentary. ' . - 'Example input: "The market for electric vehicles is expanding rapidly. In contrast, traditional motorcycle sales are declining globally. Aside from transportation, the price of copper remains volatile." ' . - 'Example output:\n' . - 'The market for electric vehicles\n' . - 'In contrast, traditional motorcycle\n' . - 'Aside from transportation, the price\n'; + $systemPrompt = <<openAiAPIService->isUsingOpenAi() || $this->openAiSettingsService->getChatEndpointEnabled()) { $completion = $this->openAiAPIService->createChatCompletion($userId, $model, $chunk, $systemPrompt, null, 1, $maxTokens); From a614bf8ef7ec582793d96959bb3c89eac3ac6bc5 Mon Sep 17 00:00:00 2001 From: Lukas Schaefer Date: Tue, 12 May 2026 11:06:56 +0200 Subject: [PATCH 5/5] Forgot to update test Signed-off-by: Lukas Schaefer --- tests/unit/Providers/OpenAiProviderTest.php | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/tests/unit/Providers/OpenAiProviderTest.php b/tests/unit/Providers/OpenAiProviderTest.php index 93ed3165..1c5ee39a 100644 --- a/tests/unit/Providers/OpenAiProviderTest.php +++ b/tests/unit/Providers/OpenAiProviderTest.php @@ -733,13 +733,19 @@ public function testReformatParagraphsProvider(): void { }'; $url = self::OPENAI_API_BASE . 'chat/completions'; - $systemPrompt = 'Analyze the provided text and split it into paragraphs based exclusively on thematic shifts. ' - . 'Follow these strict constraints: ' - . 'Thematic breaks only: Do not create a new paragraph for rhythm, style, or sentence flow. ' - . 'A break is allowed only when the subject matter changes significantly. ' - . 'Output format: For each identified paragraph, return only the first 8 to 12 words verbatim from the input. ' - . 'Structure: Return exactly one anchor per line. Do not include bullets, numbering, summaries, quotes, or any additional text. ' - . 'Single topic: If the text covers only one topic, return exactly one line.'; + $systemPrompt = << Application::OPENAI_DEFAULT_REQUEST_TIMEOUT, 'headers' => ['User-Agent' => Application::USER_AGENT, 'Authorization' => self::AUTHORIZATION_HEADER, 'Content-Type' => 'application/json']]; $options['body'] = json_encode([