From 886e00dbd8b31061eab1fc27b9fb732bebe6f2a4 Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 23 Jun 2026 18:35:20 +0200 Subject: [PATCH 1/3] fix(ai): migrate AiIntegrationsService off deprecated TextProcessing API Replace the OCP\TextProcessing API (deprecated since Nextcloud 30.0.0) with OCP\TaskProcessing throughout the AI integrations: - FreePromptTaskType -> TextToText (core:text2text) - SummaryTaskType -> TextToTextSummary (core:text2text:summary) - task input/output now use the ['input' => ...] / getOutput()['output'] shape - availability checks switch from in_array(class, list) to isset(types[id]) - drop the now-unused TextProcessingManager constructor dependency Callers that fed the old class constants to isLlmAvailable (AdminSettings, PageController, FollowUpClassifierListener) and the unit tests are updated to the new task type IDs. Two now-obsolete psalm-baseline entries are removed. Assisted-by: ClaudeCode:claude-opus-4-8 Signed-off-by: Hamza --- lib/Controller/PageController.php | 10 +- lib/Listener/FollowUpClassifierListener.php | 4 +- .../AiIntegrations/AiIntegrationsService.php | 60 ++++++----- lib/Settings/AdminSettings.php | 8 +- .../FollowUpClassifierListenerTest.php | 10 +- .../Service/AiIntegrationsServiceTest.php | 101 +++++++++--------- tests/psalm-baseline.xml | 6 -- 7 files changed, 99 insertions(+), 100 deletions(-) diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 427d062d67..eb5cc97e68 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -43,8 +43,8 @@ use OCP\IURLGenerator; use OCP\IUserManager; use OCP\IUserSession; -use OCP\TextProcessing\FreePromptTaskType; -use OCP\TextProcessing\SummaryTaskType; +use OCP\TaskProcessing\TaskTypes\TextToText; +use OCP\TaskProcessing\TaskTypes\TextToTextSummary; use OCP\User\IAvailabilityCoordinator; use Psr\Log\LoggerInterface; use Throwable; @@ -337,7 +337,7 @@ public function index(): TemplateResponse { $this->initialStateService->provideInitialState( 'llm_summaries_available', - $this->aiIntegrationsService->isLlmProcessingEnabled() && $this->aiIntegrationsService->isLlmAvailable(SummaryTaskType::class) + $this->aiIntegrationsService->isLlmProcessingEnabled() && $this->aiIntegrationsService->isLlmAvailable(TextToTextSummary::ID) ); $this->initialStateService->provideInitialState( @@ -347,13 +347,13 @@ public function index(): TemplateResponse { $this->initialStateService->provideInitialState( 'llm_freeprompt_available', - $this->aiIntegrationsService->isLlmProcessingEnabled() && $this->aiIntegrationsService->isLlmAvailable(FreePromptTaskType::class) + $this->aiIntegrationsService->isLlmProcessingEnabled() && $this->aiIntegrationsService->isLlmAvailable(TextToText::ID) ); $this->initialStateService->provideInitialState( 'llm_followup_available', $this->aiIntegrationsService->isLlmProcessingEnabled() - && $this->aiIntegrationsService->isLlmAvailable(FreePromptTaskType::class) + && $this->aiIntegrationsService->isLlmAvailable(TextToText::ID) ); $this->initialStateService->provideInitialState( diff --git a/lib/Listener/FollowUpClassifierListener.php b/lib/Listener/FollowUpClassifierListener.php index 4681fec611..9a8cf0e64b 100644 --- a/lib/Listener/FollowUpClassifierListener.php +++ b/lib/Listener/FollowUpClassifierListener.php @@ -17,7 +17,7 @@ use OCP\BackgroundJob\IJobList; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; -use OCP\TextProcessing\FreePromptTaskType; +use OCP\TaskProcessing\TaskTypes\TextToText; /** * @template-implements IEventListener @@ -46,7 +46,7 @@ public function handle(Event $event): void { return; } - if (!$this->aiService->isLlmAvailable(FreePromptTaskType::class)) { + if (!$this->aiService->isLlmAvailable(TextToText::ID)) { return; } diff --git a/lib/Service/AiIntegrations/AiIntegrationsService.php b/lib/Service/AiIntegrations/AiIntegrationsService.php index 49243344cc..00cdf6e7d4 100644 --- a/lib/Service/AiIntegrations/AiIntegrationsService.php +++ b/lib/Service/AiIntegrations/AiIntegrationsService.php @@ -26,15 +26,11 @@ use OCP\TaskProcessing\IManager as TaskProcessingManager; use OCP\TaskProcessing\Task as TaskProcessingTask; use OCP\TaskProcessing\TaskTypes\TextToText; -use OCP\TextProcessing\FreePromptTaskType; -use OCP\TextProcessing\IManager as TextProcessingManager; -use OCP\TextProcessing\SummaryTaskType; -use OCP\TextProcessing\Task as TextProcessingTask; +use OCP\TaskProcessing\TaskTypes\TextToTextSummary; use Psr\Log\LoggerInterface; use function array_map; use function implode; -use function in_array; use function json_decode; class AiIntegrationsService { @@ -52,7 +48,6 @@ public function __construct( private IMAPClientFactory $clientFactory, private IMailManager $mailManager, private TaskProcessingManager $taskProcessingManager, - private TextProcessingManager $textProcessingManager, private IL10N $l, private IFactory $l10nFactory, private IUserManager $userManager, @@ -135,7 +130,7 @@ public function summarizeMessages(Account $account, array $messages): void { * @throws ServiceException */ public function summarizeThread(Account $account, string $threadId, array $messages, string $currentUserId): ?string { - if (in_array(SummaryTaskType::class, $this->textProcessingManager->getAvailableTaskTypes(), true)) { + if (isset($this->taskProcessingManager->getAvailableTaskTypes()[TextToTextSummary::ID])) { $messageIds = array_map(fn ($message) => $message->getMessageId(), $messages); $cachedSummary = $this->cache->getValue($this->cache->buildUrlKey($messageIds)); if ($cachedSummary) { @@ -159,9 +154,15 @@ public function summarizeThread(Account $account, string $threadId, array $messa } $taskPrompt = implode("\n", $messagesBodies); - $summaryTask = new TextProcessingTask(SummaryTaskType::class, $taskPrompt, 'mail', $currentUserId, $threadId); - $this->textProcessingManager->runTask($summaryTask); - $summary = $summaryTask->getOutput(); + $summaryTask = new TaskProcessingTask( + TextToTextSummary::ID, + ['input' => $taskPrompt], + Application::APP_ID, + $currentUserId, + $threadId, + ); + $this->taskProcessingManager->runTask($summaryTask); + $summary = $summaryTask->getOutput()['output'] ?? null; $this->cache->addValue($this->cache->buildUrlKey($messageIds), $summary); @@ -175,7 +176,7 @@ public function summarizeThread(Account $account, string $threadId, array $messa * @param Message[] $messages */ public function generateEventData(Account $account, string $threadId, array $messages, string $currentUserId): ?EventData { - if (!in_array(FreePromptTaskType::class, $this->textProcessingManager->getAvailableTaskTypes(), true)) { + if (!isset($this->taskProcessingManager->getAvailableTaskTypes()[TextToText::ID])) { return null; } $client = $this->clientFactory->getClient($account); @@ -194,14 +195,15 @@ public function generateEventData(Account $account, string $threadId, array $mes $client->logout(); } - $task = new TextProcessingTask( - FreePromptTaskType::class, - self::EVENT_DATA_PROMPT_PREAMBLE . implode("\n\n---\n\n", $messageBodies), - 'mail', + $task = new TaskProcessingTask( + TextToText::ID, + ['input' => self::EVENT_DATA_PROMPT_PREAMBLE . implode("\n\n---\n\n", $messageBodies)], + Application::APP_ID, $currentUserId, "event_data_$threadId", ); - $result = $this->textProcessingManager->runTask($task); + $this->taskProcessingManager->runTask($task); + $result = $task->getOutput()['output'] ?? ''; try { $decoded = json_decode($result, true, 512, JSON_THROW_ON_ERROR); return new EventData($decoded['title'], $decoded['agenda']); @@ -215,7 +217,7 @@ public function generateEventData(Account $account, string $threadId, array $mes * @throws ServiceException */ public function getSmartReply(Account $account, Mailbox $mailbox, Message $message, string $currentUserId): ?array { - if (in_array(FreePromptTaskType::class, $this->textProcessingManager->getAvailableTaskTypes(), true)) { + if (isset($this->taskProcessingManager->getAvailableTaskTypes()[TextToText::ID])) { $cachedReplies = $this->cache->getValue("smartReplies_{$message->getId()}"); if ($cachedReplies) { try { @@ -255,9 +257,9 @@ public function getSmartReply(Account $account, Mailbox $mailbox, Message $messa Please, output *ONLY* a valid JSON string with the keys 'reply1' and 'reply2' for the reply suggestions. Leave out any other text besides the JSON! Be extremely succinct and write the replies from my point of view. "; - $task = new TextProcessingTask(FreePromptTaskType::class, $prompt, 'mail,', $currentUserId); - $this->textProcessingManager->runTask($task); - $replies = $task->getOutput(); + $task = new TaskProcessingTask(TextToText::ID, ['input' => $prompt], Application::APP_ID, $currentUserId); + $this->taskProcessingManager->runTask($task); + $replies = $task->getOutput()['output'] ?? ''; try { $cleaned = preg_replace('/^```json\s*|\s*```$/', '', trim($replies)); $decoded = json_decode($cleaned, true, 512, JSON_THROW_ON_ERROR); @@ -282,7 +284,7 @@ public function requiresFollowUp( Message $message, string $currentUserId, ): bool { - if (!in_array(FreePromptTaskType::class, $this->textProcessingManager->getAvailableTaskTypes(), true)) { + if (!isset($this->taskProcessingManager->getAvailableTaskTypes()[TextToText::ID])) { throw new ServiceException('No language model available for smart replies'); } @@ -321,12 +323,12 @@ public function requiresFollowUp( emailText: \"$messageBody\" The JSON output should be in the form: {\"expectsReply\": true} Never return null or undefined."; - $task = new TextProcessingTask(FreePromptTaskType::class, $prompt, Application::APP_ID, $currentUserId); + $task = new TaskProcessingTask(TextToText::ID, ['input' => $prompt], Application::APP_ID, $currentUserId); - $this->textProcessingManager->runTask($task); + $this->taskProcessingManager->runTask($task); // Can't use json_decode() here because the output contains additional garbage - return preg_match('/{\s*"expectsReply"\s*:\s*true\s*}/i', $task->getOutput()) === 1; + return preg_match('/{\s*"expectsReply"\s*:\s*true\s*}/i', $task->getOutput()['output'] ?? '') === 1; } /** @@ -340,7 +342,7 @@ public function requiresTranslation( Message $message, string $currentUserId, ): ?bool { - if (!in_array(FreePromptTaskType::class, $this->textProcessingManager->getAvailableTaskTypes(), true)) { + if (!isset($this->taskProcessingManager->getAvailableTaskTypes()[TextToText::ID])) { $this->logger->info('No language model available for checking translation needs'); return null; } @@ -390,10 +392,10 @@ public function requiresTranslation( language: \"$language\" The JSON output should be in the form: {\"needsTranslation\": true} Never return null or undefined."; - $task = new TextProcessingTask(FreePromptTaskType::class, $prompt, Application::APP_ID, $currentUserId); + $task = new TaskProcessingTask(TextToText::ID, ['input' => $prompt], Application::APP_ID, $currentUserId); - $this->textProcessingManager->runTask($task); - $output = $task->getOutput(); + $this->taskProcessingManager->runTask($task); + $output = $task->getOutput()['output'] ?? null; if ($output === null) { throw new ServiceException('Task output is null, possibly due to an error in the task processing', [ 'messageId' => $message->getId(), @@ -408,7 +410,7 @@ public function requiresTranslation( } public function isLlmAvailable(string $taskType): bool { - return in_array($taskType, $this->textProcessingManager->getAvailableTaskTypes(), true); + return array_key_exists($taskType, $this->taskProcessingManager->getAvailableTaskTypes()); } public function isTaskAvailable(string $taskName): bool { diff --git a/lib/Settings/AdminSettings.php b/lib/Settings/AdminSettings.php index 32e8c004a7..49b5dc50f0 100644 --- a/lib/Settings/AdminSettings.php +++ b/lib/Settings/AdminSettings.php @@ -21,8 +21,8 @@ use OCP\IConfig; use OCP\IInitialStateService; use OCP\Settings\ISettings; -use OCP\TextProcessing\FreePromptTaskType; -use OCP\TextProcessing\SummaryTaskType; +use OCP\TaskProcessing\TaskTypes\TextToText; +use OCP\TaskProcessing\TaskTypes\TextToTextSummary; class AdminSettings implements ISettings { /** @var IInitialStateService */ @@ -97,13 +97,13 @@ public function getForm() { $this->initialStateService->provideInitialState( Application::APP_ID, 'enabled_llm_free_prompt_backend', - $this->aiIntegrationsService->isLlmAvailable(FreePromptTaskType::class) + $this->aiIntegrationsService->isLlmAvailable(TextToText::ID) ); $this->initialStateService->provideInitialState( Application::APP_ID, 'enabled_llm_summary_backend', - $this->aiIntegrationsService->isLlmAvailable(SummaryTaskType::class) + $this->aiIntegrationsService->isLlmAvailable(TextToTextSummary::ID) ); $this->initialStateService->provideInitialState( diff --git a/tests/Unit/Listener/FollowUpClassifierListenerTest.php b/tests/Unit/Listener/FollowUpClassifierListenerTest.php index b42cf48dfe..29c38843b8 100644 --- a/tests/Unit/Listener/FollowUpClassifierListenerTest.php +++ b/tests/Unit/Listener/FollowUpClassifierListenerTest.php @@ -22,7 +22,7 @@ use OCA\Mail\Listener\FollowUpClassifierListener; use OCA\Mail\Service\AiIntegrations\AiIntegrationsService; use OCP\BackgroundJob\IJobList; -use OCP\TextProcessing\FreePromptTaskType; +use OCP\TaskProcessing\TaskTypes\TextToText; class FollowUpClassifierListenerTest extends TestCase { private FollowUpClassifierListener $listener; @@ -69,7 +69,7 @@ public function testHandle(): void { ->willReturn(true); $this->aiService->expects(self::once()) ->method('isLlmAvailable') - ->with(FreePromptTaskType::class) + ->with(TextToText::ID) ->willReturn(true); $this->jobList->expects(self::once()) ->method('scheduleAfter') @@ -136,7 +136,7 @@ public function testHandleLlmTaskUnavailable(): void { ->willReturn(true); $this->aiService->expects(self::once()) ->method('isLlmAvailable') - ->with(FreePromptTaskType::class) + ->with(TextToText::ID) ->willReturn(false); $this->jobList->expects(self::never()) ->method('scheduleAfter'); @@ -169,7 +169,7 @@ public function testHandleSkipTagged(): void { ->willReturn(true); $this->aiService->expects(self::once()) ->method('isLlmAvailable') - ->with(FreePromptTaskType::class) + ->with(TextToText::ID) ->willReturn(true); $this->jobList->expects(self::never()) ->method('scheduleAfter'); @@ -201,7 +201,7 @@ public function testHandleSkipOld(): void { ->willReturn(true); $this->aiService->expects(self::once()) ->method('isLlmAvailable') - ->with(FreePromptTaskType::class) + ->with(TextToText::ID) ->willReturn(true); $this->jobList->expects(self::never()) ->method('scheduleAfter'); diff --git a/tests/Unit/Service/AiIntegrationsServiceTest.php b/tests/Unit/Service/AiIntegrationsServiceTest.php index 442e44e0a9..c0f53a9e54 100644 --- a/tests/Unit/Service/AiIntegrationsServiceTest.php +++ b/tests/Unit/Service/AiIntegrationsServiceTest.php @@ -29,17 +29,14 @@ use OCP\L10N\IFactory; use OCP\TaskProcessing\IManager as TaskProcessingManager; use OCP\TaskProcessing\IProvider as TaskProcessingProvider; -use OCP\TextProcessing\FreePromptTaskType; -use OCP\TextProcessing\IManager as TextProcessingManager; -use OCP\TextProcessing\SummaryTaskType; -use OCP\TextProcessing\Task as TextProcessingTask; -use OCP\TextProcessing\TopicsTaskType; +use OCP\TaskProcessing\Task as TaskProcessingTask; +use OCP\TaskProcessing\TaskTypes\TextToText; +use OCP\TaskProcessing\TaskTypes\TextToTextSummary; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\NullLogger; class AiIntegrationsServiceTest extends TestCase { - private TextProcessingManager|MockObject $textProcessingManager; private IAppConfig|MockObject $appConfig; private NullLogger|MockObject $logger; private AiIntegrationsService $aiIntegrationsService; @@ -48,7 +45,6 @@ class AiIntegrationsServiceTest extends TestCase { private IMailManager|MockObject $mailManager; private TaskProcessingManager|MockObject $taskProcessingManager; private TaskProcessingProvider|MockObject $taskProcessingProvider; - private TextProcessingProvider|MockObject $textProcessingProvider; private IL10N|MockObject $l10n; private IFactory|MockObject $l10nFactory; private IUserManager|MockObject $userManager; @@ -62,7 +58,6 @@ protected function setUp(): void { $this->clientFactory = $this->createMock(IMAPClientFactory::class); $this->mailManager = $this->createMock(IMailManager::class); $this->taskProcessingManager = $this->createMock(TaskProcessingManager::class); - $this->textProcessingManager = $this->createMock(TextProcessingManager::class); $this->l10n = $this->createMock(IL10N::class); $this->l10nFactory = $this->createMock(IFactory::class); $this->userManager = $this->createMock(IUserManager::class); @@ -72,7 +67,6 @@ protected function setUp(): void { $this->clientFactory, $this->mailManager, $this->taskProcessingManager, - $this->textProcessingManager, $this->l10n, $this->l10nFactory, $this->userManager, @@ -84,7 +78,7 @@ protected function setUp(): void { public function testSummarizeThreadNoBackend(): void { $account = new Account(new MailAccount()); - $this->textProcessingManager->method('getAvailableTaskTypes')->willReturn([]); + $this->taskProcessingManager->method('getAvailableTaskTypes')->willReturn([]); $this->expectException(ServiceException::class); $this->expectExceptionMessage('No language model available for summary'); $this->aiIntegrationsService->summarizeThread($account, '', [], ''); @@ -95,7 +89,7 @@ public function testSmartReplyNoBackend(): void { $account = new Account(new MailAccount()); $mailbox = new Mailbox(); $message = new Message(); - $this->textProcessingManager + $this->taskProcessingManager ->method('getAvailableTaskTypes') ->willReturn([]); $this->expectException(ServiceException::class); @@ -110,9 +104,9 @@ public function testSmartReply(): void { $imapMessage = $this->createMock(IMAPMessage::class); $message->setUid(1); $currentUserId = 'user'; - $this->textProcessingManager + $this->taskProcessingManager ->method('getAvailableTaskTypes') - ->willReturn([FreePromptTaskType::class]); + ->willReturn([TextToText::ID => $this->taskProcessingProvider]); $this->cache->method('getValue')->willReturn(false); $this->clientFactory->method('getClient')->with($account)->willReturn($this->createMock(Horde_Imap_Client_Socket::class)); $this->mailManager->method('getImapMessage')->willReturn($imapMessage); @@ -122,11 +116,11 @@ public function testSmartReply(): void { $imapMessage->method('getFrom')->willReturn($fromList); $imapMessage->method('getPlainBody')->willReturn('This is a test message'); - $this->textProcessingManager->expects($this->once()) + $this->taskProcessingManager->expects($this->once()) ->method('runTask') - ->will($this->returnCallback(function (TextProcessingTask $task) { - $task->setOutput('{"reply1":"reply1","reply2":"reply2"}'); - return ''; + ->will($this->returnCallback(function (TaskProcessingTask $task) { + $task->setOutput(['output' => '{"reply1":"reply1","reply2":"reply2"}']); + return $task; })); $result = $this->aiIntegrationsService->getSmartReply($account, $mailbox, $message, $currentUserId); @@ -147,9 +141,9 @@ public function testSmartReplyMarkdownFormat(): void { $imapMessage = $this->createMock(IMAPMessage::class); $message->setUid(1); $currentUserId = 'user'; - $this->textProcessingManager + $this->taskProcessingManager ->method('getAvailableTaskTypes') - ->willReturn([FreePromptTaskType::class]); + ->willReturn([TextToText::ID => $this->taskProcessingProvider]); $this->cache->method('getValue')->willReturn(false); $this->clientFactory->method('getClient')->with($account)->willReturn($this->createMock(Horde_Imap_Client_Socket::class)); $this->mailManager->method('getImapMessage')->willReturn($imapMessage); @@ -159,11 +153,11 @@ public function testSmartReplyMarkdownFormat(): void { $imapMessage->method('getFrom')->willReturn($fromList); $imapMessage->method('getPlainBody')->willReturn('This is a test message'); - $this->textProcessingManager->expects($this->once()) + $this->taskProcessingManager->expects($this->once()) ->method('runTask') - ->will($this->returnCallback(function (TextProcessingTask $task) { - $task->setOutput('```json{"reply1":"reply1","reply2":"reply2"}```'); - return ''; + ->will($this->returnCallback(function (TaskProcessingTask $task) { + $task->setOutput(['output' => '```json{"reply1":"reply1","reply2":"reply2"}```']); + return $task; })); $result = $this->aiIntegrationsService->getSmartReply($account, $mailbox, $message, $currentUserId); @@ -184,9 +178,9 @@ public function testGeneratedMessage(): void { $message->setUid(1); $imapMessage = $this->createMock(IMAPMessage::class); $this->mailManager->method('getImapMessage')->willReturn($imapMessage); - $this->textProcessingManager + $this->taskProcessingManager ->method('getAvailableTaskTypes') - ->willReturn([FreePromptTaskType::class]); + ->willReturn([TextToText::ID => $this->taskProcessingProvider]); $imapMessage->method('isOneClickUnsubscribe')->willReturn(true); $replies = $this->aiIntegrationsService->getSmartReply($account, $mailbox, $message, ''); $this->assertEquals($replies, []); @@ -201,19 +195,22 @@ public function testGeneratedMessage(): void { } public function testLlmAvailable(): void { - $this->textProcessingManager + $this->taskProcessingManager ->method('getAvailableTaskTypes') - ->willReturn([SummaryTaskType::class, TopicsTaskType::class, FreePromptTaskType::class]); - $isAvailable = $this->aiIntegrationsService->isLlmAvailable(SummaryTaskType::class); + ->willReturn([ + TextToTextSummary::ID => $this->taskProcessingProvider, + TextToText::ID => $this->taskProcessingProvider, + ]); + $isAvailable = $this->aiIntegrationsService->isLlmAvailable(TextToTextSummary::ID); $this->assertTrue($isAvailable); } public function testLlmUnavailable(): void { - $this->textProcessingManager + $this->taskProcessingManager ->method('getAvailableTaskTypes') - ->willReturn([TopicsTaskType::class, FreePromptTaskType::class]); - $isAvailable = $this->aiIntegrationsService->isLlmAvailable(SummaryTaskType::class); + ->willReturn([TextToText::ID => $this->taskProcessingProvider]); + $isAvailable = $this->aiIntegrationsService->isLlmAvailable(TextToTextSummary::ID); $this->assertFalse($isAvailable); } @@ -257,9 +254,9 @@ public function testCached(): void { $message3->setThreadRootId('some-thread-root-id-1'); $messages = [ $message1,$message2,$message3]; - $this->textProcessingManager + $this->taskProcessingManager ->method('getAvailableTaskTypes') - ->willReturn([SummaryTaskType::class]); + ->willReturn([TextToTextSummary::ID => $this->taskProcessingProvider]); $messageIds = [ $message1->getMessageId(),$message2->getMessageId(),$message3->getMessageId()]; $key = $this->cache->buildUrlKey($messageIds); @@ -275,7 +272,7 @@ public function testGenerateEventDataFreePromptUnavailable(): void { $account = $this->createStub(Account::class); $message1 = new Message(); $message2 = new Message(); - $this->textProcessingManager->expects(self::once()) + $this->taskProcessingManager->expects(self::once()) ->method('getAvailableTaskTypes') ->willReturn([]); @@ -298,9 +295,9 @@ public function testGenerateEventDataInvalidJson(): void { $message2 = new Message(); $message2->setUid(2); $message2->setMailboxId(456); - $this->textProcessingManager->expects(self::once()) + $this->taskProcessingManager->expects(self::once()) ->method('getAvailableTaskTypes') - ->willReturn([FreePromptTaskType::class]); + ->willReturn([TextToText::ID => $this->taskProcessingProvider]); $imapMessage = $this->createMock(IMAPMessage::class); $this->mailManager->expects(self::exactly(2)) ->method('getImapMessage') @@ -308,9 +305,12 @@ public function testGenerateEventDataInvalidJson(): void { $imapMessage->expects(self::exactly(2)) ->method('getPlainBody') ->willReturn('plain'); - $this->textProcessingManager->expects(self::once()) + $this->taskProcessingManager->expects(self::once()) ->method('runTask') - ->willReturn('Jason'); + ->willReturnCallback(function (TaskProcessingTask $task) { + $task->setOutput(['output' => 'Jason']); + return $task; + }); $result = $this->aiIntegrationsService->generateEventData( $account, @@ -331,9 +331,9 @@ public function testGenerateEventData(): void { $message2 = new Message(); $message2->setUid(2); $message2->setMailboxId(456); - $this->textProcessingManager->expects(self::once()) + $this->taskProcessingManager->expects(self::once()) ->method('getAvailableTaskTypes') - ->willReturn([FreePromptTaskType::class]); + ->willReturn([TextToText::ID => $this->taskProcessingProvider]); $imapMessage = $this->createMock(IMAPMessage::class); $this->mailManager->expects(self::exactly(2)) ->method('getImapMessage') @@ -341,9 +341,12 @@ public function testGenerateEventData(): void { $imapMessage->expects(self::exactly(2)) ->method('getPlainBody') ->willReturn('plain'); - $this->textProcessingManager->expects(self::once()) + $this->taskProcessingManager->expects(self::once()) ->method('runTask') - ->willReturn('{"title":"Meeting", "agenda":"* Q&A"}'); + ->willReturnCallback(function (TaskProcessingTask $task) { + $task->setOutput(['output' => '{"title":"Meeting", "agenda":"* Q&A"}']); + return $task; + }); $result = $this->aiIntegrationsService->generateEventData( $account, @@ -549,7 +552,7 @@ public function testRequiresTranslationNoBackend(): void { $mailbox = new Mailbox(); $message = new Message(); - $this->textProcessingManager + $this->taskProcessingManager ->method('getAvailableTaskTypes') ->willReturn([]); $result = $this->aiIntegrationsService->requiresTranslation($account, $mailbox, $message, ''); @@ -564,9 +567,9 @@ public function testRequiresTranslation(): void { $imapMessage = $this->createMock(IMAPMessage::class); $message->setUid(1); $currentUserId = 'user'; - $this->textProcessingManager + $this->taskProcessingManager ->method('getAvailableTaskTypes') - ->willReturn([FreePromptTaskType::class]); + ->willReturn([TextToText::ID => $this->taskProcessingProvider]); $this->cache->method('getValue')->willReturn(false); $this->clientFactory->method('getClient')->with($account)->willReturn($this->createMock(Horde_Imap_Client_Socket::class)); $this->mailManager->method('getImapMessage')->willReturn($imapMessage); @@ -576,11 +579,11 @@ public function testRequiresTranslation(): void { $imapMessage->method('getFrom')->willReturn($fromList); $imapMessage->method('getPlainBody')->willReturn('Ceci n\'est pas un message'); - $this->textProcessingManager->expects($this->once()) + $this->taskProcessingManager->expects($this->once()) ->method('runTask') - ->will($this->returnCallback(function (TextProcessingTask $task) { - $task->setOutput('{"needsTranslation": true}, the message is in French that is the value returned is true '); - return ''; + ->will($this->returnCallback(function (TaskProcessingTask $task) { + $task->setOutput(['output' => '{"needsTranslation": true}, the message is in French that is the value returned is true ']); + return $task; })); $result = $this->aiIntegrationsService->requiresTranslation($account, $mailbox, $message, $currentUserId); diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml index 9760277d70..3ab4368a80 100644 --- a/tests/psalm-baseline.xml +++ b/tests/psalm-baseline.xml @@ -239,12 +239,6 @@ - - - - getOutput()]]> - - getRecipients()]]> From d8de3c0636b4eb0ea984d9b34701f4d35689e68b Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 23 Jun 2026 18:36:16 +0200 Subject: [PATCH 2/3] refactor(ai): extract AI prompts into DefaultPrompts Move the inline prompt templates out of AiIntegrationsService into a dedicated DefaultPrompts class. Templates use sprintf placeholders; the argument order is documented on each constant. Assisted-by: ClaudeCode:claude-opus-4-8 Signed-off-by: Hamza --- .../AiIntegrations/AiIntegrationsService.php | 76 +++----------- lib/Service/AiIntegrations/DefaultPrompts.php | 98 +++++++++++++++++++ 2 files changed, 109 insertions(+), 65 deletions(-) create mode 100644 lib/Service/AiIntegrations/DefaultPrompts.php diff --git a/lib/Service/AiIntegrations/AiIntegrationsService.php b/lib/Service/AiIntegrations/AiIntegrationsService.php index 00cdf6e7d4..c503cc78f4 100644 --- a/lib/Service/AiIntegrations/AiIntegrationsService.php +++ b/lib/Service/AiIntegrations/AiIntegrationsService.php @@ -35,13 +35,6 @@ class AiIntegrationsService { - private const EVENT_DATA_PROMPT_PREAMBLE = <<getPlainBody(); - $prompt = "You are tasked with formulating a helpful summary of a email message. \r\n" - . 'The summary should be in the language of this language code ' . $language . ". \r\n" - . "The summary should be less than 160 characters. \r\n" - . "Output *ONLY* the summary itself, leave out any introduction. \r\n" - . "Here is the ***E-MAIL*** for which you must generate a helpful summary: \r\n" - . "***START_OF_E-MAIL***\r\n$messageBody\r\n***END_OF_E-MAIL***\r\n"; + $prompt = sprintf(DefaultPrompts::SUMMARIZE_MESSAGE, $language, $messageBody); $task = new TaskProcessingTask( TextToText::ID, [ @@ -162,7 +150,8 @@ public function summarizeThread(Account $account, string $threadId, array $messa $threadId, ); $this->taskProcessingManager->runTask($summaryTask); - $summary = $summaryTask->getOutput()['output'] ?? null; + $output = $summaryTask->getOutput()['output'] ?? null; + $summary = $output !== null ? (string)$output : null; $this->cache->addValue($this->cache->buildUrlKey($messageIds), $summary); @@ -197,13 +186,13 @@ public function generateEventData(Account $account, string $threadId, array $mes $task = new TaskProcessingTask( TextToText::ID, - ['input' => self::EVENT_DATA_PROMPT_PREAMBLE . implode("\n\n---\n\n", $messageBodies)], + ['input' => DefaultPrompts::EVENT_DATA_PREAMBLE . implode("\n\n---\n\n", $messageBodies)], Application::APP_ID, $currentUserId, "event_data_$threadId", ); $this->taskProcessingManager->runTask($task); - $result = $task->getOutput()['output'] ?? ''; + $result = (string)($task->getOutput()['output'] ?? ''); try { $decoded = json_decode($result, true, 512, JSON_THROW_ON_ERROR); return new EventData($decoded['title'], $decoded['agenda']); @@ -243,23 +232,10 @@ public function getSmartReply(Account $account, Mailbox $mailbox, Message $messa } finally { $client->logout(); } - $prompt = "You are tasked with formulating helpful replies or reply templates to e-mails provided that have been sent to me. If you don't know some relevant information for answering the e-mails (like my schedule) leave blanks in the text that can later be filled by me. You must write the replies from my point of view as replies to the original sender of the provided e-mail! - - Formulate two extremely succinct reply suggestions to the provided ***E-MAIL***. Please, do not invent any context for the replies but, rather, leave blanks for me to fill in with relevant information where necessary. Provide the output formatted as valid JSON with the keys 'reply1' and 'reply2' for the reply suggestions. - - Each suggestion must be of 25 characters or less. - - Here is the ***E-MAIL*** for which you must suggest the replies to: - - ***START_OF_E-MAIL***" . $messageBody . " - - ***END_OF_E-MAIL*** - - Please, output *ONLY* a valid JSON string with the keys 'reply1' and 'reply2' for the reply suggestions. Leave out any other text besides the JSON! Be extremely succinct and write the replies from my point of view. - "; + $prompt = sprintf(DefaultPrompts::SMART_REPLY, $messageBody); $task = new TaskProcessingTask(TextToText::ID, ['input' => $prompt], Application::APP_ID, $currentUserId); $this->taskProcessingManager->runTask($task); - $replies = $task->getOutput()['output'] ?? ''; + $replies = (string)($task->getOutput()['output'] ?? ''); try { $cleaned = preg_replace('/^```json\s*|\s*```$/', '', trim($replies)); $decoded = json_decode($cleaned, true, 512, JSON_THROW_ON_ERROR); @@ -308,27 +284,13 @@ public function requiresFollowUp( $messageBody = $imapMessage->getPlainBody(); $messageBody = str_replace('"', '\"', $messageBody); - $prompt = "Consider the following TypeScript function prototype: ---- -/** - * This function takes in an email text and returns a boolean indicating whether the email author expects a response. - * - * @param emailText - string with the email text - * @returns boolean true if the email expects a reply, false if not - */ -declare function doesEmailExpectReply(emailText: string): Promise; ---- -Tell me what the function outputs for the following parameters. - -emailText: \"$messageBody\" -The JSON output should be in the form: {\"expectsReply\": true} -Never return null or undefined."; + $prompt = sprintf(DefaultPrompts::REQUIRES_FOLLOW_UP, $messageBody); $task = new TaskProcessingTask(TextToText::ID, ['input' => $prompt], Application::APP_ID, $currentUserId); $this->taskProcessingManager->runTask($task); // Can't use json_decode() here because the output contains additional garbage - return preg_match('/{\s*"expectsReply"\s*:\s*true\s*}/i', $task->getOutput()['output'] ?? '') === 1; + return preg_match('/{\s*"expectsReply"\s*:\s*true\s*}/i', (string)($task->getOutput()['output'] ?? '')) === 1; } /** @@ -374,28 +336,12 @@ public function requiresTranslation( $messageBody = $imapMessage->getPlainBody(); $messageBody = str_replace('"', '\"', $messageBody); - $prompt = "Consider the following TypeScript function prototype: ---- -/** - * This function takes in an email text and returns a boolean indicating whether the email needs translation from a specific language. - * - * @param emailText - string with the email text - * @param language - the language code to check against (e.g., 'en', 'de', etc.) - * @returns boolean true if the email is written in a different language than the one specified and needs translation, false if it is written in the specified language. - * only return true if whole sentences are written in a different language, not just a word or two. - */ -declare function isEmailWrittenInLanguage(emailText: string, language: string): Promise; ---- -Tell me what the function outputs for the following parameters. - -emailText: \"$messageBody\" -language: \"$language\" -The JSON output should be in the form: {\"needsTranslation\": true} -Never return null or undefined."; + $prompt = sprintf(DefaultPrompts::REQUIRES_TRANSLATION, $messageBody, $language); $task = new TaskProcessingTask(TextToText::ID, ['input' => $prompt], Application::APP_ID, $currentUserId); $this->taskProcessingManager->runTask($task); $output = $task->getOutput()['output'] ?? null; + $output = $output !== null ? (string)$output : null; if ($output === null) { throw new ServiceException('Task output is null, possibly due to an error in the task processing', [ 'messageId' => $message->getId(), diff --git a/lib/Service/AiIntegrations/DefaultPrompts.php b/lib/Service/AiIntegrations/DefaultPrompts.php new file mode 100644 index 0000000000..1c25928fa4 --- /dev/null +++ b/lib/Service/AiIntegrations/DefaultPrompts.php @@ -0,0 +1,98 @@ +; + --- + Tell me what the function outputs for the following parameters. + + emailText: "%s" + The JSON output should be in the form: {"expectsReply": true} + Never return null or undefined. + PROMPT; + + /** + * Arguments (in order): message body, language code. + */ + public const REQUIRES_TRANSLATION = <<; + --- + Tell me what the function outputs for the following parameters. + + emailText: "%s" + language: "%s" + The JSON output should be in the form: {"needsTranslation": true} + Never return null or undefined. + PROMPT; +} From 3d567f76277cc7c9a59ead2640c14245dc2db165 Mon Sep 17 00:00:00 2001 From: Hamza Date: Wed, 24 Jun 2026 17:55:14 +0200 Subject: [PATCH 3/3] fixup! refactor(ai): extract AI prompts into DefaultPrompts Signed-off-by: Hamza --- .../AiIntegrations/AiIntegrationsService.php | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/lib/Service/AiIntegrations/AiIntegrationsService.php b/lib/Service/AiIntegrations/AiIntegrationsService.php index c503cc78f4..5dea45831f 100644 --- a/lib/Service/AiIntegrations/AiIntegrationsService.php +++ b/lib/Service/AiIntegrations/AiIntegrationsService.php @@ -234,10 +234,15 @@ public function getSmartReply(Account $account, Mailbox $mailbox, Message $messa } $prompt = sprintf(DefaultPrompts::SMART_REPLY, $messageBody); $task = new TaskProcessingTask(TextToText::ID, ['input' => $prompt], Application::APP_ID, $currentUserId); - $this->taskProcessingManager->runTask($task); - $replies = (string)($task->getOutput()['output'] ?? ''); + $task = $this->taskProcessingManager->runTask($task); + $replies = trim((string)($task->getOutput()['output'] ?? '')); + if ($replies === '') { + // The task can fail or return nothing (e.g. provider timeout); treat as no replies + $this->logger->warning('Smart reply task returned no output', ['status' => $task->getStatus(), 'errorMessage' => $task->getErrorMessage()]); + return []; + } try { - $cleaned = preg_replace('/^```json\s*|\s*```$/', '', trim($replies)); + $cleaned = preg_replace('/^```json\s*|\s*```$/', '', $replies); $decoded = json_decode($cleaned, true, 512, JSON_THROW_ON_ERROR); $this->cache->addValue("smartReplies_{$message->getId()}", $replies); return $decoded; @@ -339,15 +344,13 @@ public function requiresTranslation( $prompt = sprintf(DefaultPrompts::REQUIRES_TRANSLATION, $messageBody, $language); $task = new TaskProcessingTask(TextToText::ID, ['input' => $prompt], Application::APP_ID, $currentUserId); - $this->taskProcessingManager->runTask($task); + $task = $this->taskProcessingManager->runTask($task); $output = $task->getOutput()['output'] ?? null; $output = $output !== null ? (string)$output : null; if ($output === null) { - throw new ServiceException('Task output is null, possibly due to an error in the task processing', [ - 'messageId' => $message->getId(), - 'language' => $language, - 'output' => $output, - ]); + // The task can fail or return nothing (e.g. provider timeout); can't determine, don't cache + $this->logger->warning('Translation check task returned no output', ['status' => $task->getStatus(), 'errorMessage' => $task->getErrorMessage()]); + return null; } // Can't use json_decode() here because the output contains additional garbage $result = preg_match('/{\s*"needsTranslation"\s*:\s*true\s*}/i', $output) === 1;