From a644bef2dace51cef70f1a473eac3f6faaa117a9 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 30 Apr 2026 11:45:53 +0200 Subject: [PATCH 01/22] feat(Assignments): First pass some stuff still missing Signed-off-by: Marcel Klehr --- lib/BackgroundJob/RunAssignmentsJob.php | 30 ++++ lib/Controller/AssignmentsApiController.php | 165 ++++++++++++++++++ lib/Db/Assignment.php | 99 +++++++++++ lib/Db/AssignmentMapper.php | 87 +++++++++ lib/Db/ChattyLLM/Session.php | 12 ++ lib/Db/ChattyLLM/SessionMapper.php | 18 ++ .../Version030500Date20260430083738.php | 77 ++++++++ lib/ResponseDefinitions.php | 10 ++ lib/Service/AssignmentsService.php | 47 +++++ 9 files changed, 545 insertions(+) create mode 100644 lib/BackgroundJob/RunAssignmentsJob.php create mode 100644 lib/Controller/AssignmentsApiController.php create mode 100644 lib/Db/Assignment.php create mode 100644 lib/Db/AssignmentMapper.php create mode 100644 lib/Migration/Version030500Date20260430083738.php create mode 100644 lib/Service/AssignmentsService.php diff --git a/lib/BackgroundJob/RunAssignmentsJob.php b/lib/BackgroundJob/RunAssignmentsJob.php new file mode 100644 index 00000000..7bf443d2 --- /dev/null +++ b/lib/BackgroundJob/RunAssignmentsJob.php @@ -0,0 +1,30 @@ +setAllowParallelRuns(true); + $this->setTimeSensitivity(self::TIME_SENSITIVE); + $this->setInterval(60 * 10); // 10min + } + public function run($argument) { + $userId = $argument['userId']; + $this->assignmentService->generateSummariesForNewSessions($userId); + } +} diff --git a/lib/Controller/AssignmentsApiController.php b/lib/Controller/AssignmentsApiController.php new file mode 100644 index 00000000..fe7f55e2 --- /dev/null +++ b/lib/Controller/AssignmentsApiController.php @@ -0,0 +1,165 @@ +}, array{}>|DataResponse + * + * 200: User assignments returned + * 403: User not logged in + */ + #[NoAdminRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['assignments'])] + #[Http\Attribute\ApiRoute(verb: 'GET', url: '/assignments')] + public function getUserAssignments(): DataResponse { + if ($this->userId !== null) { + try { + $assignments = iterator_to_array($this->assignmentMapper->findForUser($this->userId)); + $serializedAssignments = array_map(static function (Assignment $assignments) { + return $assignments->jsonSerialize(); + }, $assignments); + return new DataResponse(['assignments' => $serializedAssignments]); + } catch (Exception $e) { + $this->logger->error('Error while fetching assignments for user ' . $this->userId, ['exception' => $e]); + return new DataResponse(['assignments' => []]); + } + } + return new DataResponse('', HTTP::STATUS_FORBIDDEN); + } + + /** + * Get user's assignments + * + * Get a list of assignmetns for the current user. + * + * @return DataResponse|DataResponse|DataResponse + * + * 200: User tasks returned + * 403: User not logged in + */ + #[NoAdminRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['assignments'])] + #[Http\Attribute\ApiRoute(verb: 'GET', url: '/assignments/{id}')] + public function getUserAssignment(int $id): DataResponse { + if ($this->userId !== null) { + try { + $assignment = $this->assignmentMapper->find($this->userId, $id); + return new DataResponse(['assignment' => $assignment->jsonSerialize()]); + } catch (Exception $e) { + $this->logger->error('Error while fetching assignment for user ' . $this->userId, ['exception' => $e]); + return new DataResponse('', HTTP::STATUS_FORBIDDEN); + } catch (DoesNotExistException|MultipleObjectsReturnedException) { + return new DataResponse('', HTTP::STATUS_NOT_FOUND); + } + } + return new DataResponse('', HTTP::STATUS_FORBIDDEN); + } + + /** + * Get user's assignments + * + * Get a list of assignmetns for the current user. + * + * @return DataResponse|DataResponse|DataResponse + * + * 200: User tasks returned + * 403: User not logged in + */ + #[NoAdminRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['assignments'])] + #[Http\Attribute\ApiRoute(verb: 'PATCH', url: '/assignments/{id}')] + public function updateUserAssignment(int $id, ?string $prompt, ?string $recurrence, ?int $startsAt): DataResponse { + if ($this->userId !== null) { + try { + $assignment = $this->assignmentMapper->find($this->userId, $id); + if ($prompt !== null) { + $assignment->setPrompt($prompt); + } + if ($recurrence !== null) { + $assignment->setRecurrence($recurrence); + } + if ($startsAt !== null) { + $assignment->setStartsAt($startsAt); + } + $assignment->setUpdatedAt($this->timeFactory->now()->getTimestamp()); + $this->assignmentMapper->update($assignment); + return new DataResponse(['assignment' => $assignment->jsonSerialize()]); + } catch (Exception $e) { + $this->logger->error('Error while fetching assignment for user ' . $this->userId, ['exception' => $e]); + return new DataResponse('', HTTP::STATUS_FORBIDDEN); + } catch (DoesNotExistException|MultipleObjectsReturnedException) { + return new DataResponse('', HTTP::STATUS_NOT_FOUND); + } + } + return new DataResponse('', HTTP::STATUS_FORBIDDEN); + } + + /** + * Delete a user's assignment + * * + * @return DataResponse|DataResponse + * + * 200: User assignment deleted + * 403: User not logged in + */ + #[NoAdminRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['assignments'])] + #[Http\Attribute\ApiRoute(verb: 'DELETE', url: '/assignments/{id}')] + public function deleteUserAssignment(int $id): DataResponse { + if ($this->userId !== null) { + try { + $assignment = $this->assignmentMapper->find($this->userId, $id); + $this->assignmentMapper->delete($assignment); + return new DataResponse('', HTTP::STATUS_OK); + } catch (Exception $e) { + $this->logger->error('Error while fetching assignment for user ' . $this->userId, ['exception' => $e]); + return new DataResponse('', HTTP::STATUS_FORBIDDEN); + } catch (DoesNotExistException|MultipleObjectsReturnedException) { + // 200 OK because of idempotence, if we send DELETE twice, we return the same response twice + return new DataResponse('', HTTP::STATUS_OK); + } + } + return new DataResponse('', HTTP::STATUS_FORBIDDEN); + } +} diff --git a/lib/Db/Assignment.php b/lib/Db/Assignment.php new file mode 100644 index 00000000..fe0aaf6f --- /dev/null +++ b/lib/Db/Assignment.php @@ -0,0 +1,99 @@ +addType('userId', Types::STRING); + $this->addType('prompt', Types::STRING); + $this->addType('recurrence', Types::STRING); + $this->addType('startsAt', Types::INTEGER); + $this->addType('createdAt', Types::STRING); + $this->addType('updatedAt', Types::STRING); + $this->addType('lastRunAt', Types::INTEGER); + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() { + return [ + 'id' => $this->getId(), + 'user_id' => $this->getUserId(), + 'prompt' => $this->getPrompt(), + 'recurrence' => $this->getRecurrence(), + 'starts_at' => $this->getStartsAt(), + 'created_at' => $this->getCreatedAt(), + 'updated_at' => $this->getUpdatedAt(), + 'last_run_at' => $this->getLastRunAt(), + ]; + } + + /** + * Evaluates the recurrence rule and checks if a run is due + */ + public function isDueToRun(\DateTimeImmutable $now): bool { + // TODO: Use an actual algorithm here + return true; + } +} diff --git a/lib/Db/AssignmentMapper.php b/lib/Db/AssignmentMapper.php new file mode 100644 index 00000000..a9c30f15 --- /dev/null +++ b/lib/Db/AssignmentMapper.php @@ -0,0 +1,87 @@ + + */ +class AssignmentMapper extends QBMapper { + public function __construct( + IDBConnection $db, + private ITimeFactory $timeFactory, + ) { + parent::__construct($db, 'assistant_assignments', Assignment::class); + } + + /** + * @throws \OCP\DB\Exception + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @return Assignment + */ + public function find(string $userId, int $assignmentId): Assignment { + $qb = $this->db->getQueryBuilder(); + $qb->select('id') + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createPositionalParameter($assignmentId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR))); + + return $this->findEntity($qb); + } + + /** + * @return boolean + * @throws \OCP\DB\Exception + */ + public function exists(string $userId, int $assignmentId): bool { + try { + return $this->find($userId, $assignmentId) !== null; + } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { + return false; + } catch (\OCP\AppFramework\Db\MultipleObjectsReturnedException $e) { + return true; + } + } + + /** + * @return \Generator + * @throws \OCP\DB\Exception + */ + public function findForUser(string $userId): \Generator { + $qb = $this->db->getQueryBuilder(); + $qb->select(Assignment::$columns) + ->from($this->getTableName()) + ->where($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR))) + ->orderBy('created_at', 'DESC'); + + yield * $this->yieldEntities($qb); + } + + /** + * @return \Generator + * @throws \OCP\DB\Exception + */ + public function findDueAssignmentsForUser(string $userId): \Generator { + foreach ($this->findForUser($userId) as $assignment) { + if (!$assignment->isDueToRun($this->timeFactory->now())) { + continue; + } + yield $assignment; + } + } +} diff --git a/lib/Db/ChattyLLM/Session.php b/lib/Db/ChattyLLM/Session.php index fad23840..7755267e 100644 --- a/lib/Db/ChattyLLM/Session.php +++ b/lib/Db/ChattyLLM/Session.php @@ -25,6 +25,8 @@ * @method \void setAgencyConversationToken(?string $agencyConversationToken) * @method \string|null getAgencyPendingActions() * @method \void setAgencyPendingActions(?string $agencyPendingActions) + * @method \void setAssignmentId(int $id) + * @method \int getAssignmentId() */ class Session extends Entity implements \JsonSerializable { /** @var string */ @@ -55,6 +57,12 @@ class Session extends Entity implements \JsonSerializable { */ protected $isRemembered; + /** + * Session can be linked to assignments that run in this session + * @var ?int + */ + protected $assignmentId; + public static $columns = [ 'id', @@ -66,6 +74,7 @@ class Session extends Entity implements \JsonSerializable { 'summary', 'is_summary_up_to_date', 'is_remembered', + 'assignment_id' ]; public static $fields = [ 'id', @@ -77,6 +86,7 @@ class Session extends Entity implements \JsonSerializable { 'summary', 'isSummaryUpToDate', 'isRemembered', + 'assignment_id' ]; public function __construct() { @@ -88,6 +98,7 @@ public function __construct() { $this->addType('summary', Types::TEXT); $this->addType('isSummaryUpToDate', Types::SMALLINT); $this->addType('isRemembered', Types::SMALLINT); + $this->addType('assignmentId', Types::BIGINT); } #[\ReturnTypeWillChange] @@ -102,6 +113,7 @@ public function jsonSerialize() { 'summary' => $this->getSummary(), 'is_summary_up_to_date' => $this->getIsSummaryUpToDate(), 'is_remembered' => $this->getIsRemembered(), + 'assignment_id' => $this->getAssignmentId(), ]; } diff --git a/lib/Db/ChattyLLM/SessionMapper.php b/lib/Db/ChattyLLM/SessionMapper.php index 9006bcf6..6c91a424 100644 --- a/lib/Db/ChattyLLM/SessionMapper.php +++ b/lib/Db/ChattyLLM/SessionMapper.php @@ -64,6 +64,24 @@ public function getUserSession(string $userId, int $sessionId): Session { return $this->findEntity($qb); } + /** + * @param string $userId + * @param int $assignmentId + * @return Session + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws Exception + */ + public function getUserSessionForAssignment(string $userId, int $assignmentId): Session { + $qb = $this->db->getQueryBuilder(); + $qb->select(Session::$columns) + ->from($this->getTableName()) + ->where($qb->expr()->eq('assignment_id', $qb->createPositionalParameter($assignmentId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR))); + + return $this->findEntity($qb); + } + /** * @param string $userId * @return array diff --git a/lib/Migration/Version030500Date20260430083738.php b/lib/Migration/Version030500Date20260430083738.php new file mode 100644 index 00000000..a70609c5 --- /dev/null +++ b/lib/Migration/Version030500Date20260430083738.php @@ -0,0 +1,77 @@ +hasTable('assistant_assignments')) { + $schemaChanged = true; + $table = $schema->createTable('assistant_assignments'); + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + ]); + $table->addColumn('user_id', Types::STRING, [ + 'notnull' => true, + 'length' => 256, + ]); + $table->addColumn('prompt', Types::TEXT, [ + 'notnull' => true, + ]); + // this is an RFC 5545 RRULE + $table->addColumn('recurrence', Types::TEXT, [ + 'notnull' => true, + ]); + $table->addColumn('starts_at', Types::BIGINT, [ + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('last_run_at', Types::BIGINT, [ + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('created_at', Types::BIGINT, [ + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('updated_at', Types::BIGINT, [ + 'notnull' => true, + 'unsigned' => true, + ]); + $table->setPrimaryKey(['id']); + $table->addIndex(['user_id'], 'assistant_assgnmts_user_id_idx'); + } + if ($schema->hasTable('assistant_chat_sns')) { + $schemaChanged = true; + $table->addColumn('assignment_id', Types::BIGINT, [ + 'notnull' => false, + ]); + $table->addIndex(['user_id', 'assignment_id'], 'assistant_chat_assgnmt_uid'); + } + + return $schemaChanged ? $schema : null; + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index e7248ae0..c974d41b 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -84,6 +84,16 @@ * sessionAgencyPendingActions: ?array, * is_remembered: ?bool, * } + * + * @psalm-type AssistantAssignment = array{ + * id: int, + * user_id: string, + * prompt: string, + * recurrence: string, + * created_at: int, + * updated_at: int, + * starts_at: int + * } */ class ResponseDefinitions { } diff --git a/lib/Service/AssignmentsService.php b/lib/Service/AssignmentsService.php new file mode 100644 index 00000000..50a5de67 --- /dev/null +++ b/lib/Service/AssignmentsService.php @@ -0,0 +1,47 @@ +assignmentMapper->findDueAssignmentsForUser($userId) as $assignment) { + try { + $session = $this->sessionMapper->getUserSessionForAssignment($userId, $assignment->getId()); + } catch (DoesNotExistException $e) { + } catch (MultipleObjectsReturnedException $e) { + } + } + } catch (Exception $e) { + + } + } +} From 76bc21dea22c5d112241d1e9e13d9c0a33d9a744 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 5 May 2026 09:09:18 +0200 Subject: [PATCH 02/22] refactor: Introduce ChatService Move business logic from CHattyLLMController to ChatServiec Signed-off-by: Marcel Klehr --- lib/BackgroundJob/RunAssignmentsJob.php | 1 - lib/Controller/AssignmentsApiController.php | 1 - lib/Controller/ChattyLLMController.php | 589 +++------------- lib/Service/BadRequestException.php | 6 + lib/Service/ChatService.php | 739 ++++++++++++++++++++ lib/Service/InternalException.php | 6 + lib/Service/NotFoundException.php | 6 + lib/Service/UnauthorizedException.php | 6 + 8 files changed, 845 insertions(+), 509 deletions(-) create mode 100644 lib/Service/BadRequestException.php create mode 100644 lib/Service/ChatService.php create mode 100644 lib/Service/InternalException.php create mode 100644 lib/Service/NotFoundException.php create mode 100644 lib/Service/UnauthorizedException.php diff --git a/lib/BackgroundJob/RunAssignmentsJob.php b/lib/BackgroundJob/RunAssignmentsJob.php index 7bf443d2..97cb4f1f 100644 --- a/lib/BackgroundJob/RunAssignmentsJob.php +++ b/lib/BackgroundJob/RunAssignmentsJob.php @@ -9,7 +9,6 @@ namespace OCA\Assistant\BackgroundJob; -use OCA\Assistant\Service\SessionSummaryService; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\TimedJob; diff --git a/lib/Controller/AssignmentsApiController.php b/lib/Controller/AssignmentsApiController.php index fe7f55e2..97b56d36 100644 --- a/lib/Controller/AssignmentsApiController.php +++ b/lib/Controller/AssignmentsApiController.php @@ -34,7 +34,6 @@ public function __construct( private AssignmentMapper $assignmentMapper, private LoggerInterface $logger, private ITimeFactory $timeFactory, - ) { parent::__construct($appName, $request); } diff --git a/lib/Controller/ChattyLLMController.php b/lib/Controller/ChattyLLMController.php index 8758e4dc..28a24f6e 100644 --- a/lib/Controller/ChattyLLMController.php +++ b/lib/Controller/ChattyLLMController.php @@ -10,10 +10,11 @@ use OCA\Assistant\AppInfo\Application; use OCA\Assistant\Db\ChattyLLM\Message; use OCA\Assistant\Db\ChattyLLM\MessageMapper; -use OCA\Assistant\Db\ChattyLLM\Session; use OCA\Assistant\Db\ChattyLLM\SessionMapper; use OCA\Assistant\ResponseDefinitions; -use OCA\Assistant\Service\SessionSummaryService; +use OCA\Assistant\Service\BadRequestException; +use OCA\Assistant\Service\ChatService; +use OCA\Assistant\Service\InternalException; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\AppFramework\Http; @@ -26,14 +27,9 @@ use OCP\IL10N; use OCP\IRequest; use OCP\IUserManager; -use OCP\TaskProcessing\Exception\Exception; use OCP\TaskProcessing\Exception\NotFoundException; -use OCP\TaskProcessing\Exception\PreConditionNotMetException; -use OCP\TaskProcessing\Exception\UnauthorizedException; -use OCP\TaskProcessing\Exception\ValidationException; use OCP\TaskProcessing\IManager as ITaskProcessingManager; use OCP\TaskProcessing\Task; -use OCP\TaskProcessing\TaskTypes\TextToTextChat; use Psr\Log\LoggerInterface; /** @@ -56,7 +52,7 @@ public function __construct( private IAppConfig $appConfig, private IUserManager $userManager, private ?string $userId, - private SessionSummaryService $sessionSummaryService, + private ChatService $chatService, ) { parent::__construct($appName, $request); $this->agencyActionData = [ @@ -213,50 +209,16 @@ private function improveAgencyActionNames(array $actions): array { #[NoAdminRequired] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])] public function newSession(int $timestamp, ?string $title = null): JSONResponse { - if ($timestamp > 10_000_000_000) { - $timestamp = intdiv($timestamp, 1000); - } - if ($this->userId === null) { - return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); - } - - $user = $this->userManager->get($this->userId); - if ($user === null) { - return new JSONResponse(['error' => $this->l10n->t('User not found')], Http::STATUS_UNAUTHORIZED); - } - - $userInstructions = $this->appConfig->getValueString( - Application::APP_ID, - 'chat_user_instructions', - Application::CHAT_USER_INSTRUCTIONS, - lazy: true, - ) ?: Application::CHAT_USER_INSTRUCTIONS; - $userInstructions = str_replace('{user}', $user->getDisplayName(), $userInstructions); - try { - $session = new Session(); - $session->setUserId($this->userId); - $session->setTitle($title); - $session->setTimestamp($timestamp); - $session->setAgencyConversationToken(null); - $session->setAgencyPendingActions(null); - $this->sessionMapper->insert($session); - - $systemMsg = new Message(); - $systemMsg->setSessionId($session->getId()); - $systemMsg->setRole('system'); - $systemMsg->setAttachments('[]'); - $systemMsg->setContent($userInstructions); - $systemMsg->setTimestamp($session->getTimestamp()); - $systemMsg->setSources('[]'); - $this->messageMapper->insert($systemMsg); - + $session = $this->chatService->createChatSession($this->userId, $timestamp, $title); return new JSONResponse([ 'session' => $session->jsonSerialize(), ]); - } catch (\OCP\DB\Exception|\RuntimeException $e) { + } catch (InternalException $e) { $this->logger->warning('Failed to create a chat session', ['exception' => $e]); return new JSONResponse(['error' => $this->l10n->t('Failed to create a chat session')], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (\OCA\Assistant\Service\UnauthorizedException $e) { + return new JSONResponse(['error' => $this->l10n->t('Unauthorized')], Http::STATUS_UNAUTHORIZED); } } @@ -267,24 +229,25 @@ public function newSession(int $timestamp, ?string $title = null): JSONResponse * * @param integer $sessionId The chat session ID * @param string $title The new chat session title - * @return JSONResponse|JSONResponse + * @return JSONResponse|JSONResponse * * 200: The title has been updated successfully + * 404: Session not found * 401: Not logged in */ #[NoAdminRequired] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])] public function updateSessionTitle(int $sessionId, string $title): JSONResponse { - if ($this->userId === null) { - return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); - } - try { - $this->sessionMapper->updateSessionTitle($this->userId, $sessionId, $title); + $this->chatService->updateSession($this->userId, $sessionId, $title); return new JSONResponse(); - } catch (\OCP\DB\Exception|\RuntimeException $e) { + } catch (InternalException $e) { $this->logger->warning('Failed to update the chat session', ['exception' => $e]); return new JSONResponse(['error' => $this->l10n->t('Failed to update the chat session')], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (\OCA\Assistant\Service\NotFoundException $e) { + return new JSONResponse(['error' => $this->l10n->t('Session not found')], Http::STATUS_NOT_FOUND); + } catch (\OCA\Assistant\Service\UnauthorizedException $e) { + return new JSONResponse(['error' => $this->l10n->t('Unauthorized')], Http::STATUS_UNAUTHORIZED); } } @@ -302,31 +265,13 @@ public function updateSessionTitle(int $sessionId, string $title): JSONResponse #[NoAdminRequired] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])] public function updateChatSession(int $sessionId, ?string $title = null, ?bool $is_remembered = null): JSONResponse { - if ($this->userId === null) { - return new JSONResponse(['error' => $this->l10n->t('Could not find session')], Http::STATUS_NOT_FOUND); - } - if ($title === null && $is_remembered === null) { - return new JSONResponse(); - } - try { - $session = $this->sessionMapper->getUserSession($this->userId, $sessionId); - if ($title !== null) { - $session->setTitle($title); - } - if ($is_remembered !== null) { - $session->setIsRemembered($is_remembered); - // schedule summarizer jobs for this chat user - if ($is_remembered) { - $this->sessionSummaryService->scheduleJobsForUser($this->userId); - } - } - $this->sessionMapper->update($session); + $this->chatService->updateSession($this->userId, $sessionId, $title, $is_remembered); return new JSONResponse(); - } catch (\OCP\DB\Exception|\RuntimeException $e) { + } catch (InternalException $e) { $this->logger->warning('Failed to update the chat session', ['exception' => $e]); return new JSONResponse(['error' => $this->l10n->t('Failed to update the chat session')], Http::STATUS_INTERNAL_SERVER_ERROR); - } catch (DoesNotExistException|MultipleObjectsReturnedException $e) { + } catch (\OCA\Assistant\Service\NotFoundException|\OCA\Assistant\Service\UnauthorizedException $e) { return new JSONResponse(['error' => $this->l10n->t('Could not find session')], Http::STATUS_NOT_FOUND); } } @@ -345,18 +290,15 @@ public function updateChatSession(int $sessionId, ?string $title = null, ?bool $ #[NoAdminRequired] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])] public function deleteSession(int $sessionId): JSONResponse { - if ($this->userId === null) { - return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); - } - try { // we don't delete the tasks - $this->sessionMapper->deleteSession($this->userId, $sessionId); - $this->messageMapper->deleteMessagesBySession($sessionId); + $this->chatService->deleteSession($this->userId, $sessionId); return new JSONResponse(); - } catch (\OCP\DB\Exception|\RuntimeException $e) { + } catch (InternalException $e) { $this->logger->warning('Failed to delete the chat session', ['exception' => $e]); return new JSONResponse(['error' => $this->l10n->t('Failed to delete the chat session')], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (\OCA\Assistant\Service\UnauthorizedException $e) { + return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); } } @@ -373,16 +315,14 @@ public function deleteSession(int $sessionId): JSONResponse { #[NoAdminRequired] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])] public function getSessions(): JSONResponse { - if ($this->userId === null) { - return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); - } - try { - $sessions = $this->sessionMapper->getUserSessions($this->userId); + $sessions = $this->chatService->getSessionsForUser($this->userId); return new JSONResponse($sessions); - } catch (\OCP\DB\Exception $e) { + } catch (InternalException $e) { $this->logger->warning('Failed to get chat sessions', ['exception' => $e]); return new JSONResponse(['error' => $this->l10n->t('Failed to get chat sessions')], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (\OCA\Assistant\Service\UnauthorizedException $e) { + return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); } } @@ -409,61 +349,18 @@ public function getSessions(): JSONResponse { public function newMessage( int $sessionId, string $role, string $content, int $timestamp, ?array $attachments = null, bool $firstHumanMessage = false, ): JSONResponse { - if ($timestamp > 10_000_000_000) { - $timestamp = intdiv($timestamp, 1000); - } - if ($this->userId === null) { - return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); - } - if (strlen($content) > Application::MAX_TEXT_INPUT_LENGTH) { - return new JSONResponse(['error' => $this->l10n->t('The new message is too long')], Http::STATUS_BAD_REQUEST); - } - try { - $sessionExists = $this->sessionMapper->exists($this->userId, $sessionId); - if (!$sessionExists) { - return new JSONResponse(['error' => $this->l10n->t('Session not found')], Http::STATUS_NOT_FOUND); - } - - // refuse empty text content if context agent is not available (we do classic chat) AND there is no attachment - // in other words: accept empty content if we are using agency OR there are attachments - $content = trim($content); - if (empty($content) - && (!class_exists('OCP\\TaskProcessing\\TaskTypes\\ContextAgentInteraction') - || !isset($this->taskProcessingManager->getAvailableTaskTypes()[\OCP\TaskProcessing\TaskTypes\ContextAgentInteraction::ID])) - && $attachments === null - ) { - return new JSONResponse(['error' => $this->l10n->t('Message content is empty')], Http::STATUS_BAD_REQUEST); - } - - $message = new Message(); - $message->setSessionId($sessionId); - $message->setRole($role); - $message->setContent($content); - $message->setTimestamp($timestamp); - $message->setSources('[]'); - $message->setAttachments('[]'); - if ($attachments !== null) { - $encodedAttachments = json_encode($attachments); - if ($encodedAttachments !== false) { - $message->setAttachments($encodedAttachments); - } - } - $this->messageMapper->insert($message); - - if ($firstHumanMessage) { - // set the title of the session based on first human message - $this->sessionMapper->updateSessionTitle( - $this->userId, - $sessionId, - strlen($content) > 140 ? mb_substr($content, 0, 140) . '...' : $content, - ); - } - + $message = $this->chatService->createMessage($this->userId, $sessionId, $role, $content, $timestamp, $attachments, $firstHumanMessage); return new JSONResponse($message->jsonSerialize()); - } catch (\OCP\DB\Exception $e) { + } catch (InternalException $e) { $this->logger->warning('Failed to add a chat message', ['exception' => $e]); return new JSONResponse(['error' => $this->l10n->t('Failed to add a chat message')], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (BadRequestException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } catch (\OCA\Assistant\Service\NotFoundException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_NOT_FOUND); + } catch (\OCA\Assistant\Service\UnauthorizedException $e) { + return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); } } @@ -484,28 +381,18 @@ public function newMessage( #[NoAdminRequired] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])] public function getMessages(int $sessionId, int $limit = 20, int $cursor = 0): JSONResponse { - if ($this->userId === null) { - return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); - } - try { - $sessionExists = $this->sessionMapper->exists($this->userId, $sessionId); - if (!$sessionExists) { - return new JSONResponse(['error' => $this->l10n->t('Session not found')], Http::STATUS_NOT_FOUND); - } - - /** @var list $messages */ - $messages = $this->messageMapper->getMessages($sessionId, $cursor, $limit); - if ($messages[0]->getRole() === 'system') { - array_shift($messages); - } - + $messages = $this->chatService->getSessionMessages($this->userId, $sessionId, $limit, $cursor); return new JSONResponse(array_map(static function (Message $message) { return $message->jsonSerialize(); }, $messages)); - } catch (\OCP\DB\Exception $e) { + } catch (InternalException $e) { $this->logger->warning('Failed to get chat messages', ['exception' => $e]); return new JSONResponse(['error' => $this->l10n->t('Failed to get chat messages')], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (\OCA\Assistant\Service\NotFoundException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_NOT_FOUND); + } catch (\OCA\Assistant\Service\UnauthorizedException $e) { + return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); } } @@ -525,22 +412,16 @@ public function getMessages(int $sessionId, int $limit = 20, int $cursor = 0): J #[NoAdminRequired] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])] public function getMessage(int $sessionId, int $messageId): JSONResponse { - if ($this->userId === null) { - return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); - } - try { - $sessionExists = $this->sessionMapper->exists($this->userId, $sessionId); - if (!$sessionExists) { - return new JSONResponse(['error' => $this->l10n->t('Session not found')], Http::STATUS_NOT_FOUND); - } - - $message = $this->messageMapper->getMessageById($sessionId, $messageId); - + $message = $this->chatService->getSessionMessage($this->userId, $sessionId, $messageId); return new JSONResponse($message->jsonSerialize()); - } catch (\OCP\DB\Exception $e) { + } catch (InternalException $e) { $this->logger->warning('Failed to get chat messages', ['exception' => $e]); return new JSONResponse(['error' => $this->l10n->t('Failed to get chat message')], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (\OCA\Assistant\Service\NotFoundException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_NOT_FOUND); + } catch (\OCA\Assistant\Service\UnauthorizedException $e) { + return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); } } @@ -565,19 +446,15 @@ public function deleteMessage(int $messageId, int $sessionId): JSONResponse { } try { - $sessionExists = $this->sessionMapper->exists($this->userId, $sessionId); - if (!$sessionExists) { - return new JSONResponse(['error' => $this->l10n->t('Session not found')], Http::STATUS_NOT_FOUND); - } - $message = $this->messageMapper->getMessageById($sessionId, $messageId); - - // do not delete the related task - $this->messageMapper->deleteMessageById($sessionId, $messageId); - + $this->chatService->deleteSessionMessage($this->userId, $sessionId, $messageId); return new JSONResponse(); - } catch (\OCP\DB\Exception|\RuntimeException $e) { + } catch (InternalException $e) { $this->logger->warning('Failed to delete a chat message', ['exception' => $e]); return new JSONResponse(['error' => $this->l10n->t('Failed to delete a chat message')], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (\OCA\Assistant\Service\NotFoundException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_NOT_FOUND); + } catch (\OCA\Assistant\Service\UnauthorizedException $e) { + return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); } } @@ -602,129 +479,22 @@ public function deleteMessage(int $messageId, int $sessionId): JSONResponse { #[NoAdminRequired] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])] public function generateForSession(int $sessionId, int $agencyConfirm = 0): JSONResponse { - if ($this->userId === null) { + try { + $taskId = $this->chatService->scheduleMessageGeneration($this->userId, $sessionId, $agencyConfirm); + } catch (InternalException $e) { + $this->logger->warning('Failed to schedule message generation', ['exception' => $e]); + return new JSONResponse(['error' => $this->l10n->t('Failed to schedule message generation')], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (BadRequestException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } catch (\OCA\Assistant\Service\NotFoundException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_NOT_FOUND); + } catch (\OCA\Assistant\Service\UnauthorizedException $e) { return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); } - $sessionExists = $this->sessionMapper->exists($this->userId, $sessionId); - if (!$sessionExists) { - return new JSONResponse(['error' => $this->l10n->t('Session not found')], Http::STATUS_NOT_FOUND); - } - - if (class_exists('OCP\\TaskProcessing\\TaskTypes\\ContextAgentInteraction') - && isset($this->taskProcessingManager->getAvailableTaskTypes()[\OCP\TaskProcessing\TaskTypes\ContextAgentInteraction::ID]) - ) { - $lastUserMessage = $this->messageMapper->getLastHumanMessage($sessionId); - $session = $this->sessionMapper->getUserSession($this->userId, $sessionId); - $lastConversationToken = $session->getAgencyConversationToken() ?? '{}'; - - $lastAttachments = $lastUserMessage->jsonSerialize()['attachments']; - $audioAttachment = $lastAttachments[0] ?? null; - // see https://github.com/vimeo/psalm/issues/7980 - $isContextAgentAudioAvailable = false; - if (class_exists('OCP\\TaskProcessing\\TaskTypes\\ContextAgentAudioInteraction')) { - $isContextAgentAudioAvailable = isset($this->taskProcessingManager->getAvailableTaskTypes()[\OCP\TaskProcessing\TaskTypes\ContextAgentAudioInteraction::ID]); - } - if ($audioAttachment !== null - && isset($audioAttachment['type']) - && $audioAttachment['type'] === 'Audio' - && $isContextAgentAudioAvailable - ) { - // audio agency - $fileId = $audioAttachment['file_id']; - try { - $taskId = $this->scheduleAgencyAudioTask($fileId, $agencyConfirm, $lastConversationToken, $sessionId, $lastUserMessage->getId()); - } catch (\Exception $e) { - return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); - } - } else { - // classic agency - $prompt = $lastUserMessage->getContent(); - try { - $taskId = $this->scheduleAgencyTask($prompt, $agencyConfirm, $lastConversationToken, $sessionId); - } catch (\Exception $e) { - return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); - } - } - } else { - // classic chat - $systemPrompt = ''; - $firstMessage = $this->messageMapper->getFirstNMessages($sessionId, 1); - if ($firstMessage->getRole() === 'system') { - $systemPrompt = $firstMessage->getContent(); - } - $history = $this->getRawLastMessages($sessionId); - do { - $lastUserMessage = array_pop($history); - } while ($lastUserMessage->getRole() !== 'human'); - - $lastAttachments = $lastUserMessage->jsonSerialize()['attachments']; - $audioAttachment = $lastAttachments[0] ?? null; - $isAudioToAudioAvailable = false; - if (class_exists('OCP\\TaskProcessing\\TaskTypes\\AudioToAudioChat')) { - $isAudioToAudioAvailable = isset($this->taskProcessingManager->getAvailableTaskTypes()[\OCP\TaskProcessing\TaskTypes\AudioToAudioChat::ID]); - } - if ($audioAttachment !== null - && isset($audioAttachment['type']) - && $audioAttachment['type'] === 'Audio' - && $isAudioToAudioAvailable - ) { - // for an audio chat task, let's try to get the remote audio IDs for all the previous audio messages - $history = $this->getAudioHistory($history); - $fileId = $audioAttachment['file_id']; - try { - $taskId = $this->scheduleAudioChatTask($fileId, $systemPrompt, $history, $sessionId, $lastUserMessage->getId()); - } catch (\Exception $e) { - return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); - } - } else { - // for a text chat task, let's only use text in the history - $history = array_map(static function (Message $message) { - return json_encode([ - 'role' => $message->getRole(), - 'content' => $message->getContent(), - ]); - }, $history); - try { - $taskId = $this->scheduleLLMChatTask($lastUserMessage->getContent(), $systemPrompt, $history, $sessionId); - } catch (\Exception $e) { - return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); - } - } - } - return new JSONResponse(['taskId' => $taskId]); } - private function getAudioHistory(array $history): array { - // history is a list of JSON strings - // The content is the remote audio ID (or the transcription as fallback) - // We only use the audio ID for assistant messages, if we have one and if it's not expired - // The audio ID is found in integration_openai's AudioToAudioChat response for example - // It is an optional output of AudioToAudioChat tasks - return array_map(static function (Message $message) { - $entry = [ - 'role' => $message->getRole(), - ]; - $attachments = $message->jsonSerialize()['attachments']; - if ($message->getRole() === 'assistant' - && count($attachments) > 0 - && $attachments[0]['type'] === 'Audio' - && isset($attachments[0]['remote_audio_id']) - ) { - if (!isset($attachments[0]['remote_audio_expires_at']) - || time() < $attachments[0]['remote_audio_expires_at'] - ) { - $entry['audio'] = ['id' => $attachments[0]['remote_audio_id']]; - return json_encode($entry); - } - } - - $entry['content'] = $message->getContent(); - return json_encode($entry); - }, $history); - } - /** * Regenerate response for a message * @@ -747,26 +517,20 @@ private function getAudioHistory(array $history): array { #[NoAdminRequired] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])] public function regenerateForSession(int $sessionId, int $messageId): JSONResponse { - if ($this->userId === null) { - return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); - } - - $sessionExists = $this->sessionMapper->exists($this->userId, $sessionId); - if (!$sessionExists) { - return new JSONResponse(['error' => $this->l10n->t('Session not found')], Http::STATUS_NOT_FOUND); - } - - $message = $this->messageMapper->getMessageById($sessionId, $messageId); - - // we don't delete the related task try { - $this->messageMapper->deleteMessageById($sessionId, $messageId); - } catch (\OCP\DB\Exception|\RuntimeException $e) { + $this->chatService->deleteSessionMessage($this->userId, $sessionId, $messageId); + $taskId = $this->chatService->scheduleMessageGeneration($this->userId, $sessionId, 0); + return new JSONResponse(['taskId' => $taskId]); + } catch (InternalException $e) { $this->logger->warning('Failed to delete the last message', ['exception' => $e]); return new JSONResponse(['error' => $this->l10n->t('Failed to delete the last message')], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (\OCA\Assistant\Service\NotFoundException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_NOT_FOUND); + } catch (\OCA\Assistant\Service\UnauthorizedException $e) { + return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); + } catch (BadRequestException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); } - - return $this->generateForSession($sessionId); } /** @@ -908,10 +672,6 @@ public function checkSession(int $sessionId): JSONResponse { * * @param integer $sessionId The chat session ID * @return JSONResponse|JSONResponse - * @throws AppConfigTypeConflictException - * @throws DoesNotExistException - * @throws MultipleObjectsReturnedException - * @throws \OCP\DB\Exception * * 200: The task has been successfully scheduled * 401: Not logged in @@ -921,47 +681,18 @@ public function checkSession(int $sessionId): JSONResponse { #[NoAdminRequired] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])] public function generateTitle(int $sessionId): JSONResponse { - if ($this->userId === null) { - return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); - } - - $user = $this->userManager->get($this->userId); - if ($user === null) { - return new JSONResponse(['error' => $this->l10n->t('User not found')], Http::STATUS_UNAUTHORIZED); - } - - $sessionExists = $this->sessionMapper->exists($this->userId, $sessionId); - if (!$sessionExists) { - return new JSONResponse(['error' => $this->l10n->t('Session not found')], Http::STATUS_NOT_FOUND); - } - try { - $userInstructions = $this->appConfig->getValueString( - Application::APP_ID, - 'chat_user_instructions_title', - Application::CHAT_USER_INSTRUCTIONS_TITLE, - lazy: true, - ) ?: Application::CHAT_USER_INSTRUCTIONS_TITLE; - $userInstructions = str_replace('{user}', $user->getDisplayName(), $userInstructions); - - $history = $this->getRawLastMessages($sessionId); - // history is a list of JSON strings - $history = array_map(static function (Message $message) { - return json_encode([ - 'role' => $message->getRole(), - 'content' => $message->getContent(), - ], JSON_THROW_ON_ERROR); - }, $history); - - try { - $taskId = $this->scheduleLLMChatTask($userInstructions, $userInstructions, $history, $sessionId, false); - } catch (\Exception $e) { - return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); - } + $taskId = $this->chatService->scheduleTitleGeneration($this->userId, $sessionId); return new JSONResponse(['taskId' => $taskId]); - } catch (\OCP\DB\Exception|\JsonException $e) { + } catch (InternalException|\JsonException $e) { $this->logger->warning('Failed to generate a title for the chat session', ['exception' => $e]); - return new JSONResponse(['error' => $this->l10n->t('Failed to generate a title for the chat session')], Http::STATUS_INTERNAL_SERVER_ERROR); + return new JSONResponse(['error' => $this->l10n->t('Failed to delete the last message')], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (\OCA\Assistant\Service\NotFoundException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_NOT_FOUND); + } catch (\OCA\Assistant\Service\UnauthorizedException $e) { + return new JSONResponse(['error' => $this->l10n->t('User not logged in')], Http::STATUS_UNAUTHORIZED); + } catch (BadRequestException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); } } @@ -1035,160 +766,4 @@ public function checkTitleGenerationTask(int $taskId, int $sessionId): JSONRespo } return new JSONResponse(['error' => 'unknown_error', 'task_status' => $task->getstatus()], Http::STATUS_BAD_REQUEST); } - - /** - * Get the last N messages (assistant and user messages, avoid initial system prompt) as an array - * - * @param integer $sessionId - * @return array - * @throws AppConfigTypeConflictException - * @throws \OCP\DB\Exception - */ - private function getRawLastMessages(int $sessionId): array { - $lastNMessages = intval($this->appConfig->getValueString(Application::APP_ID, 'chat_last_n_messages', '10', lazy: true)); - $messages = $this->messageMapper->getMessages($sessionId, 0, $lastNMessages); - - if ($messages[0]->getRole() === 'system') { - array_shift($messages); - } - return $messages; - } - - private function checkIfSessionIsThinking(string $customId): void { - try { - $tasks = $this->taskProcessingManager->getUserTasksByApp($this->userId, Application::APP_ID . ':chatty-llm', $customId); - } catch (\OCP\TaskProcessing\Exception\Exception $e) { - throw new \Exception('task_query_failed'); - } - $tasks = array_filter($tasks, static function (Task $task) { - return $task->getStatus() === Task::STATUS_RUNNING || $task->getStatus() === Task::STATUS_SCHEDULED; - }); - // prevent scheduling multiple llm tasks simultaneously for one session - if (!empty($tasks)) { - throw new \Exception('session_already_thinking'); - } - } - - /** - * Schedule the LLM task - * - * @param string $newPrompt - * @param string $systemPrompt - * @param array $history - * @param int $sessionId - * @param bool $isMessage whether we want to generate a message or a session title - * @return int - * @throws Exception - * @throws PreConditionNotMetException - * @throws UnauthorizedException - * @throws ValidationException - */ - private function scheduleLLMChatTask( - string $newPrompt, string $systemPrompt, array $history, int $sessionId, bool $isMessage = true, - ): int { - $customId = ($isMessage - ? 'chatty-llm:' - : 'chatty-title:') . $sessionId; - $this->checkIfSessionIsThinking($customId); - $input = [ - 'input' => $newPrompt, - 'system_prompt' => $systemPrompt, - 'history' => $history, - ]; - if (isset($this->taskProcessingManager->getAvailableTaskTypes()[TextToTextChat::ID]['optionalInputShape']['memories'])) { - $input['memories'] = $this->sessionSummaryService->getMemories($this->userId); - } - $task = new Task(TextToTextChat::ID, $input, Application::APP_ID . ':chatty-llm', $this->userId, $customId); - $this->taskProcessingManager->scheduleTask($task); - return $task->getId() ?? 0; - } - - /** - * Schedule an agency task - * - * @param string $content - * @param int $confirmation - * @param string $conversationToken - * @param int $sessionId - * @return int - * @throws Exception - * @throws PreConditionNotMetException - * @throws UnauthorizedException - * @throws ValidationException - */ - private function scheduleAgencyTask(string $content, int $confirmation, string $conversationToken, int $sessionId): int { - $customId = 'chatty-llm:' . $sessionId; - $this->checkIfSessionIsThinking($customId); - $taskInput = [ - 'input' => $content, - 'confirmation' => $confirmation, - 'conversation_token' => $conversationToken, - ]; - /** @psalm-suppress UndefinedClass */ - if (isset($this->taskProcessingManager->getAvailableTaskTypes()[\OCP\TaskProcessing\TaskTypes\ContextAgentInteraction::ID]['optionalInputShape']['memories'])) { - $taskInput['memories'] = $this->sessionSummaryService->getMemories($this->userId); - } - /** @psalm-suppress UndefinedClass */ - $task = new Task( - \OCP\TaskProcessing\TaskTypes\ContextAgentInteraction::ID, - $taskInput, - Application::APP_ID . ':chatty-llm', - $this->userId, - $customId - ); - $this->taskProcessingManager->scheduleTask($task); - return $task->getId() ?? 0; - } - - private function scheduleAudioChatTask( - int $audioFileId, string $systemPrompt, array $history, int $sessionId, int $queryMessageId, - ): int { - $customId = 'chatty-llm:' . $sessionId . ':' . $queryMessageId; - $this->checkIfSessionIsThinking($customId); - $input = [ - 'input' => $audioFileId, - 'system_prompt' => $systemPrompt, - 'history' => $history, - ]; - /** @psalm-suppress UndefinedClass */ - if (isset($this->taskProcessingManager->getAvailableTaskTypes()[\OCP\TaskProcessing\TaskTypes\AudioToAudioChat::ID]['optionalInputShape']['memories'])) { - $input['memories'] = $this->sessionSummaryService->getMemories($this->userId); - } - /** @psalm-suppress UndefinedClass */ - $task = new Task( - \OCP\TaskProcessing\TaskTypes\AudioToAudioChat::ID, - $input, - Application::APP_ID . ':chatty-llm', - $this->userId, - $customId, - ); - $this->taskProcessingManager->scheduleTask($task); - return $task->getId() ?? 0; - } - - private function scheduleAgencyAudioTask( - int $audioFileId, int $confirmation, string $conversationToken, int $sessionId, int $queryMessageId, - ): int { - $customId = 'chatty-llm:' . $sessionId . ':' . $queryMessageId; - $this->checkIfSessionIsThinking($customId); - $taskInput = [ - 'input' => $audioFileId, - 'confirmation' => $confirmation, - 'conversation_token' => $conversationToken, - ]; - /** @psalm-suppress UndefinedClass */ - if (isset($this->taskProcessingManager->getAvailableTaskTypes()[\OCP\TaskProcessing\TaskTypes\ContextAgentAudioInteraction::ID]['optionalInputShape']['memories'])) { - $taskInput['memories'] = $this->sessionSummaryService->getMemories($this->userId); - } - /** @psalm-suppress UndefinedClass */ - $task = new Task( - \OCP\TaskProcessing\TaskTypes\ContextAgentAudioInteraction::ID, - $taskInput, - Application::APP_ID . ':chatty-llm', - $this->userId, - $customId - ); - $this->taskProcessingManager->scheduleTask($task); - return $task->getId() ?? 0; - } } diff --git a/lib/Service/BadRequestException.php b/lib/Service/BadRequestException.php new file mode 100644 index 00000000..27414a49 --- /dev/null +++ b/lib/Service/BadRequestException.php @@ -0,0 +1,6 @@ +l10n->t('Unauthorized')); + } + $user = $this->userManager->get($userId); + if ($user === null) { + throw new UnauthorizedException($this->l10n->t('User not found')); + } + + if ($timestamp > 10_000_000_000) { + $timestamp = intdiv($timestamp, 1000); + } + + $userInstructions = $this->appConfig->getValueString( + Application::APP_ID, + 'chat_user_instructions', + Application::CHAT_USER_INSTRUCTIONS, + lazy: true, + ) ?: Application::CHAT_USER_INSTRUCTIONS; + $userInstructions = str_replace('{user}', $user->getDisplayName(), $userInstructions); + + $session = new Session(); + $session->setUserId($userId); + $session->setTitle($title); + $session->setTimestamp($timestamp); + $session->setAgencyConversationToken(null); + $session->setAgencyPendingActions(null); + try { + $this->sessionMapper->insert($session); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + + $systemMsg = new Message(); + $systemMsg->setSessionId($session->getId()); + $systemMsg->setRole('system'); + $systemMsg->setAttachments('[]'); + $systemMsg->setContent($userInstructions); + $systemMsg->setTimestamp($session->getTimestamp()); + $systemMsg->setSources('[]'); + try { + $this->messageMapper->insert($systemMsg); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + + return $session; + } + + /** + * @throws InternalException + * @throws NotFoundException + * @throws UnauthorizedException + */ + public function updateSession(?string $userId, int $sessionId, ?string $title = null, ?bool $isRemembered = null): Session { + if ($userId === null) { + throw new UnauthorizedException($this->l10n->t('Unauthorized')); + } + try { + $session = $this->sessionMapper->getUserSession($userId, $sessionId); + } catch (DoesNotExistException $e) { + throw new NotFoundException($this->l10n->t('Session not found'), previous: $e); + } catch (MultipleObjectsReturnedException|Exception $e) { + throw new InternalException(previous: $e); + } + if ($title === null && $isRemembered === null) { + return $session; + } + if ($title !== null) { + $session->setTitle($title); + } + if ($isRemembered !== null) { + $session->setIsRemembered($isRemembered); + // schedule summarizer jobs for this chat user + if ($isRemembered) { + $this->sessionSummaryService->scheduleJobsForUser($userId); + } + } + try { + $this->sessionMapper->update($session); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + return $session; + } + + /** + * @throws InternalException + * @throws UnauthorizedException + */ + public function deleteSession(?string $userId, int $sessionId): void { + if ($userId === null) { + throw new UnauthorizedException($this->l10n->t('Unauthorized')); + } + + try { + $this->sessionMapper->deleteSession($userId, $sessionId); + $this->messageMapper->deleteMessagesBySession($sessionId); + } catch (Exception|\RuntimeException $e) { + throw new InternalException(previous: $e); + } + } + + /** + * @return list + * @throws InternalException + * @throws UnauthorizedException + */ + public function getSessionsForUser(?string $userId): array { + if ($userId === null) { + throw new UnauthorizedException($this->l10n->t('Unauthorized')); + } + try { + return $this->sessionMapper->getUserSessions($userId); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + } + + /** + * @throws BadRequestException + * @throws InternalException + * @throws NotFoundException + * @throws UnauthorizedException + */ + public function createMessage(?string $userId, int $sessionId, string $role, string $content, int $timestamp, ?array $attachments, bool $firstHumanMessage = false): Message { + if ($userId === null) { + throw new UnauthorizedException($this->l10n->t('Unauthorized')); + } + + if (strlen($content) > Application::MAX_TEXT_INPUT_LENGTH) { + throw new BadRequestException($this->l10n->t('The new message is too long')); + } + + if ($timestamp > 10_000_000_000) { + $timestamp = intdiv($timestamp, 1000); + } + + // refuse empty text content if context agent is not available (we do classic chat) AND there is no attachment + // in other words: accept empty content if we are using agency OR there are attachments + $content = trim($content); + if (empty($content) + && !$this->isContextAgentAvailable() + && $attachments === null + ) { + throw new BadRequestException($this->l10n->t('Message content is empty')); + } + + try { + $sessionExists = $this->sessionMapper->exists($userId, $sessionId); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + if (!$sessionExists) { + throw new NotFoundException($this->l10n->t('Session not found')); + } + + $message = new Message(); + $message->setSessionId($sessionId); + $message->setRole($role); + $message->setContent($content); + $message->setTimestamp($timestamp); + $message->setSources('[]'); + $message->setAttachments('[]'); + if ($attachments !== null) { + try { + $encodedAttachments = json_encode($attachments, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new BadRequestException($this->l10n->t('Failed to encode attachments')); + } + if ($encodedAttachments !== false) { + $message->setAttachments($encodedAttachments); + } + } + try { + $this->messageMapper->insert($message); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + if ($firstHumanMessage) { + // set the title of the session based on first human message + try { + $this->sessionMapper->updateSessionTitle( + $userId, + $sessionId, + strlen($content) > 140 ? mb_substr($content, 0, 140) . '...' : $content, + ); + } catch (Exception $e) { + $this->logger->error('Failed to update session title', ['exception' => $e]); + // pass as the main operation succeeded + } + } + return $message; + } + + /** + * @return list + * @throws InternalException + * @throws NotFoundException + * @throws UnauthorizedException + */ + public function getSessionMessages(?string $userId, int $sessionId, $limit = 20, int $cursor = 0): array { + if ($userId === null) { + throw new UnauthorizedException($this->l10n->t('Unauthorized')); + } + + try { + $sessionExists = $this->sessionMapper->exists($userId, $sessionId); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + if (!$sessionExists) { + throw new NotFoundException($this->l10n->t('Session not found')); + } + + /** @var list $messages */ + try { + $messages = $this->messageMapper->getMessages($sessionId, $cursor, $limit); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + if ($messages[0]->getRole() === 'system') { + array_shift($messages); + } + + return $messages; + } + + /** + * @throws InternalException + * @throws NotFoundException + * @throws UnauthorizedException + */ + public function getSessionMessage(?string $userId, int $sessionId, int $messageId): Message { + if ($userId === null) { + throw new UnauthorizedException($this->l10n->t('Unauthorized')); + } + + try { + $sessionExists = $this->sessionMapper->exists($userId, $sessionId); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + if (!$sessionExists) { + throw new NotFoundException($this->l10n->t('Session not found')); + } + try { + return $this->messageMapper->getMessageById($sessionId, $messageId); + } catch (DoesNotExistException $e) { + throw new NotFoundException($this->l10n->t('Message not found'), previous: $e); + } catch (MultipleObjectsReturnedException|Exception $e) { + throw new InternalException(previous: $e); + } + } + + /** + * @throws InternalException + * @throws NotFoundException + * @throws UnauthorizedException + */ + public function deleteSessionMessage(?string $userId, int $sessionId, int $messageId): void { + if ($userId === null) { + throw new UnauthorizedException($this->l10n->t('Unauthorized')); + } + try { + $sessionExists = $this->sessionMapper->exists($userId, $sessionId); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + if (!$sessionExists) { + throw new NotFoundException($this->l10n->t('Session not found')); + } + + try { + $this->messageMapper->deleteMessageById($sessionId, $messageId); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + } + + /** + * @throws InternalException + * @throws BadRequestException + * @throws NotFoundException + * @throws UnauthorizedException + */ + public function scheduleMessageGeneration(?string $userId, int $sessionId, int $agencyConfirm = 0): int { + if ($userId === null) { + throw new UnauthorizedException($this->l10n->t('Unauthorized')); + } + try { + $sessionExists = $this->sessionMapper->exists($userId, $sessionId); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + if (!$sessionExists) { + throw new NotFoundException($this->l10n->t('Session not found')); + } + + if ($this->isContextAgentAvailable()) { + try { + $lastUserMessage = $this->messageMapper->getLastHumanMessage($sessionId); + } catch (DoesNotExistException $e) { + throw new NotFoundException($this->l10n->t('No user message found in this session'), previous: $e); + } catch (MultipleObjectsReturnedException|Exception $e) { + throw new InternalException(previous: $e); + } + + + try { + $session = $this->sessionMapper->getUserSession($userId, $sessionId); + } catch (DoesNotExistException $e) { + throw new NotFoundException($this->l10n->t('Session not found'), previous: $e); + } catch (MultipleObjectsReturnedException|Exception $e) { + throw new InternalException(previous: $e); + } + $lastConversationToken = $session->getAgencyConversationToken() ?? '{}'; + + $lastAttachments = $lastUserMessage->jsonSerialize()['attachments']; + $audioAttachment = $lastAttachments[0] ?? null; + $isContextAgentAudioAvailable = $this->isContextAgentAudioAvailable(); + if ($audioAttachment !== null + && isset($audioAttachment['type']) + && $audioAttachment['type'] === 'Audio' + && $isContextAgentAudioAvailable + ) { + // audio agency + $fileId = $audioAttachment['file_id']; + $taskId = $this->scheduleAgencyAudioTask($userId, $fileId, $agencyConfirm, $lastConversationToken, $sessionId, $lastUserMessage->getId()); + } else { + // classic agency + $prompt = $lastUserMessage->getContent(); + $taskId = $this->scheduleAgencyTask($userId, $prompt, $agencyConfirm, $lastConversationToken, $sessionId); + } + } else { + // classic chat + $systemPrompt = ''; + try { + $firstMessage = $this->messageMapper->getFirstNMessages($sessionId, 1); + } catch (DoesNotExistException $e) { + throw new NotFoundException($this->l10n->t('No message found in this session'), previous: $e); + } catch (MultipleObjectsReturnedException|Exception $e) { + throw new InternalException(previous: $e); + } + if ($firstMessage->getRole() === 'system') { + $systemPrompt = $firstMessage->getContent(); + } + try { + $history = $this->getRawLastMessages($sessionId); + } catch (Exception|AppConfigTypeConflictException $e) { + throw new InternalException(previous: $e); + } + do { + $lastUserMessage = array_pop($history); + } while ($lastUserMessage->getRole() !== 'human'); + + $lastAttachments = $lastUserMessage->jsonSerialize()['attachments']; + $audioAttachment = $lastAttachments[0] ?? null; + $isAudioToAudioAvailable = $this->isContextAgentAudioAvailable(); + if ($audioAttachment !== null + && isset($audioAttachment['type']) + && $audioAttachment['type'] === 'Audio' + && $isAudioToAudioAvailable + ) { + // for an audio chat task, let's try to get the remote audio IDs for all the previous audio messages + $history = $this->getAudioHistory($history); + $fileId = $audioAttachment['file_id']; + $taskId = $this->scheduleAudioChatTask($userId, $fileId, $systemPrompt, $history, $sessionId, $lastUserMessage->getId()); + } else { + // for a text chat task, let's only use text in the history + $history = array_map(static function (Message $message) { + return json_encode([ + 'role' => $message->getRole(), + 'content' => $message->getContent(), + ]); + }, $history); + $taskId = $this->scheduleLLMChatTask($userId, $lastUserMessage->getContent(), $systemPrompt, $history, $sessionId); + } + } + return $taskId; + } + + /** + * @throws BadRequestException + * @throws InternalException + * @throws NotFoundException + * @throws UnauthorizedException|\JsonException + */ + public function scheduleTitleGeneration(?string $userId, int $sessionId): int { + if ($userId === null) { + throw new UnauthorizedException($this->l10n->t('Unauthorized')); + } + try { + $sessionExists = $this->sessionMapper->exists($userId, $sessionId); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + if (!$sessionExists) { + throw new NotFoundException($this->l10n->t('Session not found')); + } + + $user = $this->userManager->get($userId); + if ($user === null) { + throw new InternalException($this->l10n->t('User not found')); + } + + $userInstructions = $this->appConfig->getValueString( + Application::APP_ID, + 'chat_user_instructions_title', + Application::CHAT_USER_INSTRUCTIONS_TITLE, + lazy: true, + ) ?: Application::CHAT_USER_INSTRUCTIONS_TITLE; + $userInstructions = str_replace('{user}', $user->getDisplayName(), $userInstructions); + + try { + $history = $this->getRawLastMessages($sessionId); + } catch (Exception|AppConfigTypeConflictException $e) { + throw new InternalException(previous: $e); + } + // history is a list of JSON strings + $history = array_map(static function (Message $message) { + return json_encode([ + 'role' => $message->getRole(), + 'content' => $message->getContent(), + ], JSON_THROW_ON_ERROR); + }, $history); + return $this->scheduleLLMChatTask($userId, $userInstructions, $userInstructions, $history, $sessionId, false); + } + + public function isContextAgentAvailable(): bool { + if (!class_exists('OCP\\TaskProcessing\\TaskTypes\\ContextAgentInteraction')) { + return false; + } + return in_array(\OCP\TaskProcessing\TaskTypes\ContextAgentInteraction::ID, $this->taskProcessingManager->getAvailableTaskTypeIds()); + } + + public function isContextAgentAudioAvailable(): bool { + if (!class_exists('OCP\\TaskProcessing\\TaskTypes\\ContextAgentAudioInteraction')) { + return false; + } + return in_array(\OCP\TaskProcessing\TaskTypes\ContextAgentAudioInteraction::ID, $this->taskProcessingManager->getAvailableTaskTypeIds()); + } + + + private function getAudioHistory(array $history): array { + // history is a list of JSON strings + // The content is the remote audio ID (or the transcription as fallback) + // We only use the audio ID for assistant messages, if we have one and if it's not expired + // The audio ID is found in integration_openai's AudioToAudioChat response for example + // It is an optional output of AudioToAudioChat tasks + return array_map(static function (Message $message) { + $entry = [ + 'role' => $message->getRole(), + ]; + $attachments = $message->jsonSerialize()['attachments']; + if ($message->getRole() === 'assistant' + && count($attachments) > 0 + && $attachments[0]['type'] === 'Audio' + && isset($attachments[0]['remote_audio_id']) + ) { + if (!isset($attachments[0]['remote_audio_expires_at']) + || time() < $attachments[0]['remote_audio_expires_at'] + ) { + $entry['audio'] = ['id' => $attachments[0]['remote_audio_id']]; + return json_encode($entry); + } + } + + $entry['content'] = $message->getContent(); + return json_encode($entry); + }, $history); + } + + /** + * Get the last N messages (assistant and user messages, avoid initial system prompt) as an array + * + * @param integer $sessionId + * @return array + * @throws AppConfigTypeConflictException + * @throws \OCP\DB\Exception + */ + private function getRawLastMessages(int $sessionId): array { + $lastNMessages = (int)$this->appConfig->getValueString(Application::APP_ID, 'chat_last_n_messages', '10', lazy: true); + $messages = $this->messageMapper->getMessages($sessionId, 0, $lastNMessages); + + if ($messages[0]->getRole() === 'system') { + array_shift($messages); + } + return $messages; + } + + /** + * @param string|null $userId + * @param string $customId + * @return void + * @throws BadRequestException + * @throws InternalException + */ + private function checkIfSessionIsThinking(?string $userId, string $customId): void { + try { + $tasks = $this->taskProcessingManager->getUserTasksByApp($userId, Application::APP_ID . ':chatty-llm', $customId); + } catch (\OCP\TaskProcessing\Exception\Exception $e) { + throw new BadRequestException('task_query_failed', previous: $e); + } catch (\JsonException $e) { + throw new InternalException(previous: $e); + } + $tasks = array_filter($tasks, static function (Task $task) { + return $task->getStatus() === Task::STATUS_RUNNING || $task->getStatus() === Task::STATUS_SCHEDULED; + }); + // prevent scheduling multiple llm tasks simultaneously for one session + if (!empty($tasks)) { + throw new BadRequestException('session_already_thinking'); + } + } + + /** + * Schedule a Chat task + * + * @throws BadRequestException + * @throws InternalException + */ + private function scheduleLLMChatTask( + ?string $userId, + string $newPrompt, + string $systemPrompt, + array $history, + int $sessionId, + bool $isMessage = true, + ): int { + $customId = ($isMessage + ? 'chatty-llm:' + : 'chatty-title:') . $sessionId; + $this->checkIfSessionIsThinking($userId, $customId); + $input = [ + 'input' => $newPrompt, + 'system_prompt' => $systemPrompt, + 'history' => $history, + ]; + if (isset($this->taskProcessingManager->getAvailableTaskTypes()[TextToTextChat::ID]['optionalInputShape']['memories'])) { + $input['memories'] = $this->sessionSummaryService->getMemories($userId); + } + $task = new Task(TextToTextChat::ID, $input, Application::APP_ID . ':chatty-llm', $userId, $customId); + try { + $this->taskProcessingManager->scheduleTask($task); + } catch (PreConditionNotMetException $e) { + throw new BadRequestException('pre_condition_not_met', previous: $e); + } catch (\OCP\TaskProcessing\Exception\UnauthorizedException $e) { + throw new BadRequestException('unauthorized', previous: $e); + } catch (ValidationException $e) { + throw new BadRequestException('validation_failed', previous: $e); + } catch (\OCP\TaskProcessing\Exception\Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalException(previous: $e); + } + return $task->getId() ?? 0; + } + + /** + * Schedule an agency chat task + * + * @throws BadRequestException + * @throws InternalException + */ + private function scheduleAgencyTask( + ?string $userId, + string $content, + int $confirmation, + string $conversationToken, + int $sessionId, + ): int { + $customId = 'chatty-llm:' . $sessionId; + $this->checkIfSessionIsThinking($userId, $customId); + $taskInput = [ + 'input' => $content, + 'confirmation' => $confirmation, + 'conversation_token' => $conversationToken, + ]; + /** @psalm-suppress UndefinedClass */ + if (isset($this->taskProcessingManager->getAvailableTaskTypes()[\OCP\TaskProcessing\TaskTypes\ContextAgentInteraction::ID]['optionalInputShape']['memories'])) { + $taskInput['memories'] = $this->sessionSummaryService->getMemories($userId); + } + /** @psalm-suppress UndefinedClass */ + $task = new Task( + \OCP\TaskProcessing\TaskTypes\ContextAgentInteraction::ID, + $taskInput, + Application::APP_ID . ':chatty-llm', + $userId, + $customId + ); + try { + $this->taskProcessingManager->scheduleTask($task); + } catch (PreConditionNotMetException $e) { + throw new BadRequestException('pre_condition_not_met', previous: $e); + } catch (\OCP\TaskProcessing\Exception\UnauthorizedException $e) { + throw new BadRequestException('unauthorized', previous: $e); + } catch (ValidationException $e) { + throw new BadRequestException('validation_failed', previous: $e); + } catch (\OCP\TaskProcessing\Exception\Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalException(previous: $e); + } + return $task->getId() ?? 0; + } + + /** + * Schedule an audio chat task + * @throws BadRequestException + * @throws InternalException + */ + private function scheduleAudioChatTask( + ?string $userId, + int $audioFileId, + string $systemPrompt, + array $history, + int $sessionId, + int $queryMessageId, + ): int { + $customId = 'chatty-llm:' . $sessionId . ':' . $queryMessageId; + $this->checkIfSessionIsThinking($userId, $customId); + $input = [ + 'input' => $audioFileId, + 'system_prompt' => $systemPrompt, + 'history' => $history, + ]; + /** @psalm-suppress UndefinedClass */ + if (isset($this->taskProcessingManager->getAvailableTaskTypes()[\OCP\TaskProcessing\TaskTypes\AudioToAudioChat::ID]['optionalInputShape']['memories'])) { + $input['memories'] = $this->sessionSummaryService->getMemories($userId); + } + /** @psalm-suppress UndefinedClass */ + $task = new Task( + \OCP\TaskProcessing\TaskTypes\AudioToAudioChat::ID, + $input, + Application::APP_ID . ':chatty-llm', + $userId, + $customId, + ); + try { + $this->taskProcessingManager->scheduleTask($task); + } catch (PreConditionNotMetException $e) { + throw new BadRequestException('pre_condition_not_met', previous: $e); + } catch (\OCP\TaskProcessing\Exception\UnauthorizedException $e) { + throw new BadRequestException('unauthorized', previous: $e); + } catch (ValidationException $e) { + throw new BadRequestException('validation_failed', previous: $e); + } catch (\OCP\TaskProcessing\Exception\Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalException(previous: $e); + } + return $task->getId() ?? 0; + } + + /** + * Schedule an agency audio chat task + * @throws BadRequestException + * @throws InternalException + */ + private function scheduleAgencyAudioTask( + ?string $userId, + int $audioFileId, + int $confirmation, + string $conversationToken, + int $sessionId, + int $queryMessageId, + ): int { + $customId = 'chatty-llm:' . $sessionId . ':' . $queryMessageId; + $this->checkIfSessionIsThinking($userId, $customId); + $taskInput = [ + 'input' => $audioFileId, + 'confirmation' => $confirmation, + 'conversation_token' => $conversationToken, + ]; + /** @psalm-suppress UndefinedClass */ + if (isset($this->taskProcessingManager->getAvailableTaskTypes()[\OCP\TaskProcessing\TaskTypes\ContextAgentAudioInteraction::ID]['optionalInputShape']['memories'])) { + $taskInput['memories'] = $this->sessionSummaryService->getMemories($userId); + } + /** @psalm-suppress UndefinedClass */ + $task = new Task( + \OCP\TaskProcessing\TaskTypes\ContextAgentAudioInteraction::ID, + $taskInput, + Application::APP_ID . ':chatty-llm', + $userId, + $customId + ); + try { + $this->taskProcessingManager->scheduleTask($task); + } catch (PreConditionNotMetException $e) { + throw new BadRequestException('pre_condition_not_met', previous: $e); + } catch (\OCP\TaskProcessing\Exception\UnauthorizedException $e) { + throw new BadRequestException('unauthorized', previous: $e); + } catch (ValidationException $e) { + throw new BadRequestException('validation_failed', previous: $e); + } catch (\OCP\TaskProcessing\Exception\Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalException(previous: $e); + } + return $task->getId() ?? 0; + } +} diff --git a/lib/Service/InternalException.php b/lib/Service/InternalException.php new file mode 100644 index 00000000..ad4463c3 --- /dev/null +++ b/lib/Service/InternalException.php @@ -0,0 +1,6 @@ + Date: Tue, 5 May 2026 09:55:16 +0200 Subject: [PATCH 03/22] feat(Assignments): Implement creating and running assignments Signed-off-by: Marcel Klehr --- lib/BackgroundJob/RunAssignmentsJob.php | 14 +++- lib/Controller/AssignmentsApiController.php | 28 ++++++++ lib/Db/ChattyLLM/Message.php | 2 + lib/Db/ChattyLLM/MessageMapper.php | 4 +- lib/Service/AssignmentsService.php | 79 ++++++++++++++++++--- lib/Service/ChatService.php | 4 +- 6 files changed, 113 insertions(+), 18 deletions(-) diff --git a/lib/BackgroundJob/RunAssignmentsJob.php b/lib/BackgroundJob/RunAssignmentsJob.php index 97cb4f1f..3d0656a2 100644 --- a/lib/BackgroundJob/RunAssignmentsJob.php +++ b/lib/BackgroundJob/RunAssignmentsJob.php @@ -9,13 +9,17 @@ namespace OCA\Assistant\BackgroundJob; +use OCA\Assistant\Service\AssignmentsService; +use OCA\Assistant\Service\InternalException; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\TimedJob; +use Psr\Log\LoggerInterface; -class GenerateNewChatSummaries extends TimedJob { +class RunAssignmentsJob extends TimedJob { public function __construct( ITimeFactory $timeFactory, - private AssignmentService $assignmentService, + private AssignmentsService $assignmentService, + private LoggerInterface $logger, ) { parent::__construct($timeFactory); $this->setAllowParallelRuns(true); @@ -24,6 +28,10 @@ public function __construct( } public function run($argument) { $userId = $argument['userId']; - $this->assignmentService->generateSummariesForNewSessions($userId); + try { + $this->assignmentService->runDueAssignmentsForUser($userId); + } catch (InternalException $e) { + $this->logger->error('Error running assignments for user ' . $userId, ['exception' => $e]); + } } } diff --git a/lib/Controller/AssignmentsApiController.php b/lib/Controller/AssignmentsApiController.php index 97b56d36..0c1a6dcb 100644 --- a/lib/Controller/AssignmentsApiController.php +++ b/lib/Controller/AssignmentsApiController.php @@ -10,6 +10,9 @@ use OCA\Assistant\Db\Assignment; use OCA\Assistant\Db\ChattyLLM\AssignmentMapper; use OCA\Assistant\ResponseDefinitions; +use OCA\Assistant\Service\AssignmentsService; +use OCA\Assistant\Service\InternalException; +use OCA\Assistant\Service\UnauthorizedException; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\AppFramework\Http; @@ -34,10 +37,35 @@ public function __construct( private AssignmentMapper $assignmentMapper, private LoggerInterface $logger, private ITimeFactory $timeFactory, + private AssignmentsService $assignmentsService, ) { parent::__construct($appName, $request); } + /** + * Create a new assignment + * + * @return DataResponse|DataResponse + * + * 200: User assignments returned + * 403: User not logged in + */ + #[NoAdminRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['assignments'])] + #[Http\Attribute\ApiRoute(verb: 'POST', url: '/assignments')] + public function createUserAssignment(string $prompt, int $startsAt, string $recurrence): DataResponse { + try { + $assignment = $this->assignmentsService->createAssignment($this->userId, $prompt, $startsAt, $recurrence); + $serializedAssignment = $assignment->jsonSerialize(); + return new DataResponse(['assignment' => $serializedAssignment]); + } catch (InternalException $e) { + $this->logger->error('Error while fetching assignments for user ' . $this->userId, ['exception' => $e]); + return new DataResponse('', Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (UnauthorizedException $e) { + return new DataResponse('', Http::STATUS_FORBIDDEN); + } + } + /** * Get user's assignments * diff --git a/lib/Db/ChattyLLM/Message.php b/lib/Db/ChattyLLM/Message.php index b60252bb..065413d1 100644 --- a/lib/Db/ChattyLLM/Message.php +++ b/lib/Db/ChattyLLM/Message.php @@ -29,6 +29,8 @@ * @method \void setAttachments(?string $attachments) */ class Message extends Entity implements \JsonSerializable { + public const ROLE_HUMAN = 'human'; + /** @var int */ protected $sessionId; /** @var string */ diff --git a/lib/Db/ChattyLLM/MessageMapper.php b/lib/Db/ChattyLLM/MessageMapper.php index 44ff6192..4f338f04 100644 --- a/lib/Db/ChattyLLM/MessageMapper.php +++ b/lib/Db/ChattyLLM/MessageMapper.php @@ -56,7 +56,7 @@ public function getLastHumanMessage(int $sessionId): Message { $qb->select(Message::$columns) ->from($this->getTableName()) ->where($qb->expr()->eq('session_id', $qb->createPositionalParameter($sessionId, IQueryBuilder::PARAM_INT))) - ->andWhere($qb->expr()->eq('role', $qb->createPositionalParameter('human', IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->eq('role', $qb->createPositionalParameter(Message::ROLE_HUMAN, IQueryBuilder::PARAM_STR))) ->orderBy('timestamp', 'DESC') ->setMaxResults(1); @@ -68,7 +68,7 @@ public function getLastNonEmptyHumanMessage(int $sessionId): Message { $qb->select(Message::$columns) ->from($this->getTableName()) ->where($qb->expr()->eq('session_id', $qb->createPositionalParameter($sessionId, IQueryBuilder::PARAM_INT))) - ->andWhere($qb->expr()->eq('role', $qb->createPositionalParameter('human', IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->eq('role', $qb->createPositionalParameter(Message::ROLE_HUMAN, IQueryBuilder::PARAM_STR))) ->andWhere($qb->expr()->nonEmptyString('content')) ->orderBy('timestamp', 'DESC') ->setMaxResults(1); diff --git a/lib/Service/AssignmentsService.php b/lib/Service/AssignmentsService.php index 50a5de67..bbef730c 100644 --- a/lib/Service/AssignmentsService.php +++ b/lib/Service/AssignmentsService.php @@ -9,39 +9,96 @@ namespace OCA\Assistant\Service; +use OCA\Assistant\BackgroundJob\RunAssignmentsJob; +use OCA\Assistant\Db\Assignment; use OCA\Assistant\Db\ChattyLLM\AssignmentMapper; -use OCA\Assistant\Db\ChattyLLM\MessageMapper; +use OCA\Assistant\Db\ChattyLLM\Message; use OCA\Assistant\Db\ChattyLLM\SessionMapper; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\IJobList; use OCP\DB\Exception; -use OCP\IAppConfig; use Psr\Log\LoggerInterface; class AssignmentsService { public function __construct( private AssignmentMapper $assignmentMapper, private SessionMapper $sessionMapper, - private MessageMapper $messageMapper, - private TaskProcessingService $taskProcessingService, + private ChatService $chatService, + private ITimeFactory $timeFactory, private LoggerInterface $logger, - private IAppConfig $appConfig, private IJobList $jobList, ) { } - public function runDueAssignmentsForUser(string $userId) { + /** + * @throws InternalException + * @throws UnauthorizedException + */ + public function createAssignment(?string $userId, string $prompt, int $startsAt, string $recurrence): Assignment { + if ($userId === null) { + throw new UnauthorizedException(); + } + $assignment = new Assignment(); + $assignment->setUserId($userId); + $assignment->setPrompt($prompt); + $assignment->setStartsAt($startsAt); + $assignment->setRecurrence($recurrence); + try { + $this->assignmentMapper->insert($assignment); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + $session = $this->chatService->createChatSession($userId, $this->timeFactory->now()->getTimestamp(), 'Assignment ' . $assignment->getId()); // TODO: Add a proper title here + $session->setAssignmentId($assignment->getId()); + try { + $this->sessionMapper->update($session); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + if (!$this->jobList->has(RunAssignmentsJob::class, ['userId' => $userId])) { + $this->jobList->add(RunAssignmentsJob::class, ['userId' => $userId]); + } + return $assignment; + } + + /** + * @throws InternalException + */ + public function runDueAssignmentsForUser(?string $userId) { try { foreach ($this->assignmentMapper->findDueAssignmentsForUser($userId) as $assignment) { - try { - $session = $this->sessionMapper->getUserSessionForAssignment($userId, $assignment->getId()); - } catch (DoesNotExistException $e) { - } catch (MultipleObjectsReturnedException $e) { - } + $this->scheduleAssignmentRun($userId, $assignment->getId()); } } catch (Exception $e) { + throw new InternalException(previous: $e); + } + } + public function scheduleAssignmentRun(?string $userId, int $assignmentId): void { + try { + try { + $session = $this->sessionMapper->getUserSessionForAssignment($userId, $assignmentId); + } catch (DoesNotExistException $e) { + throw new NotFoundException(previous: $e); + } catch (MultipleObjectsReturnedException $e) { + throw new InternalException(previous: $e); + } + $assignment = $this->assignmentMapper->find($userId, $assignmentId); + $this->chatService->createMessage($userId, $session->getId(), Message::ROLE_HUMAN, $assignment->getPrompt(), $this->timeFactory->now()->getTimestamp()); + $this->chatService->scheduleMessageGeneration($userId, $session->getId()); + } catch (BadRequestException|InternalException|DoesNotExistException|MultipleObjectsReturnedException|Exception $e) { + $this->logger->error('Error while running assignment ' . $assignment->getId() . ' for user ' . $userId, ['exception' => $e]); + } catch (NotFoundException $e) { + try { + $this->assignmentMapper->delete($assignment); + } catch (Exception $e) { + $this->logger->error('Error while deleting assignment ' . $assignment->getId() . ' for user ' . $userId, ['exception' => $e]); + } + } catch (UnauthorizedException $e) { + // this should not happen + $this->logger->error('Unauthorized to run assignment ' . $assignment->getId() . ' for user ' . $userId, ['exception' => $e]); } } } diff --git a/lib/Service/ChatService.php b/lib/Service/ChatService.php index b758087d..ff127c08 100644 --- a/lib/Service/ChatService.php +++ b/lib/Service/ChatService.php @@ -164,7 +164,7 @@ public function getSessionsForUser(?string $userId): array { * @throws NotFoundException * @throws UnauthorizedException */ - public function createMessage(?string $userId, int $sessionId, string $role, string $content, int $timestamp, ?array $attachments, bool $firstHumanMessage = false): Message { + public function createMessage(?string $userId, int $sessionId, string $role, string $content, int $timestamp, ?array $attachments = null, bool $firstHumanMessage = false): Message { if ($userId === null) { throw new UnauthorizedException($this->l10n->t('Unauthorized')); } @@ -393,7 +393,7 @@ public function scheduleMessageGeneration(?string $userId, int $sessionId, int $ } do { $lastUserMessage = array_pop($history); - } while ($lastUserMessage->getRole() !== 'human'); + } while ($lastUserMessage->getRole() !== Message::ROLE_HUMAN); $lastAttachments = $lastUserMessage->jsonSerialize()['attachments']; $audioAttachment = $lastAttachments[0] ?? null; From 882d471b13f0caac59d20ed5d52a7edd80d43893 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 5 May 2026 10:27:29 +0200 Subject: [PATCH 04/22] feat(Assignments): Implement recurrence rule checking Signed-off-by: Marcel Klehr --- composer.json | 1 + composer.lock | 275 +++++++++++++++++++- lib/Controller/AssignmentsApiController.php | 7 +- lib/Db/Assignment.php | 42 ++- lib/Service/AssignmentsService.php | 7 +- 5 files changed, 326 insertions(+), 6 deletions(-) diff --git a/composer.json b/composer.json index 0b4f1448..6c9d816e 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "html2text/html2text": "^4.3", "phpoffice/phpword": "^1.2", "ralouphie/mimey": "^1.0", + "simshaun/recurr": "^5.0", "smalot/pdfparser": "^2.11" }, "scripts": { diff --git a/composer.lock b/composer.lock index 0c7e6a5f..2cfb9b18 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,142 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1044674e3dc6cc194dc15097e173000b", + "content-hash": "8b9a5743024889b768ae6fc5144429d8", "packages": [ + { + "name": "doctrine/collections", + "version": "2.6.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/collections.git", + "reference": "7713da39d8e237f28411d6a616a3dce5e20d5de2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/collections/zipball/7713da39d8e237f28411d6a616a3dce5e20d5de2", + "reference": "7713da39d8e237f28411d6a616a3dce5e20d5de2", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1", + "php": "^8.1", + "symfony/polyfill-php84": "^1.30" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "ext-json": "*", + "phpstan/phpstan": "^2.1.30", + "phpstan/phpstan-phpunit": "^2.0.7", + "phpunit/phpunit": "^10.5.58 || ^11.5.42 || ^12.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Collections\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Collections library that adds additional functionality on top of PHP arrays.", + "homepage": "https://www.doctrine-project.org/projects/collections.html", + "keywords": [ + "array", + "collections", + "iterators", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/collections/issues", + "source": "https://github.com/doctrine/collections/tree/2.6.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fcollections", + "type": "tidelift" + } + ], + "time": "2026-01-15T10:01:58+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, { "name": "erusev/parsedown", "version": "1.8.0", @@ -351,6 +485,65 @@ }, "time": "2016-09-28T03:36:23+00:00" }, + { + "name": "simshaun/recurr", + "version": "v5.0.3", + "source": { + "type": "git", + "url": "https://github.com/simshaun/recurr.git", + "reference": "7b136768d64f257065e38a804ee6d2f9af6ba6d1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/simshaun/recurr/zipball/7b136768d64f257065e38a804ee6d2f9af6ba6d1", + "reference": "7b136768d64f257065e38a804ee6d2f9af6ba6d1", + "shasum": "" + }, + "require": { + "doctrine/collections": "~1.6||^2.0", + "php": "^7.2||^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.16", + "symfony/yaml": "^5.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Recurr\\": "src/Recurr/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Shaun Simmons", + "email": "shaun@shaun.pub", + "homepage": "https://shaun.pub" + } + ], + "description": "PHP library for working with recurrence rules", + "homepage": "https://github.com/simshaun/recurr", + "keywords": [ + "dates", + "events", + "recurrence", + "recurring", + "rrule" + ], + "support": { + "issues": "https://github.com/simshaun/recurr/issues", + "source": "https://github.com/simshaun/recurr/tree/v5.0.3" + }, + "time": "2024-12-12T15:39:24+00:00" + }, { "name": "smalot/pdfparser", "version": "v2.12.4", @@ -486,6 +679,86 @@ } ], "time": "2024-12-23T08:48:59+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06", + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T18:47:49+00:00" } ], "packages-dev": [ diff --git a/lib/Controller/AssignmentsApiController.php b/lib/Controller/AssignmentsApiController.php index 0c1a6dcb..86793dd0 100644 --- a/lib/Controller/AssignmentsApiController.php +++ b/lib/Controller/AssignmentsApiController.php @@ -132,6 +132,7 @@ public function getUserAssignment(int $id): DataResponse { * * 200: User tasks returned * 403: User not logged in + * 400: Malformed recurrence rule */ #[NoAdminRequired] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['assignments'])] @@ -144,7 +145,11 @@ public function updateUserAssignment(int $id, ?string $prompt, ?string $recurren $assignment->setPrompt($prompt); } if ($recurrence !== null) { - $assignment->setRecurrence($recurrence); + try { + $assignment->setRecurrence($recurrence); + } catch (\InvalidArgumentException $e) { + return new DataResponse('', HTTP::STATUS_BAD_REQUEST); + } } if ($startsAt !== null) { $assignment->setStartsAt($startsAt); diff --git a/lib/Db/Assignment.php b/lib/Db/Assignment.php index fe0aaf6f..550c604b 100644 --- a/lib/Db/Assignment.php +++ b/lib/Db/Assignment.php @@ -11,6 +11,13 @@ use OCP\AppFramework\Db\Entity; use OCP\DB\Types; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\NotFoundExceptionInterface; +use Recurr\Exception\InvalidRRule; +use Recurr\RecurrenceCollection; +use Recurr\Rule; +use Recurr\Transformer\Constraint\AfterConstraint; +use function OCP\Log\logger; /** * @method \string getUserId() @@ -18,7 +25,6 @@ * @method \string getPrompt() * @method \void setPrompt(string $prompt) * @method \string getRecurrence() - * @method \void setRecurrence(string $recurrence) * @method \int getStartsAt() * @method \void setStartsAt(int $startsAt) * @method \int getCreatedAt() @@ -89,11 +95,41 @@ public function jsonSerialize() { ]; } + /** + * @throws \InvalidArgumentException + */ + public function setRecurrence(string $recurrence): void { + try { + new Rule($recurrence); + } catch (InvalidRRule $e) { + throw new \InvalidArgumentException('Invalid recurrence rule: ' . $recurrence, previous: $e); + } + $this->setter('recurrence', [$recurrence]); + } + /** * Evaluates the recurrence rule and checks if a run is due */ public function isDueToRun(\DateTimeImmutable $now): bool { - // TODO: Use an actual algorithm here - return true; + try { + $startsAt = new \DateTime('@' . $this->getStartsAt()); + // Find recurrences after the last run or after the current time if this assignment has never run + $rule = new Rule($this->getRecurrence(), $startsAt); + $transformer = new \Recurr\Transformer\ArrayTransformer(); + $constraint = new AfterConstraint($this->getLastRunAt() !== 0 ? new \DateTime('@' . $this->getLastRunAt()) : $startsAt, true); + /** @var RecurrenceCollection $collection */ + $collection = $transformer->transform($rule, $constraint); + if ($collection->isEmpty()) { + return false; + } + $nextRecurrence = $collection->first(); + if ($nextRecurrence->getStart()->getTimestamp() <= $now->getTimestamp() && $nextRecurrence->getStart()->getTimestamp() > $this->getLastRunAt()) { + return true; + } + } catch (InvalidRRule|\Exception|NotFoundExceptionInterface|ContainerExceptionInterface $e) { + // this should not happen, as we validate the rule on setRecurrence, but just in case, we catch the exception and log it + logger('assistant')->error($e->getMessage(), ['exception' => $e]); + } + return false; } } diff --git a/lib/Service/AssignmentsService.php b/lib/Service/AssignmentsService.php index bbef730c..95aa32ed 100644 --- a/lib/Service/AssignmentsService.php +++ b/lib/Service/AssignmentsService.php @@ -35,6 +35,7 @@ public function __construct( /** * @throws InternalException * @throws UnauthorizedException + * @throws BadRequestException */ public function createAssignment(?string $userId, string $prompt, int $startsAt, string $recurrence): Assignment { if ($userId === null) { @@ -44,7 +45,11 @@ public function createAssignment(?string $userId, string $prompt, int $startsAt, $assignment->setUserId($userId); $assignment->setPrompt($prompt); $assignment->setStartsAt($startsAt); - $assignment->setRecurrence($recurrence); + try { + $assignment->setRecurrence($recurrence); + } catch (\InvalidArgumentException $e) { + throw new BadRequestException('Invalid recurrence rule', previous: $e); + } try { $this->assignmentMapper->insert($assignment); } catch (Exception $e) { From 71bf6f96b55c7f2a49a7fb940fb0750920100e64 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 5 May 2026 11:50:42 +0200 Subject: [PATCH 05/22] fix(Assignments): Minor fixes to get things to run Signed-off-by: Marcel Klehr --- appinfo/info.xml | 2 +- lib/BackgroundJob/RunAssignmentsJob.php | 2 +- lib/Controller/AssignmentsApiController.php | 2 +- lib/Db/AssignmentMapper.php | 2 +- lib/Db/ChattyLLM/Session.php | 2 +- lib/Migration/Version030500Date20260430083738.php | 1 + lib/Service/AssignmentsService.php | 6 +++++- 7 files changed, 11 insertions(+), 6 deletions(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 61eee98f..b1ad4441 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -62,7 +62,7 @@ Known providers: More details on how to set this up in the [admin docs](https://docs.nextcloud.com/server/latest/admin_manual/ai/index.html) ]]> - 3.4.1 + 3.5.0-dev.1 agpl Julien Veyssier Assistant diff --git a/lib/BackgroundJob/RunAssignmentsJob.php b/lib/BackgroundJob/RunAssignmentsJob.php index 3d0656a2..5af2f110 100644 --- a/lib/BackgroundJob/RunAssignmentsJob.php +++ b/lib/BackgroundJob/RunAssignmentsJob.php @@ -24,7 +24,7 @@ public function __construct( parent::__construct($timeFactory); $this->setAllowParallelRuns(true); $this->setTimeSensitivity(self::TIME_SENSITIVE); - $this->setInterval(60 * 10); // 10min + $this->setInterval(60); // 1min } public function run($argument) { $userId = $argument['userId']; diff --git a/lib/Controller/AssignmentsApiController.php b/lib/Controller/AssignmentsApiController.php index 86793dd0..1f4f0afe 100644 --- a/lib/Controller/AssignmentsApiController.php +++ b/lib/Controller/AssignmentsApiController.php @@ -8,7 +8,7 @@ namespace OCA\Assistant\Controller; use OCA\Assistant\Db\Assignment; -use OCA\Assistant\Db\ChattyLLM\AssignmentMapper; +use OCA\Assistant\Db\AssignmentMapper; use OCA\Assistant\ResponseDefinitions; use OCA\Assistant\Service\AssignmentsService; use OCA\Assistant\Service\InternalException; diff --git a/lib/Db/AssignmentMapper.php b/lib/Db/AssignmentMapper.php index a9c30f15..1dcb41b7 100644 --- a/lib/Db/AssignmentMapper.php +++ b/lib/Db/AssignmentMapper.php @@ -7,7 +7,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Assistant\Db\ChattyLLM; +namespace OCA\Assistant\Db; use OCA\Assistant\Db\Assignment; use OCP\AppFramework\Db\DoesNotExistException; diff --git a/lib/Db/ChattyLLM/Session.php b/lib/Db/ChattyLLM/Session.php index 7755267e..7aafd090 100644 --- a/lib/Db/ChattyLLM/Session.php +++ b/lib/Db/ChattyLLM/Session.php @@ -86,7 +86,7 @@ class Session extends Entity implements \JsonSerializable { 'summary', 'isSummaryUpToDate', 'isRemembered', - 'assignment_id' + 'assignmentId' ]; public function __construct() { diff --git a/lib/Migration/Version030500Date20260430083738.php b/lib/Migration/Version030500Date20260430083738.php index a70609c5..80e1b650 100644 --- a/lib/Migration/Version030500Date20260430083738.php +++ b/lib/Migration/Version030500Date20260430083738.php @@ -66,6 +66,7 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt } if ($schema->hasTable('assistant_chat_sns')) { $schemaChanged = true; + $table = $schema->getTable('assistant_chat_sns'); $table->addColumn('assignment_id', Types::BIGINT, [ 'notnull' => false, ]); diff --git a/lib/Service/AssignmentsService.php b/lib/Service/AssignmentsService.php index 95aa32ed..d51d8f56 100644 --- a/lib/Service/AssignmentsService.php +++ b/lib/Service/AssignmentsService.php @@ -11,7 +11,7 @@ use OCA\Assistant\BackgroundJob\RunAssignmentsJob; use OCA\Assistant\Db\Assignment; -use OCA\Assistant\Db\ChattyLLM\AssignmentMapper; +use OCA\Assistant\Db\AssignmentMapper; use OCA\Assistant\Db\ChattyLLM\Message; use OCA\Assistant\Db\ChattyLLM\SessionMapper; use OCP\AppFramework\Db\DoesNotExistException; @@ -41,10 +41,14 @@ public function createAssignment(?string $userId, string $prompt, int $startsAt, if ($userId === null) { throw new UnauthorizedException(); } + $now = $this->timeFactory->now()->getTimestamp(); $assignment = new Assignment(); $assignment->setUserId($userId); $assignment->setPrompt($prompt); $assignment->setStartsAt($startsAt); + $assignment->setLastRunAt(0); + $assignment->setCreatedAt($now); + $assignment->setUpdatedAt($now); try { $assignment->setRecurrence($recurrence); } catch (\InvalidArgumentException $e) { From 046cdacf51877d1cace0df32d2600dcb51a62560 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 5 May 2026 12:58:01 +0200 Subject: [PATCH 06/22] fix(Assignments): Minor fixes to get things to run Signed-off-by: Marcel Klehr --- lib/Db/AssignmentMapper.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/Db/AssignmentMapper.php b/lib/Db/AssignmentMapper.php index 1dcb41b7..33005363 100644 --- a/lib/Db/AssignmentMapper.php +++ b/lib/Db/AssignmentMapper.php @@ -36,7 +36,7 @@ public function __construct( */ public function find(string $userId, int $assignmentId): Assignment { $qb = $this->db->getQueryBuilder(); - $qb->select('id') + $qb->select(Assignment::$columns) ->from($this->getTableName()) ->where($qb->expr()->eq('id', $qb->createPositionalParameter($assignmentId, IQueryBuilder::PARAM_INT))) ->andWhere($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR))); @@ -69,7 +69,7 @@ public function findForUser(string $userId): \Generator { ->where($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR))) ->orderBy('created_at', 'DESC'); - yield * $this->yieldEntities($qb); + yield from $this->yieldEntities($qb); } /** @@ -78,6 +78,9 @@ public function findForUser(string $userId): \Generator { */ public function findDueAssignmentsForUser(string $userId): \Generator { foreach ($this->findForUser($userId) as $assignment) { + if ($assignment === null) { + continue; + } if (!$assignment->isDueToRun($this->timeFactory->now())) { continue; } From 66c47d7ada18beb37558de1d8fe8430a053421f3 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 5 May 2026 13:00:27 +0200 Subject: [PATCH 07/22] fix(Assignments): Minor fixes to get things to run Signed-off-by: Marcel Klehr --- lib/Service/AssignmentsService.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/Service/AssignmentsService.php b/lib/Service/AssignmentsService.php index d51d8f56..4aacc5b9 100644 --- a/lib/Service/AssignmentsService.php +++ b/lib/Service/AssignmentsService.php @@ -78,6 +78,9 @@ public function createAssignment(?string $userId, string $prompt, int $startsAt, public function runDueAssignmentsForUser(?string $userId) { try { foreach ($this->assignmentMapper->findDueAssignmentsForUser($userId) as $assignment) { + if ($assignment === null) { + continue; + } $this->scheduleAssignmentRun($userId, $assignment->getId()); } } catch (Exception $e) { From dec32dcc0f55df91f44e5fb27922fcf083d7d573 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 5 May 2026 13:27:12 +0200 Subject: [PATCH 08/22] fix(Assignments): Minor fixes to get things to run Signed-off-by: Marcel Klehr --- lib/Db/Assignment.php | 9 ++--- lib/Db/ChattyLLM/Message.php | 2 ++ lib/Listener/ChattyLLMTaskListener.php | 2 +- lib/Service/AssignmentsService.php | 19 +++++++++- lib/Service/ChatService.php | 50 +++++++++++++++++++++++++- 5 files changed, 75 insertions(+), 7 deletions(-) diff --git a/lib/Db/Assignment.php b/lib/Db/Assignment.php index 550c604b..5cce2586 100644 --- a/lib/Db/Assignment.php +++ b/lib/Db/Assignment.php @@ -113,19 +113,20 @@ public function setRecurrence(string $recurrence): void { public function isDueToRun(\DateTimeImmutable $now): bool { try { $startsAt = new \DateTime('@' . $this->getStartsAt()); + $lastRunAt = new \DateTime('@' . $this->getLastRunAt()); // Find recurrences after the last run or after the current time if this assignment has never run $rule = new Rule($this->getRecurrence(), $startsAt); $transformer = new \Recurr\Transformer\ArrayTransformer(); - $constraint = new AfterConstraint($this->getLastRunAt() !== 0 ? new \DateTime('@' . $this->getLastRunAt()) : $startsAt, true); + $constraint = new AfterConstraint($this->getLastRunAt() !== 0 ? $lastRunAt : $startsAt, false); /** @var RecurrenceCollection $collection */ $collection = $transformer->transform($rule, $constraint); if ($collection->isEmpty()) { return false; } $nextRecurrence = $collection->first(); - if ($nextRecurrence->getStart()->getTimestamp() <= $now->getTimestamp() && $nextRecurrence->getStart()->getTimestamp() > $this->getLastRunAt()) { - return true; - } + $isDue = $nextRecurrence->getStart()->getTimestamp() <= $now->getTimestamp() && $nextRecurrence->getStart()->getTimestamp() > $this->getLastRunAt(); + \OCP\Log\logger('assistant')->debug('Next recurrence of assignment ' . $this->getId().' of user ' . $this->getUserId() . ': ' . $nextRecurrence->getStart()->format('Y-m-d H:i:s') . ' - isDue: ' . ($isDue ? 'true' : 'false')); + return $isDue; } catch (InvalidRRule|\Exception|NotFoundExceptionInterface|ContainerExceptionInterface $e) { // this should not happen, as we validate the rule on setRecurrence, but just in case, we catch the exception and log it logger('assistant')->error($e->getMessage(), ['exception' => $e]); diff --git a/lib/Db/ChattyLLM/Message.php b/lib/Db/ChattyLLM/Message.php index 065413d1..2cc97727 100644 --- a/lib/Db/ChattyLLM/Message.php +++ b/lib/Db/ChattyLLM/Message.php @@ -31,6 +31,8 @@ class Message extends Entity implements \JsonSerializable { public const ROLE_HUMAN = 'human'; + public const ROLE_ASSISTANT = 'assistant'; + /** @var int */ protected $sessionId; /** @var string */ diff --git a/lib/Listener/ChattyLLMTaskListener.php b/lib/Listener/ChattyLLMTaskListener.php index 46490878..5cdc3882 100644 --- a/lib/Listener/ChattyLLMTaskListener.php +++ b/lib/Listener/ChattyLLMTaskListener.php @@ -70,7 +70,7 @@ public function handle(Event $event): void { $message = new Message(); $message->setSessionId($sessionId); $message->setOcpTaskId($task->getId()); - $message->setRole('assistant'); + $message->setRole(Message::ROLE_ASSISTANT); $message->setTimestamp(time()); $sources = json_encode($taskOutput['sources'] ?? []); $message->setSources($sources ?: '[]'); diff --git a/lib/Service/AssignmentsService.php b/lib/Service/AssignmentsService.php index 4aacc5b9..4e0ba66f 100644 --- a/lib/Service/AssignmentsService.php +++ b/lib/Service/AssignmentsService.php @@ -19,6 +19,7 @@ use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\IJobList; use OCP\DB\Exception; +use OCP\IL10N; use Psr\Log\LoggerInterface; class AssignmentsService { @@ -29,6 +30,7 @@ public function __construct( private ITimeFactory $timeFactory, private LoggerInterface $logger, private IJobList $jobList, + private IL10N $l10n, ) { } @@ -98,10 +100,25 @@ public function scheduleAssignmentRun(?string $userId, int $assignmentId): void throw new InternalException(previous: $e); } $assignment = $this->assignmentMapper->find($userId, $assignmentId); + $assignment->setLastRunAt($this->timeFactory->now()->getTimestamp()); + $this->assignmentMapper->update($assignment); $this->chatService->createMessage($userId, $session->getId(), Message::ROLE_HUMAN, $assignment->getPrompt(), $this->timeFactory->now()->getTimestamp()); - $this->chatService->scheduleMessageGeneration($userId, $session->getId()); + $this->chatService->scheduleAssignmentMessageGeneration($userId, $session->getId()); } catch (BadRequestException|InternalException|DoesNotExistException|MultipleObjectsReturnedException|Exception $e) { $this->logger->error('Error while running assignment ' . $assignment->getId() . ' for user ' . $userId, ['exception' => $e]); + if (isset($session)) { + try { + $this->chatService->createMessage( + $userId, + $session->getId(), + message::ROLE_ASSISTANT, + $this->l10n->t('An error occurred while scheduling this assignment run. Reach out to your system administrator if this issue persists.'), + $this->timeFactory->now()->getTimestamp() + ); + } catch (BadRequestException|InternalException|NotFoundException|UnauthorizedException $e) { + $this->logger->error('Error while creating error message for assignment ' . $assignment->getId() . ' for user ' . $userId, ['exception' => $e]); + } + } } catch (NotFoundException $e) { try { $this->assignmentMapper->delete($assignment); diff --git a/lib/Service/ChatService.php b/lib/Service/ChatService.php index ff127c08..91cc1de0 100644 --- a/lib/Service/ChatService.php +++ b/lib/Service/ChatService.php @@ -421,6 +421,54 @@ public function scheduleMessageGeneration(?string $userId, int $sessionId, int $ return $taskId; } + /** + * @throws InternalException + * @throws BadRequestException + * @throws NotFoundException + * @throws UnauthorizedException + */ + public function scheduleAssignmentMessageGeneration(?string $userId, int $sessionId): int { + if ($userId === null) { + throw new UnauthorizedException($this->l10n->t('Unauthorized')); + } + try { + $sessionExists = $this->sessionMapper->exists($userId, $sessionId); + } catch (Exception $e) { + throw new InternalException(previous: $e); + } + if (!$sessionExists) { + throw new NotFoundException($this->l10n->t('Session not found')); + } + + if (!$this->isContextAgentAvailable()) { + throw new BadRequestException('context_agent_not_available'); + } + try { + $lastUserMessage = $this->messageMapper->getLastHumanMessage($sessionId); + } catch (DoesNotExistException $e) { + throw new NotFoundException($this->l10n->t('No user message found in this session'), previous: $e); + } catch (MultipleObjectsReturnedException|Exception $e) { + throw new InternalException(previous: $e); + } + + + try { + $session = $this->sessionMapper->getUserSession($userId, $sessionId); + } catch (DoesNotExistException $e) { + throw new NotFoundException($this->l10n->t('Session not found'), previous: $e); + } catch (MultipleObjectsReturnedException|Exception $e) { + throw new InternalException(previous: $e); + } + // We reset the context for each interaction, because this is an assignment, + // the assistant does not remember things between assignment runs + $lastConversationToken = '{}'; + + // classic agency + $prompt = $lastUserMessage->getContent(); + $taskId = $this->scheduleAgencyTask($userId, $prompt, 0, $lastConversationToken, $sessionId); + return $taskId; + } + /** * @throws BadRequestException * @throws InternalException @@ -494,7 +542,7 @@ private function getAudioHistory(array $history): array { 'role' => $message->getRole(), ]; $attachments = $message->jsonSerialize()['attachments']; - if ($message->getRole() === 'assistant' + if ($message->getRole() === Message::ROLE_ASSISTANT && count($attachments) > 0 && $attachments[0]['type'] === 'Audio' && isset($attachments[0]['remote_audio_id']) From cf1af7fdd294ad5b0aecba4a012ff1ab1f26f9e3 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 6 May 2026 10:34:21 +0200 Subject: [PATCH 09/22] fix(Assignments): Set RunAssignmentsJob to 10min Signed-off-by: Marcel Klehr --- lib/BackgroundJob/RunAssignmentsJob.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/BackgroundJob/RunAssignmentsJob.php b/lib/BackgroundJob/RunAssignmentsJob.php index 5af2f110..3d0656a2 100644 --- a/lib/BackgroundJob/RunAssignmentsJob.php +++ b/lib/BackgroundJob/RunAssignmentsJob.php @@ -24,7 +24,7 @@ public function __construct( parent::__construct($timeFactory); $this->setAllowParallelRuns(true); $this->setTimeSensitivity(self::TIME_SENSITIVE); - $this->setInterval(60); // 1min + $this->setInterval(60 * 10); // 10min } public function run($argument) { $userId = $argument['userId']; From 2f5c288e5dfe9e915d165afc7c38a6d2808b1d20 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 6 May 2026 10:53:13 +0200 Subject: [PATCH 10/22] fix: Run cs:fix Signed-off-by: Marcel Klehr --- lib/Db/Assignment.php | 2 +- lib/Db/AssignmentMapper.php | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/Db/Assignment.php b/lib/Db/Assignment.php index 5cce2586..b0d9abc8 100644 --- a/lib/Db/Assignment.php +++ b/lib/Db/Assignment.php @@ -125,7 +125,7 @@ public function isDueToRun(\DateTimeImmutable $now): bool { } $nextRecurrence = $collection->first(); $isDue = $nextRecurrence->getStart()->getTimestamp() <= $now->getTimestamp() && $nextRecurrence->getStart()->getTimestamp() > $this->getLastRunAt(); - \OCP\Log\logger('assistant')->debug('Next recurrence of assignment ' . $this->getId().' of user ' . $this->getUserId() . ': ' . $nextRecurrence->getStart()->format('Y-m-d H:i:s') . ' - isDue: ' . ($isDue ? 'true' : 'false')); + \OCP\Log\logger('assistant')->debug('Next recurrence of assignment ' . $this->getId() . ' of user ' . $this->getUserId() . ': ' . $nextRecurrence->getStart()->format('Y-m-d H:i:s') . ' - isDue: ' . ($isDue ? 'true' : 'false')); return $isDue; } catch (InvalidRRule|\Exception|NotFoundExceptionInterface|ContainerExceptionInterface $e) { // this should not happen, as we validate the rule on setRecurrence, but just in case, we catch the exception and log it diff --git a/lib/Db/AssignmentMapper.php b/lib/Db/AssignmentMapper.php index 33005363..ce1740fd 100644 --- a/lib/Db/AssignmentMapper.php +++ b/lib/Db/AssignmentMapper.php @@ -9,7 +9,6 @@ namespace OCA\Assistant\Db; -use OCA\Assistant\Db\Assignment; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\AppFramework\Db\QBMapper; From 1860b0ed44cb0180c3fe23a9d79904f756997e43 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 6 May 2026 11:15:16 +0200 Subject: [PATCH 11/22] fix: Fix psalm issues Signed-off-by: Marcel Klehr --- lib/Controller/AssignmentsApiController.php | 15 ++++++++++----- lib/Controller/ChattyLLMController.php | 2 ++ lib/Db/Assignment.php | 2 +- lib/Db/AssignmentMapper.php | 6 +++--- lib/Db/ChattyLLM/MessageMapper.php | 2 +- lib/Db/ChattyLLM/SessionMapper.php | 2 +- tests/psalm-baseline.xml | 6 ++++++ 7 files changed, 24 insertions(+), 11 deletions(-) diff --git a/lib/Controller/AssignmentsApiController.php b/lib/Controller/AssignmentsApiController.php index 1f4f0afe..630dc092 100644 --- a/lib/Controller/AssignmentsApiController.php +++ b/lib/Controller/AssignmentsApiController.php @@ -45,7 +45,7 @@ public function __construct( /** * Create a new assignment * - * @return DataResponse|DataResponse + * @return DataResponse|DataResponse * * 200: User assignments returned * 403: User not logged in @@ -83,6 +83,7 @@ public function getUserAssignments(): DataResponse { if ($this->userId !== null) { try { $assignments = iterator_to_array($this->assignmentMapper->findForUser($this->userId)); + /** @var list $serializedAssignments */ $serializedAssignments = array_map(static function (Assignment $assignments) { return $assignments->jsonSerialize(); }, $assignments); @@ -100,7 +101,7 @@ public function getUserAssignments(): DataResponse { * * Get a list of assignmetns for the current user. * - * @return DataResponse|DataResponse|DataResponse + * @return DataResponse|DataResponse * * 200: User tasks returned * 403: User not logged in @@ -112,7 +113,9 @@ public function getUserAssignment(int $id): DataResponse { if ($this->userId !== null) { try { $assignment = $this->assignmentMapper->find($this->userId, $id); - return new DataResponse(['assignment' => $assignment->jsonSerialize()]); + /** @var AssistantAssignment $serializedAssignment */ + $serializedAssignment = $assignment->jsonSerialize(); + return new DataResponse(['assignment' => $serializedAssignment]); } catch (Exception $e) { $this->logger->error('Error while fetching assignment for user ' . $this->userId, ['exception' => $e]); return new DataResponse('', HTTP::STATUS_FORBIDDEN); @@ -128,7 +131,7 @@ public function getUserAssignment(int $id): DataResponse { * * Get a list of assignmetns for the current user. * - * @return DataResponse|DataResponse|DataResponse + * @return DataResponse|DataResponse * * 200: User tasks returned * 403: User not logged in @@ -156,7 +159,9 @@ public function updateUserAssignment(int $id, ?string $prompt, ?string $recurren } $assignment->setUpdatedAt($this->timeFactory->now()->getTimestamp()); $this->assignmentMapper->update($assignment); - return new DataResponse(['assignment' => $assignment->jsonSerialize()]); + /** @var AssistantAssignment $serializedAssignment */ + $serializedAssignment = $assignment->jsonSerialize(); + return new DataResponse(['assignment' => $serializedAssignment]); } catch (Exception $e) { $this->logger->error('Error while fetching assignment for user ' . $this->userId, ['exception' => $e]); return new DataResponse('', HTTP::STATUS_FORBIDDEN); diff --git a/lib/Controller/ChattyLLMController.php b/lib/Controller/ChattyLLMController.php index 28a24f6e..8dfc5b03 100644 --- a/lib/Controller/ChattyLLMController.php +++ b/lib/Controller/ChattyLLMController.php @@ -8,6 +8,7 @@ namespace OCA\Assistant\Controller; use OCA\Assistant\AppInfo\Application; +use OCA\Assistant\Db\Assignment; use OCA\Assistant\Db\ChattyLLM\Message; use OCA\Assistant\Db\ChattyLLM\MessageMapper; use OCA\Assistant\Db\ChattyLLM\SessionMapper; @@ -316,6 +317,7 @@ public function deleteSession(int $sessionId): JSONResponse { #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])] public function getSessions(): JSONResponse { try { + /** @var list $sessions */ $sessions = $this->chatService->getSessionsForUser($this->userId); return new JSONResponse($sessions); } catch (InternalException $e) { diff --git a/lib/Db/Assignment.php b/lib/Db/Assignment.php index b0d9abc8..9a199473 100644 --- a/lib/Db/Assignment.php +++ b/lib/Db/Assignment.php @@ -125,7 +125,7 @@ public function isDueToRun(\DateTimeImmutable $now): bool { } $nextRecurrence = $collection->first(); $isDue = $nextRecurrence->getStart()->getTimestamp() <= $now->getTimestamp() && $nextRecurrence->getStart()->getTimestamp() > $this->getLastRunAt(); - \OCP\Log\logger('assistant')->debug('Next recurrence of assignment ' . $this->getId() . ' of user ' . $this->getUserId() . ': ' . $nextRecurrence->getStart()->format('Y-m-d H:i:s') . ' - isDue: ' . ($isDue ? 'true' : 'false')); + logger('assistant')->debug('Next recurrence of assignment ' . $this->getId() . ' of user ' . $this->getUserId() . ': ' . $nextRecurrence->getStart()->format('Y-m-d H:i:s') . ' - isDue: ' . ($isDue ? 'true' : 'false')); return $isDue; } catch (InvalidRRule|\Exception|NotFoundExceptionInterface|ContainerExceptionInterface $e) { // this should not happen, as we validate the rule on setRecurrence, but just in case, we catch the exception and log it diff --git a/lib/Db/AssignmentMapper.php b/lib/Db/AssignmentMapper.php index ce1740fd..f4746254 100644 --- a/lib/Db/AssignmentMapper.php +++ b/lib/Db/AssignmentMapper.php @@ -49,7 +49,7 @@ public function find(string $userId, int $assignmentId): Assignment { */ public function exists(string $userId, int $assignmentId): bool { try { - return $this->find($userId, $assignmentId) !== null; + return (bool)$this->find($userId, $assignmentId); } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { return false; } catch (\OCP\AppFramework\Db\MultipleObjectsReturnedException $e) { @@ -58,7 +58,7 @@ public function exists(string $userId, int $assignmentId): bool { } /** - * @return \Generator + * @return \Generator * @throws \OCP\DB\Exception */ public function findForUser(string $userId): \Generator { @@ -72,7 +72,7 @@ public function findForUser(string $userId): \Generator { } /** - * @return \Generator + * @return \Generator * @throws \OCP\DB\Exception */ public function findDueAssignmentsForUser(string $userId): \Generator { diff --git a/lib/Db/ChattyLLM/MessageMapper.php b/lib/Db/ChattyLLM/MessageMapper.php index 4f338f04..a0c4c0ce 100644 --- a/lib/Db/ChattyLLM/MessageMapper.php +++ b/lib/Db/ChattyLLM/MessageMapper.php @@ -80,7 +80,7 @@ public function getLastNonEmptyHumanMessage(int $sessionId): Message { * @param int $sessionId * @param int $cursor * @param int $limit - * @return array + * @return list * @throws \OCP\DB\Exception */ public function getMessages(int $sessionId, int $cursor, int $limit): array { diff --git a/lib/Db/ChattyLLM/SessionMapper.php b/lib/Db/ChattyLLM/SessionMapper.php index 6c91a424..babf812c 100644 --- a/lib/Db/ChattyLLM/SessionMapper.php +++ b/lib/Db/ChattyLLM/SessionMapper.php @@ -84,7 +84,7 @@ public function getUserSessionForAssignment(string $userId, int $assignmentId): /** * @param string $userId - * @return array + * @return list * @throws \OCP\DB\Exception */ public function getUserSessions(string $userId): array { diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml index 44e30f29..14dd255c 100644 --- a/tests/psalm-baseline.xml +++ b/tests/psalm-baseline.xml @@ -20,6 +20,12 @@ }, array{}>]]> + + + + + + From 3752b4d11ace6c5e209d7a4c74f5d27e153ce69d Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 6 May 2026 11:36:11 +0200 Subject: [PATCH 12/22] fix: Fix openapi spec Signed-off-by: Marcel Klehr --- lib/Controller/AssignmentsApiController.php | 22 +- openapi.json | 895 +++++++++++++++++++- 2 files changed, 905 insertions(+), 12 deletions(-) diff --git a/lib/Controller/AssignmentsApiController.php b/lib/Controller/AssignmentsApiController.php index 630dc092..d225c249 100644 --- a/lib/Controller/AssignmentsApiController.php +++ b/lib/Controller/AssignmentsApiController.php @@ -44,7 +44,9 @@ public function __construct( /** * Create a new assignment - * + * @param string $prompt The prompt to be sent to the assistant when the assignment is executed + * @param int $startsAt The timestamp when the assignment should start being executed + * @param string $recurrence The recurrence rule for the assignment, in RRULE format (e.g. "FREQ=DAILY;INTERVAL=1" for a daily assignment) * @return DataResponse|DataResponse * * 200: User assignments returned @@ -97,14 +99,15 @@ public function getUserAssignments(): DataResponse { } /** - * Get user's assignments + * Get user's assignment * - * Get a list of assignmetns for the current user. + * @param int $id The id of the assignment to return * * @return DataResponse|DataResponse * * 200: User tasks returned * 403: User not logged in + * 404: Assignment not found */ #[NoAdminRequired] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['assignments'])] @@ -127,15 +130,19 @@ public function getUserAssignment(int $id): DataResponse { } /** - * Get user's assignments + * Update a user's assignment * - * Get a list of assignmetns for the current user. + * @param int $id The id of the assignment + * @param string|null $prompt The prompt to be sent to the assistant when the assignment is executed + * @param int|null $startsAt The timestamp when the assignment should start being executed + * @param string|null $recurrence The recurrence rule for the assignment, in RRULE format * * @return DataResponse|DataResponse * * 200: User tasks returned * 403: User not logged in * 400: Malformed recurrence rule + * 404: Assignment not found */ #[NoAdminRequired] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['assignments'])] @@ -174,10 +181,11 @@ public function updateUserAssignment(int $id, ?string $prompt, ?string $recurren /** * Delete a user's assignment - * * + * + * @param int $id The id of the assignment to delete * @return DataResponse|DataResponse * - * 200: User assignment deleted + * 200: User assignment deleted or not found * 403: User not logged in */ #[NoAdminRequired] diff --git a/openapi.json b/openapi.json index 2db53a8a..08e6970a 100644 --- a/openapi.json +++ b/openapi.json @@ -20,6 +20,45 @@ } }, "schemas": { + "Assignment": { + "type": "object", + "required": [ + "id", + "user_id", + "prompt", + "recurrence", + "created_at", + "updated_at", + "starts_at" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "user_id": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "recurrence": { + "type": "string" + }, + "created_at": { + "type": "integer", + "format": "int64" + }, + "updated_at": { + "type": "integer", + "format": "int64" + }, + "starts_at": { + "type": "integer", + "format": "int64" + } + } + }, "ChatAgencyMessage": { "allOf": [ { @@ -3451,6 +3490,24 @@ } } } + }, + "404": { + "description": "Session not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } } } } @@ -4953,11 +5010,6 @@ } } } - }, - "text/plain": { - "schema": { - "type": "string" - } } } }, @@ -5233,6 +5285,839 @@ } } } + }, + "/ocs/v2.php/apps/assistant/assignments": { + "post": { + "operationId": "assignments_api-create-user-assignment", + "summary": "Create a new assignment", + "tags": [ + "assignments" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "prompt", + "startsAt", + "recurrence" + ], + "properties": { + "prompt": { + "type": "string", + "description": "The prompt to be sent to the assistant when the assignment is executed" + }, + "startsAt": { + "type": "integer", + "format": "int64", + "description": "The timestamp when the assignment should start being executed" + }, + "recurrence": { + "type": "string", + "description": "The recurrence rule for the assignment, in RRULE format (e.g. \"FREQ=DAILY;INTERVAL=1\" for a daily assignment)" + } + } + } + } + } + }, + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "User assignments returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "assignment" + ], + "properties": { + "assignment": { + "$ref": "#/components/schemas/Assignment" + } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "User not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "get": { + "operationId": "assignments_api-get-user-assignments", + "summary": "Get user's assignments", + "description": "Get a list of assignmetns for the current user.", + "tags": [ + "assignments" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "User assignments returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "assignments" + ], + "properties": { + "assignments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Assignment" + } + } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "User not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/assistant/assignments/{id}": { + "get": { + "operationId": "assignments_api-get-user-assignment", + "summary": "Get user's assignment", + "tags": [ + "assignments" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The id of the assignment to return", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "User tasks returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "assignment" + ], + "properties": { + "assignment": { + "$ref": "#/components/schemas/Assignment" + } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "User not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "404": { + "description": "Assignment not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "patch": { + "operationId": "assignments_api-update-user-assignment", + "summary": "Update a user's assignment", + "tags": [ + "assignments" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "prompt": { + "type": "string", + "nullable": true, + "description": "The prompt to be sent to the assistant when the assignment is executed" + }, + "recurrence": { + "type": "string", + "nullable": true, + "description": "The recurrence rule for the assignment, in RRULE format" + }, + "startsAt": { + "type": "integer", + "format": "int64", + "nullable": true, + "description": "The timestamp when the assignment should start being executed" + } + } + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The id of the assignment", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "User tasks returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "assignment" + ], + "properties": { + "assignment": { + "$ref": "#/components/schemas/Assignment" + } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "User not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "400": { + "description": "Malformed recurrence rule", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "404": { + "description": "Assignment not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "assignments_api-delete-user-assignment", + "summary": "Delete a user's assignment", + "tags": [ + "assignments" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The id of the assignment to delete", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "User assignment deleted or not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "403": { + "description": "User not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } } }, "tags": [] From 4c9a7ea799d0d81875bf86539e740033ede8e179 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 6 May 2026 11:44:06 +0200 Subject: [PATCH 13/22] fix: Fix REUSE compliance Signed-off-by: Marcel Klehr --- lib/Controller/ChattyLLMController.php | 1 - lib/Service/BadRequestException.php | 5 +++++ lib/Service/ChatService.php | 5 +++++ lib/Service/InternalException.php | 5 +++++ lib/Service/NotFoundException.php | 5 +++++ lib/Service/UnauthorizedException.php | 4 ++++ 6 files changed, 24 insertions(+), 1 deletion(-) diff --git a/lib/Controller/ChattyLLMController.php b/lib/Controller/ChattyLLMController.php index 8dfc5b03..72a976f6 100644 --- a/lib/Controller/ChattyLLMController.php +++ b/lib/Controller/ChattyLLMController.php @@ -8,7 +8,6 @@ namespace OCA\Assistant\Controller; use OCA\Assistant\AppInfo\Application; -use OCA\Assistant\Db\Assignment; use OCA\Assistant\Db\ChattyLLM\Message; use OCA\Assistant\Db\ChattyLLM\MessageMapper; use OCA\Assistant\Db\ChattyLLM\SessionMapper; diff --git a/lib/Service/BadRequestException.php b/lib/Service/BadRequestException.php index 27414a49..2012395b 100644 --- a/lib/Service/BadRequestException.php +++ b/lib/Service/BadRequestException.php @@ -1,5 +1,10 @@ Date: Thu, 7 May 2026 07:37:32 +0200 Subject: [PATCH 14/22] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Marcel Klehr --- lib/Controller/AssignmentsApiController.php | 2 +- lib/Controller/ChattyLLMController.php | 2 +- lib/Db/Assignment.php | 8 ++++---- lib/ResponseDefinitions.php | 3 ++- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/lib/Controller/AssignmentsApiController.php b/lib/Controller/AssignmentsApiController.php index d225c249..db1ac2ca 100644 --- a/lib/Controller/AssignmentsApiController.php +++ b/lib/Controller/AssignmentsApiController.php @@ -95,7 +95,7 @@ public function getUserAssignments(): DataResponse { return new DataResponse(['assignments' => []]); } } - return new DataResponse('', HTTP::STATUS_FORBIDDEN); + return new DataResponse('', Http::STATUS_FORBIDDEN); } /** diff --git a/lib/Controller/ChattyLLMController.php b/lib/Controller/ChattyLLMController.php index 72a976f6..8c936f41 100644 --- a/lib/Controller/ChattyLLMController.php +++ b/lib/Controller/ChattyLLMController.php @@ -687,7 +687,7 @@ public function generateTitle(int $sessionId): JSONResponse { return new JSONResponse(['taskId' => $taskId]); } catch (InternalException|\JsonException $e) { $this->logger->warning('Failed to generate a title for the chat session', ['exception' => $e]); - return new JSONResponse(['error' => $this->l10n->t('Failed to delete the last message')], Http::STATUS_INTERNAL_SERVER_ERROR); + return new JSONResponse(['error' => $this->l10n->t('Failed to generate a title for the chat session')], Http::STATUS_INTERNAL_SERVER_ERROR); } catch (\OCA\Assistant\Service\NotFoundException $e) { return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_NOT_FOUND); } catch (\OCA\Assistant\Service\UnauthorizedException $e) { diff --git a/lib/Db/Assignment.php b/lib/Db/Assignment.php index 9a199473..712e27f7 100644 --- a/lib/Db/Assignment.php +++ b/lib/Db/Assignment.php @@ -75,10 +75,10 @@ public function __construct() { $this->addType('userId', Types::STRING); $this->addType('prompt', Types::STRING); $this->addType('recurrence', Types::STRING); - $this->addType('startsAt', Types::INTEGER); - $this->addType('createdAt', Types::STRING); - $this->addType('updatedAt', Types::STRING); - $this->addType('lastRunAt', Types::INTEGER); + $this->addType('startsAt', Types::BIGINT); + $this->addType('createdAt', Types::BIGINT); + $this->addType('updatedAt', Types::BIGINT); + $this->addType('lastRunAt', Types::BIGINT); } #[\ReturnTypeWillChange] diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index c974d41b..70be1018 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -92,7 +92,8 @@ * recurrence: string, * created_at: int, * updated_at: int, - * starts_at: int + * starts_at: int, + * last_run_at: int * } */ class ResponseDefinitions { From c044d7886b5a9649d868acbed500e51bb6b8c56b Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 7 May 2026 07:53:20 +0200 Subject: [PATCH 15/22] fix: Address review comments Signed-off-by: Marcel Klehr --- lib/BackgroundJob/RunAssignmentsJob.php | 3 ++- lib/Controller/AssignmentsApiController.php | 14 ++++++------- lib/Controller/ChattyLLMController.php | 8 ++++++-- lib/Db/Assignment.php | 2 +- lib/Db/ChattyLLM/Message.php | 1 + .../Version030500Date20260430083738.php | 17 +++++++++++----- lib/ResponseDefinitions.php | 1 + lib/Service/AssignmentsService.php | 20 ++++++++++++------- lib/Service/ChatService.php | 13 ++++++------ lib/Service/SessionSummaryService.php | 3 ++- 10 files changed, 52 insertions(+), 30 deletions(-) diff --git a/lib/BackgroundJob/RunAssignmentsJob.php b/lib/BackgroundJob/RunAssignmentsJob.php index 3d0656a2..8e3106cc 100644 --- a/lib/BackgroundJob/RunAssignmentsJob.php +++ b/lib/BackgroundJob/RunAssignmentsJob.php @@ -11,6 +11,7 @@ use OCA\Assistant\Service\AssignmentsService; use OCA\Assistant\Service\InternalException; +use OCA\Assistant\Service\UnauthorizedException; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\TimedJob; use Psr\Log\LoggerInterface; @@ -30,7 +31,7 @@ public function run($argument) { $userId = $argument['userId']; try { $this->assignmentService->runDueAssignmentsForUser($userId); - } catch (InternalException $e) { + } catch (InternalException|UnauthorizedException $e) { $this->logger->error('Error running assignments for user ' . $userId, ['exception' => $e]); } } diff --git a/lib/Controller/AssignmentsApiController.php b/lib/Controller/AssignmentsApiController.php index db1ac2ca..bc4f3fc9 100644 --- a/lib/Controller/AssignmentsApiController.php +++ b/lib/Controller/AssignmentsApiController.php @@ -73,7 +73,7 @@ public function createUserAssignment(string $prompt, int $startsAt, string $recu * * Get a list of assignmetns for the current user. * - * @return DataResponse}, array{}>|DataResponse + * @return DataResponse}, array{}>|DataResponse * * 200: User assignments returned * 403: User not logged in @@ -92,7 +92,7 @@ public function getUserAssignments(): DataResponse { return new DataResponse(['assignments' => $serializedAssignments]); } catch (Exception $e) { $this->logger->error('Error while fetching assignments for user ' . $this->userId, ['exception' => $e]); - return new DataResponse(['assignments' => []]); + return new DataResponse('', Http::STATUS_INTERNAL_SERVER_ERROR); } } return new DataResponse('', Http::STATUS_FORBIDDEN); @@ -137,7 +137,7 @@ public function getUserAssignment(int $id): DataResponse { * @param int|null $startsAt The timestamp when the assignment should start being executed * @param string|null $recurrence The recurrence rule for the assignment, in RRULE format * - * @return DataResponse|DataResponse + * @return DataResponse|DataResponse * * 200: User tasks returned * 403: User not logged in @@ -158,7 +158,7 @@ public function updateUserAssignment(int $id, ?string $prompt, ?string $recurren try { $assignment->setRecurrence($recurrence); } catch (\InvalidArgumentException $e) { - return new DataResponse('', HTTP::STATUS_BAD_REQUEST); + return new DataResponse('', Http::STATUS_BAD_REQUEST); } } if ($startsAt !== null) { @@ -171,12 +171,12 @@ public function updateUserAssignment(int $id, ?string $prompt, ?string $recurren return new DataResponse(['assignment' => $serializedAssignment]); } catch (Exception $e) { $this->logger->error('Error while fetching assignment for user ' . $this->userId, ['exception' => $e]); - return new DataResponse('', HTTP::STATUS_FORBIDDEN); + return new DataResponse('', Http::STATUS_INTERNAL_SERVER_ERROR); } catch (DoesNotExistException|MultipleObjectsReturnedException) { - return new DataResponse('', HTTP::STATUS_NOT_FOUND); + return new DataResponse('', Http::STATUS_NOT_FOUND); } } - return new DataResponse('', HTTP::STATUS_FORBIDDEN); + return new DataResponse('', Http::STATUS_FORBIDDEN); } /** diff --git a/lib/Controller/ChattyLLMController.php b/lib/Controller/ChattyLLMController.php index 8c936f41..88298dea 100644 --- a/lib/Controller/ChattyLLMController.php +++ b/lib/Controller/ChattyLLMController.php @@ -15,6 +15,7 @@ use OCA\Assistant\Service\BadRequestException; use OCA\Assistant\Service\ChatService; use OCA\Assistant\Service\InternalException; +use OCA\Assistant\Service\UnauthorizedException; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\AppFramework\Http; @@ -257,10 +258,11 @@ public function updateSessionTitle(int $sessionId, string $title): JSONResponse * @param integer $sessionId The chat session ID * @param string|null $title The new chat session title * @param bool|null $is_remembered The new is_remembered status: Whether to remember the insights from this chat session across all chat session - * @return JSONResponse|JSONResponse + * @return JSONResponse|JSONResponse * * 200: The title has been updated successfully * 404: The session was not found + * 403: User is not logged in */ #[NoAdminRequired] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])] @@ -271,8 +273,10 @@ public function updateChatSession(int $sessionId, ?string $title = null, ?bool $ } catch (InternalException $e) { $this->logger->warning('Failed to update the chat session', ['exception' => $e]); return new JSONResponse(['error' => $this->l10n->t('Failed to update the chat session')], Http::STATUS_INTERNAL_SERVER_ERROR); - } catch (\OCA\Assistant\Service\NotFoundException|\OCA\Assistant\Service\UnauthorizedException $e) { + } catch (\OCA\Assistant\Service\NotFoundException) { return new JSONResponse(['error' => $this->l10n->t('Could not find session')], Http::STATUS_NOT_FOUND); + } catch (UnauthorizedException $e) { + return new JSONResponse(['error' => $this->l10n->t('User is not logged in')], Http::STATUS_UNAUTHORIZED); } } diff --git a/lib/Db/Assignment.php b/lib/Db/Assignment.php index 712e27f7..ee4e03bc 100644 --- a/lib/Db/Assignment.php +++ b/lib/Db/Assignment.php @@ -62,7 +62,7 @@ class Assignment extends Entity implements \JsonSerializable { ]; public static $fields = [ 'id', - 'user_id', + 'userId', 'prompt', 'recurrence', 'startsAt', diff --git a/lib/Db/ChattyLLM/Message.php b/lib/Db/ChattyLLM/Message.php index 2cc97727..c94611e8 100644 --- a/lib/Db/ChattyLLM/Message.php +++ b/lib/Db/ChattyLLM/Message.php @@ -32,6 +32,7 @@ class Message extends Entity implements \JsonSerializable { public const ROLE_HUMAN = 'human'; public const ROLE_ASSISTANT = 'assistant'; + public const ROLE_SYSTEM = 'system'; /** @var int */ protected $sessionId; diff --git a/lib/Migration/Version030500Date20260430083738.php b/lib/Migration/Version030500Date20260430083738.php index 80e1b650..fdca676f 100644 --- a/lib/Migration/Version030500Date20260430083738.php +++ b/lib/Migration/Version030500Date20260430083738.php @@ -33,6 +33,7 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt $table = $schema->createTable('assistant_assignments'); $table->addColumn('id', Types::BIGINT, [ 'autoincrement' => true, + 'unsigned' => true, ]); $table->addColumn('user_id', Types::STRING, [ 'notnull' => true, @@ -65,12 +66,18 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt $table->addIndex(['user_id'], 'assistant_assgnmts_user_id_idx'); } if ($schema->hasTable('assistant_chat_sns')) { - $schemaChanged = true; $table = $schema->getTable('assistant_chat_sns'); - $table->addColumn('assignment_id', Types::BIGINT, [ - 'notnull' => false, - ]); - $table->addIndex(['user_id', 'assignment_id'], 'assistant_chat_assgnmt_uid'); + if (!$table->hasColumn('assignment_id')) { + $schemaChanged = true; + $table->addColumn('assignment_id', Types::BIGINT, [ + 'notnull' => false, + 'unsigned' => true, + ]); + } + if (!$table->hasIndex('assistant_chat_assgnmt_uid')) { + $schemaChanged = true; + $table->addIndex(['user_id', 'assignment_id'], 'assistant_chat_assgnmt_uid'); + } } return $schemaChanged ? $schema : null; diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 70be1018..90015c5b 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -60,6 +60,7 @@ * summary: ?string, * is_remembered: bool, * is_summary_up_to_date: bool, + * assignment_id: ?int * } * * @psalm-type AssistantChatMessage = array{ diff --git a/lib/Service/AssignmentsService.php b/lib/Service/AssignmentsService.php index 4e0ba66f..d834c17b 100644 --- a/lib/Service/AssignmentsService.php +++ b/lib/Service/AssignmentsService.php @@ -75,9 +75,12 @@ public function createAssignment(?string $userId, string $prompt, int $startsAt, } /** - * @throws InternalException + * @throws InternalException|UnauthorizedException */ - public function runDueAssignmentsForUser(?string $userId) { + public function runDueAssignmentsForUser(?string $userId): void { + if ($userId === null) { + throw new UnauthorizedException(); + } try { foreach ($this->assignmentMapper->findDueAssignmentsForUser($userId) as $assignment) { if ($assignment === null) { @@ -105,7 +108,7 @@ public function scheduleAssignmentRun(?string $userId, int $assignmentId): void $this->chatService->createMessage($userId, $session->getId(), Message::ROLE_HUMAN, $assignment->getPrompt(), $this->timeFactory->now()->getTimestamp()); $this->chatService->scheduleAssignmentMessageGeneration($userId, $session->getId()); } catch (BadRequestException|InternalException|DoesNotExistException|MultipleObjectsReturnedException|Exception $e) { - $this->logger->error('Error while running assignment ' . $assignment->getId() . ' for user ' . $userId, ['exception' => $e]); + $this->logger->error('Error while running assignment ' . $assignmentId . ' for user ' . $userId, ['exception' => $e]); if (isset($session)) { try { $this->chatService->createMessage( @@ -116,18 +119,21 @@ public function scheduleAssignmentRun(?string $userId, int $assignmentId): void $this->timeFactory->now()->getTimestamp() ); } catch (BadRequestException|InternalException|NotFoundException|UnauthorizedException $e) { - $this->logger->error('Error while creating error message for assignment ' . $assignment->getId() . ' for user ' . $userId, ['exception' => $e]); + $this->logger->error('Error while creating error message for assignment ' . $assignmentId . ' for user ' . $userId, ['exception' => $e]); } } } catch (NotFoundException $e) { try { + $assignment = $this->assignmentMapper->find($userId, $assignmentId); $this->assignmentMapper->delete($assignment); - } catch (Exception $e) { - $this->logger->error('Error while deleting assignment ' . $assignment->getId() . ' for user ' . $userId, ['exception' => $e]); + } catch (Exception|MultipleObjectsReturnedException $e) { + $this->logger->error('Error while deleting assignment ' . $assignmentId . ' for user ' . $userId, ['exception' => $e]); + } catch (DoesNotExistException $e) { + // pass } } catch (UnauthorizedException $e) { // this should not happen - $this->logger->error('Unauthorized to run assignment ' . $assignment->getId() . ' for user ' . $userId, ['exception' => $e]); + $this->logger->error('Unauthorized to run assignment ' . $assignmentId . ' for user ' . $userId, ['exception' => $e]); } } } diff --git a/lib/Service/ChatService.php b/lib/Service/ChatService.php index b4d3a385..4c39b2bd 100644 --- a/lib/Service/ChatService.php +++ b/lib/Service/ChatService.php @@ -27,6 +27,7 @@ use Psr\Log\LoggerInterface; class ChatService { + public const EMPTY_CONVERSATION_TOKEN = '{}'; public function __construct( private readonly IUserManager $userManager, @@ -79,7 +80,7 @@ public function createChatSession(?string $userId, ?int $timestamp = null, ?stri $systemMsg = new Message(); $systemMsg->setSessionId($session->getId()); - $systemMsg->setRole('system'); + $systemMsg->setRole(Message::ROLE_SYSTEM); $systemMsg->setAttachments('[]'); $systemMsg->setContent($userInstructions); $systemMsg->setTimestamp($session->getTimestamp()); @@ -265,7 +266,7 @@ public function getSessionMessages(?string $userId, int $sessionId, $limit = 20, } catch (Exception $e) { throw new InternalException(previous: $e); } - if ($messages[0]->getRole() === 'system') { + if ($messages[0]->getRole() === Message::ROLE_SYSTEM) { array_shift($messages); } @@ -360,7 +361,7 @@ public function scheduleMessageGeneration(?string $userId, int $sessionId, int $ } catch (MultipleObjectsReturnedException|Exception $e) { throw new InternalException(previous: $e); } - $lastConversationToken = $session->getAgencyConversationToken() ?? '{}'; + $lastConversationToken = $session->getAgencyConversationToken() ?? self::EMPTY_CONVERSATION_TOKEN; $lastAttachments = $lastUserMessage->jsonSerialize()['attachments']; $audioAttachment = $lastAttachments[0] ?? null; @@ -388,7 +389,7 @@ public function scheduleMessageGeneration(?string $userId, int $sessionId, int $ } catch (MultipleObjectsReturnedException|Exception $e) { throw new InternalException(previous: $e); } - if ($firstMessage->getRole() === 'system') { + if ($firstMessage->getRole() === Message::ROLE_SYSTEM) { $systemPrompt = $firstMessage->getContent(); } try { @@ -466,7 +467,7 @@ public function scheduleAssignmentMessageGeneration(?string $userId, int $sessio } // We reset the context for each interaction, because this is an assignment, // the assistant does not remember things between assignment runs - $lastConversationToken = '{}'; + $lastConversationToken = self::EMPTY_CONVERSATION_TOKEN; // classic agency $prompt = $lastUserMessage->getContent(); @@ -577,7 +578,7 @@ private function getRawLastMessages(int $sessionId): array { $lastNMessages = (int)$this->appConfig->getValueString(Application::APP_ID, 'chat_last_n_messages', '10', lazy: true); $messages = $this->messageMapper->getMessages($sessionId, 0, $lastNMessages); - if ($messages[0]->getRole() === 'system') { + if ($messages[0]->getRole() === Message::ROLE_SYSTEM) { array_shift($messages); } return $messages; diff --git a/lib/Service/SessionSummaryService.php b/lib/Service/SessionSummaryService.php index b7ca5fb0..d5a604ea 100644 --- a/lib/Service/SessionSummaryService.php +++ b/lib/Service/SessionSummaryService.php @@ -12,6 +12,7 @@ use OCA\Assistant\AppInfo\Application; use OCA\Assistant\BackgroundJob\GenerateNewChatSummaries; use OCA\Assistant\BackgroundJob\RegenerateOutdatedChatSummariesJob; +use OCA\Assistant\Db\ChattyLLM\Message; use OCA\Assistant\Db\ChattyLLM\MessageMapper; use OCA\Assistant\Db\ChattyLLM\Session; use OCA\Assistant\Db\ChattyLLM\SessionMapper; @@ -45,7 +46,7 @@ private function generateSummaries(array $sessions): void { foreach ($sessions as $session) { try { $messages = $this->messageMapper->getMessages($session->getId(), 0, self::SUMMARY_MESSAGE_LIMIT); - if ($messages[0]->getRole() === 'system') { + if ($messages[0]->getRole() === Message::ROLE_SYSTEM) { array_shift($messages); } From 04c77fa762c2abb0ae41a60a54f523f194e62d9f Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 7 May 2026 07:55:55 +0200 Subject: [PATCH 16/22] fix: Address review comments Signed-off-by: Marcel Klehr --- lib/Controller/AssignmentsApiController.php | 2 +- lib/Controller/ChattyLLMController.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Controller/AssignmentsApiController.php b/lib/Controller/AssignmentsApiController.php index bc4f3fc9..d7866f0e 100644 --- a/lib/Controller/AssignmentsApiController.php +++ b/lib/Controller/AssignmentsApiController.php @@ -71,7 +71,7 @@ public function createUserAssignment(string $prompt, int $startsAt, string $recu /** * Get user's assignments * - * Get a list of assignmetns for the current user. + * Get a list of assignments for the current user. * * @return DataResponse}, array{}>|DataResponse * diff --git a/lib/Controller/ChattyLLMController.php b/lib/Controller/ChattyLLMController.php index 88298dea..a8b84fd4 100644 --- a/lib/Controller/ChattyLLMController.php +++ b/lib/Controller/ChattyLLMController.php @@ -262,7 +262,7 @@ public function updateSessionTitle(int $sessionId, string $title): JSONResponse * * 200: The title has been updated successfully * 404: The session was not found - * 403: User is not logged in + * 401: User is not logged in */ #[NoAdminRequired] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])] From e993dbbcbbbc0d50be36fb7477729568366f79ca Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 7 May 2026 07:56:04 +0200 Subject: [PATCH 17/22] fix: Regenerate openapi spec Signed-off-by: Marcel Klehr --- openapi.json | 120 +++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 103 insertions(+), 17 deletions(-) diff --git a/openapi.json b/openapi.json index 08e6970a..cc445bd1 100644 --- a/openapi.json +++ b/openapi.json @@ -29,7 +29,8 @@ "recurrence", "created_at", "updated_at", - "starts_at" + "starts_at", + "last_run_at" ], "properties": { "id": { @@ -56,6 +57,10 @@ "starts_at": { "type": "integer", "format": "int64" + }, + "last_run_at": { + "type": "integer", + "format": "int64" } } }, @@ -150,7 +155,8 @@ "agency_pending_actions", "summary", "is_remembered", - "is_summary_up_to_date" + "is_summary_up_to_date", + "assignment_id" ], "properties": { "id": { @@ -186,6 +192,11 @@ }, "is_summary_up_to_date": { "type": "boolean" + }, + "assignment_id": { + "type": "integer", + "format": "int64", + "nullable": true } } }, @@ -3083,29 +3094,44 @@ } }, "401": { - "description": "Current user is not logged in", + "description": "User is not logged in", "content": { "application/json": { "schema": { - "type": "object", - "required": [ - "ocs" - ], - "properties": { - "ocs": { + "anyOf": [ + { "type": "object", "required": [ - "meta", - "data" + "error" ], "properties": { - "meta": { - "$ref": "#/components/schemas/OCSMeta" - }, - "data": {} + "error": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } } } - } + ] } } } @@ -5475,7 +5501,7 @@ "get": { "operationId": "assignments_api-get-user-assignments", "summary": "Get user's assignments", - "description": "Get a list of assignmetns for the current user.", + "description": "Get a list of assignments for the current user.", "tags": [ "assignments" ], @@ -5571,6 +5597,36 @@ } } }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, "401": { "description": "Current user is not logged in", "content": { @@ -5961,6 +6017,36 @@ } } }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, "401": { "description": "Current user is not logged in", "content": { From 5a1bfe300c5d9b1fe2be3b14e1c910be2566645d Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 7 May 2026 08:36:57 +0200 Subject: [PATCH 18/22] Fix: Address review comment Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Marcel Klehr --- lib/Controller/AssignmentsApiController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Controller/AssignmentsApiController.php b/lib/Controller/AssignmentsApiController.php index d7866f0e..868d2100 100644 --- a/lib/Controller/AssignmentsApiController.php +++ b/lib/Controller/AssignmentsApiController.php @@ -137,7 +137,7 @@ public function getUserAssignment(int $id): DataResponse { * @param int|null $startsAt The timestamp when the assignment should start being executed * @param string|null $recurrence The recurrence rule for the assignment, in RRULE format * - * @return DataResponse|DataResponse + * @return DataResponse|DataResponse * * 200: User tasks returned * 403: User not logged in From 109fb6254a465078c6f419a2f355b1b7939f5637 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 7 May 2026 08:37:56 +0200 Subject: [PATCH 19/22] Fix: Address review comment Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Marcel Klehr --- lib/Service/ChatService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Service/ChatService.php b/lib/Service/ChatService.php index 4c39b2bd..1b80b991 100644 --- a/lib/Service/ChatService.php +++ b/lib/Service/ChatService.php @@ -578,7 +578,7 @@ private function getRawLastMessages(int $sessionId): array { $lastNMessages = (int)$this->appConfig->getValueString(Application::APP_ID, 'chat_last_n_messages', '10', lazy: true); $messages = $this->messageMapper->getMessages($sessionId, 0, $lastNMessages); - if ($messages[0]->getRole() === Message::ROLE_SYSTEM) { + if (!empty($messages) && $messages[0]->getRole() === Message::ROLE_SYSTEM) { array_shift($messages); } return $messages; From 0e8ea5d935e1d629a49e07a55095578e853d2f9d Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 7 May 2026 08:43:40 +0200 Subject: [PATCH 20/22] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Marcel Klehr --- lib/Controller/AssignmentsApiController.php | 2 +- lib/Db/ChattyLLM/Session.php | 4 ++-- lib/Service/AssignmentsService.php | 2 +- lib/Service/ChatService.php | 13 ++++++++++--- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/lib/Controller/AssignmentsApiController.php b/lib/Controller/AssignmentsApiController.php index 868d2100..7c467e0d 100644 --- a/lib/Controller/AssignmentsApiController.php +++ b/lib/Controller/AssignmentsApiController.php @@ -61,7 +61,7 @@ public function createUserAssignment(string $prompt, int $startsAt, string $recu $serializedAssignment = $assignment->jsonSerialize(); return new DataResponse(['assignment' => $serializedAssignment]); } catch (InternalException $e) { - $this->logger->error('Error while fetching assignments for user ' . $this->userId, ['exception' => $e]); + $this->logger->error('Error while creating assignment for user ' . $this->userId, ['exception' => $e]); return new DataResponse('', Http::STATUS_INTERNAL_SERVER_ERROR); } catch (UnauthorizedException $e) { return new DataResponse('', Http::STATUS_FORBIDDEN); diff --git a/lib/Db/ChattyLLM/Session.php b/lib/Db/ChattyLLM/Session.php index 7aafd090..0c5fc6d0 100644 --- a/lib/Db/ChattyLLM/Session.php +++ b/lib/Db/ChattyLLM/Session.php @@ -25,8 +25,8 @@ * @method \void setAgencyConversationToken(?string $agencyConversationToken) * @method \string|null getAgencyPendingActions() * @method \void setAgencyPendingActions(?string $agencyPendingActions) - * @method \void setAssignmentId(int $id) - * @method \int getAssignmentId() + * @method \void setAssignmentId(?int $id) + * @method \int|null getAssignmentId() */ class Session extends Entity implements \JsonSerializable { /** @var string */ diff --git a/lib/Service/AssignmentsService.php b/lib/Service/AssignmentsService.php index d834c17b..41ec639c 100644 --- a/lib/Service/AssignmentsService.php +++ b/lib/Service/AssignmentsService.php @@ -114,7 +114,7 @@ public function scheduleAssignmentRun(?string $userId, int $assignmentId): void $this->chatService->createMessage( $userId, $session->getId(), - message::ROLE_ASSISTANT, + Message::ROLE_ASSISTANT, $this->l10n->t('An error occurred while scheduling this assignment run. Reach out to your system administrator if this issue persists.'), $this->timeFactory->now()->getTimestamp() ); diff --git a/lib/Service/ChatService.php b/lib/Service/ChatService.php index 1b80b991..588d9333 100644 --- a/lib/Service/ChatService.php +++ b/lib/Service/ChatService.php @@ -266,7 +266,7 @@ public function getSessionMessages(?string $userId, int $sessionId, $limit = 20, } catch (Exception $e) { throw new InternalException(previous: $e); } - if ($messages[0]->getRole() === Message::ROLE_SYSTEM) { + if (!empty($messages) && $messages[0]->getRole() === Message::ROLE_SYSTEM) { array_shift($messages); } @@ -397,9 +397,16 @@ public function scheduleMessageGeneration(?string $userId, int $sessionId, int $ } catch (Exception|AppConfigTypeConflictException $e) { throw new InternalException(previous: $e); } - do { + $lastUserMessage = null; + while ($history !== []) { $lastUserMessage = array_pop($history); - } while ($lastUserMessage->getRole() !== Message::ROLE_HUMAN); + if ($lastUserMessage !== null && $lastUserMessage->getRole() === Message::ROLE_HUMAN) { + break; + } + } + if (!$lastUserMessage instanceof Message || $lastUserMessage->getRole() !== Message::ROLE_HUMAN) { + throw new NotFoundException($this->l10n->t('No human message found in this session')); + } $lastAttachments = $lastUserMessage->jsonSerialize()['attachments']; $audioAttachment = $lastAttachments[0] ?? null; From 48d4c82523adfa6fe0841767aa09b4398d9d6683 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 7 May 2026 08:46:42 +0200 Subject: [PATCH 21/22] fix: Address review comments Signed-off-by: Marcel Klehr --- lib/Service/AssignmentsService.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/Service/AssignmentsService.php b/lib/Service/AssignmentsService.php index 41ec639c..99de5366 100644 --- a/lib/Service/AssignmentsService.php +++ b/lib/Service/AssignmentsService.php @@ -93,7 +93,13 @@ public function runDueAssignmentsForUser(?string $userId): void { } } + /** + * @throws UnauthorizedException + */ public function scheduleAssignmentRun(?string $userId, int $assignmentId): void { + if ($userId === null) { + throw new UnauthorizedException(); + } try { try { $session = $this->sessionMapper->getUserSessionForAssignment($userId, $assignmentId); @@ -131,9 +137,6 @@ public function scheduleAssignmentRun(?string $userId, int $assignmentId): void } catch (DoesNotExistException $e) { // pass } - } catch (UnauthorizedException $e) { - // this should not happen - $this->logger->error('Unauthorized to run assignment ' . $assignmentId . ' for user ' . $userId, ['exception' => $e]); } } } From e483ae2d64dd48a3d13938bd3e6fe7c97707d6a6 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 7 May 2026 09:01:29 +0200 Subject: [PATCH 22/22] fix: Address review comments Signed-off-by: Marcel Klehr --- lib/Controller/AssignmentsApiController.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/Controller/AssignmentsApiController.php b/lib/Controller/AssignmentsApiController.php index 7c467e0d..fc75899b 100644 --- a/lib/Controller/AssignmentsApiController.php +++ b/lib/Controller/AssignmentsApiController.php @@ -121,12 +121,12 @@ public function getUserAssignment(int $id): DataResponse { return new DataResponse(['assignment' => $serializedAssignment]); } catch (Exception $e) { $this->logger->error('Error while fetching assignment for user ' . $this->userId, ['exception' => $e]); - return new DataResponse('', HTTP::STATUS_FORBIDDEN); + return new DataResponse('', Http::STATUS_FORBIDDEN); } catch (DoesNotExistException|MultipleObjectsReturnedException) { - return new DataResponse('', HTTP::STATUS_NOT_FOUND); + return new DataResponse('', Http::STATUS_NOT_FOUND); } } - return new DataResponse('', HTTP::STATUS_FORBIDDEN); + return new DataResponse('', Http::STATUS_FORBIDDEN); } /** @@ -196,15 +196,15 @@ public function deleteUserAssignment(int $id): DataResponse { try { $assignment = $this->assignmentMapper->find($this->userId, $id); $this->assignmentMapper->delete($assignment); - return new DataResponse('', HTTP::STATUS_OK); + return new DataResponse('', Http::STATUS_OK); } catch (Exception $e) { $this->logger->error('Error while fetching assignment for user ' . $this->userId, ['exception' => $e]); - return new DataResponse('', HTTP::STATUS_FORBIDDEN); + return new DataResponse('', Http::STATUS_FORBIDDEN); } catch (DoesNotExistException|MultipleObjectsReturnedException) { // 200 OK because of idempotence, if we send DELETE twice, we return the same response twice - return new DataResponse('', HTTP::STATUS_OK); + return new DataResponse('', Http::STATUS_OK); } } - return new DataResponse('', HTTP::STATUS_FORBIDDEN); + return new DataResponse('', Http::STATUS_FORBIDDEN); } }