diff --git a/appinfo/routes.php b/appinfo/routes.php index c5725229..e90d64b1 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -65,5 +65,14 @@ // Talk bots ['name' => 'talkBot#registerExAppTalkBot', 'url' => '/api/v1/talk_bot', 'verb' => 'POST'], ['name' => 'talkBot#unregisterExAppTalkBot', 'url' => '/api/v1/talk_bot', 'verb' => 'DELETE'], + + // Speech-To-Text + ['name' => 'speechToText#registerProvider', 'url' => '/api/v1/speech_to_text', 'verb' => 'POST'], + ['name' => 'speechToText#unregisterProvider', 'url' => '/api/v1/speech_to_text', 'verb' => 'DELETE'], + + // Text-Processing + ['name' => 'textProcessing#registerProvider', 'url' => '/api/v1/text_processing', 'verb' => 'POST'], + ['name' => 'textProcessing#unregisterProvider', 'url' => '/api/v1/text_processing', 'verb' => 'DELETE'], + ['name' => 'textProcessing#registerProvider', 'url' => '/api/v1/text_processing/task_type', 'verb' => 'POST'], ], ]; diff --git a/docs/tech_details/api/speechtotext.rst b/docs/tech_details/api/speechtotext.rst new file mode 100644 index 00000000..9d534221 --- /dev/null +++ b/docs/tech_details/api/speechtotext.rst @@ -0,0 +1,47 @@ +============== +Speech-To-Text +============== + +AppAPI provides a Speech-To-Text (STT) service +that can be used to register ExApp as a custom STT model and transcribe audio files via it. + +Registering ExApp STT provider (OCS) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +OCS endpoint: ``POST /apps/app_api/api/v1/speech_to_text`` + +Request data +************ + +.. code-block:: json + + { + "name": "unique_provider_name", + "display_name": "Provider Display Name", + "action_handler_route": "/handler_route_on_ex_app", + } + +Response +******** + +On successful registration response with status code 200 is returned. + +Unregistering ExApp STT provider (OCS) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +OCS endpoint: ``DELETE /apps/app_api/api/v1/speech_to_text`` + +Request data +************ + +.. code-block:: json + + { + "name": "unique_provider_name", + } + +Response +******** + +On successful unregister response with status code 200 is returned. + diff --git a/docs/tech_details/api/textprocessing.rst b/docs/tech_details/api/textprocessing.rst new file mode 100644 index 00000000..f0354fb7 --- /dev/null +++ b/docs/tech_details/api/textprocessing.rst @@ -0,0 +1,90 @@ +=============== +Text-Processing +=============== + +AppAPI provides a text-processing service +that can be used to register ExApps providers and to process passed through text. + +Registering text-processing provider (OCS) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +OCS endpoint: ``POST /apps/app_api/api/v1/text_processing`` + +Request data +************ + +.. code-block:: json + + { + "name": "unique_provider_name", + "display_name": "Provider Display Name", + "description": "Provider Description", + "action_handler_route": "/handler_route_on_ex_app", + "action_type": "unique_task_type_name", + } + +Response +******** + +On successful registration response with status code 200 is returned. + +Unregistering text-processing provider (OCS) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +OCS endpoint: ``DELETE /apps/app_api/api/v1/text_processing`` + +Request data +************ + +.. code-block:: json + + { + "name": "unique_provider_name", + } + +Response +******** + +On successful unregister response with status code 200 is returned. + + +Registering Text-Processing task type (OCS) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +OCS endpoint: ``POST /apps/app_api/api/v1/text_processing/task_type`` + +Request data +************ + +.. code-block:: json + + { + "name": "unique_task_type_name", + "display_name": "Task Type Display Name", + "description": "Task Type Description", + } + +Response +******** + +On successful registration response with status code 200 is returned. + +Unregistering Text-Processing task type (OCS) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +OCS endpoint: ``DELETE /apps/app_api/api/v1/text_processing/task_type`` + +Request data +************ + +.. code-block:: json + + { + "name": "unique_task_type_name", + } + +Response +******** + +On successful unregister response with status code 200 is returned. + diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index d0a37c68..77b80937 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -15,6 +15,8 @@ use OCA\AppAPI\Profiler\AppAPIDataCollector; use OCA\AppAPI\PublicCapabilities; +use OCA\AppAPI\Service\SpeechToTextService; +use OCA\AppAPI\Service\TextProcessingService; use OCA\DAV\Events\SabrePluginAuthInitEvent; use OCA\Files\Event\LoadAdditionalScriptsEvent; use OCP\AppFramework\App; @@ -56,6 +58,19 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class); $context->registerNotifierService(ExAppNotifier::class); $context->registerNotifierService(ExAppAdminNotifier::class); + + // Dynamic anonymous providers registration + $container = $this->getContainer(); + try { + /** @var TextProcessingService $textProcessingService */ + $textProcessingService = $container->get(TextProcessingService::class); + $textProcessingService->registerExAppTextProcessingProviders($context); + + /** @var SpeechToTextService $speechToTextService */ + $speechToTextService = $container->get(SpeechToTextService::class); + $speechToTextService->registerExAppSpeechToTextProviders($context); + } catch (NotFoundExceptionInterface|ContainerExceptionInterface) { + } } public function boot(IBootContext $context): void { @@ -73,10 +88,13 @@ public function boot(IBootContext $context): void { public function registerDavAuth(): void { $container = $this->getContainer(); - $dispatcher = $container->query(IEventDispatcher::class); - $dispatcher->addListener('OCA\DAV\Connector\Sabre::addPlugin', function (SabrePluginEvent $event) use ($container) { - $event->getServer()->addPlugin($container->query(DavPlugin::class)); - }); + try { + $dispatcher = $container->get(IEventDispatcher::class); + $dispatcher->addListener('OCA\DAV\Connector\Sabre::addPlugin', function (SabrePluginEvent $event) use ($container) { + $event->getServer()->addPlugin($container->get(DavPlugin::class)); + }); + } catch (NotFoundExceptionInterface|ContainerExceptionInterface) { + } } /** diff --git a/lib/Controller/SpeechToTextController.php b/lib/Controller/SpeechToTextController.php new file mode 100644 index 00000000..2656cf77 --- /dev/null +++ b/lib/Controller/SpeechToTextController.php @@ -0,0 +1,86 @@ +request = $request; + $this->service = $service; + $this->speechToTextService = $speechToTextService; + } + + /** + * @NoAdminRequired + * @PublicPage + * + * @param string $name + * @param string $displayName + * @param string $actionHandlerRoute + * + * @throws OCSBadRequestException + * @return Response + */ + #[NoCSRFRequired] + #[PublicPage] + #[AppAPIAuth] + public function registerProvider(string $name, string $displayName, string $actionHandlerRoute): Response { + $appId = $this->request->getHeader('EX-APP-ID'); + $exApp = $this->service->getExApp($appId); + + $provider = $this->speechToTextService->registerSpeechToTextProvider($exApp, $name, $displayName, $actionHandlerRoute); + + if ($provider === null) { + throw new OCSBadRequestException('Failed to register STT provider'); + } + + return new DataResponse(); + } + + /** + * @NoAdminRequired + * @PublicPage + * + * @param string $name + * + * @throws OCSBadRequestException + * @return Response + */ + #[NoCSRFRequired] + #[PublicPage] + #[AppAPIAuth] + public function unregisterProvider(string $name): Response { + $appId = $this->request->getHeader('EX-APP-ID'); + $exApp = $this->service->getExApp($appId); + $unregistered = $this->speechToTextService->unregisterSpeechToTextProvider($exApp, $name); + + if ($unregistered === null) { + throw new OCSBadRequestException('Failed to unregister STT provider'); + } + + return new DataResponse(); + } +} diff --git a/lib/Controller/TextProcessingController.php b/lib/Controller/TextProcessingController.php new file mode 100644 index 00000000..735f42d5 --- /dev/null +++ b/lib/Controller/TextProcessingController.php @@ -0,0 +1,88 @@ +request = $request; + $this->service = $service; + $this->textProcessingService = $textProcessingService; + } + + /** + * @NoAdminRequired + * @PublicPage + * + * @param string $name + * @param string $displayName + * @param string $description + * @param string $actionHandlerRoute + * @param string $actionType + * + * @throws OCSBadRequestException + * @return Response + */ + #[NoCSRFRequired] + #[PublicPage] + #[AppEcosystemAuth] + public function registerProvider(string $name, string $displayName, string $description, string $actionHandlerRoute, string $actionType): Response { + $appId = $this->request->getHeader('EX-APP-ID'); + $exApp = $this->service->getExApp($appId); + + $provider = $this->textProcessingService->registerTextProcesingProvider($exApp, $name, $displayName, $description, $actionHandlerRoute); + + if ($provider === null) { + throw new OCSBadRequestException('Failed to register text processing provider'); + } + + return new DataResponse(); + } + + /** + * @NoAdminRequired + * @PublicPage + * + * @param string $name + * + * @throws OCSBadRequestException + * @return Response + */ + #[NoCSRFRequired] + #[PublicPage] + #[AppEcosystemAuth] + public function unregisterProvider(string $name): Response { + $appId = $this->request->getHeader('EX-APP-ID'); + $exApp = $this->service->getExApp($appId); + $unregistered = $this->textProcessingService->unregisterTextProcessingProvider($exApp, $name); + + if ($unregistered === null) { + throw new OCSBadRequestException('Failed to unregister text processing provider'); + } + + return new DataResponse(); + } +} diff --git a/lib/Db/ExAppSpeechToTextProvider.php b/lib/Db/ExAppSpeechToTextProvider.php new file mode 100644 index 00000000..ae9052d8 --- /dev/null +++ b/lib/Db/ExAppSpeechToTextProvider.php @@ -0,0 +1,63 @@ +addType('appid', 'string'); + $this->addType('name', 'string'); + $this->addType('displayName', 'string'); + $this->addType('actionHandlerRoute', 'string'); + + if (isset($params['id'])) { + $this->setId($params['id']); + } + if (isset($params['appid'])) { + $this->setAppid($params['appid']); + } + if (isset($params['name'])) { + $this->setName($params['name']); + } + if (isset($params['display_name'])) { + $this->setDisplayName($params['display_name']); + } + if (isset($params['action_handler_route'])) { + $this->setActionHandlerRoute($params['action_handler_route']); + } + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'appid' => $this->getAppid(), + 'name' => $this->getName(), + 'display_name' => $this->getDisplayName(), + 'action_handler_route' => $this->getActionHandlerRoute(), + ]; + } +} diff --git a/lib/Db/ExAppSpeechToTextProviderMapper.php b/lib/Db/ExAppSpeechToTextProviderMapper.php new file mode 100644 index 00000000..5b0d4a1d --- /dev/null +++ b/lib/Db/ExAppSpeechToTextProviderMapper.php @@ -0,0 +1,66 @@ + + */ +class ExAppSpeechToTextProviderMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'ex_apps_speech_to_text'); + } + + /** + * @throws Exception + */ + public function findAll(int $limit = null, int $offset = null): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->tableName) + ->setMaxResults($limit) + ->setFirstResult($offset); + return $this->findEntities($qb); + } + + /** + * @param string $appId + * + * @throws Exception + * @return ExAppSpeechToTextProvider[] + */ + public function findByAppid(string $appId): array { + $qb = $this->db->getQueryBuilder(); + return $this->findEntities($qb->select('*') + ->from($this->tableName) + ->where($qb->expr()->eq('appid', $qb->createNamedParameter($appId), IQueryBuilder::PARAM_STR)) + ); + } + + /** + * @param string $appId + * @param string $name + * + * @throws DoesNotExistException + * @throws Exception + * @throws MultipleObjectsReturnedException + * + * @return ExAppSpeechToTextProvider + */ + public function findByAppidName(string $appId, string $name): ExAppSpeechToTextProvider { + $qb = $this->db->getQueryBuilder(); + return $this->findEntity($qb->select('*') + ->from($this->tableName) + ->where($qb->expr()->eq('appid', $qb->createNamedParameter($appId), IQueryBuilder::PARAM_STR)) + ->andWhere($qb->expr()->eq('name', $qb->createNamedParameter($name), IQueryBuilder::PARAM_STR)) + ); + } +} diff --git a/lib/Db/ExAppTextProcessingProvider.php b/lib/Db/ExAppTextProcessingProvider.php new file mode 100644 index 00000000..7d9d08f4 --- /dev/null +++ b/lib/Db/ExAppTextProcessingProvider.php @@ -0,0 +1,77 @@ +addType('appid', 'string'); + $this->addType('name', 'string'); + $this->addType('displayName', 'string'); + $this->addType('description', 'string'); + $this->addType('actionHandlerRoute', 'string'); + $this->addType('actionType', 'string'); + + if (isset($params['id'])) { + $this->setId($params['id']); + } + if (isset($params['appid'])) { + $this->setAppid($params['appid']); + } + if (isset($params['name'])) { + $this->setName($params['name']); + } + if (isset($params['display_name'])) { + $this->setDisplayName($params['display_name']); + } + if (isset($params['description'])) { + $this->setDescription($params['description']); + } + if (isset($params['action_handler_route'])) { + $this->setActionHandlerRoute($params['action_handler_route']); + } + if (isset($params['action_type'])) { + $this->setActionType($params['action_type']); + } + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'appid' => $this->getAppid(), + 'name' => $this->getName(), + 'display_name' => $this->getDisplayName(), + 'description' => $this->getDescription(), + 'action_handler_route' => $this->getActionHandlerRoute(), + 'action_type' => $this->getActionType(), + ]; + } +} diff --git a/lib/Db/ExAppTextProcessingProviderMapper.php b/lib/Db/ExAppTextProcessingProviderMapper.php new file mode 100644 index 00000000..38573d95 --- /dev/null +++ b/lib/Db/ExAppTextProcessingProviderMapper.php @@ -0,0 +1,66 @@ + + */ +class ExAppTextProcessingProviderMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'ex_apps_text_processing'); + } + + /** + * @throws Exception + */ + public function findAll(int $limit = null, int $offset = null): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->tableName) + ->setMaxResults($limit) + ->setFirstResult($offset); + return $this->findEntities($qb); + } + + /** + * @param string $appId + * + * @throws Exception + * @return ExAppTextProcessingProvider[] + */ + public function findByAppid(string $appId): array { + $qb = $this->db->getQueryBuilder(); + return $this->findEntities($qb->select('*') + ->from($this->tableName) + ->where($qb->expr()->eq('appid', $qb->createNamedParameter($appId), IQueryBuilder::PARAM_STR)) + ); + } + + /** + * @param string $appId + * @param string $name + * + * @throws DoesNotExistException + * @throws Exception + * @throws MultipleObjectsReturnedException + * + * @return ExAppTextProcessingProvider + */ + public function findByAppidName(string $appId, string $name): ExAppTextProcessingProvider { + $qb = $this->db->getQueryBuilder(); + return $this->findEntity($qb->select('*') + ->from($this->tableName) + ->where($qb->expr()->eq('appid', $qb->createNamedParameter($appId), IQueryBuilder::PARAM_STR)) + ->andWhere($qb->expr()->eq('name', $qb->createNamedParameter($name), IQueryBuilder::PARAM_STR)) + ); + } +} diff --git a/lib/Db/ExAppTextProcessingTaskType.php b/lib/Db/ExAppTextProcessingTaskType.php new file mode 100644 index 00000000..2a8ae291 --- /dev/null +++ b/lib/Db/ExAppTextProcessingTaskType.php @@ -0,0 +1,61 @@ +addType('appid', 'string'); + $this->addType('name', 'string'); + $this->addType('displayName', 'string'); + $this->addType('description', 'string'); + + if (isset($params['id'])) { + $this->setId($params['id']); + } + if (isset($params['appid'])) { + $this->setAppid($params['appid']); + } + if (isset($params['name'])) { + $this->setName($params['name']); + } + if (isset($params['display_name'])) { + $this->setDisplayName($params['display_name']); + } + if (isset($params['description'])) { + $this->setDescription($params['description']); + } + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'appid' => $this->getAppid(), + 'name' => $this->getName(), + 'display_name' => $this->getDisplayName(), + 'description' => $this->getDescription(), + ]; + } +} diff --git a/lib/Db/ExAppTextProcessingTaskTypeMapper.php b/lib/Db/ExAppTextProcessingTaskTypeMapper.php new file mode 100644 index 00000000..40b6edb7 --- /dev/null +++ b/lib/Db/ExAppTextProcessingTaskTypeMapper.php @@ -0,0 +1,52 @@ + + */ +class ExAppTextProcessingTaskTypeMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'ex_apps_tp_task_types'); + } + + /** + * @throws Exception + */ + public function findAll(int $limit = null, int $offset = null): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->tableName) + ->setMaxResults($limit) + ->setFirstResult($offset); + return $this->findEntities($qb); + } + + /** + * @param string $appId + * @param string $name + * + * @throws DoesNotExistException + * @throws Exception + * @throws MultipleObjectsReturnedException + * + * @return ExAppTextProcessingTaskType + */ + public function findByAppidName(string $appId, string $name): ExAppTextProcessingTaskType { + $qb = $this->db->getQueryBuilder(); + return $this->findEntity($qb->select('*') + ->from($this->tableName) + ->where($qb->expr()->eq('appid', $qb->createNamedParameter($appId), IQueryBuilder::PARAM_STR)) + ->andWhere($qb->expr()->eq('name', $qb->createNamedParameter($name), IQueryBuilder::PARAM_STR)) + ); + } +} diff --git a/lib/Migration/Version1000Date202305221555.php b/lib/Migration/Version1000Date202305221555.php index 2643ba4e..2affd1f8 100644 --- a/lib/Migration/Version1000Date202305221555.php +++ b/lib/Migration/Version1000Date202305221555.php @@ -293,6 +293,101 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt $table->addUniqueIndex(['appid', 'scope_group'], 'ex_apps_scopes__idx'); } + // Speech-To-Text and Text-processing APIs tables + if (!$schema->hasTable('ex_apps_text_processing')) { + $table = $schema->createTable('ex_apps_text_processing'); + + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->addColumn('appid', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + ]); + $table->addColumn('name', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('display_name', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('description', Types::STRING, [ + 'notnull' => true, + 'length' => 128, + ]); + // ExApp route to forward the action + $table->addColumn('action_handler_route', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('action_type', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + + $table->setPrimaryKey(['id'], 'ex_apps_text_processing_id'); + $table->addUniqueIndex(['appid', 'name'], 'text_processing_appid_name'); + } + + if (!$schema->hasTable('ex_apps_tp_task_types')) { + $table = $schema->createTable('ex_apps_tp_task_types'); + + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->addColumn('appid', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + ]); + $table->addColumn('name', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('display_name', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('description', Types::STRING, [ + 'notnull' => true, + 'length' => 128, + ]); + + $table->setPrimaryKey(['id'], 'ex_apps_task_types_id'); + $table->addUniqueIndex(['name'], 'speech_to_text_types_name'); + } + + if (!$schema->hasTable('ex_apps_speech_to_text')) { + $table = $schema->createTable('ex_apps_speech_to_text'); + + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->addColumn('appid', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + ]); + $table->addColumn('name', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('display_name', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + // ExApp route to forward the action + $table->addColumn('action_handler_route', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + + $table->setPrimaryKey(['id'], 'ex_apps_speech_to_text_id'); + $table->addUniqueIndex(['appid', 'name'], 'speech_to_text_appid_name'); + } + return $schema; } } diff --git a/lib/Service/SpeechToTextService.php b/lib/Service/SpeechToTextService.php new file mode 100644 index 00000000..0b720c20 --- /dev/null +++ b/lib/Service/SpeechToTextService.php @@ -0,0 +1,152 @@ +service = $service; + $this->cache = $cacheFactory->createDistributed(Application::APP_ID . '/ex_apps_speech_to_text'); + $this->speechToTextProviderMapper = $speechToTextProviderMapper; + $this->logger = $logger; + } + + public function getSpeechToTextProviders(): array { + $cacheKey = '/ex_app_speech_to_text_providers'; + $cached = $this->cache->get($cacheKey); + if ($cached !== null) { + return array_map(function ($cachedEntry) { + return $cachedEntry instanceof ExAppSpeechToTextProvider ? $cachedEntry : new ExAppSpeechToTextProvider($cachedEntry); + }, $cached); + } + + $providers = $this->speechToTextProviderMapper->findAll(); + $this->cache->set($cacheKey, $providers); + return $providers; + } + + public function getExAppSpeechToTextProvider(ExApp $exApp, string $name): ?ExAppSpeechToTextProvider { + $cacheKey = '/ex_app_speech_to_text_providers/' . $exApp->getAppid() . '/' . $name; + $cached = $this->cache->get($cacheKey); + if ($cached !== null) { + return $cached instanceof ExAppSpeechToTextProvider ? $cached : new ExAppSpeechToTextProvider($cached); + } + + $provider = $this->speechToTextProviderMapper->findByAppidName($exApp->getAppid(), $name); + $this->cache->set($cacheKey, $provider); + return $provider; + } + + public function registerSpeechToTextProvider(ExApp $exApp, string $name, string $displayName, string $actionHandlerRoute): ?ExAppSpeechToTextProvider { + $provider = new ExAppSpeechToTextProvider([ + 'appid' => $exApp->getAppid(), + 'name' => $name, + 'display_name' => $displayName, + 'action_handler_route' => $actionHandlerRoute, + ]); + try { + $this->speechToTextProviderMapper->insert($provider); + $this->cache->remove('/ex_app_speech_to_text_providers'); + return $provider; + } catch (Exception $e) { + $this->logger->error('Failed to register SpeechToText provider', ['exception' => $e]); + return null; + } + } + + public function unregisterSpeechToTextProvider(ExApp $exApp, string $name): ?ExAppSpeechToTextProvider { + $provider = $this->getExAppSpeechToTextProvider($exApp, $name); + if ($provider === null) { + return null; + } + try { + $this->speechToTextProviderMapper->delete($provider); + $this->cache->remove('/ex_app_speech_to_text_providers'); + return $provider; + } catch (Exception $e) { + $this->logger->error('Failed to unregister STT provider', ['exception' => $e]); + return null; + } + } + + /** + * Register ExApp anonymous providers implementations of ISpeechToTextProvider + * so that they can be used as regular providers in DI container + * + * @param IAppContainer $container + * @param IRegistrationContext $context + * + * @return void + */ + public function registerExAppSpeechToTextProviders(IRegistrationContext &$context): void { + $exAppsProviders = $this->getSpeechToTextProviders(); + /** @var ExAppSpeechToTextProvider $exAppProvider */ + foreach ($exAppsProviders as $exAppProvider) { + $sttProvider = $this->getAnonymousExAppProvider($exAppProvider); + $class = '\\OCA\\AppAPI\\' . $exAppProvider->getAppid() . '_' . $exAppProvider->getName(); + $context->registerService($class, function () use ($sttProvider) { + return $sttProvider; + }); + $context->registerSpeechToTextProvider($class); + } + } + + private function getAnonymousExAppProvider(ExAppSpeechToTextProvider $provider): ?ISpeechToTextProvider { + return new class ($this->service, $provider) implements ISpeechToTextProvider { + private AppAPIService $service; + private ExAppSpeechToTextProvider $sttProvider; + + public function __construct( + AppAPIService $service, + ExAppSpeechToTextProvider $sttProvider, + ) { + $this->service = $service; + $this->sttProvider = $sttProvider; + } + + public function getName(): string { + return $this->sttProvider->getDisplayName(); + } + + public function transcribeFile(File $file): string { + $route = $this->sttProvider->getActionHandlerRoute(); + $exApp = $this->service->getExApp($this->sttProvider->getAppid()); + + $response = $this->service->requestToExApp(null, null, $exApp, $route, 'POST', [ + 'fileid' => $file->getId(), + ]); + + if ($response->getStatusCode() !== Http::STATUS_OK) { + throw new \Exception('Failed to transcribe file'); + } + + return $response->getBody(); + } + }; + } +} diff --git a/lib/Service/TextProcessingService.php b/lib/Service/TextProcessingService.php new file mode 100644 index 00000000..d648b14a --- /dev/null +++ b/lib/Service/TextProcessingService.php @@ -0,0 +1,239 @@ +service = $service; + $this->cache = $cacheFactory->createDistributed(Application::APP_ID . '/ex_apps_text_processing'); + $this->textProcessingProviderMapper = $textProcessingProviderMapper; + $this->textProcessingTaskTypeMapper = $textProcessingTaskTypeMapper; + } + + public function getTextProcessingProviders(): array { + $cacheKey = '/ex_app_text_processing_providers'; + $cached = $this->cache->get($cacheKey); + if ($cached !== null) { + return array_map(function ($cachedEntry) { + return $cachedEntry instanceof ExAppTextProcessingProvider ? $cachedEntry : new ExAppTextProcessingProvider($cachedEntry); + }, $cached); + } + + $providers = $this->textProcessingProviderMapper->findAll(); + $this->cache->set($cacheKey, $providers); + return $providers; + } + + public function getExAppTextProcessingProvider(ExApp $exApp, string $name): ?ExAppTextProcessingProvider { + $cacheKey = '/ex_app_text_processing_providers/' . $exApp->getAppid() . '/' . $name; + $cached = $this->cache->get($cacheKey); + if ($cached !== null) { + return $cached instanceof ExAppTextProcessingProvider ? $cached : new ExAppTextProcessingProvider($cached); + } + + $provider = $this->textProcessingProviderMapper->findByAppidName($exApp->getAppid(), $name); + $this->cache->set($cacheKey, $provider); + return $provider; + } + + public function registerTextProcessingProvider( + ExApp $exApp, + string $name, + string $displayName, + string $description, + string $actionHandlerRoute, + string $actionType + ): ?ExAppTextProcessingProvider { + $provider = $this->textProcessingProviderMapper->findByAppidName($exApp->getAppid(), $name); + if ($provider !== null) { + return null; + } + + $provider = new ExAppTextProcessingProvider([ + 'appid' => $exApp->getAppid(), + 'name' => $name, + 'display_name' => $displayName, + 'description' => $description, + 'action_handler_route' => $actionHandlerRoute, + 'action_type' => $actionType, + ]); + $this->textProcessingProviderMapper->insert($provider); + + $this->cache->remove('/ex_app_text_processing_providers'); + $this->cache->remove('/ex_app_text_processing_providers/' . $exApp->getAppid() . '/' . $name); + + return $provider; + } + + /** + * Register dynamic text processing providers anonymous classes. + * For each text processing provider register anonymous class for IProvider and ITaskType in DI container. + * + * @param IAppContainer $container + * @param IRegistrationContext $context + * + * @return void + */ + public function registerExAppTextProcessingProviders(IRegistrationContext &$context): void { + $exAppsProviders = $this->getTextProcessingProviders(); + /** @var ExAppTextProcessingProvider $exAppProvider */ + foreach ($exAppsProviders as $exAppProvider) { + $exApp = $this->service->getExApp($exAppProvider->getAppid()); + $tpTaskType = $this->getExAppTextProcessingTaskType($exApp, $exAppProvider->getActionType()); + $taskType = $this->getAnonymousTaskType($exAppProvider, $tpTaskType); + $taskTypeClassName = '\\OCA\\AppAPI\\' . $tpTaskType->getAppid() . '_' . $tpTaskType->getName(); + $context->registerService($taskTypeClassName, function () use ($taskType) { + return $taskType; + }); + + $provider = $this->getAnonymousExAppProvider($exAppProvider, $taskTypeClassName); + $className = '\\OCA\\AppAPI\\' . $exAppProvider->getAppid() . '_' . $exAppProvider->getName(); + $context->registerService($className, function () use ($provider) { + return $provider; + }); + $context->registerTextProcessingProvider($className); + } + } + + /** + * + * + * @param ExAppTextProcessingProvider $provider + * @param string $taskTypeClassName + * + * @return IProvider + */ + private function getAnonymousExAppProvider(ExAppTextProcessingProvider $provider, string $taskTypeClassName): IProvider { + return new class ($this->service, $provider, $taskTypeClassName) implements IProvider { + private AppAPIService $service; + private ExAppTextProcessingProvider $provider; + private string $taskTypeClassName; + + public function __construct( + AppAPIService $service, + ExAppTextProcessingProvider $provider, + string $taskTypeClassName, + ) { + $this->service = $service; + $this->provider = $provider; + $this->taskTypeClassName = $taskTypeClassName; + } + + public function getName(): string { + return $this->provider->getDisplayName(); + } + + public function process(string $prompt): string { + $exApp = $this->service->getExApp($this->provider->getAppid()); + $route = $this->provider->getActionHandlerRoute(); + + $response = $this->service->requestToExApp(null, null, $exApp, $route, 'POST', [ + 'prompt' => $prompt, + ]); + + if ($response->getStatusCode() !== 200) { + throw new \Exception('Failed to process prompt'); + } + + return $response->getBody(); + } + + public function getTaskType(): string { + return $this->taskTypeClassName; + } + }; + } + + /** + * Build dynamic anonymous class implementing ITaskType + * for given ExAppTextProcessingProvider and ExAppTextProcessingTaskType data. + * + * @param ExAppTextProcessingProvider $provider + * @param ExAppTextProcessingTaskType $tpTaskType + * + * @return ITaskType + */ + private function getAnonymousTaskType(ExAppTextProcessingProvider $provider, ExAppTextProcessingTaskType $tpTaskType): ITaskType { + return new class ($tpTaskType) implements ITaskType { + private ExAppTextProcessingTaskType $tpTaskType; + public function __construct( + ExAppTextProcessingTaskType $tpTaskType, + ) { + $this->tpTaskType = $tpTaskType; + } + + public function getName(): string { + return $this->tpTaskType->getDisplayName(); + } + + public function getDescription(): string { + return $this->tpTaskType->getDescription(); + } + }; + } + + public function registerTextProcessingTaskType(ExApp $exApp, string $name, string $displayName, string $description): ?ExAppTextProcessingTaskType { + $taskType = $this->textProcessingTaskTypeMapper->findByAppidName($exApp->getAppid(), $name); + if ($taskType !== null) { + return null; + } + + $taskType = new ExAppTextProcessingTaskType([ + 'appid' => $exApp->getAppid(), + 'name' => $name, + 'display_name' => $displayName, + 'description' => $description, + ]); + $this->textProcessingTaskTypeMapper->insert($taskType); + $this->cache->set('/ex_app_text_processing_task_types/' . $exApp->getAppid() . '/' . $name, $taskType); + return $taskType; + } + + public function unregisterTextProcessingTaskType(ExApp $exApp, string $name): ?ExAppTextProcessingTaskType { + $taskType = $this->getExAppTextProcessingTaskType($exApp, $name); + if ($taskType === null) { + return null; + } + + $this->textProcessingTaskTypeMapper->delete($taskType); + $this->cache->remove('/ex_app_text_processing_task_types/' . $exApp->getAppid() . '/' . $name); + return $taskType; + } + + public function getExAppTextProcessingTaskType(ExApp $exApp, string $name): ?ExAppTextProcessingTaskType { + $cacheKey = '/ex_app_text_processing_task_types/' . $exApp->getAppid() . '/' . $name; + $cached = $this->cache->get($cacheKey); + if ($cached !== null) { + return $cached instanceof ExAppTextProcessingTaskType ? $cached : new ExAppTextProcessingTaskType($cached); + } + + $taskType = $this->textProcessingTaskTypeMapper->findByAppidName($exApp->getAppid(), $name); + $this->cache->set($cacheKey, $taskType); + return $taskType; + } +}