Skip to content

Commit aff80b8

Browse files
committed
feat: add search task type and provider only returning sources
Signed-off-by: Julien Veyssier <[email protected]>
1 parent 2ea9768 commit aff80b8

File tree

3 files changed

+282
-0
lines changed

3 files changed

+282
-0
lines changed

lib/AppInfo/Application.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
use OCA\ContextChat\Listener\ShareListener;
1313
use OCA\ContextChat\Listener\UserDeletedListener;
1414
use OCA\ContextChat\TaskProcessing\ContextChatProvider;
15+
use OCA\ContextChat\TaskProcessing\ContextChatSearchProvider;
16+
use OCA\ContextChat\TaskProcessing\ContextChatSearchTaskType;
1517
use OCA\ContextChat\TaskProcessing\ContextChatTaskType;
1618
use OCP\App\Events\AppDisableEvent;
1719
use OCP\AppFramework\App;
@@ -82,6 +84,8 @@ public function register(IRegistrationContext $context): void {
8284
$context->registerEventListener(ShareDeletedEvent::class, ShareListener::class);
8385
$context->registerTaskProcessingTaskType(ContextChatTaskType::class);
8486
$context->registerTaskProcessingProvider(ContextChatProvider::class);
87+
$context->registerTaskProcessingTaskType(ContextChatSearchTaskType::class);
88+
$context->registerTaskProcessingProvider(ContextChatSearchProvider::class);
8589
}
8690

