From a2f752d36aa9322878cb453fa29a22a2315ac992 Mon Sep 17 00:00:00 2001 From: Anna Visman Date: Fri, 5 Jun 2026 16:50:18 +0200 Subject: [PATCH 1/4] feat(search): add chat message search to mapper and service with unit tests Signed-off-by: Anna Visman --- lib/Db/ChattyLLM/MessageMapper.php | 29 +++++ lib/Service/ChatService.php | 25 ++++ tests/unit/Service/ChatServiceSearchTest.php | 130 +++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 tests/unit/Service/ChatServiceSearchTest.php diff --git a/lib/Db/ChattyLLM/MessageMapper.php b/lib/Db/ChattyLLM/MessageMapper.php index a0c4c0ce8..bab91013e 100644 --- a/lib/Db/ChattyLLM/MessageMapper.php +++ b/lib/Db/ChattyLLM/MessageMapper.php @@ -181,4 +181,33 @@ public function deleteMessageById(int $sessionId, int $messageId): void { $qb->executeStatement(); } + + /** + * @param string $userId + * @param string $query + * @param int $limit + * @return list + * @throws \OCP\DB\Exception + */ + public function searchMessages(string $userId, string $query, int $limit = 100): array { + $qb = $this->db->getQueryBuilder(); + $qb->select(Message::$columns) + ->from($this->getTableName(), 'm') + ->join('m', 'assistant_chat_sns', 's', + $qb->expr()->eq('m.session_id', 's.id') + ) + ->where($qb->expr()->eq('s.user_id', + $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR) + )) + ->andWhere($qb->expr()->iLike('m.content', + $qb->createPositionalParameter( + '%' . $this->db->escapeLikeParameter($query) . '%', + IQueryBuilder::PARAM_STR + ) + )) + ->orderBy('m.timestamp', 'DESC') + ->setMaxResults($limit); + + return $this->findEntities($qb); + } } diff --git a/lib/Service/ChatService.php b/lib/Service/ChatService.php index cfddf61d3..b9ea135b3 100644 --- a/lib/Service/ChatService.php +++ b/lib/Service/ChatService.php @@ -168,6 +168,31 @@ public function getSessionsForUser(?string $userId): array { } } + /** + * @return array{messages: list>, sessionIds: list} + * @throws UnauthorizedException + * @throws InternalException + */ + public function searchMessages(?string $userId, string $query): array { + if ($userId === null) { + throw new UnauthorizedException($this->l10n->t('Unauthorized')); + } + if (trim($query) === '') { + return ['messages' => [], 'sessionIds' => []]; + } + try { + $messages = $this->messageMapper->searchMessages($userId, $query); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + $sessionIds = array_values(array_unique( + array_map(fn(Message $m) => $m->getSessionId(), $messages) + )); + return [ + 'messages' => array_map(fn(Message $m) => $m->jsonSerialize(), $messages), + 'sessionIds' => $sessionIds, + ]; + } /** * @throws BadRequestException * @throws InternalException diff --git a/tests/unit/Service/ChatServiceSearchTest.php b/tests/unit/Service/ChatServiceSearchTest.php new file mode 100644 index 000000000..7f1514190 --- /dev/null +++ b/tests/unit/Service/ChatServiceSearchTest.php @@ -0,0 +1,130 @@ +messageMapper = $this->createMock(MessageMapper::class); + + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnArgument(0); + + $this->service = new ChatService( + $this->createMock(IUserManager::class), + $this->createMock(IAppConfig::class), + $l10n, + $this->createMock(SessionMapper::class), + $this->messageMapper, + $this->createMock(SessionSummaryService::class), + $this->createMock(IManager::class), + $this->createMock(LoggerInterface::class), + $this->createMock(ITimeFactory::class), + ); + } + + public function testSearchMessagesUserIdNull(): void { + // UserId = null should throw an error + $this->expectException(UnauthorizedException::class); + $this->service->searchMessages(null, 'hello'); + } + + public function testSearchMessagesBlankQuery(): void { + // A blank query should not hit the database but return empty + $this->messageMapper->expects($this->never()) + ->method('searchMessages'); + + $result = $this->service->searchMessages('user1', ' '); + + $this->assertSame([], $result['messages']); + $this->assertSame([], $result['sessionIds']); + } + + public function testSearchMessagesSameSession(): void { + // Two messages from the same session + $msg1 = new Message(); + $msg1->setSessionId(1); + $msg1->setRole(Message::ROLE_HUMAN); + $msg1->setContent('hello assistant'); + $msg1->setTimestamp(1000); + $msg1->setSources('[]'); + $msg1->setAttachments('[]'); + + $msg2 = new Message(); + $msg2->setSessionId(1); + $msg2->setRole(Message::ROLE_ASSISTANT); + $msg2->setContent('hello human'); + $msg2->setTimestamp(1001); + $msg2->setSources('[]'); + $msg2->setAttachments('[]'); + + $this->messageMapper->expects($this->once()) + ->method('searchMessages') + ->with('user1', 'hello') + ->willReturn([$msg1, $msg2]); + + $result = $this->service->searchMessages('user1', 'hello'); + + // Two messages returned + $this->assertCount(2, $result['messages']); + // Check that the messages have the same session ID + $this->assertSame([1], $result['sessionIds']); + } + + public function testSearchMessagesDifferentSession(): void { + $msg1 = new Message(); + $msg1->setSessionId(2); + $msg1->setRole(Message::ROLE_HUMAN); + $msg1->setContent('hello from session 2'); + $msg1->setTimestamp(1000); + $msg1->setSources('[]'); + $msg1->setAttachments('[]'); + + $msg2 = new Message(); + $msg2->setSessionId(3); + $msg2->setRole(Message::ROLE_HUMAN); + $msg2->setContent('hello from session 3'); + $msg2->setTimestamp(1001); + $msg2->setSources('[]'); + $msg2->setAttachments('[]'); + + $this->messageMapper->expects($this->once()) + ->method('searchMessages') + ->with('user1', 'hello') + ->willReturn([$msg1, $msg2]); + + $result = $this->service->searchMessages('user1', 'hello'); + + $this->assertCount(2, $result['messages']); + + // Check that the messages have different session IDs + $this->assertSame([2, 3], $result['sessionIds']); + } +} \ No newline at end of file From ce256b2c7d09fa3c1bf7cb70ca34cc9749ad49be Mon Sep 17 00:00:00 2001 From: Anna Visman Date: Sun, 7 Jun 2026 18:04:57 +0200 Subject: [PATCH 2/4] feat(search): add search endpoint to ChattyLLMController and route Signed-off-by: Anna Visman --- appinfo/routes.php | 1 + lib/Controller/ChattyLLMController.php | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/appinfo/routes.php b/appinfo/routes.php index 1022f74c3..63383af03 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -48,6 +48,7 @@ ['name' => 'chattyLLM#deleteMessage', 'url' => '/chat/delete_message', 'verb' => 'DELETE'], ['name' => 'chattyLLM#getMessages', 'url' => '/chat/messages', 'verb' => 'GET'], ['name' => 'chattyLLM#getMessage', 'url' => '/chat/sessions/{sessionId}/messages/{messageId}', 'verb' => 'GET'], + ['name' => 'chattyLLM#searchMessages', 'url' => '/chat/search', 'verb' => 'GET'], ['name' => 'chattyLLM#generateForSession', 'url' => '/chat/generate', 'verb' => 'GET'], ['name' => 'chattyLLM#regenerateForSession', 'url' => '/chat/regenerate', 'verb' => 'GET'], ['name' => 'chattyLLM#checkSession', 'url' => '/chat/check_session', 'verb' => 'GET'], diff --git a/lib/Controller/ChattyLLMController.php b/lib/Controller/ChattyLLMController.php index 765d8518d..9655df521 100644 --- a/lib/Controller/ChattyLLMController.php +++ b/lib/Controller/ChattyLLMController.php @@ -466,6 +466,32 @@ public function deleteMessage(int $messageId, int $sessionId): JSONResponse { } } + /** + * Search chat messages + * + * Search through all chat messages for the current user + * + * @param string $query The search query + * @return JSONResponse, sessionIds: list}, array{}>|JSONResponse + * + * 200: Search results returned successfully + * 401: Not logged in + */ + #[NoAdminRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])] + public function searchMessages(string $query): JSONResponse { + try { + $result = $this->chatService->searchMessages($this->userId, $query); + return new JSONResponse($result); + } catch (InternalException $e) { + $this->logger->warning('Failed to search chat messages', ['exception' => $e]); + return new JSONResponse(['error' => $this->l10n->t('Failed to search chat messages')], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (UnauthorizedException $e) { + return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); + } + } + + /** * Generate a new assistant message * From ef7416bfd8b218ab16d355e4d380e49a6876fd8e Mon Sep 17 00:00:00 2001 From: Anna Visman Date: Sun, 7 Jun 2026 18:07:01 +0200 Subject: [PATCH 3/4] enable CI Signed-off-by: Anna Visman From 002a45dd58a6aafef87efee5c3e875ae2d5013cb Mon Sep 17 00:00:00 2001 From: Anna Visman Date: Sun, 7 Jun 2026 18:28:23 +0200 Subject: [PATCH 4/4] small formatting changes to make the code in line with the existing code Signed-off-by: Anna Visman --- lib/Controller/ChattyLLMController.php | 3 ++- lib/Db/ChattyLLM/MessageMapper.php | 15 +++------------ lib/Service/ChatService.php | 3 ++- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/lib/Controller/ChattyLLMController.php b/lib/Controller/ChattyLLMController.php index 9655df521..d104afc12 100644 --- a/lib/Controller/ChattyLLMController.php +++ b/lib/Controller/ChattyLLMController.php @@ -480,13 +480,14 @@ public function deleteMessage(int $messageId, int $sessionId): JSONResponse { #[NoAdminRequired] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])] public function searchMessages(string $query): JSONResponse { + try { $result = $this->chatService->searchMessages($this->userId, $query); return new JSONResponse($result); } catch (InternalException $e) { $this->logger->warning('Failed to search chat messages', ['exception' => $e]); return new JSONResponse(['error' => $this->l10n->t('Failed to search chat messages')], Http::STATUS_INTERNAL_SERVER_ERROR); - } catch (UnauthorizedException $e) { + } catch (\OCA\Assistant\Service\UnauthorizedException $e) { return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); } } diff --git a/lib/Db/ChattyLLM/MessageMapper.php b/lib/Db/ChattyLLM/MessageMapper.php index bab91013e..454fc336d 100644 --- a/lib/Db/ChattyLLM/MessageMapper.php +++ b/lib/Db/ChattyLLM/MessageMapper.php @@ -193,18 +193,9 @@ public function searchMessages(string $userId, string $query, int $limit = 100): $qb = $this->db->getQueryBuilder(); $qb->select(Message::$columns) ->from($this->getTableName(), 'm') - ->join('m', 'assistant_chat_sns', 's', - $qb->expr()->eq('m.session_id', 's.id') - ) - ->where($qb->expr()->eq('s.user_id', - $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR) - )) - ->andWhere($qb->expr()->iLike('m.content', - $qb->createPositionalParameter( - '%' . $this->db->escapeLikeParameter($query) . '%', - IQueryBuilder::PARAM_STR - ) - )) + ->join('m', 'assistant_chat_sns', 's', $qb->expr()->eq('m.session_id', 's.id')) + ->where($qb->expr()->eq('s.user_id', $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->iLike('m.content', $qb->createPositionalParameter('%' . $this->db->escapeLikeParameter($query) . '%', IQueryBuilder::PARAM_STR))) ->orderBy('m.timestamp', 'DESC') ->setMaxResults($limit); diff --git a/lib/Service/ChatService.php b/lib/Service/ChatService.php index b9ea135b3..3df748316 100644 --- a/lib/Service/ChatService.php +++ b/lib/Service/ChatService.php @@ -177,6 +177,7 @@ public function searchMessages(?string $userId, string $query): array { if ($userId === null) { throw new UnauthorizedException($this->l10n->t('Unauthorized')); } + // For empty queries return two empty lists right away if (trim($query) === '') { return ['messages' => [], 'sessionIds' => []]; } @@ -189,7 +190,7 @@ public function searchMessages(?string $userId, string $query): array { array_map(fn(Message $m) => $m->getSessionId(), $messages) )); return [ - 'messages' => array_map(fn(Message $m) => $m->jsonSerialize(), $messages), + 'messages' => array_map(fn(Message $m) => $m->jsonSerialize(), $messages), // convert Message objects into plain arrays 'sessionIds' => $sessionIds, ]; }