Skip to content

Add search task type and provider only returning sources #129

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
184 changes: 184 additions & 0 deletions lib/TaskProcessing/ContextChatSearchProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\ContextChat\TaskProcessing;

use OCA\ContextChat\AppInfo\Application;
use OCA\ContextChat\Logger;
use OCA\ContextChat\Service\LangRopeService;
use OCA\ContextChat\Service\MetadataService;
use OCA\ContextChat\Type\ScopeType;
use OCP\IL10N;
use OCP\TaskProcessing\ISynchronousProvider;

class ContextChatSearchProvider implements ISynchronousProvider {

public function __construct(
private LangRopeService $langRopeService,
private IL10N $l10n,
private Logger $logger,
private MetadataService $metadataService,
) {
}

public function getId(): string {
return Application::APP_ID . '-context_chat_search';
}

public function getName(): string {
return $this->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 [];
}

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<string>}
* @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['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);
}

// unscoped query
if ($input['scopeType'] === ScopeType::NONE) {
$response = $this->langRopeService->query($userId, $input['prompt']);
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<string> $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');
}

// TODO use a different query to only ask for sources to the backend app
$response = $this->langRopeService->query(
$userId,
$input['prompt'],
true,
$input['scopeType'],
$processedScopes,
);

return $this->processResponse($userId, $response);
}

/**
* Validate and enrich sources JSON strings of the response
*
* @param string $userId
* @param array $response
* @return array{sources: list<string>}
* @throws \RuntimeException
*/
private function processResponse(string $userId, array $response): array {
if (isset($response['error'])) {
throw new \RuntimeException('No result in ContextChat response: ' . $response['error']);
}
if (!isset($response['sources']) || !is_array($response['sources'])) {
throw new \RuntimeException('Invalid response from ContextChat, expected "sources" keys: ' . json_encode($response));
}

if (count($response['sources']) === 0) {
$this->logger->info('No sources found in the response', ['response' => $response]);
return [
'sources' => [],
];
}

$jsonSources = array_filter(array_map(
fn ($source) => json_encode($source),
$this->metadataService->getEnrichedSources($userId, ...$response['sources'] ?? []),
), fn ($json) => is_string($json));

if (count($jsonSources) === 0) {
$this->logger->warning('No sources could be enriched', ['sources' => $response['sources']]);
} elseif (count($jsonSources) !== count($response['sources'] ?? [])) {
$this->logger->warning('Some sources could not be enriched', ['sources' => $response['sources'], 'jsonSources' => $jsonSources]);
}

return [
'sources' => $jsonSources,
];
}
}
94 changes: 94 additions & 0 deletions lib/TaskProcessing/ContextChatSearchTaskType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\ContextChat\TaskProcessing;

use OCA\ContextChat\AppInfo\Application;
use OCP\IL10N;
use OCP\TaskProcessing\EShapeType;
use OCP\TaskProcessing\ITaskType;
use OCP\TaskProcessing\ShapeDescriptor;

class ContextChatSearchTaskType implements ITaskType {
public const ID = Application::APP_ID . ':context_chat_search';

public function __construct(
private IL10N $l,
) {
}

/**
* @inheritDoc
* @since 2.3.0
*/
public function getName(): string {
return $this->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,
),
];
}

/**
* @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,
),
];
}
}
Loading