8791
public function boot(IBootContext $context): void {
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\ContextChat\TaskProcessing;
11+
12+
use OCA\ContextChat\AppInfo\Application;
13+
use OCA\ContextChat\Logger;
14+
use OCA\ContextChat\Service\LangRopeService;
15+
use OCA\ContextChat\Service\MetadataService;
16+
use OCA\ContextChat\Type\ScopeType;
17+
use OCP\IL10N;
18+
use OCP\TaskProcessing\ISynchronousProvider;
19+
20+
class ContextChatSearchProvider implements ISynchronousProvider {
21+
22+
public function __construct(
23+
private LangRopeService $langRopeService,
24+
private IL10N $l10n,
25+
private Logger $logger,
26+
private MetadataService $metadataService,
27+
) {
28+
}
29+
30+
public function getId(): string {
31+
return Application::APP_ID . '-context_chat_search';
32+
}
33+
34+
public function getName(): string {
35+
return $this->l10n->t('Nextcloud Assistant Context Chat Search Provider');
36+
}
37+
38+
public function getTaskTypeId(): string {
39+
return ContextChatSearchTaskType::ID;
40+
}
41+
42+
public function getExpectedRuntime(): int {
43+
return 120;
44+
}
45+
46+
public function getInputShapeEnumValues(): array {
47+
return [];
48+
}
49+
50+
public function getInputShapeDefaults(): array {
51+
return [];
52+
}
53+
54+
public function getOptionalInputShape(): array {
55+
return [];
56+
}
57+
58+
public function getOptionalInputShapeEnumValues(): array {
59+
return [];
60+
}
61+
62+
public function getOptionalInputShapeDefaults(): array {
63+
return [];
64+
}
65+
66+
public function getOutputShapeEnumValues(): array {
67+
return [];
68+
}
69+
70+
public function getOptionalOutputShape(): array {
71+
return [];
72+
}
73+
74+
public function getOptionalOutputShapeEnumValues(): array {
75+
return [];
76+
}
77+
78+
/**
79+
* @inheritDoc
80+
* @return array{sources: list<string>}
81+
* @throws \RuntimeException
82+
*/
83+
public function process(?string $userId, array $input, callable $reportProgress): array {
84+
if ($userId === null) {
85+
throw new \RuntimeException('User ID is required to process the prompt.');
86+
}
87+
88+
if (!isset($input['prompt']) || !is_string($input['prompt'])) {
89+
throw new \RuntimeException('Invalid input, expected "prompt" key with string value');
90+
}
91+
92+
if (
93+
!isset($input['scopeType']) || !is_string($input['scopeType'])
94+
|| !isset($input['scopeList']) || !is_array($input['scopeList'])
95+
|| !isset($input['scopeListMeta']) || !is_string($input['scopeListMeta'])
96+
) {
97+
throw new \RuntimeException('Invalid input, expected "scopeType" key with string value, "scopeList" key with array value and "scopeListMeta" key with string value');
98+
}
99+
100+
try {
101+
ScopeType::validate($input['scopeType']);
102+
} catch (\InvalidArgumentException $e) {
103+
throw new \RuntimeException($e->getMessage(), intval($e->getCode()), $e);
104+
}
105+
106+
// unscoped query
107+
if ($input['scopeType'] === ScopeType::NONE) {
108+
$response = $this->langRopeService->query($userId, $input['prompt']);
109+
if (isset($response['error'])) {
110+
throw new \RuntimeException('No result in ContextChat response. ' . $response['error']);
111+
}
112+
return $this->processResponse($userId, $response);
113+
}
114+
115+
// scoped query
116+
$scopeList = array_unique($input['scopeList']);
117+
if (count($scopeList) === 0) {
118+
throw new \RuntimeException('Empty scope list provided, use unscoped query instead');
119+
}
120+
121+
if ($input['scopeType'] === ScopeType::PROVIDER) {
122+
/** @var array<string> $scopeList */
123+
$processedScopes = $scopeList;
124+
$this->logger->debug('No need to index sources, querying ContextChat', ['scopeType' => $input['scopeType'], 'scopeList' => $processedScopes]);
125+
} else {
126+
// this should never happen
127+
throw new \InvalidArgumentException('Invalid scope type');
128+
}
129+
130+
if (count($processedScopes) === 0) {
131+
throw new \RuntimeException('No supported sources found in the scope list, extend the list or use unscoped query instead');
132+
}
133+
134+
// TODO use a different query to only ask for sources to the backend app
135+
$response = $this->langRopeService->query(
136+
$userId,
137+
$input['prompt'],
138+
true,
139+
$input['scopeType'],
140+
$processedScopes,
141+
);
142+
143+
return $this->processResponse($userId, $response);
144+
}
145+
146+
/**
147+
* Validate and enrich sources JSON strings of the response
148+
*
149+
* @param string $userId
150+
* @param array $response
151+
* @return array{sources: list<string>}
152+
* @throws \RuntimeException
153+
*/
154+
private function processResponse(string $userId, array $response): array {
155+
if (isset($response['error'])) {
156+
throw new \RuntimeException('No result in ContextChat response: ' . $response['error']);
157+
}
158+
if (!isset($response['sources']) || !is_array($response['sources'])) {
159+
throw new \RuntimeException('Invalid response from ContextChat, expected "sources" keys: ' . json_encode($response));
160+
}
161+
162+
if (count($response['sources']) === 0) {
163+
$this->logger->info('No sources found in the response', ['response' => $response]);
164+
return [
165+
'sources' => [],
166+
];
167+
}
168+
169+
$jsonSources = array_filter(array_map(
170+
fn ($source) => json_encode($source),
171+
$this->metadataService->getEnrichedSources($userId, ...$response['sources'] ?? []),
172+
), fn ($json) => is_string($json));
173+
174+
if (count($jsonSources) === 0) {
175+
$this->logger->warning('No sources could be enriched', ['sources' => $response['sources']]);
176+
} elseif (count($jsonSources) !== count($response['sources'] ?? [])) {
177+
$this->logger->warning('Some sources could not be enriched', ['sources' => $response['sources'], 'jsonSources' => $jsonSources]);
178+
}
179+
180+
return [
181+
'sources' => $jsonSources,
182+
];
183+
}
184+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\ContextChat\TaskProcessing;
11+
12+
use OCA\ContextChat\AppInfo\Application;
13+
use OCP\IL10N;
14+
use OCP\TaskProcessing\EShapeType;
15+
use OCP\TaskProcessing\ITaskType;
16+
use OCP\TaskProcessing\ShapeDescriptor;
17+
18+
class ContextChatSearchTaskType implements ITaskType {
19+
public const ID = Application::APP_ID . ':context_chat_search';
20+
21+
public function __construct(
22+
private IL10N $l,
23+
) {
24+
}
25+
26+
/**
27+
* @inheritDoc
28+
* @since 2.3.0
29+
*/
30+
public function getName(): string {
31+
return $this->l->t('Context Chat search');
32+
}
33+
34+
/**
35+
* @inheritDoc
36+
* @since 2.3.0
37+
*/
38+
public function getDescription(): string {
39+
return $this->l->t('Search with Context Chat.');
40+
}
41+
42+
/**
43+
* @return string
44+
* @since 2.3.0
45+
*/
46+
public function getId(): string {
47+
return self::ID;
48+
}
49+
50+
/**
51+
* @return ShapeDescriptor[]
52+
* @since 2.3.0
53+
*/
54+
public function getInputShape(): array {
55+
return [
56+
'prompt' => new ShapeDescriptor(
57+
$this->l->t('Prompt'),
58+
$this->l->t('Search your documents, files and more'),
59+
EShapeType::Text,
60+
),
61+
'scopeType' => new ShapeDescriptor(
62+
$this->l->t('Scope type'),
63+
$this->l->t('none, provider'),
64+
EShapeType::Text,
65+
),
66+
'scopeList' => new ShapeDescriptor(
67+
$this->l->t('Scope list'),
68+
$this->l->t('list of providers'),
69+
EShapeType::ListOfTexts,
70+
),
71+
'scopeListMeta' => new ShapeDescriptor(
72+
$this->l->t('Scope list metadata'),
73+
$this->l->t('Required to nicely render the scope list in assistant'),
74+
EShapeType::Text,
75+
),
76+
];
77+
}
78+
79+
/**
80+
* @return ShapeDescriptor[]
81+
* @since 2.3.0
82+
*/
83+
public function getOutputShape(): array {
84+
return [
85+
// each string is a json encoded object
86+
// { id: string, label: string, icon: string, url: string }
87+
'sources' => new ShapeDescriptor(
88+
$this->l->t('Sources'),
89+
$this->l->t('The sources that were found'),
90+
EShapeType::ListOfTexts,
91+
),
92+
];
93+
}
94+
}

0 commit comments

Comments
 (0)