Skip to content

Commit 8d53de5

Browse files
authored
Merge pull request #422 from nextcloud/feat/memories
feat: Implement memories
2 parents b4d808f + e2d8346 commit 8d53de5

17 files changed

+987
-61
lines changed

appinfo/routes.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@
3636
['name' => 'assistantApi#getOutputFile', 'url' => '/api/{apiVersion}/task/{ocpTaskId}/output-file/{fileId}/download', 'verb' => 'GET', 'requirements' => $requirements],
3737
['name' => 'assistantApi#runFileAction', 'url' => '/api/{apiVersion}/file-action/{fileId}/{taskTypeId}', 'verb' => 'POST', 'requirements' => $requirements],
3838

39+
['name' => 'chattyLLM#newSession', 'url' => '/chat/sessions', 'verb' => 'POST', 'postfix' => 'restful'],
40+
['name' => 'chattyLLM#updateChatSession', 'url' => '/chat/sessions/{sessionId}', 'verb' => 'PUT', 'postfix' => 'restful'],
41+
['name' => 'chattyLLM#deleteSession', 'url' => '/chat/sessions/{sessionId}', 'verb' => 'DELETE', 'postfix' => 'restful'],
42+
3943
['name' => 'chattyLLM#newSession', 'url' => '/chat/new_session', 'verb' => 'PUT'],
4044
['name' => 'chattyLLM#updateSessionTitle', 'url' => '/chat/update_session', 'verb' => 'PATCH'],
4145
['name' => 'chattyLLM#deleteSession', 'url' => '/chat/delete_session', 'verb' => 'DELETE'],
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Assistant\BackgroundJob;
11+
12+
use OCA\Assistant\Service\SessionSummaryService;
13+
use OCP\AppFramework\Utility\ITimeFactory;
14+
use OCP\BackgroundJob\TimedJob;
15+
16+
class GenerateNewChatSummaries extends TimedJob {
17+
public function __construct(
18+
ITimeFactory $timeFactory,
19+
private SessionSummaryService $sessionSummaryService,
20+
) {
21+
parent::__construct($timeFactory);
22+
$this->setInterval(60 * 10); // 10min
23+
}
24+
public function run($argument) {
25+
$userId = $argument['userId'];
26+
$this->sessionSummaryService->generateSummariesForNewSessions($userId);
27+
}
28+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Assistant\BackgroundJob;
11+
12+
use OCA\Assistant\Service\SessionSummaryService;
13+
use OCP\AppFramework\Utility\ITimeFactory;
14+
use OCP\BackgroundJob\TimedJob;
15+
16+
class RegenerateOutdatedChatSummariesJob extends TimedJob {
17+
18+
public function __construct(
19+
ITimeFactory $timeFactory,
20+
private SessionSummaryService $sessionSummaryService,
21+
) {
22+
parent::__construct($timeFactory);
23+
$this->setInterval(60 * 60 * 24); // 24h
24+
}
25+
public function run($argument) {
26+
$userId = $argument['userId'];
27+
$this->sessionSummaryService->regenerateSummariesForOutdatedSessions($userId);
28+
}
29+
}

lib/Controller/ChattyLLMController.php

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use OCA\Assistant\Db\ChattyLLM\Session;
1414
use OCA\Assistant\Db\ChattyLLM\SessionMapper;
1515
use OCA\Assistant\ResponseDefinitions;
16+
use OCA\Assistant\Service\SessionSummaryService;
1617
use OCP\AppFramework\Db\DoesNotExistException;
1718
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
1819
use OCP\AppFramework\Http;
@@ -55,6 +56,7 @@ public function __construct(
5556
private IAppConfig $appConfig,
5657
private IUserManager $userManager,
5758
private ?string $userId,
59+
private SessionSummaryService $sessionSummaryService,
5860
) {
5961
parent::__construct($appName, $request);
6062
$this->agencyActionData = [
@@ -190,6 +192,49 @@ public function updateSessionTitle(int $sessionId, string $title): JSONResponse
190192
}
191193
}
192194

195+
/**
196+
* Update session
197+
*
198+
* @param integer $sessionId The chat session ID
199+
* @param string|null $title The new chat session title
200+
* @param bool|null $is_remembered The new is_remembered status: Whether to remember the insights from this chat session across all chat session
201+
* @return JSONResponse<Http::STATUS_OK, list{}, array{}>|JSONResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{error: string}, array{}>
202+
*
203+
* 200: The title has been updated successfully
204+
* 404: The session was not found
205+
*/
206+
#[NoAdminRequired]
207+
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])]
208+
public function updateChatSession(int $sessionId, ?string $title = null, ?bool $is_remembered = null): JSONResponse {
209+
if ($this->userId === null) {
210+
return new JSONResponse(['error' => $this->l10n->t('Could not find session')], Http::STATUS_NOT_FOUND);
211+
}
212+
if ($title === null && $is_remembered === null) {
213+
return new JSONResponse();
214+
}
215+
216+
try {
217+
$session = $this->sessionMapper->getUserSession($this->userId, $sessionId);
218+
if ($title !== null) {
219+
$session->setTitle($title);
220+
}
221+
if ($is_remembered !== null) {
222+
$session->setIsRemembered($is_remembered);
223+
// schedule summarizer jobs for this chat user
224+
if ($is_remembered) {
225+
$this->sessionSummaryService->scheduleJobsForUser($this->userId);
226+
}
227+
}
228+
$this->sessionMapper->update($session);
229+
return new JSONResponse();
230+
} catch (\OCP\DB\Exception|\RuntimeException $e) {
231+
$this->logger->warning('Failed to update the chat session', ['exception' => $e]);
232+
return new JSONResponse(['error' => $this->l10n->t('Failed to update the chat session')], Http::STATUS_INTERNAL_SERVER_ERROR);
233+
} catch (DoesNotExistException|MultipleObjectsReturnedException $e) {
234+
return new JSONResponse(['error' => $this->l10n->t('Could not find session')], Http::STATUS_NOT_FOUND);
235+
}
236+
}
237+
193238
/**
194239
* Delete a chat session
195240
*
@@ -779,6 +824,7 @@ public function checkSession(int $sessionId): JSONResponse {
779824
'messageTaskId' => null,
780825
'titleTaskId' => null,
781826
'sessionTitle' => $session->getTitle(),
827+
'is_remembered' => $session->getIsRemembered(),
782828
'sessionAgencyPendingActions' => $p,
783829
];
784830
if (!empty($messageTasks)) {
@@ -990,6 +1036,9 @@ private function scheduleLLMChatTask(
9901036
'system_prompt' => $systemPrompt,
9911037
'history' => $history,
9921038
];
1039+
if (isset($this->taskProcessingManager->getAvailableTaskTypes()[TextToTextChat::ID]['optionalInputShape']['memories'])) {
1040+
$input['memories'] = $this->sessionSummaryService->getUserSessionSummaries($this->userId);
1041+
}
9931042
$task = new Task(TextToTextChat::ID, $input, Application::APP_ID . ':chatty-llm', $this->userId, $customId);
9941043
$this->taskProcessingManager->scheduleTask($task);
9951044
return $task->getId() ?? 0;
@@ -1017,6 +1066,10 @@ private function scheduleAgencyTask(string $content, int $confirmation, string $
10171066
'conversation_token' => $conversationToken,
10181067
];
10191068
/** @psalm-suppress UndefinedClass */
1069+
if (isset($this->taskProcessingManager->getAvailableTaskTypes()[\OCP\TaskProcessing\TaskTypes\ContextAgentInteraction::ID]['optionalInputShape']['memories'])) {
1070+
$taskInput['memories'] = $this->sessionSummaryService->getUserSessionSummaries($this->userId);
1071+
}
1072+
/** @psalm-suppress UndefinedClass */
10201073
$task = new Task(
10211074
\OCP\TaskProcessing\TaskTypes\ContextAgentInteraction::ID,
10221075
$taskInput,
@@ -1039,6 +1092,10 @@ private function scheduleAudioChatTask(
10391092
'history' => $history,
10401093
];
10411094
/** @psalm-suppress UndefinedClass */
1095+
if (isset($this->taskProcessingManager->getAvailableTaskTypes()[\OCP\TaskProcessing\TaskTypes\AudioToAudioChat::ID]['optionalInputShape']['memories'])) {
1096+
$input['memories'] = $this->sessionSummaryService->getUserSessionSummaries($this->userId);
1097+
}
1098+
/** @psalm-suppress UndefinedClass */
10421099
$task = new Task(
10431100
\OCP\TaskProcessing\TaskTypes\AudioToAudioChat::ID,
10441101
$input,
@@ -1061,6 +1118,10 @@ private function scheduleAgencyAudioTask(
10611118
'conversation_token' => $conversationToken,
10621119
];
10631120
/** @psalm-suppress UndefinedClass */
1121+
if (isset($this->taskProcessingManager->getAvailableTaskTypes()[\OCP\TaskProcessing\TaskTypes\ContextAgentAudioInteraction::ID]['optionalInputShape']['memories'])) {
1122+
$taskInput['memories'] = $this->sessionSummaryService->getUserSessionSummaries($this->userId);
1123+
}
1124+
/** @psalm-suppress UndefinedClass */
10641125
$task = new Task(
10651126
\OCP\TaskProcessing\TaskTypes\ContextAgentAudioInteraction::ID,
10661127
$taskInput,

lib/Db/ChattyLLM/Message.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,11 @@ class Message extends Entity implements \JsonSerializable {
6666
];
6767

6868
public function __construct() {
69-
$this->addType('session_id', Types::INTEGER);
69+
$this->addType('sessionId', Types::INTEGER);
7070
$this->addType('role', Types::STRING);
7171
$this->addType('content', Types::STRING);
7272
$this->addType('timestamp', Types::INTEGER);
73-
$this->addType('ocp_task_id', Types::INTEGER);
73+
$this->addType('ocpTaskId', Types::INTEGER);
7474
$this->addType('sources', Types::STRING);
7575
$this->addType('attachments', Types::STRING);
7676
}

lib/Db/ChattyLLM/Session.php

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
* @method \void setUserId(string $userId)
1818
* @method \string|null getTitle()
1919
* @method \void setTitle(?string $title)
20+
* @method \string|null getSummary()
21+
* @method \void setSummary(?string $title)
2022
* @method \int getTimestamp()
2123
* @method \void setTimestamp(int $timestamp)
2224
* @method \string|null getAgencyConversationToken()
@@ -36,13 +38,34 @@ class Session extends Entity implements \JsonSerializable {
3638
/** @var ?string */
3739
protected $agencyPendingActions;
3840

41+
/**
42+
* Will be used to inject into assistant memories upon calling LLM
43+
*
44+
* @var ?string
45+
*/
46+
protected $summary;
47+
48+
/** @var int */
49+
protected $isSummaryUpToDate;
50+
51+
/**
52+
* Whether to remember the insights from this chat session across all chat sessions
53+
*
54+
* @var int
55+
*/
56+
protected $isRemembered;
57+
58+
3959
public static $columns = [
4060
'id',
4161
'user_id',
4262
'title',
4363
'timestamp',
4464
'agency_conversation_token',
4565
'agency_pending_actions',
66+
'summary',
67+
'is_summary_up_to_date',
68+
'is_remembered',
4669
];
4770
public static $fields = [
4871
'id',
@@ -51,14 +74,20 @@ class Session extends Entity implements \JsonSerializable {
5174
'timestamp',
5275
'agencyConversationToken',
5376
'agencyPendingActions',
77+
'summary',
78+
'isSummaryUpToDate',
79+
'isRemembered',
5480
];
5581

5682
public function __construct() {
57-
$this->addType('user_id', Types::STRING);
83+
$this->addType('userId', Types::STRING);
5884
$this->addType('title', Types::STRING);
5985
$this->addType('timestamp', Types::INTEGER);
60-
$this->addType('agency_conversation_token', Types::STRING);
61-
$this->addType('agency_pending_actions', Types::STRING);
86+
$this->addType('agencyConversationToken', Types::STRING);
87+
$this->addType('agencyPendingActions', Types::STRING);
88+
$this->addType('summary', Types::TEXT);
89+
$this->addType('isSummaryUpToDate', Types::SMALLINT);
90+
$this->addType('isRemembered', Types::SMALLINT);
6291
}
6392

6493
#[\ReturnTypeWillChange]
@@ -70,6 +99,25 @@ public function jsonSerialize() {
7099
'timestamp' => $this->getTimestamp(),
71100
'agency_conversation_token' => $this->getAgencyConversationToken(),
72101
'agency_pending_actions' => $this->getAgencyPendingActions(),
102+
'summary' => $this->getSummary(),
103+
'is_summary_up_to_date' => $this->getIsSummaryUpToDate(),
104+
'is_remembered' => $this->getIsRemembered(),
73105
];
74106
}
107+
108+
public function setIsSummaryUpToDate(bool $value): void {
109+
$this->setter('isSummaryUpToDate', [$value ? 1 : 0]);
110+
}
111+
112+
public function setIsRemembered(bool $value): void {
113+
$this->setter('isRemembered', [$value ? 1 : 0]);
114+
}
115+
116+
public function getIsSummaryUpToDate(): bool {
117+
return $this->getter('isSummaryUpToDate') === 1;
118+
}
119+
120+
public function getIsRemembered(): bool {
121+
return $this->getter('isRemembered') === 1;
122+
}
75123
}

lib/Db/ChattyLLM/SessionMapper.php

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,75 @@ public function getUserSessions(string $userId): array {
7979
return $this->findEntities($qb);
8080
}
8181

82+
/**
83+
* @return array<Session>
84+
* @throws \OCP\DB\Exception
85+
*/
86+
public function getRememberedUserSessions(string $userId, int $limit = 0): array {
87+
$qb = $this->db->getQueryBuilder();
88+
$qb->select(Session::$columns)
89+
->from($this->getTableName())
90+
->where($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR)))
91+
->andWhere($qb->expr()->eq('is_remembered', $qb->createPositionalParameter(1, IQueryBuilder::PARAM_INT)))
92+
->orderBy('timestamp', 'DESC');
93+
94+
if ($limit > 0) {
95+
$qb->setMaxResults($limit);
96+
}
97+
98+
return $this->findEntities($qb);
99+
}
100+
101+
/**
102+
* @return array<Session>
103+
* @throws \OCP\DB\Exception
104+
*/
105+
public function getRememberedUserSessionsWithOutdatedSummaries(string $userId, int $limit): array {
106+
$qb = $this->db->getQueryBuilder();
107+
$qb->select(Session::$columns)
108+
->from($this->getTableName())
109+
->where($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR)))
110+
->andWhere($qb->expr()->eq('is_remembered', $qb->createPositionalParameter(1, IQueryBuilder::PARAM_INT)))
111+
->andWhere($qb->expr()->eq('is_summary_up_to_date', $qb->createPositionalParameter(0, IQueryBuilder::PARAM_INT)))
112+
->setMaxResults($limit)
113+
->orderBy('timestamp', 'DESC');
114+
115+
return $this->findEntities($qb);
116+
}
117+
118+
/**
119+
* @return array<Session>
120+
* @throws \OCP\DB\Exception
121+
*/
122+
public function getRememberedSessionsWithOutdatedSummaries(int $limit): array {
123+
$qb = $this->db->getQueryBuilder();
124+
$qb->select(Session::$columns)
125+
->from($this->getTableName())
126+
->where($qb->expr()->eq('is_remembered', $qb->createPositionalParameter(1, IQueryBuilder::PARAM_INT)))
127+
->andWhere($qb->expr()->eq('is_summary_up_to_date', $qb->createPositionalParameter(0, IQueryBuilder::PARAM_INT)))
128+
->setMaxResults($limit)
129+
->orderBy('timestamp', 'DESC');
130+
131+
return $this->findEntities($qb);
132+
}
133+
134+
/**
135+
* @return array<Session>
136+
* @throws \OCP\DB\Exception
137+
*/
138+
public function getRememberedUserSessionsWithoutSummaries(string $userId, int $limit): array {
139+
$qb = $this->db->getQueryBuilder();
140+
$qb->select(Session::$columns)
141+
->from($this->getTableName())
142+
->where($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR)))
143+
->andWhere($qb->expr()->eq('is_remembered', $qb->createPositionalParameter(1, IQueryBuilder::PARAM_INT)))
144+
->andWhere($qb->expr()->isNull('summary'))
145+
->setMaxResults($limit)
146+
->orderBy('timestamp', 'DESC');
147+
148+
return $this->findEntities($qb);
149+
}
150+
82151
/**
83152
* @param string $userId
84153
* @param integer $sessionId
@@ -110,4 +179,10 @@ public function deleteSession(string $userId, int $sessionId) {
110179

111180
$qb->executeStatement();
112181
}
182+
183+
public function updateSessionIsRemembered(?string $userId, int $sessionId, bool $is_remembered) {
184+
$session = $this->getUserSession($userId, $sessionId);
185+
$session->setIsRemembered($is_remembered);
186+
$this->update($session);
187+
}
113188
}

0 commit comments

Comments
 (0)