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..e7d3ac37 --- /dev/null +++ b/lib/TaskProcessing/ReformatParagraphsProvider.php @@ -0,0 +1,214 @@ + 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; + } + + public function __construct( + private OpenAiAPIService $openAiAPIService, + private IAppConfig $appConfig, + private OpenAiSettingsService $openAiSettingsService, + private IL10N $l, + private ChunkService $chunkService, + private ?string $userId, + ) { + } + + public function getId(): string { + return Application::APP_ID . '-text2text:reformatparagraphs'; + } + + public function getName(): string { + return $this->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->openAiSettingsService->getAdminDefaultCompletionModelId(); + 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 InvalidArgumentException('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->openAiSettingsService->getAdminDefaultCompletionModelId(); + } + $chunks = $this->chunkService->chunkSplitPrompt($prompt, false); + $result = ''; + $increase = 1.0 / (float)count($chunks); + $progress = 0.0; + + foreach ($chunks as $chunk) { + $systemPrompt = <<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 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); + $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..1c5ee39a 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,86 @@ 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 = << 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); + } + }