diff --git a/appinfo/info.xml b/appinfo/info.xml index 57d3031d..c2b8fc5e 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -48,6 +48,7 @@ Refer to the [Context Chat Backend's readme](https://github.com/nextcloud/contex OCA\ContextChat\Command\Prompt + OCA\ContextChat\Command\Search OCA\ContextChat\Command\ScanFiles OCA\ContextChat\Command\Statistics diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 0e081f74..c0a42885 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -12,6 +12,8 @@ use OCA\ContextChat\Listener\ShareListener; use OCA\ContextChat\Listener\UserDeletedListener; use OCA\ContextChat\TaskProcessing\ContextChatProvider; +use OCA\ContextChat\TaskProcessing\ContextChatSearchProvider; +use OCA\ContextChat\TaskProcessing\ContextChatSearchTaskType; use OCA\ContextChat\TaskProcessing\ContextChatTaskType; use OCP\App\Events\AppDisableEvent; use OCP\AppFramework\App; @@ -82,6 +84,8 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(ShareDeletedEvent::class, ShareListener::class); $context->registerTaskProcessingTaskType(ContextChatTaskType::class); $context->registerTaskProcessingProvider(ContextChatProvider::class); + $context->registerTaskProcessingTaskType(ContextChatSearchTaskType::class); + $context->registerTaskProcessingProvider(ContextChatSearchProvider::class); } public function boot(IBootContext $context): void { diff --git a/lib/Command/Search.php b/lib/Command/Search.php new file mode 100644 index 00000000..e5d7d8e4 --- /dev/null +++ b/lib/Command/Search.php @@ -0,0 +1,84 @@ +setName('context_chat:search') + ->setDescription('Search with Nextcloud Assistant Context Chat') + ->addArgument( + 'uid', + InputArgument::REQUIRED, + 'The ID of the user to search the documents of' + ) + ->addArgument( + 'prompt', + InputArgument::REQUIRED, + 'The prompt' + ) + ->addOption( + 'context-providers', + null, + InputOption::VALUE_REQUIRED, + 'Context providers to use (as a comma-separated list without brackets)', + ); + } + + protected function execute(InputInterface $input, OutputInterface $output) { + $userId = $input->getArgument('uid'); + $prompt = $input->getArgument('prompt'); + $contextProviders = $input->getOption('context-providers'); + + if (!empty($contextProviders)) { + $contextProviders = preg_replace('/\s*,+\s*/', ',', $contextProviders); + $contextProvidersArray = array_filter(explode(',', $contextProviders), fn ($source) => !empty($source)); + $task = new Task(ContextChatSearchTaskType::ID, [ + 'prompt' => $prompt, + 'scopeType' => ScopeType::PROVIDER, + 'scopeList' => $contextProvidersArray, + 'scopeListMeta' => '', + ], 'context_chat', $userId); + } else { + $task = new Task(ContextChatSearchTaskType::ID, [ + 'prompt' => $prompt, + 'scopeType' => ScopeType::NONE, + 'scopeList' => [], + 'scopeListMeta' => '', + ], 'context_chat', $userId); + } + + $this->taskProcessingManager->scheduleTask($task); + while (!in_array(($task = $this->taskProcessingManager->getTask($task->getId()))->getStatus(), [Task::STATUS_FAILED, Task::STATUS_SUCCESSFUL], true)) { + sleep(1); + } + if ($task->getStatus() === Task::STATUS_SUCCESSFUL) { + $output->writeln(var_export($task->getOutput(), true)); + return 0; + } else { + $output->writeln('' . $task->getErrorMessage() . ''); + return 1; + } + } +} diff --git a/lib/Service/LangRopeService.php b/lib/Service/LangRopeService.php index 20e29421..f4d8da55 100644 --- a/lib/Service/LangRopeService.php +++ b/lib/Service/LangRopeService.php @@ -334,6 +334,30 @@ public function query(string $userId, string $prompt, bool $useContext = true, ? return $this->requestToExApp('/query', 'POST', $params); } + /** + * @param string $userId + * @param string $prompt + * @param ?string $scopeType + * @param ?array $scopeList + * @param int|null $limit + * @return array + */ + public function docSearch(string $userId, string $prompt, ?string $scopeType = null, ?array $scopeList = null, ?int $limit = null): array { + $params = [ + 'query' => $prompt, + 'userId' => $userId, + ]; + if ($scopeType !== null && $scopeList !== null) { + $params['scopeType'] = $scopeType; + $params['scopeList'] = $scopeList; + } + if ($limit !== null) { + $params['ctxLimit'] = $limit; + } + + return $this->requestToExApp('/docSearch', 'POST', $params); + } + /** * @param string $userId * @param string $prompt diff --git a/lib/TaskProcessing/ContextChatSearchProvider.php b/lib/TaskProcessing/ContextChatSearchProvider.php new file mode 100644 index 00000000..f73e1c19 --- /dev/null +++ b/lib/TaskProcessing/ContextChatSearchProvider.php @@ -0,0 +1,206 @@ +l10n->t('Nextcloud Assistant Context Chat Search Provider'); + } + + public function getTaskTypeId(): string { + return ContextChatSearchTaskType::ID; + } + + public function getExpectedRuntime(): int { + return 120; + } + + public function getInputShapeEnumValues(): array { + return []; + } + + public function getInputShapeDefaults(): array { + return [ + 'limit' => 10, + ]; + } + + public function getOptionalInputShape(): array { + return []; + } + + public function getOptionalInputShapeEnumValues(): array { + return []; + } + + public function getOptionalInputShapeDefaults(): array { + return []; + } + + public function getOutputShapeEnumValues(): array { + return []; + } + + public function getOptionalOutputShape(): array { + return []; + } + + public function getOptionalOutputShapeEnumValues(): array { + return []; + } + + /** + * @inheritDoc + * @return array{sources: list} + * @throws \RuntimeException + */ + public function process(?string $userId, array $input, callable $reportProgress): array { + if ($userId === null) { + throw new \RuntimeException('User ID is required to process the prompt.'); + } + + if (!isset($input['prompt']) || !is_string($input['prompt'])) { + throw new \RuntimeException('Invalid input, expected "prompt" key with string value'); + } + + if (!isset($input['limit']) || !is_numeric($input['limit'])) { + throw new \RuntimeException('Invalid input, expected "limit" key with number value'); + } + $limit = (int)$input['limit']; + + if ( + !isset($input['scopeType']) || !is_string($input['scopeType']) + || !isset($input['scopeList']) || !is_array($input['scopeList']) + || !isset($input['scopeListMeta']) || !is_string($input['scopeListMeta']) + ) { + throw new \RuntimeException('Invalid input, expected "scopeType" key with string value, "scopeList" key with array value and "scopeListMeta" key with string value'); + } + + try { + ScopeType::validate($input['scopeType']); + } catch (\InvalidArgumentException $e) { + throw new \RuntimeException($e->getMessage(), intval($e->getCode()), $e); + } + if ($input['scopeType'] === ScopeType::SOURCE) { + throw new \InvalidArgumentException('Invalid scope type, source cannot be used to search'); + } + + // unscoped query + if ($input['scopeType'] === ScopeType::NONE) { + $response = $this->langRopeService->docSearch( + $userId, + $input['prompt'], + null, + null, + $limit, + ); + if (isset($response['error'])) { + throw new \RuntimeException('No result in ContextChat response. ' . $response['error']); + } + return $this->processResponse($userId, $response); + } + + // scoped query + $scopeList = array_unique($input['scopeList']); + if (count($scopeList) === 0) { + throw new \RuntimeException('Empty scope list provided, use unscoped query instead'); + } + + if ($input['scopeType'] === ScopeType::PROVIDER) { + /** @var array $scopeList */ + $processedScopes = $scopeList; + $this->logger->debug('No need to index sources, querying ContextChat', ['scopeType' => $input['scopeType'], 'scopeList' => $processedScopes]); + } else { + // this should never happen + throw new \InvalidArgumentException('Invalid scope type'); + } + + if (count($processedScopes) === 0) { + throw new \RuntimeException('No supported sources found in the scope list, extend the list or use unscoped query instead'); + } + + $response = $this->langRopeService->docSearch( + $userId, + $input['prompt'], + $input['scopeType'], + $processedScopes, + $limit, + ); + + return $this->processResponse($userId, $response); + } + + /** + * Validate and enrich sources JSON strings of the response + * + * @param string $userId + * @param array $response + * @return array{sources: list} + * @throws \RuntimeException + */ + private function processResponse(string $userId, array $response): array { + if (isset($response['error'])) { + throw new \RuntimeException('Error received in ContextChat document search request: ' . $response['error']); + } + if (!array_is_list($response)) { + throw new \RuntimeException('Invalid response from ContextChat, expected a list: ' . json_encode($response)); + } + + if (count($response) === 0) { + $this->logger->info('No sources found in the response', ['response' => $response]); + return [ + 'sources' => [], + ]; + } + + $sources = $response; + $jsonSources = array_filter(array_map( + fn ($source) => json_encode($source), + $this->metadataService->getEnrichedSources( + $userId, + ...array_map( + fn ($source) => $source['source_id'] ?? null, + $sources, + ), + ), + ), fn ($json) => is_string($json)); + + if (count($jsonSources) === 0) { + $this->logger->warning('No sources could be enriched', ['sources' => $sources]); + } elseif (count($jsonSources) !== count($sources)) { + $this->logger->warning('Some sources could not be enriched', ['sources' => $sources, 'jsonSources' => $jsonSources]); + } + + return [ + 'sources' => $jsonSources, + ]; + } +} diff --git a/lib/TaskProcessing/ContextChatSearchTaskType.php b/lib/TaskProcessing/ContextChatSearchTaskType.php new file mode 100644 index 00000000..16f40623 --- /dev/null +++ b/lib/TaskProcessing/ContextChatSearchTaskType.php @@ -0,0 +1,99 @@ +l->t('Context Chat search'); + } + + /** + * @inheritDoc + * @since 2.3.0 + */ + public function getDescription(): string { + return $this->l->t('Search with Context Chat.'); + } + + /** + * @return string + * @since 2.3.0 + */ + public function getId(): string { + return self::ID; + } + + /** + * @return ShapeDescriptor[] + * @since 2.3.0 + */ + public function getInputShape(): array { + return [ + 'prompt' => new ShapeDescriptor( + $this->l->t('Prompt'), + $this->l->t('Search your documents, files and more'), + EShapeType::Text, + ), + 'scopeType' => new ShapeDescriptor( + $this->l->t('Scope type'), + $this->l->t('none, provider'), + EShapeType::Text, + ), + 'scopeList' => new ShapeDescriptor( + $this->l->t('Scope list'), + $this->l->t('list of providers'), + EShapeType::ListOfTexts, + ), + 'scopeListMeta' => new ShapeDescriptor( + $this->l->t('Scope list metadata'), + $this->l->t('Required to nicely render the scope list in assistant'), + EShapeType::Text, + ), + 'limit' => new ShapeDescriptor( + $this->l->t('Max result number'), + $this->l->t('Maximum number of results returned by Context Chat'), + EShapeType::Number, + ), + ]; + } + + /** + * @return ShapeDescriptor[] + * @since 2.3.0 + */ + public function getOutputShape(): array { + return [ + // each string is a json encoded object + // { id: string, label: string, icon: string, url: string } + 'sources' => new ShapeDescriptor( + $this->l->t('Sources'), + $this->l->t('The sources that were found'), + EShapeType::ListOfTexts, + ), + ]; + } +}