diff options
author | Marcel Klehr <mklehr@gmx.net> | 2023-07-21 11:20:31 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-07-21 11:20:31 +0200 |
commit | 7c80d66ee5cbbc4e40dfa1a7e0ef115b62980942 (patch) | |
tree | 37053a55e2329bf05f6a166342096d8616b83ecc | |
parent | e9b8a34cce59c7bd6ef6bec6b9c4846c062e4a7b (diff) | |
parent | 6d568b0d32d1255f76608e9d6b4b154dc57e5fea (diff) | |
download | nextcloud-server-7c80d66ee5cbbc4e40dfa1a7e0ef115b62980942.tar.gz nextcloud-server-7c80d66ee5cbbc4e40dfa1a7e0ef115b62980942.zip |
Merge pull request #38854 from nextcloud/enh/llm-api
30 files changed, 1989 insertions, 1 deletions
diff --git a/core/Controller/TextProcessingApiController.php b/core/Controller/TextProcessingApiController.php new file mode 100644 index 00000000000..9ed332644e1 --- /dev/null +++ b/core/Controller/TextProcessingApiController.php @@ -0,0 +1,137 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2023 Marcel Klehr <mklehr@gmx.net> + * + * @author Marcel Klehr <mklehr@gmx.net> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + + +namespace OC\Core\Controller; + +use InvalidArgumentException; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\DataResponse; +use OCP\Common\Exception\NotFoundException; +use OCP\IL10N; +use OCP\IRequest; +use OCP\TextProcessing\ITaskType; +use OCP\TextProcessing\Task; +use OCP\TextProcessing\IManager; +use OCP\PreConditionNotMetException; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; +use Psr\Log\LoggerInterface; + +class TextProcessingApiController extends \OCP\AppFramework\OCSController { + public function __construct( + string $appName, + IRequest $request, + private IManager $languageModelManager, + private IL10N $l, + private ?string $userId, + private ContainerInterface $container, + private LoggerInterface $logger, + ) { + parent::__construct($appName, $request); + } + + /** + * This endpoint returns all available LanguageModel task types + * + * @PublicPage + */ + public function taskTypes(): DataResponse { + $typeClasses = $this->languageModelManager->getAvailableTaskTypes(); + $types = []; + foreach ($typeClasses as $typeClass) { + try { + /** @var ITaskType $object */ + $object = $this->container->get($typeClass); + } catch (NotFoundExceptionInterface|ContainerExceptionInterface $e) { + $this->logger->warning('Could not find ' . $typeClass, ['exception' => $e]); + continue; + } + $types[] = [ + 'id' => $typeClass, + 'name' => $object->getName(), + 'description' => $object->getDescription(), + ]; + } + + return new DataResponse([ + 'types' => $types, + ]); + } + + /** + * This endpoint allows scheduling a language model task + * + * @PublicPage + * @UserRateThrottle(limit=20, period=120) + * @AnonRateThrottle(limit=5, period=120) + */ + public function schedule(string $input, string $type, string $appId, string $identifier = ''): DataResponse { + try { + $task = new Task($type, $input, $appId, $this->userId, $identifier); + } catch (InvalidArgumentException) { + return new DataResponse(['message' => $this->l->t('Requested task type does not exist')], Http::STATUS_BAD_REQUEST); + } + try { + $this->languageModelManager->scheduleTask($task); + + $json = $task->jsonSerialize(); + + return new DataResponse([ + 'task' => $json, + ]); + } catch (PreConditionNotMetException) { + return new DataResponse(['message' => $this->l->t('Necessary language model provider is not available')], Http::STATUS_PRECONDITION_FAILED); + } + } + + /** + * This endpoint allows checking the status and results of a task. + * Tasks are removed 1 week after receiving their last update. + * + * @PublicPage + * @param int $id The id of the task + */ + public function getTask(int $id): DataResponse { + try { + $task = $this->languageModelManager->getTask($id); + + if ($this->userId !== $task->getUserId()) { + return new DataResponse(['message' => $this->l->t('Task not found')], Http::STATUS_NOT_FOUND); + } + + $json = $task->jsonSerialize(); + + return new DataResponse([ + 'task' => $json, + ]); + } catch (NotFoundException $e) { + return new DataResponse(['message' => $this->l->t('Task not found')], Http::STATUS_NOT_FOUND); + } catch (\RuntimeException $e) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } +} diff --git a/core/Migrations/Version28000Date20230616104802.php b/core/Migrations/Version28000Date20230616104802.php new file mode 100644 index 00000000000..a4520b3c5bc --- /dev/null +++ b/core/Migrations/Version28000Date20230616104802.php @@ -0,0 +1,102 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2023 Marcel Klehr <mklehr@gmx.net> + * + * @author Marcel Klehr <mklehr@gmx.net> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +namespace OC\Core\Migrations; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Introduce llm_tasks table + */ +class Version28000Date20230616104802 extends SimpleMigrationStep { + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if (!$schema->hasTable('llm_tasks')) { + $table = $schema->createTable('llm_tasks'); + + $table->addColumn('id', Types::BIGINT, [ + 'notnull' => true, + 'length' => 64, + 'autoincrement' => true, + ]); + $table->addColumn('type', Types::STRING, [ + 'notnull' => true, + 'length' => 255, + ]); + $table->addColumn('input', Types::TEXT, [ + 'notnull' => true, + ]); + $table->addColumn('output', Types::TEXT, [ + 'notnull' => false, + ]); + $table->addColumn('status', Types::INTEGER, [ + 'notnull' => false, + 'length' => 6, + 'default' => 0, + ]); + $table->addColumn('user_id', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('app_id', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + 'default' => '', + ]); + $table->addColumn('identifier', Types::STRING, [ + 'notnull' => true, + 'length' => 255, + 'default' => '', + ]); + $table->addColumn('last_updated', 'integer', [ + 'notnull' => false, + 'length' => 4, + 'default' => 0, + 'unsigned' => true, + ]); + + $table->setPrimaryKey(['id'], 'llm_tasks_id_index'); + $table->addUniqueIndex(['status', 'type'], 'llm_tasks_status_type'); + $table->addIndex(['last_updated'], 'llm_tasks_updated'); + + return $schema; + } + + return null; + } +} diff --git a/core/routes.php b/core/routes.php index 0f9729e54eb..4790f32af32 100644 --- a/core/routes.php +++ b/core/routes.php @@ -145,6 +145,10 @@ $application->registerRoutes($this, [ ['root' => '/translation', 'name' => 'TranslationApi#languages', 'url' => '/languages', 'verb' => 'GET'], ['root' => '/translation', 'name' => 'TranslationApi#translate', 'url' => '/translate', 'verb' => 'POST'], + + ['root' => '/textprocessing', 'name' => 'TextProcessingApi#taskTypes', 'url' => '/tasktypes', 'verb' => 'GET'], + ['root' => '/textprocessing', 'name' => 'TextProcessingApi#schedule', 'url' => '/schedule', 'verb' => 'POST'], + ['root' => '/textprocessing', 'name' => 'TextProcessingApi#getTask', 'url' => '/task/{id}', 'verb' => 'GET'], ], ]); diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 25c402b3084..fcd1020be10 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -197,6 +197,7 @@ return array( 'OCP\\Comments\\IllegalIDChangeException' => $baseDir . '/lib/public/Comments/IllegalIDChangeException.php', 'OCP\\Comments\\MessageTooLongException' => $baseDir . '/lib/public/Comments/MessageTooLongException.php', 'OCP\\Comments\\NotFoundException' => $baseDir . '/lib/public/Comments/NotFoundException.php', + 'OCP\\Common\\Exception\\NotFoundException' => $baseDir . '/lib/public/Common/Exception/NotFoundException.php', 'OCP\\Config\\BeforePreferenceDeletedEvent' => $baseDir . '/lib/public/Config/BeforePreferenceDeletedEvent.php', 'OCP\\Config\\BeforePreferenceSetEvent' => $baseDir . '/lib/public/Config/BeforePreferenceSetEvent.php', 'OCP\\Console\\ConsoleEvent' => $baseDir . '/lib/public/Console/ConsoleEvent.php', @@ -626,6 +627,17 @@ return array( 'OCP\\Talk\\IConversationOptions' => $baseDir . '/lib/public/Talk/IConversationOptions.php', 'OCP\\Talk\\ITalkBackend' => $baseDir . '/lib/public/Talk/ITalkBackend.php', 'OCP\\Template' => $baseDir . '/lib/public/Template.php', + 'OCP\\TextProcessing\\Events\\AbstractTextProcessingEvent' => $baseDir . '/lib/public/TextProcessing/Events/AbstractTextProcessingEvent.php', + 'OCP\\TextProcessing\\Events\\TaskFailedEvent' => $baseDir . '/lib/public/TextProcessing/Events/TaskFailedEvent.php', + 'OCP\\TextProcessing\\Events\\TaskSuccessfulEvent' => $baseDir . '/lib/public/TextProcessing/Events/TaskSuccessfulEvent.php', + 'OCP\\TextProcessing\\FreePromptTaskType' => $baseDir . '/lib/public/TextProcessing/FreePromptTaskType.php', + 'OCP\\TextProcessing\\HeadlineTaskType' => $baseDir . '/lib/public/TextProcessing/HeadlineTaskType.php', + 'OCP\\TextProcessing\\IManager' => $baseDir . '/lib/public/TextProcessing/IManager.php', + 'OCP\\TextProcessing\\IProvider' => $baseDir . '/lib/public/TextProcessing/IProvider.php', + 'OCP\\TextProcessing\\ITaskType' => $baseDir . '/lib/public/TextProcessing/ITaskType.php', + 'OCP\\TextProcessing\\SummaryTaskType' => $baseDir . '/lib/public/TextProcessing/SummaryTaskType.php', + 'OCP\\TextProcessing\\Task' => $baseDir . '/lib/public/TextProcessing/Task.php', + 'OCP\\TextProcessing\\TopicsTaskType' => $baseDir . '/lib/public/TextProcessing/TopicsTaskType.php', 'OCP\\Translation\\CouldNotTranslateException' => $baseDir . '/lib/public/Translation/CouldNotTranslateException.php', 'OCP\\Translation\\IDetectLanguageProvider' => $baseDir . '/lib/public/Translation/IDetectLanguageProvider.php', 'OCP\\Translation\\ITranslationManager' => $baseDir . '/lib/public/Translation/ITranslationManager.php', @@ -1049,6 +1061,7 @@ return array( 'OC\\Core\\Controller\\ReferenceController' => $baseDir . '/core/Controller/ReferenceController.php', 'OC\\Core\\Controller\\SearchController' => $baseDir . '/core/Controller/SearchController.php', 'OC\\Core\\Controller\\SetupController' => $baseDir . '/core/Controller/SetupController.php', + 'OC\\Core\\Controller\\TextProcessingApiController' => $baseDir . '/core/Controller/TextProcessingApiController.php', 'OC\\Core\\Controller\\TranslationApiController' => $baseDir . '/core/Controller/TranslationApiController.php', 'OC\\Core\\Controller\\TwoFactorChallengeController' => $baseDir . '/core/Controller/TwoFactorChallengeController.php', 'OC\\Core\\Controller\\UnifiedSearchController' => $baseDir . '/core/Controller/UnifiedSearchController.php', @@ -1127,6 +1140,7 @@ return array( 'OC\\Core\\Migrations\\Version27000Date20220613163520' => $baseDir . '/core/Migrations/Version27000Date20220613163520.php', 'OC\\Core\\Migrations\\Version27000Date20230309104325' => $baseDir . '/core/Migrations/Version27000Date20230309104325.php', 'OC\\Core\\Migrations\\Version27000Date20230309104802' => $baseDir . '/core/Migrations/Version27000Date20230309104802.php', + 'OC\\Core\\Migrations\\Version28000Date20230616104802' => $baseDir . '/core/Migrations/Version28000Date20230616104802.php', 'OC\\Core\\Notification\\CoreNotifier' => $baseDir . '/core/Notification/CoreNotifier.php', 'OC\\Core\\Service\\LoginFlowV2Service' => $baseDir . '/core/Service/LoginFlowV2Service.php', 'OC\\DB\\Adapter' => $baseDir . '/lib/private/DB/Adapter.php', @@ -1500,6 +1514,7 @@ return array( 'OC\\RepairException' => $baseDir . '/lib/private/RepairException.php', 'OC\\Repair\\AddBruteForceCleanupJob' => $baseDir . '/lib/private/Repair/AddBruteForceCleanupJob.php', 'OC\\Repair\\AddCleanupUpdaterBackupsJob' => $baseDir . '/lib/private/Repair/AddCleanupUpdaterBackupsJob.php', + 'OC\\Repair\\AddRemoveOldTasksBackgroundJob' => $baseDir . '/lib/private/Repair/AddRemoveOldTasksBackgroundJob.php', 'OC\\Repair\\CleanTags' => $baseDir . '/lib/private/Repair/CleanTags.php', 'OC\\Repair\\CleanUpAbandonedApps' => $baseDir . '/lib/private/Repair/CleanUpAbandonedApps.php', 'OC\\Repair\\ClearFrontendCaches' => $baseDir . '/lib/private/Repair/ClearFrontendCaches.php', @@ -1651,6 +1666,11 @@ return array( 'OC\\Template\\ResourceLocator' => $baseDir . '/lib/private/Template/ResourceLocator.php', 'OC\\Template\\ResourceNotFoundException' => $baseDir . '/lib/private/Template/ResourceNotFoundException.php', 'OC\\Template\\TemplateFileLocator' => $baseDir . '/lib/private/Template/TemplateFileLocator.php', + 'OC\\TextProcessing\\Db\\Task' => $baseDir . '/lib/private/TextProcessing/Db/Task.php', + 'OC\\TextProcessing\\Db\\TaskMapper' => $baseDir . '/lib/private/TextProcessing/Db/TaskMapper.php', + 'OC\\TextProcessing\\Manager' => $baseDir . '/lib/private/TextProcessing/Manager.php', + 'OC\\TextProcessing\\RemoveOldTasksBackgroundJob' => $baseDir . '/lib/private/TextProcessing/RemoveOldTasksBackgroundJob.php', + 'OC\\TextProcessing\\TaskBackgroundJob' => $baseDir . '/lib/private/TextProcessing/TaskBackgroundJob.php', 'OC\\Translation\\TranslationManager' => $baseDir . '/lib/private/Translation/TranslationManager.php', 'OC\\URLGenerator' => $baseDir . '/lib/private/URLGenerator.php', 'OC\\Updater' => $baseDir . '/lib/private/Updater.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index cb1ae9e537b..783e63550c0 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -230,6 +230,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Comments\\IllegalIDChangeException' => __DIR__ . '/../../..' . '/lib/public/Comments/IllegalIDChangeException.php', 'OCP\\Comments\\MessageTooLongException' => __DIR__ . '/../../..' . '/lib/public/Comments/MessageTooLongException.php', 'OCP\\Comments\\NotFoundException' => __DIR__ . '/../../..' . '/lib/public/Comments/NotFoundException.php', + 'OCP\\Common\\Exception\\NotFoundException' => __DIR__ . '/../../..' . '/lib/public/Common/Exception/NotFoundException.php', 'OCP\\Config\\BeforePreferenceDeletedEvent' => __DIR__ . '/../../..' . '/lib/public/Config/BeforePreferenceDeletedEvent.php', 'OCP\\Config\\BeforePreferenceSetEvent' => __DIR__ . '/../../..' . '/lib/public/Config/BeforePreferenceSetEvent.php', 'OCP\\Console\\ConsoleEvent' => __DIR__ . '/../../..' . '/lib/public/Console/ConsoleEvent.php', @@ -659,6 +660,17 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Talk\\IConversationOptions' => __DIR__ . '/../../..' . '/lib/public/Talk/IConversationOptions.php', 'OCP\\Talk\\ITalkBackend' => __DIR__ . '/../../..' . '/lib/public/Talk/ITalkBackend.php', 'OCP\\Template' => __DIR__ . '/../../..' . '/lib/public/Template.php', + 'OCP\\TextProcessing\\Events\\AbstractTextProcessingEvent' => __DIR__ . '/../../..' . '/lib/public/TextProcessing/Events/AbstractTextProcessingEvent.php', + 'OCP\\TextProcessing\\Events\\TaskFailedEvent' => __DIR__ . '/../../..' . '/lib/public/TextProcessing/Events/TaskFailedEvent.php', + 'OCP\\TextProcessing\\Events\\TaskSuccessfulEvent' => __DIR__ . '/../../..' . '/lib/public/TextProcessing/Events/TaskSuccessfulEvent.php', + 'OCP\\TextProcessing\\FreePromptTaskType' => __DIR__ . '/../../..' . '/lib/public/TextProcessing/FreePromptTaskType.php', + 'OCP\\TextProcessing\\HeadlineTaskType' => __DIR__ . '/../../..' . '/lib/public/TextProcessing/HeadlineTaskType.php', + 'OCP\\TextProcessing\\IManager' => __DIR__ . '/../../..' . '/lib/public/TextProcessing/IManager.php', + 'OCP\\TextProcessing\\IProvider' => __DIR__ . '/../../..' . '/lib/public/TextProcessing/IProvider.php', + 'OCP\\TextProcessing\\ITaskType' => __DIR__ . '/../../..' . '/lib/public/TextProcessing/ITaskType.php', + 'OCP\\TextProcessing\\SummaryTaskType' => __DIR__ . '/../../..' . '/lib/public/TextProcessing/SummaryTaskType.php', + 'OCP\\TextProcessing\\Task' => __DIR__ . '/../../..' . '/lib/public/TextProcessing/Task.php', + 'OCP\\TextProcessing\\TopicsTaskType' => __DIR__ . '/../../..' . '/lib/public/TextProcessing/TopicsTaskType.php', 'OCP\\Translation\\CouldNotTranslateException' => __DIR__ . '/../../..' . '/lib/public/Translation/CouldNotTranslateException.php', 'OCP\\Translation\\IDetectLanguageProvider' => __DIR__ . '/../../..' . '/lib/public/Translation/IDetectLanguageProvider.php', 'OCP\\Translation\\ITranslationManager' => __DIR__ . '/../../..' . '/lib/public/Translation/ITranslationManager.php', @@ -1082,6 +1094,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Core\\Controller\\ReferenceController' => __DIR__ . '/../../..' . '/core/Controller/ReferenceController.php', 'OC\\Core\\Controller\\SearchController' => __DIR__ . '/../../..' . '/core/Controller/SearchController.php', 'OC\\Core\\Controller\\SetupController' => __DIR__ . '/../../..' . '/core/Controller/SetupController.php', + 'OC\\Core\\Controller\\TextProcessingApiController' => __DIR__ . '/../../..' . '/core/Controller/TextProcessingApiController.php', 'OC\\Core\\Controller\\TranslationApiController' => __DIR__ . '/../../..' . '/core/Controller/TranslationApiController.php', 'OC\\Core\\Controller\\TwoFactorChallengeController' => __DIR__ . '/../../..' . '/core/Controller/TwoFactorChallengeController.php', 'OC\\Core\\Controller\\UnifiedSearchController' => __DIR__ . '/../../..' . '/core/Controller/UnifiedSearchController.php', @@ -1160,6 +1173,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Core\\Migrations\\Version27000Date20220613163520' => __DIR__ . '/../../..' . '/core/Migrations/Version27000Date20220613163520.php', 'OC\\Core\\Migrations\\Version27000Date20230309104325' => __DIR__ . '/../../..' . '/core/Migrations/Version27000Date20230309104325.php', 'OC\\Core\\Migrations\\Version27000Date20230309104802' => __DIR__ . '/../../..' . '/core/Migrations/Version27000Date20230309104802.php', + 'OC\\Core\\Migrations\\Version28000Date20230616104802' => __DIR__ . '/../../..' . '/core/Migrations/Version28000Date20230616104802.php', 'OC\\Core\\Notification\\CoreNotifier' => __DIR__ . '/../../..' . '/core/Notification/CoreNotifier.php', 'OC\\Core\\Service\\LoginFlowV2Service' => __DIR__ . '/../../..' . '/core/Service/LoginFlowV2Service.php', 'OC\\DB\\Adapter' => __DIR__ . '/../../..' . '/lib/private/DB/Adapter.php', @@ -1533,6 +1547,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\RepairException' => __DIR__ . '/../../..' . '/lib/private/RepairException.php', 'OC\\Repair\\AddBruteForceCleanupJob' => __DIR__ . '/../../..' . '/lib/private/Repair/AddBruteForceCleanupJob.php', 'OC\\Repair\\AddCleanupUpdaterBackupsJob' => __DIR__ . '/../../..' . '/lib/private/Repair/AddCleanupUpdaterBackupsJob.php', + 'OC\\Repair\\AddRemoveOldTasksBackgroundJob' => __DIR__ . '/../../..' . '/lib/private/Repair/AddRemoveOldTasksBackgroundJob.php', 'OC\\Repair\\CleanTags' => __DIR__ . '/../../..' . '/lib/private/Repair/CleanTags.php', 'OC\\Repair\\CleanUpAbandonedApps' => __DIR__ . '/../../..' . '/lib/private/Repair/CleanUpAbandonedApps.php', 'OC\\Repair\\ClearFrontendCaches' => __DIR__ . '/../../..' . '/lib/private/Repair/ClearFrontendCaches.php', @@ -1684,6 +1699,11 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Template\\ResourceLocator' => __DIR__ . '/../../..' . '/lib/private/Template/ResourceLocator.php', 'OC\\Template\\ResourceNotFoundException' => __DIR__ . '/../../..' . '/lib/private/Template/ResourceNotFoundException.php', 'OC\\Template\\TemplateFileLocator' => __DIR__ . '/../../..' . '/lib/private/Template/TemplateFileLocator.php', + 'OC\\TextProcessing\\Db\\Task' => __DIR__ . '/../../..' . '/lib/private/TextProcessing/Db/Task.php', + 'OC\\TextProcessing\\Db\\TaskMapper' => __DIR__ . '/../../..' . '/lib/private/TextProcessing/Db/TaskMapper.php', + 'OC\\TextProcessing\\Manager' => __DIR__ . '/../../..' . '/lib/private/TextProcessing/Manager.php', + 'OC\\TextProcessing\\RemoveOldTasksBackgroundJob' => __DIR__ . '/../../..' . '/lib/private/TextProcessing/RemoveOldTasksBackgroundJob.php', + 'OC\\TextProcessing\\TaskBackgroundJob' => __DIR__ . '/../../..' . '/lib/private/TextProcessing/TaskBackgroundJob.php', 'OC\\Translation\\TranslationManager' => __DIR__ . '/../../..' . '/lib/private/Translation/TranslationManager.php', 'OC\\URLGenerator' => __DIR__ . '/../../..' . '/lib/private/URLGenerator.php', 'OC\\Updater' => __DIR__ . '/../../..' . '/lib/private/Updater.php', diff --git a/lib/private/AppFramework/Bootstrap/RegistrationContext.php b/lib/private/AppFramework/Bootstrap/RegistrationContext.php index 8fcafab2d87..5aea2a7a744 100644 --- a/lib/private/AppFramework/Bootstrap/RegistrationContext.php +++ b/lib/private/AppFramework/Bootstrap/RegistrationContext.php @@ -33,6 +33,7 @@ use Closure; use OCP\Calendar\Resource\IBackend as IResourceBackend; use OCP\Calendar\Room\IBackend as IRoomBackend; use OCP\Collaboration\Reference\IReferenceProvider; +use OCP\TextProcessing\IProvider as ITextProcessingProvider; use OCP\SpeechToText\ISpeechToTextProvider; use OCP\Talk\ITalkBackend; use OCP\Translation\ITranslationProvider; @@ -115,6 +116,9 @@ class RegistrationContext { /** @var ServiceRegistration<ISpeechToTextProvider>[] */ private $speechToTextProviders = []; + /** @var ServiceRegistration<ITextProcessingProvider>[] */ + private $textProcessingProviders = []; + /** @var ServiceRegistration<ICustomTemplateProvider>[] */ private $templateProviders = []; @@ -262,6 +266,12 @@ class RegistrationContext { $providerClass ); } + public function registerTextProcessingProvider(string $providerClass): void { + $this->context->registerTextProcessingProvider( + $this->appId, + $providerClass + ); + } public function registerTemplateProvider(string $providerClass): void { $this->context->registerTemplateProvider( @@ -429,6 +439,10 @@ class RegistrationContext { $this->speechToTextProviders[] = new ServiceRegistration($appId, $class); } + public function registerTextProcessingProvider(string $appId, string $class): void { + $this->textProcessingProviders[] = new ServiceRegistration($appId, $class); + } + public function registerTemplateProvider(string $appId, string $class): void { $this->templateProviders[] = new ServiceRegistration($appId, $class); } @@ -708,6 +722,13 @@ class RegistrationContext { } /** + * @return ServiceRegistration<ITextProcessingProvider>[] + */ + public function getTextProcessingProviders(): array { + return $this->textProcessingProviders; + } + + /** * @return ServiceRegistration<ICustomTemplateProvider>[] */ public function getTemplateProviders(): array { diff --git a/lib/private/Repair.php b/lib/private/Repair.php index 9c6a6cd00f2..05624a2423a 100644 --- a/lib/private/Repair.php +++ b/lib/private/Repair.php @@ -34,6 +34,7 @@ */ namespace OC; +use OC\Repair\AddRemoveOldTasksBackgroundJob; use OC\Repair\CleanUpAbandonedApps; use OCP\AppFramework\QueryException; use OCP\AppFramework\Utility\ITimeFactory; @@ -210,6 +211,7 @@ class Repair implements IOutput { \OCP\Server::get(AddTokenCleanupJob::class), \OCP\Server::get(CleanUpAbandonedApps::class), \OCP\Server::get(AddMissingSecretJob::class), + \OCP\Server::get(AddRemoveOldTasksBackgroundJob::class), ]; } diff --git a/lib/private/Repair/AddRemoveOldTasksBackgroundJob.php b/lib/private/Repair/AddRemoveOldTasksBackgroundJob.php new file mode 100644 index 00000000000..94ae39f2183 --- /dev/null +++ b/lib/private/Repair/AddRemoveOldTasksBackgroundJob.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2023 Marcel Klehr <mklehr@gmx.net> + * + * @author Marcel Klehr <mklehr@gmx.net> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +namespace OC\Repair; + +use OC\TextProcessing\RemoveOldTasksBackgroundJob; +use OCP\BackgroundJob\IJobList; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class AddRemoveOldTasksBackgroundJob implements IRepairStep { + private IJobList $jobList; + + public function __construct(IJobList $jobList) { + $this->jobList = $jobList; + } + + public function getName(): string { + return 'Add language model tasks cleanup job'; + } + + public function run(IOutput $output) { + $this->jobList->add(RemoveOldTasksBackgroundJob::class); + } +} diff --git a/lib/private/Server.php b/lib/private/Server.php index f98ee051a32..03c03e1b6ed 100644 --- a/lib/private/Server.php +++ b/lib/private/Server.php @@ -1470,6 +1470,8 @@ class Server extends ServerContainer implements IServerContainer { $this->registerAlias(IEventSourceFactory::class, EventSourceFactory::class); + $this->registerAlias(\OCP\TextProcessing\IManager::class, \OC\TextProcessing\Manager::class); + $this->connectDispatcher(); } diff --git a/lib/private/Setup.php b/lib/private/Setup.php index d847125cc79..0993fe54f47 100644 --- a/lib/private/Setup.php +++ b/lib/private/Setup.php @@ -53,6 +53,7 @@ use Exception; use InvalidArgumentException; use OC\Authentication\Token\PublicKeyTokenProvider; use OC\Authentication\Token\TokenCleanupJob; +use OC\TextProcessing\RemoveOldTasksBackgroundJob; use OC\Log\Rotate; use OC\Preview\BackgroundCleanupJob; use OCP\AppFramework\Utility\ITimeFactory; @@ -453,6 +454,7 @@ class Setup { $jobList->add(TokenCleanupJob::class); $jobList->add(Rotate::class); $jobList->add(BackgroundCleanupJob::class); + $jobList->add(RemoveOldTasksBackgroundJob::class); } /** diff --git a/lib/private/TextProcessing/Db/Task.php b/lib/private/TextProcessing/Db/Task.php new file mode 100644 index 00000000000..8c2ddb74f1f --- /dev/null +++ b/lib/private/TextProcessing/Db/Task.php @@ -0,0 +1,112 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2023 Marcel Klehr <mklehr@gmx.net> + * + * @author Marcel Klehr <mklehr@gmx.net> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +namespace OC\TextProcessing\Db; + +use OCP\AppFramework\Db\Entity; +use OCP\TextProcessing\Task as OCPTask; + +/** + * @method setType(string $type) + * @method string getType() + * @method setLastUpdated(int $lastUpdated) + * @method int getLastUpdated() + * @method setInput(string $type) + * @method string getInput() + * @method setOutput(string $type) + * @method string getOutput() + * @method setStatus(int $type) + * @method int getStatus() + * @method setUserId(string $type) + * @method string getuserId() + * @method setAppId(string $type) + * @method string getAppId() + * @method setIdentifier(string $type) + * @method string getIdentifier() + */ +class Task extends Entity { + protected $lastUpdated; + protected $type; + protected $input; + protected $output; + protected $status; + protected $userId; + protected $appId; + protected $identifier; + + /** + * @var string[] + */ + public static array $columns = ['id', 'last_updated', 'type', 'input', 'output', 'status', 'user_id', 'app_id', 'identifier']; + + /** + * @var string[] + */ + public static array $fields = ['id', 'lastUpdated', 'type', 'input', 'output', 'status', 'userId', 'appId', 'identifier']; + + + public function __construct() { + // add types in constructor + $this->addType('id', 'integer'); + $this->addType('lastUpdated', 'integer'); + $this->addType('type', 'string'); + $this->addType('input', 'string'); + $this->addType('output', 'string'); + $this->addType('status', 'integer'); + $this->addType('userId', 'string'); + $this->addType('appId', 'string'); + $this->addType('identifier', 'string'); + } + + public function toRow(): array { + return array_combine(self::$columns, array_map(function ($field) { + return $this->{'get'.ucfirst($field)}(); + }, self::$fields)); + } + + public static function fromPublicTask(OCPTask $task): Task { + /** @var Task $task */ + $task = Task::fromParams([ + 'id' => $task->getId(), + 'type' => $task->getType(), + 'lastUpdated' => time(), + 'status' => $task->getStatus(), + 'input' => $task->getInput(), + 'output' => $task->getOutput(), + 'userId' => $task->getUserId(), + 'appId' => $task->getAppId(), + 'identifier' => $task->getIdentifier(), + ]); + return $task; + } + + public function toPublicTask(): OCPTask { + $task = new OCPTask($this->getType(), $this->getInput(), $this->getAppId(), $this->getuserId(), $this->getIdentifier()); + $task->setId($this->getId()); + $task->setStatus($this->getStatus()); + $task->setOutput($this->getOutput()); + return $task; + } +} diff --git a/lib/private/TextProcessing/Db/TaskMapper.php b/lib/private/TextProcessing/Db/TaskMapper.php new file mode 100644 index 00000000000..508f3fdf3b8 --- /dev/null +++ b/lib/private/TextProcessing/Db/TaskMapper.php @@ -0,0 +1,78 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2023 Marcel Klehr <mklehr@gmx.net> + * + * @author Marcel Klehr <mklehr@gmx.net> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +namespace OC\TextProcessing\Db; + +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\Entity; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\AppFramework\Db\QBMapper; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\DB\Exception; +use OCP\IDBConnection; + +/** + * @extends QBMapper<Task> + */ +class TaskMapper extends QBMapper { + public function __construct( + IDBConnection $db, + private ITimeFactory $timeFactory, + ) { + parent::__construct($db, 'llm_tasks', Task::class); + } + + /** + * @param int $id + * @return Task + * @throws Exception + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + */ + public function find(int $id): Task { + $qb = $this->db->getQueryBuilder(); + $qb->select(Task::$columns) + ->from($this->tableName) + ->where($qb->expr()->eq('id', $qb->createPositionalParameter($id))); + return $this->findEntity($qb); + } + + /** + * @param int $timeout + * @return int the number of deleted tasks + * @throws Exception + */ + public function deleteOlderThan(int $timeout): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->tableName) + ->where($qb->expr()->lt('last_updated', $qb->createPositionalParameter(time() - $timeout))); + return $qb->executeStatement(); + } + + public function update(Entity $entity): Entity { + $entity->setLastUpdated($this->timeFactory->now()->getTimestamp()); + return parent::update($entity); + } +} diff --git a/lib/private/TextProcessing/Manager.php b/lib/private/TextProcessing/Manager.php new file mode 100644 index 00000000000..f52482bbb32 --- /dev/null +++ b/lib/private/TextProcessing/Manager.php @@ -0,0 +1,182 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2023 Marcel Klehr <mklehr@gmx.net> + * + * @author Marcel Klehr <mklehr@gmx.net> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +namespace OC\TextProcessing; + +use OC\AppFramework\Bootstrap\Coordinator; +use OC\TextProcessing\Db\Task as DbTask; +use OCP\TextProcessing\Task as OCPTask; +use OC\TextProcessing\Db\TaskMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\BackgroundJob\IJobList; +use OCP\Common\Exception\NotFoundException; +use OCP\DB\Exception; +use OCP\IServerContainer; +use OCP\TextProcessing\IManager; +use OCP\TextProcessing\IProvider; +use OCP\PreConditionNotMetException; +use Psr\Log\LoggerInterface; +use RuntimeException; +use Throwable; + +class Manager implements IManager { + /** @var ?IProvider[] */ + private ?array $providers = null; + + public function __construct( + private IServerContainer $serverContainer, + private Coordinator $coordinator, + private LoggerInterface $logger, + private IJobList $jobList, + private TaskMapper $taskMapper, + ) { + } + + public function getProviders(): array { + $context = $this->coordinator->getRegistrationContext(); + if ($context === null) { + return []; + } + + if ($this->providers !== null) { + return $this->providers; + } + + $this->providers = []; + + foreach ($context->getTextProcessingProviders() as $providerServiceRegistration) { + $class = $providerServiceRegistration->getService(); + try { + $this->providers[$class] = $this->serverContainer->get($class); + } catch (Throwable $e) { + $this->logger->error('Failed to load Text processing provider ' . $class, [ + 'exception' => $e, + ]); + } + } + + return $this->providers; + } + + public function hasProviders(): bool { + $context = $this->coordinator->getRegistrationContext(); + if ($context === null) { + return false; + } + return count($context->getTextProcessingProviders()) > 0; + } + + /** + * @inheritDoc + */ + public function getAvailableTaskTypes(): array { + $tasks = []; + foreach ($this->getProviders() as $provider) { + $tasks[$provider->getTaskType()] = true; + } + return array_keys($tasks); + } + + public function canHandleTask(OCPTask $task): bool { + return in_array($task->getType(), $this->getAvailableTaskTypes()); + } + + /** + * @inheritDoc + */ + public function runTask(OCPTask $task): string { + if (!$this->canHandleTask($task)) { + throw new PreConditionNotMetException('No text processing provider is installed that can handle this task'); + } + foreach ($this->getProviders() as $provider) { + if (!$task->canUseProvider($provider)) { + continue; + } + try { + $task->setStatus(OCPTask::STATUS_RUNNING); + if ($task->getId() === null) { + $taskEntity = $this->taskMapper->insert(DbTask::fromPublicTask($task)); + $task->setId($taskEntity->getId()); + } else { + $this->taskMapper->update(DbTask::fromPublicTask($task)); + } + $output = $task->visitProvider($provider); + $task->setOutput($output); + $task->setStatus(OCPTask::STATUS_SUCCESSFUL); + $this->taskMapper->update(DbTask::fromPublicTask($task)); + return $output; + } catch (\RuntimeException $e) { + $this->logger->info('LanguageModel call using provider ' . $provider->getName() . ' failed', ['exception' => $e]); + $task->setStatus(OCPTask::STATUS_FAILED); + $this->taskMapper->update(DbTask::fromPublicTask($task)); + throw $e; + } catch (\Throwable $e) { + $this->logger->info('LanguageModel call using provider ' . $provider->getName() . ' failed', ['exception' => $e]); + $task->setStatus(OCPTask::STATUS_FAILED); + $this->taskMapper->update(DbTask::fromPublicTask($task)); + throw new RuntimeException('LanguageModel call using provider ' . $provider->getName() . ' failed: ' . $e->getMessage(), 0, $e); + } + } + + throw new RuntimeException('Could not run task'); + } + + /** + * @inheritDoc + * @throws Exception + */ + public function scheduleTask(OCPTask $task): void { + if (!$this->canHandleTask($task)) { + throw new PreConditionNotMetException('No LanguageModel provider is installed that can handle this task'); + } + $task->setStatus(OCPTask::STATUS_SCHEDULED); + $taskEntity = DbTask::fromPublicTask($task); + $this->taskMapper->insert($taskEntity); + $task->setId($taskEntity->getId()); + $this->jobList->add(TaskBackgroundJob::class, [ + 'taskId' => $task->getId() + ]); + } + + /** + * @param int $id The id of the task + * @return OCPTask + * @throws RuntimeException If the query failed + * @throws NotFoundException If the task could not be found + */ + public function getTask(int $id): OCPTask { + try { + $taskEntity = $this->taskMapper->find($id); + return $taskEntity->toPublicTask(); + } catch (DoesNotExistException $e) { + throw new NotFoundException('Could not find task with the provided id'); + } catch (MultipleObjectsReturnedException $e) { + throw new RuntimeException('Could not uniquely identify task with given id', 0, $e); + } catch (Exception $e) { + throw new RuntimeException('Failure while trying to find task by id: '.$e->getMessage(), 0, $e); + } + } +} diff --git a/lib/private/TextProcessing/RemoveOldTasksBackgroundJob.php b/lib/private/TextProcessing/RemoveOldTasksBackgroundJob.php new file mode 100644 index 00000000000..89d329acfbb --- /dev/null +++ b/lib/private/TextProcessing/RemoveOldTasksBackgroundJob.php @@ -0,0 +1,59 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2023 Marcel Klehr <mklehr@gmx.net> + * + * @author Marcel Klehr <mklehr@gmx.net> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + + +namespace OC\TextProcessing; + +use OC\TextProcessing\Db\TaskMapper; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; +use OCP\DB\Exception; +use Psr\Log\LoggerInterface; + +class RemoveOldTasksBackgroundJob extends TimedJob { + public const MAX_TASK_AGE_SECONDS = 60 * 50 * 24 * 7; // 1 week + + public function __construct( + ITimeFactory $timeFactory, + private TaskMapper $taskMapper, + private LoggerInterface $logger, + + ) { + parent::__construct($timeFactory); + $this->setInterval(60 * 60 * 24); + } + + /** + * @param mixed $argument + * @inheritDoc + */ + protected function run($argument) { + try { + $this->taskMapper->deleteOlderThan(self::MAX_TASK_AGE_SECONDS); + } catch (Exception $e) { + $this->logger->warning('Failed to delete stale language model tasks', ['exception' => $e]); + } + } +} diff --git a/lib/private/TextProcessing/TaskBackgroundJob.php b/lib/private/TextProcessing/TaskBackgroundJob.php new file mode 100644 index 00000000000..4c24b3e531f --- /dev/null +++ b/lib/private/TextProcessing/TaskBackgroundJob.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2023 Marcel Klehr <mklehr@gmx.net> + * + * @author Marcel Klehr <mklehr@gmx.net> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + + +namespace OC\TextProcessing; + +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\QueuedJob; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\TextProcessing\Events\TaskFailedEvent; +use OCP\TextProcessing\Events\TaskSuccessfulEvent; +use OCP\TextProcessing\IManager; + +class TaskBackgroundJob extends QueuedJob { + public function __construct( + ITimeFactory $timeFactory, + private IManager $textProcessingManager, + private IEventDispatcher $eventDispatcher, + ) { + parent::__construct($timeFactory); + // We want to avoid overloading the machine with these jobs + // so we only allow running one job at a time + $this->setAllowParallelRuns(false); + } + + /** + * @param array{taskId: int} $argument + * @inheritDoc + */ + protected function run($argument) { + $taskId = $argument['taskId']; + $task = $this->textProcessingManager->getTask($taskId); + try { + $this->textProcessingManager->runTask($task); + $event = new TaskSuccessfulEvent($task); + } catch (\Throwable $e) { + $event = new TaskFailedEvent($task, $e->getMessage()); + } + $this->eventDispatcher->dispatchTyped($event); + } +} diff --git a/lib/public/AppFramework/Bootstrap/IRegistrationContext.php b/lib/public/AppFramework/Bootstrap/IRegistrationContext.php index 66cf1ef2306..720803a78d1 100644 --- a/lib/public/AppFramework/Bootstrap/IRegistrationContext.php +++ b/lib/public/AppFramework/Bootstrap/IRegistrationContext.php @@ -37,6 +37,7 @@ use OCP\Collaboration\Reference\IReferenceProvider; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\Template\ICustomTemplateProvider; use OCP\IContainer; +use OCP\TextProcessing\IProvider as ITextProcessingProvider; use OCP\Notification\INotifier; use OCP\Preview\IProviderV2; use OCP\SpeechToText\ISpeechToTextProvider; @@ -220,6 +221,16 @@ interface IRegistrationContext { public function registerSpeechToTextProvider(string $providerClass): void; /** + * Register a custom text processing provider class that provides a promptable language model + * through the OCP\TextProcessing APIs + * + * @param string $providerClass + * @psalm-param class-string<ITextProcessingProvider> $providerClass + * @since 27.1.0 + */ + public function registerTextProcessingProvider(string $providerClass): void; + + /** * Register a custom template provider class that is able to inject custom templates * in addition to the user defined ones * diff --git a/lib/public/Common/Exception/NotFoundException.php b/lib/public/Common/Exception/NotFoundException.php new file mode 100644 index 00000000000..a30e1c42b8b --- /dev/null +++ b/lib/public/Common/Exception/NotFoundException.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2023 Marcel Klehr <mklehr@gmx.net> + * + * @author Marcel Klehr <mklehr@gmx.net> + * + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see <http://www.gnu.org/licenses/> + * + */ +namespace OCP\Common\Exception; + +/** + * This is thrown whenever something was expected to exist but doesn't + * + * @since 27.1.0 + */ +class NotFoundException extends \Exception { + /** + * Constructor + * @param string $msg the error message + * @since 27.1.0 + */ + public function __construct(string $msg) { + parent::__construct($msg); + } +} diff --git a/lib/public/TextProcessing/Events/AbstractTextProcessingEvent.php b/lib/public/TextProcessing/Events/AbstractTextProcessingEvent.php new file mode 100644 index 00000000000..329889e61f0 --- /dev/null +++ b/lib/public/TextProcessing/Events/AbstractTextProcessingEvent.php @@ -0,0 +1,51 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2023 Marcel Klehr <mklehr@gmx.net> + * + * @author Marcel Klehr <mklehr@gmx.net> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +namespace OCP\TextProcessing\Events; + +use OCP\EventDispatcher\Event; +use OCP\TextProcessing\Task; + +/** + * @since 27.1.0 + */ +abstract class AbstractTextProcessingEvent extends Event { + /** + * @since 27.1.0 + */ + public function __construct( + private Task $task + ) { + parent::__construct(); + } + + /** + * @return Task + * @since 27.1.0 + */ + public function getTask(): Task { + return $this->task; + } +} diff --git a/lib/public/TextProcessing/Events/TaskFailedEvent.php b/lib/public/TextProcessing/Events/TaskFailedEvent.php new file mode 100644 index 00000000000..f9765e362dc --- /dev/null +++ b/lib/public/TextProcessing/Events/TaskFailedEvent.php @@ -0,0 +1,30 @@ +<?php + +namespace OCP\TextProcessing\Events; + +use OCP\TextProcessing\Task; + +/** + * @since 27.1.0 + */ +class TaskFailedEvent extends AbstractTextProcessingEvent { + /** + * @param Task $task + * @param string $errorMessage + * @since 27.1.0 + */ + public function __construct( + Task $task, + private string $errorMessage, + ) { + parent::__construct($task); + } + + /** + * @return string + * @since 27.1.0 + */ + public function getErrorMessage(): string { + return $this->errorMessage; + } +} diff --git a/lib/public/TextProcessing/Events/TaskSuccessfulEvent.php b/lib/public/TextProcessing/Events/TaskSuccessfulEvent.php new file mode 100644 index 00000000000..df4d2ba6227 --- /dev/null +++ b/lib/public/TextProcessing/Events/TaskSuccessfulEvent.php @@ -0,0 +1,9 @@ +<?php + +namespace OCP\TextProcessing\Events; + +/** + * @since 27.1.0 + */ +class TaskSuccessfulEvent extends AbstractTextProcessingEvent { +} diff --git a/lib/public/TextProcessing/FreePromptTaskType.php b/lib/public/TextProcessing/FreePromptTaskType.php new file mode 100644 index 00000000000..dcc27df77c9 --- /dev/null +++ b/lib/public/TextProcessing/FreePromptTaskType.php @@ -0,0 +1,62 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2023 Marcel Klehr <mklehr@gmx.net> + * + * @author Marcel Klehr <mklehr@gmx.net> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +namespace OCP\TextProcessing; + +use OCP\IL10N; + +/** + * This is the text processing task type for free prompting + * @since 27.1.0 + */ +class FreePromptTaskType implements ITaskType { + /** + * Constructor for FreePromptTaskType + * + * @param IL10N $l + * @since 27.1.0 + */ + public function __construct( + private IL10N $l, + ) { + } + + + /** + * @inheritDoc + * @since 27.1.0 + */ + public function getName(): string { + return $this->l->t('Free prompt'); + } + + /** + * @inheritDoc + * @since 27.1.0 + */ + public function getDescription(): string { + return $this->l->t('Runs an arbitrary prompt through the built-in language model.'); + } +} diff --git a/lib/public/TextProcessing/HeadlineTaskType.php b/lib/public/TextProcessing/HeadlineTaskType.php new file mode 100644 index 00000000000..ad38848ea87 --- /dev/null +++ b/lib/public/TextProcessing/HeadlineTaskType.php @@ -0,0 +1,62 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2023 Marcel Klehr <mklehr@gmx.net> + * + * @author Marcel Klehr <mklehr@gmx.net> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +namespace OCP\TextProcessing; + +use OCP\IL10N; + +/** + * This is the text processing task type for creating headline + * @since 27.1.0 + */ +class HeadlineTaskType implements ITaskType { + /** + * Constructor for HeadlineTaskType + * + * @param IL10N $l + * @since 27.1.0 + */ + public function __construct( + private IL10N $l, + ) { + } + + + /** + * @inheritDoc + * @since 27.1.0 + */ + public function getName(): string { + return $this->l->t('Generate headline'); + } + + /** + * @inheritDoc + * @since 27.1.0 + */ + public function getDescription(): string { + return $this->l->t('Generates a possible headline for a text'); + } +} diff --git a/lib/public/TextProcessing/IManager.php b/lib/public/TextProcessing/IManager.php new file mode 100644 index 00000000000..90e25894d4f --- /dev/null +++ b/lib/public/TextProcessing/IManager.php @@ -0,0 +1,77 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2023 Marcel Klehr <mklehr@gmx.net> + * + * @author Marcel Klehr <mklehr@gmx.net> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + + +namespace OCP\TextProcessing; + +use OCP\Common\Exception\NotFoundException; +use OCP\PreConditionNotMetException; +use RuntimeException; + +/** + * API surface for apps interacting with and making use of LanguageModel providers + * without known which providers are installed + * @since 27.1.0 + */ +interface IManager { + /** + * @since 27.1.0 + */ + public function hasProviders(): bool; + + /** + * @return class-string<ITaskType>[] + * @since 27.1.0 + */ + public function getAvailableTaskTypes(): array; + + /** + * @param Task $task The task to run + * @throws PreConditionNotMetException If no or not the requested provider was registered but this method was still called + * @throws RuntimeException If something else failed + * @since 27.1.0 + */ + public function runTask(Task $task): string; + + /** + * Will schedule an LLM inference process in the background. The result will become available + * with the \OCP\LanguageModel\Events\TaskSuccessfulEvent + * If inference fails a \OCP\LanguageModel\Events\TaskFailedEvent will be dispatched instead + * + * @param Task $task The task to schedule + * @throws PreConditionNotMetException If no or not the requested provider was registered but this method was still called + * @since 27.1.0 + */ + public function scheduleTask(Task $task) : void; + + /** + * @param int $id The id of the task + * @return Task + * @throws RuntimeException If the query failed + * @throws NotFoundException If the task could not be found + * @since 27.1.0 + */ + public function getTask(int $id): Task; +} diff --git a/lib/public/TextProcessing/IProvider.php b/lib/public/TextProcessing/IProvider.php new file mode 100644 index 00000000000..6132e60b493 --- /dev/null +++ b/lib/public/TextProcessing/IProvider.php @@ -0,0 +1,62 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2023 Marcel Klehr <mklehr@gmx.net> + * + * @author Marcel Klehr <mklehr@gmx.net> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + + +namespace OCP\TextProcessing; + +use RuntimeException; + +/** + * This is the interface that is implemented by apps that + * implement a text processing provider + * @template T of ITaskType + * @since 27.1.0 + */ +interface IProvider { + /** + * The localized name of this provider + * @since 27.1.0 + */ + public function getName(): string; + + /** + * Processes a text + * + * @param string $prompt The input text + * @return string the output text + * @since 27.1.0 + * @throws RuntimeException If the text could not be processed + */ + public function process(string $prompt): string; + + /** + * Returns the task type class string of the task type, that this + * provider handles + * + * @since 27.1.0 + * @return class-string<T> + */ + public function getTaskType(): string; +} diff --git a/lib/public/TextProcessing/ITaskType.php b/lib/public/TextProcessing/ITaskType.php new file mode 100644 index 00000000000..d08da3f7ac7 --- /dev/null +++ b/lib/public/TextProcessing/ITaskType.php @@ -0,0 +1,49 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2023 Marcel Klehr <mklehr@gmx.net> + * + * @author Marcel Klehr <mklehr@gmx.net> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +namespace OCP\TextProcessing; + +/** + * This is a task type interface that is implemented by text processing + * task types + * @since 27.1.0 + */ +interface ITaskType { + /** + * Returns the localized name of this task type + * + * @since 27.1.0 + * @return string + */ + public function getName(): string; + + /** + * Returns the localized description of this task type + * + * @since 27.1.0 + * @return string + */ + public function getDescription(): string; +} diff --git a/lib/public/TextProcessing/SummaryTaskType.php b/lib/public/TextProcessing/SummaryTaskType.php new file mode 100644 index 00000000000..3d80cee47f8 --- /dev/null +++ b/lib/public/TextProcessing/SummaryTaskType.php @@ -0,0 +1,62 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2023 Marcel Klehr <mklehr@gmx.net> + * + * @author Marcel Klehr <mklehr@gmx.net> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +namespace OCP\TextProcessing; + +use OCP\IL10N; + +/** + * This is the text processing task type for summaries + * @since 27.1.0 + */ +class SummaryTaskType implements ITaskType { + /** + * Constructor for SummaryTaskType + * + * @param IL10N $l + * @since 27.1.0 + */ + public function __construct( + private IL10N $l, + ) { + } + + + /** + * @inheritDoc + * @since 27.1.0 + */ + public function getName(): string { + return $this->l->t('Summarize'); + } + + /** + * @inheritDoc + * @since 27.1.0 + */ + public function getDescription(): string { + return $this->l->t('Summarizes text by reducing its length without losing key information.'); + } +} diff --git a/lib/public/TextProcessing/Task.php b/lib/public/TextProcessing/Task.php new file mode 100644 index 00000000000..446e414cb04 --- /dev/null +++ b/lib/public/TextProcessing/Task.php @@ -0,0 +1,221 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2023 Marcel Klehr <mklehr@gmx.net> + * + * @author Marcel Klehr <mklehr@gmx.net> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +namespace OCP\TextProcessing; + +/** + * This is a text processing task + * @since 27.1.0 + * @psalm-template T of ITaskType + * @psalm-template S as class-string<T> + * @psalm-template P as IProvider<T> + */ +final class Task implements \JsonSerializable { + protected ?int $id = null; + protected ?string $output = null; + + /** + * @since 27.1.0 + */ + public const TYPES = [ + FreePromptTaskType::class, + SummaryTaskType::class, + HeadlineTaskType::class, + TopicsTaskType::class, + ]; + + /** + * @since 27.1.0 + */ + public const STATUS_FAILED = 4; + /** + * @since 27.1.0 + */ + public const STATUS_SUCCESSFUL = 3; + /** + * @since 27.1.0 + */ + public const STATUS_RUNNING = 2; + /** + * @since 27.1.0 + */ + public const STATUS_SCHEDULED = 1; + /** + * @since 27.1.0 + */ + public const STATUS_UNKNOWN = 0; + + /** + * @psalm-var self::STATUS_* + */ + protected int $status = self::STATUS_UNKNOWN; + + /** + * @psalm-param S $type + * @param string $type + * @param string $input + * @param string $appId + * @param string|null $userId + * @param string $identifier An arbitrary identifier for this task. max length: 255 chars + * @since 27.1.0 + */ + final public function __construct( + protected string $type, + protected string $input, + protected string $appId, + protected ?string $userId, + protected string $identifier = '', + ) { + } + + /** + * @psalm-param P $provider + * @param IProvider $provider + * @return string + * @since 27.1.0 + */ + public function visitProvider(IProvider $provider): string { + if ($this->canUseProvider($provider)) { + return $provider->process($this->getInput()); + } else { + throw new \RuntimeException('Task of type ' . $this->getType() . ' cannot visit provider with task type ' . $provider->getTaskType()); + } + } + + /** + * @psalm-param P $provider + * @param IProvider $provider + * @return bool + * @since 27.1.0 + */ + public function canUseProvider(IProvider $provider): bool { + return $provider->getTaskType() === $this->getType(); + } + + /** + * @psalm-return S + * @since 27.1.0 + */ + final public function getType(): string { + return $this->type; + } + + /** + * @return string|null + * @since 27.1.0 + */ + final public function getOutput(): ?string { + return $this->output; + } + + /** + * @param string|null $output + * @since 27.1.0 + */ + final public function setOutput(?string $output): void { + $this->output = $output; + } + + /** + * @psalm-return self::STATUS_* + * @since 27.1.0 + */ + final public function getStatus(): int { + return $this->status; + } + + /** + * @psalm-param self::STATUS_* $status + * @since 27.1.0 + */ + final public function setStatus(int $status): void { + $this->status = $status; + } + + /** + * @return int|null + * @since 27.1.0 + */ + final public function getId(): ?int { + return $this->id; + } + + /** + * @param int|null $id + * @since 27.1.0 + */ + final public function setId(?int $id): void { + $this->id = $id; + } + + /** + * @return string + * @since 27.1.0 + */ + final public function getInput(): string { + return $this->input; + } + + /** + * @return string + * @since 27.1.0 + */ + final public function getAppId(): string { + return $this->appId; + } + + /** + * @return string + * @since 27.1.0 + */ + final public function getIdentifier(): string { + return $this->identifier; + } + + /** + * @return string|null + * @since 27.1.0 + */ + final public function getUserId(): ?string { + return $this->userId; + } + + /** + * @psalm-return array{id: ?int, type: S, status: 0|1|2|3|4, userId: ?string, appId: string, input: string, output: ?string, identifier: string} + * @since 27.1.0 + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'type' => $this->getType(), + 'status' => $this->getStatus(), + 'userId' => $this->getUserId(), + 'appId' => $this->getAppId(), + 'input' => $this->getInput(), + 'output' => $this->getOutput(), + 'identifier' => $this->getIdentifier(), + ]; + } +} diff --git a/lib/public/TextProcessing/TopicsTaskType.php b/lib/public/TextProcessing/TopicsTaskType.php new file mode 100644 index 00000000000..6162b9a13e9 --- /dev/null +++ b/lib/public/TextProcessing/TopicsTaskType.php @@ -0,0 +1,62 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2023 Marcel Klehr <mklehr@gmx.net> + * + * @author Marcel Klehr <mklehr@gmx.net> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +namespace OCP\TextProcessing; + +use OCP\IL10N; + +/** + * This is the text processing task type for topics extraction + * @since 27.1.0 + */ +class TopicsTaskType implements ITaskType { + /** + * Constructor for TopicsTaskType + * + * @param IL10N $l + * @since 27.1.0 + */ + public function __construct( + private IL10N $l, + ) { + } + + + /** + * @inheritDoc + * @since 27.1.0 + */ + public function getName(): string { + return $this->l->t('Extract topics'); + } + + /** + * @inheritDoc + * @since 27.1.0 + */ + public function getDescription(): string { + return $this->l->t('Extracts topics from a text and outputs them separated by commas.'); + } +} diff --git a/tests/lib/BackgroundJob/DummyJobList.php b/tests/lib/BackgroundJob/DummyJobList.php index 42b69cfbe41..8574f462ca7 100644 --- a/tests/lib/BackgroundJob/DummyJobList.php +++ b/tests/lib/BackgroundJob/DummyJobList.php @@ -141,7 +141,7 @@ class DummyJobList extends \OC\BackgroundJob\JobList { } public function hasReservedJob(?string $className = null): bool { - return $this->reserved[$className ?? '']; + return isset($this->reserved[$className ?? '']) && $this->reserved[$className ?? '']; } public function setHasReservedJob(?string $className, bool $hasReserved): void { diff --git a/tests/lib/TextProcessing/TextProcessingTest.php b/tests/lib/TextProcessing/TextProcessingTest.php new file mode 100644 index 00000000000..797571019ce --- /dev/null +++ b/tests/lib/TextProcessing/TextProcessingTest.php @@ -0,0 +1,338 @@ +<?php +/** + * Copyright (c) 2023 Marcel Klehr <mklehr@gmx.net> + * This file is licensed under the Affero General Public License version 3 or + * later. + * See the COPYING-README file. + */ + +namespace Test\TextProcessing; + +use OC\AppFramework\Bootstrap\Coordinator; +use OC\AppFramework\Bootstrap\RegistrationContext; +use OC\AppFramework\Bootstrap\ServiceRegistration; +use OC\EventDispatcher\EventDispatcher; +use OC\TextProcessing\Db\Task as DbTask; +use OC\TextProcessing\Db\TaskMapper; +use OC\TextProcessing\Manager; +use OC\TextProcessing\RemoveOldTasksBackgroundJob; +use OC\TextProcessing\TaskBackgroundJob; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Common\Exception\NotFoundException; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IServerContainer; +use OCP\TextProcessing\Events\TaskFailedEvent; +use OCP\TextProcessing\Events\TaskSuccessfulEvent; +use OCP\TextProcessing\FreePromptTaskType; +use OCP\TextProcessing\IManager; +use OCP\TextProcessing\IProvider; +use OCP\TextProcessing\SummaryTaskType; +use OCP\PreConditionNotMetException; +use OCP\TextProcessing\Task; +use OCP\TextProcessing\TopicsTaskType; +use PHPUnit\Framework\Constraint\IsInstanceOf; +use Psr\Log\LoggerInterface; +use Test\BackgroundJob\DummyJobList; + +class SuccessfulSummaryProvider implements IProvider { + public bool $ran = false; + + public function getName(): string { + return 'TEST Vanilla LLM Provider'; + } + + public function process(string $prompt): string { + $this->ran = true; + return $prompt . ' Summarize'; + } + + public function getTaskType(): string { + return SummaryTaskType::class; + } +} + +class FailingSummaryProvider implements IProvider { + public bool $ran = false; + + public function getName(): string { + return 'TEST Vanilla LLM Provider'; + } + + public function process(string $prompt): string { + $this->ran = true; + throw new \Exception('ERROR'); + } + + public function getTaskType(): string { + return SummaryTaskType::class; + } +} + +class FreePromptProvider implements IProvider { + public bool $ran = false; + + public function getName(): string { + return 'TEST Free Prompt Provider'; + } + + public function process(string $prompt): string { + $this->ran = true; + return $prompt . ' Free Prompt'; + } + + public function getTaskType(): string { + return FreePromptTaskType::class; + } +} + +class TextProcessingTest extends \Test\TestCase { + private IManager $manager; + private Coordinator $coordinator; + + protected function setUp(): void { + parent::setUp(); + + $this->providers = [ + SuccessfulSummaryProvider::class => new SuccessfulSummaryProvider(), + FailingSummaryProvider::class => new FailingSummaryProvider(), + FreePromptProvider::class => new FreePromptProvider(), + ]; + + $this->serverContainer = $this->createMock(IServerContainer::class); + $this->serverContainer->expects($this->any())->method('get')->willReturnCallback(function ($class) { + return $this->providers[$class]; + }); + + $this->eventDispatcher = new EventDispatcher( + new \Symfony\Component\EventDispatcher\EventDispatcher(), + $this->serverContainer, + \OC::$server->get(LoggerInterface::class), + ); + + $this->registrationContext = $this->createMock(RegistrationContext::class); + $this->coordinator = $this->createMock(Coordinator::class); + $this->coordinator->expects($this->any())->method('getRegistrationContext')->willReturn($this->registrationContext); + + $this->currentTime = new \DateTimeImmutable('now'); + + $this->taskMapper = $this->createMock(TaskMapper::class); + $this->tasksDb = []; + $this->taskMapper + ->expects($this->any()) + ->method('insert') + ->willReturnCallback(function (DbTask $task) { + $task->setId(count($this->tasksDb) ? max(array_keys($this->tasksDb)) : 1); + $task->setLastUpdated($this->currentTime->getTimestamp()); + $this->tasksDb[$task->getId()] = $task->toRow(); + return $task; + }); + $this->taskMapper + ->expects($this->any()) + ->method('update') + ->willReturnCallback(function (DbTask $task) { + $task->setLastUpdated($this->currentTime->getTimestamp()); + $this->tasksDb[$task->getId()] = $task->toRow(); + return $task; + }); + $this->taskMapper + ->expects($this->any()) + ->method('find') + ->willReturnCallback(function (int $id) { + if (!isset($this->tasksDb[$id])) { + throw new DoesNotExistException('Could not find it'); + } + return DbTask::fromRow($this->tasksDb[$id]); + }); + $this->taskMapper + ->expects($this->any()) + ->method('deleteOlderThan') + ->willReturnCallback(function (int $timeout) { + $this->tasksDb = array_filter($this->tasksDb, function (array $task) use ($timeout) { + return $task['last_updated'] >= $this->currentTime->getTimestamp() - $timeout; + }); + }); + + $this->jobList = $this->createPartialMock(DummyJobList::class, ['add']); + $this->jobList->expects($this->any())->method('add')->willReturnCallback(function () { + }); + + $this->manager = new Manager( + $this->serverContainer, + $this->coordinator, + \OC::$server->get(LoggerInterface::class), + $this->jobList, + $this->taskMapper, + ); + } + + public function testShouldNotHaveAnyProviders() { + $this->registrationContext->expects($this->any())->method('getTextProcessingProviders')->willReturn([]); + $this->assertCount(0, $this->manager->getAvailableTaskTypes()); + $this->assertFalse($this->manager->hasProviders()); + $this->expectException(PreConditionNotMetException::class); + $this->manager->runTask(new \OCP\TextProcessing\Task(FreePromptTaskType::class, 'Hello', 'test', null)); + } + + public function testProviderShouldBeRegisteredAndRun() { + $this->registrationContext->expects($this->any())->method('getTextProcessingProviders')->willReturn([ + new ServiceRegistration('test', SuccessfulSummaryProvider::class) + ]); + $this->assertCount(1, $this->manager->getAvailableTaskTypes()); + $this->assertTrue($this->manager->hasProviders()); + $this->assertEquals('Hello Summarize', $this->manager->runTask(new Task(SummaryTaskType::class, 'Hello', 'test', null))); + + // Summaries are not implemented by the vanilla provider, only free prompt + $this->expectException(PreConditionNotMetException::class); + $this->manager->runTask(new Task(FreePromptTaskType::class, 'Hello', 'test', null)); + } + + public function testProviderShouldBeRegisteredAndScheduled() { + // register provider + $this->registrationContext->expects($this->any())->method('getTextProcessingProviders')->willReturn([ + new ServiceRegistration('test', SuccessfulSummaryProvider::class) + ]); + $this->assertCount(1, $this->manager->getAvailableTaskTypes()); + $this->assertTrue($this->manager->hasProviders()); + + // create task object + $task = new Task(SummaryTaskType::class, 'Hello', 'test', null); + $this->assertNull($task->getId()); + $this->assertNull($task->getOutput()); + + // schedule works + $this->assertEquals(Task::STATUS_UNKNOWN, $task->getStatus()); + $this->manager->scheduleTask($task); + + // Task object is up-to-date + $this->assertNotNull($task->getId()); + $this->assertNull($task->getOutput()); + $this->assertEquals(Task::STATUS_SCHEDULED, $task->getStatus()); + + // Task object retrieved from db is up-to-date + $task2 = $this->manager->getTask($task->getId()); + $this->assertEquals($task->getId(), $task2->getId()); + $this->assertEquals('Hello', $task2->getInput()); + $this->assertNull($task2->getOutput()); + $this->assertEquals(Task::STATUS_SCHEDULED, $task2->getStatus()); + + $this->eventDispatcher = $this->createMock(IEventDispatcher::class); + $this->eventDispatcher->expects($this->once())->method('dispatchTyped')->with(new IsInstanceOf(TaskSuccessfulEvent::class)); + + // run background job + $bgJob = new TaskBackgroundJob( + \OC::$server->get(ITimeFactory::class), + $this->manager, + $this->eventDispatcher, + ); + $bgJob->setArgument(['taskId' => $task->getId()]); + $bgJob->start($this->jobList); + $provider = $this->providers[SuccessfulSummaryProvider::class]; + $this->assertTrue($provider->ran); + + // Task object retrieved from db is up-to-date + $task3 = $this->manager->getTask($task->getId()); + $this->assertEquals($task->getId(), $task3->getId()); + $this->assertEquals('Hello', $task3->getInput()); + $this->assertEquals('Hello Summarize', $task3->getOutput()); + $this->assertEquals(Task::STATUS_SUCCESSFUL, $task3->getStatus()); + } + + public function testMultipleProvidersShouldBeRegisteredAndRunCorrectly() { + $this->registrationContext->expects($this->any())->method('getTextProcessingProviders')->willReturn([ + new ServiceRegistration('test', SuccessfulSummaryProvider::class), + new ServiceRegistration('test', FreePromptProvider::class), + ]); + $this->assertCount(2, $this->manager->getAvailableTaskTypes()); + $this->assertTrue($this->manager->hasProviders()); + + // Try free prompt again + $this->assertEquals('Hello Free Prompt', $this->manager->runTask(new Task(FreePromptTaskType::class, 'Hello', 'test', null))); + + // Try summary task + $this->assertEquals('Hello Summarize', $this->manager->runTask(new Task(SummaryTaskType::class, 'Hello', 'test', null))); + + // Topics are not implemented by both the vanilla provider and the full provider + $this->expectException(PreConditionNotMetException::class); + $this->manager->runTask(new Task(TopicsTaskType::class, 'Hello', 'test', null)); + } + + public function testNonexistentTask() { + $this->expectException(NotFoundException::class); + $this->manager->getTask(98765432456); + } + + public function testTaskFailure() { + // register provider + $this->registrationContext->expects($this->any())->method('getTextProcessingProviders')->willReturn([ + new ServiceRegistration('test', FailingSummaryProvider::class), + ]); + $this->assertCount(1, $this->manager->getAvailableTaskTypes()); + $this->assertTrue($this->manager->hasProviders()); + + // create task object + $task = new Task(SummaryTaskType::class, 'Hello', 'test', null); + $this->assertNull($task->getId()); + $this->assertNull($task->getOutput()); + + // schedule works + $this->assertEquals(Task::STATUS_UNKNOWN, $task->getStatus()); + $this->manager->scheduleTask($task); + + // Task object is up-to-date + $this->assertNotNull($task->getId()); + $this->assertNull($task->getOutput()); + $this->assertEquals(Task::STATUS_SCHEDULED, $task->getStatus()); + + // Task object retrieved from db is up-to-date + $task2 = $this->manager->getTask($task->getId()); + $this->assertEquals($task->getId(), $task2->getId()); + $this->assertEquals('Hello', $task2->getInput()); + $this->assertNull($task2->getOutput()); + $this->assertEquals(Task::STATUS_SCHEDULED, $task2->getStatus()); + + $this->eventDispatcher = $this->createMock(IEventDispatcher::class); + $this->eventDispatcher->expects($this->once())->method('dispatchTyped')->with(new IsInstanceOf(TaskFailedEvent::class)); + + // run background job + $bgJob = new TaskBackgroundJob( + \OC::$server->get(ITimeFactory::class), + $this->manager, + $this->eventDispatcher, + ); + $bgJob->setArgument(['taskId' => $task->getId()]); + $bgJob->start($this->jobList); + $provider = $this->providers[FailingSummaryProvider::class]; + $this->assertTrue($provider->ran); + + // Task object retrieved from db is up-to-date + $task3 = $this->manager->getTask($task->getId()); + $this->assertEquals($task->getId(), $task3->getId()); + $this->assertEquals('Hello', $task3->getInput()); + $this->assertNull($task3->getOutput()); + $this->assertEquals(Task::STATUS_FAILED, $task3->getStatus()); + } + + public function testOldTasksShouldBeCleanedUp() { + $this->registrationContext->expects($this->any())->method('getTextProcessingProviders')->willReturn([ + new ServiceRegistration('test', SuccessfulSummaryProvider::class) + ]); + $this->assertCount(1, $this->manager->getAvailableTaskTypes()); + $this->assertTrue($this->manager->hasProviders()); + $task = new Task(SummaryTaskType::class, 'Hello', 'test', null); + $this->assertEquals('Hello Summarize', $this->manager->runTask($task)); + + $this->currentTime = $this->currentTime->add(new \DateInterval('P1Y')); + // run background job + $bgJob = new RemoveOldTasksBackgroundJob( + \OC::$server->get(ITimeFactory::class), + $this->taskMapper, + \OC::$server->get(LoggerInterface::class), + ); + $bgJob->setArgument([]); + $bgJob->start($this->jobList); + + $this->expectException(NotFoundException::class); + $this->manager->getTask($task->getId()); + } +} |