Skip to content

Commit 9ce1588

Browse files
Merge pull request #13661 from nextcloud/feat/13430/unread-messages-summary
feat(chat): Add API to summarize chat messages
2 parents 437943a + fb841e8 commit 9ce1588

27 files changed

+1009
-69
lines changed

appinfo/routes/routesChatController.php

+2
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
'ocs' => [
2222
/** @see \OCA\Talk\Controller\ChatController::receiveMessages() */
2323
['name' => 'Chat#receiveMessages', 'url' => '/api/{apiVersion}/chat/{token}', 'verb' => 'GET', 'requirements' => $requirements],
24+
/** @see \OCA\Talk\Controller\ChatController::summarizeChat() */
25+
['name' => 'Chat#summarizeChat', 'url' => '/api/{apiVersion}/chat/{token}/summarize', 'verb' => 'POST', 'requirements' => $requirements],
2426
/** @see \OCA\Talk\Controller\ChatController::sendMessage() */
2527
['name' => 'Chat#sendMessage', 'url' => '/api/{apiVersion}/chat/{token}', 'verb' => 'POST', 'requirements' => $requirements],
2628
/** @see \OCA\Talk\Controller\ChatController::clearHistory() */

docs/capabilities.md

+2
Original file line numberDiff line numberDiff line change
@@ -160,5 +160,7 @@
160160
* `archived-conversations` (local) - Conversations can be marked as archived which will hide them from the conversation list by default
161161
* `talk-polls-drafts` - Whether moderators can store and retrieve poll drafts
162162
* `download-call-participants` - Whether the endpoints for moderators to download the call participants is available
163+
* `chat-summary-api` (local) - Whether the endpoint to get summarized chat messages in a conversation is available
164+
* `config => chat => summary-threshold` (local) - Number of unread messages that should exist to show a "Generate summary" option
163165
* `config => call => start-without-media` (local) - Boolean, whether media should be disabled when starting or joining a conversation
164166
* `config => call => max-duration` - Integer, maximum call duration in seconds. Please note that this should only be used with system cron and with a reasonable high value, due to the expended duration until the background job ran.

lib/Capabilities.php

+17
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
use OCP\IConfig;
1919
use OCP\IUser;
2020
use OCP\IUserSession;
21+
use OCP\TaskProcessing\IManager as ITaskProcessingManager;
22+
use OCP\TaskProcessing\TaskTypes\TextToTextSummary;
2123
use OCP\Translation\ITranslationManager;
2224
use OCP\Util;
2325

@@ -108,6 +110,12 @@ class Capabilities implements IPublicCapability {
108110
'download-call-participants',
109111
];
110112

113+
public const CONDITIONAL_FEATURES = [
114+
'message-expiration',
115+
'reactions',
116+
'chat-summary-api',
117+
];
118+
111119
public const LOCAL_FEATURES = [
112120
'favorites',
113121
'chat-read-status',
@@ -119,6 +127,7 @@ class Capabilities implements IPublicCapability {
119127
'remind-me-later',
120128
'note-to-self',
121129
'archived-conversations',
130+
'chat-summary-api',
122131
];
123132

124133
public const LOCAL_CONFIGS = [
@@ -135,6 +144,7 @@ class Capabilities implements IPublicCapability {
135144
'read-privacy',
136145
'has-translation-providers',
137146
'typing-privacy',
147+
'summary-threshold',
138148
],
139149
'conversations' => [
140150
'can-create',
@@ -164,6 +174,7 @@ public function __construct(
164174
protected IUserSession $userSession,
165175
protected IAppManager $appManager,
166176
protected ITranslationManager $translationManager,
177+
protected ITaskProcessingManager $taskProcessingManager,
167178
ICacheFactory $cacheFactory,
168179
) {
169180
$this->talkCache = $cacheFactory->createLocal('talk::');
@@ -207,6 +218,7 @@ public function getCapabilities(): array {
207218
'read-privacy' => Participant::PRIVACY_PUBLIC,
208219
'has-translation-providers' => $this->translationManager->hasProviders(),
209220
'typing-privacy' => Participant::PRIVACY_PUBLIC,
221+
'summary-threshold' => 100,
210222
],
211223
'conversations' => [
212224
'can-create' => $user instanceof IUser && !$this->talkConfig->isNotAllowedToCreateConversations($user)
@@ -300,6 +312,11 @@ public function getCapabilities(): array {
300312
$capabilities['config']['call']['can-enable-sip'] = $this->talkConfig->canUserEnableSIP($user);
301313
}
302314

315+
$supportedTaskTypes = $this->taskProcessingManager->getAvailableTaskTypes();
316+
if (isset($supportedTaskTypes[TextToTextSummary::ID])) {
317+
$capabilities['features'][] = 'chat-summary-api';
318+
}
319+
303320
return [
304321
'spreed' => $capabilities,
305322
];

lib/Controller/ChatController.php

+145
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@
88

99
namespace OCA\Talk\Controller;
1010

11+
use OCA\Talk\AppInfo\Application;
1112
use OCA\Talk\Chat\AutoComplete\SearchPlugin;
1213
use OCA\Talk\Chat\AutoComplete\Sorter;
1314
use OCA\Talk\Chat\ChatManager;
1415
use OCA\Talk\Chat\MessageParser;
1516
use OCA\Talk\Chat\Notifier;
1617
use OCA\Talk\Chat\ReactionManager;
1718
use OCA\Talk\Exceptions\CannotReachRemoteException;
19+
use OCA\Talk\Exceptions\ChatSummaryException;
1820
use OCA\Talk\Federation\Authenticator;
1921
use OCA\Talk\GuestManager;
2022
use OCA\Talk\MatterbridgeManager;
@@ -51,6 +53,7 @@
5153
use OCP\AppFramework\Http\Attribute\PublicPage;
5254
use OCP\AppFramework\Http\Attribute\UserRateLimit;
5355
use OCP\AppFramework\Http\DataResponse;
56+
use OCP\AppFramework\Services\IAppConfig;
5457
use OCP\AppFramework\Utility\ITimeFactory;
5558
use OCP\Collaboration\AutoComplete\IManager;
5659
use OCP\Collaboration\Collaborators\ISearchResult;
@@ -62,14 +65,20 @@
6265
use OCP\IRequest;
6366
use OCP\IUserManager;
6467
use OCP\RichObjectStrings\InvalidObjectExeption;
68+
use OCP\RichObjectStrings\IRichTextFormatter;
6569
use OCP\RichObjectStrings\IValidator;
6670
use OCP\Security\ITrustedDomainHelper;
6771
use OCP\Security\RateLimiting\IRateLimitExceededException;
6872
use OCP\Share\Exceptions\ShareNotFound;
6973
use OCP\Share\IShare;
74+
use OCP\TaskProcessing\Exception\Exception;
75+
use OCP\TaskProcessing\IManager as ITaskProcessingManager;
76+
use OCP\TaskProcessing\Task;
77+
use OCP\TaskProcessing\TaskTypes\TextToTextSummary;
7078
use OCP\User\Events\UserLiveStatusEvent;
7179
use OCP\UserStatus\IManager as IUserStatusManager;
7280
use OCP\UserStatus\IUserStatus;
81+
use Psr\Log\LoggerInterface;
7382

7483
/**
7584
* @psalm-import-type TalkChatMentionSuggestion from ResponseDefinitions
@@ -114,6 +123,10 @@ public function __construct(
114123
protected Authenticator $federationAuthenticator,
115124
protected ProxyCacheMessageService $pcmService,
116125
protected Notifier $notifier,
126+
protected IRichTextFormatter $richTextFormatter,
127+
protected ITaskProcessingManager $taskProcessingManager,
128+
protected IAppConfig $appConfig,
129+
protected LoggerInterface $logger,
117130
) {
118131
parent::__construct($appName, $request);
119132
}
@@ -489,6 +502,138 @@ public function receiveMessages(int $lookIntoFuture,
489502
return $this->prepareCommentsAsDataResponse($comments, $lastCommonReadId);
490503
}
491504

505+
/**
506+
* Summarize the next bunch of chat messages from a given offset
507+
*
508+
* Required capability: `chat-summary-api`
509+
*
510+
* @param positive-int $fromMessageId Offset from where on the summary should be generated
511+
* @return DataResponse<Http::STATUS_CREATED, array{taskId: int, nextOffset?: int}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'ai-no-provider'|'ai-error'}, array{}>|DataResponse<Http::STATUS_NO_CONTENT, array<empty>, array{}>
512+
* @throws \InvalidArgumentException
513+
*
514+
* 201: Summary was scheduled, use the returned taskId to get the status
515+
* information and output from the TaskProcessing API:
516+
* https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-taskprocessing-api.html#fetch-a-task-by-id
517+
* If the response data contains nextOffset, not all messages could be handled in a single request.
518+
* After receiving the response a second summary should be requested with the provided nextOffset.
519+
* 204: No messages found to summarize
520+
* 400: No AI provider available or summarizing failed
521+
*/
522+
#[PublicPage]
523+
#[RequireModeratorOrNoLobby]
524+
#[RequireParticipant]
525+
public function summarizeChat(
526+
int $fromMessageId,
527+
): DataResponse {
528+
$fromMessageId = max(0, $fromMessageId);
529+
530+
$supportedTaskTypes = $this->taskProcessingManager->getAvailableTaskTypes();
531+
if (!isset($supportedTaskTypes[TextToTextSummary::ID])) {
532+
return new DataResponse([
533+
'error' => ChatSummaryException::REASON_AI_ERROR,
534+
], Http::STATUS_BAD_REQUEST);
535+
}
536+
537+
// if ($this->room->isFederatedConversation()) {
538+
// /** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\ChatController $proxy */
539+
// $proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\ChatController::class);
540+
// return $proxy->summarizeChat(
541+
// $this->room,
542+
// $this->participant,
543+
// $fromMessageId,
544+
// );
545+
// }
546+
547+
$currentUser = $this->userManager->get($this->userId);
548+
$batchSize = $this->appConfig->getAppValueInt('ai_unread_summary_batch_size', 500);
549+
$comments = $this->chatManager->waitForNewMessages($this->room, $fromMessageId, $batchSize, 0, $currentUser, true, false);
550+
$this->preloadShares($comments);
551+
552+
$messages = [];
553+
$nextOffset = 0;
554+
foreach ($comments as $comment) {
555+
$message = $this->messageParser->createMessage($this->room, $this->participant, $comment, $this->l);
556+
$this->messageParser->parseMessage($message);
557+
558+
if (!$message->getVisibility()) {
559+
continue;
560+
}
561+
562+
if ($message->getMessageType() === ChatManager::VERB_SYSTEM
563+
&& !in_array($message->getMessageRaw(), [
564+
'call_ended',
565+
'call_ended_everyone',
566+
'file_shared',
567+
'object_shared',
568+
], true)) {
569+
// Ignore system messages apart from calls, shared objects and files
570+
continue;
571+
}
572+
573+
$parsedMessage = $this->richTextFormatter->richToParsed(
574+
$message->getMessage(),
575+
$message->getMessageParameters(),
576+
);
577+
578+
$displayName = $message->getActorDisplayName();
579+
if (in_array($message->getActorType(), [
580+
Attendee::ACTOR_GUESTS,
581+
Attendee::ACTOR_EMAILS,
582+
], true)) {
583+
if ($displayName === '') {
584+
$displayName = $this->l->t('Guest');
585+
} else {
586+
$displayName = $this->l->t('%s (guest)', $displayName);
587+
}
588+
}
589+
590+
if ($comment->getParentId() !== '0') {
591+
// FIXME should add something?
592+
}
593+
594+
$messages[] = $displayName . ': ' . $parsedMessage;
595+
$nextOffset = (int)$comment->getId();
596+
}
597+
598+
if (empty($messages)) {
599+
return new DataResponse([], Http::STATUS_NO_CONTENT);
600+
}
601+
602+
$task = new Task(
603+
TextToTextSummary::ID,
604+
['input' => implode("\n\n", $messages)],
605+
Application::APP_ID,
606+
$this->userId,
607+
'summary/' . $this->room->getToken(),
608+
);
609+
610+
try {
611+
$this->taskProcessingManager->scheduleTask($task);
612+
} catch (Exception $e) {
613+
$this->logger->error('An error occurred while trying to summarize unread messages', ['exception' => $e]);
614+
return new DataResponse([
615+
'error' => ChatSummaryException::REASON_AI_ERROR,
616+
], Http::STATUS_BAD_REQUEST);
617+
}
618+
619+
$taskId = $task->getId();
620+
if ($taskId === null) {
621+
return new DataResponse([
622+
'error' => ChatSummaryException::REASON_AI_ERROR,
623+
], Http::STATUS_BAD_REQUEST);
624+
}
625+
626+
$data = [
627+
'taskId' => $taskId,
628+
];
629+
630+
if ($nextOffset !== $this->room->getLastMessageId()) {
631+
$data['nextOffset'] = $nextOffset;
632+
}
633+
634+
return new DataResponse($data, Http::STATUS_CREATED);
635+
}
636+
492637
/**
493638
* @return DataResponse<Http::STATUS_OK|Http::STATUS_NOT_MODIFIED, TalkChatMessageWithParent[], array{X-Chat-Last-Common-Read?: numeric-string, X-Chat-Last-Given?: numeric-string}>
494639
*/
+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
9+
namespace OCA\Talk\Exceptions;
10+
11+
class ChatSummaryException extends \InvalidArgumentException {
12+
public const REASON_NO_PROVIDER = 'ai-no-provider';
13+
public const REASON_AI_ERROR = 'ai-error';
14+
15+
/**
16+
* @param self::REASON_* $reason
17+
*/
18+
public function __construct(
19+
protected string $reason,
20+
) {
21+
parent::__construct($reason);
22+
}
23+
24+
/**
25+
* @return self::REASON_*
26+
*/
27+
public function getReason(): string {
28+
return $this->reason;
29+
}
30+
}

lib/ResponseDefinitions.php

+1
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,7 @@
354354
* read-privacy: int,
355355
* has-translation-providers: bool,
356356
* typing-privacy: int,
357+
* summary-threshold: positive-int,
357358
* },
358359
* conversations: array{
359360
* can-create: bool,

openapi-administration.json

+7-1
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,8 @@
204204
"max-length",
205205
"read-privacy",
206206
"has-translation-providers",
207-
"typing-privacy"
207+
"typing-privacy",
208+
"summary-threshold"
208209
],
209210
"properties": {
210211
"max-length": {
@@ -221,6 +222,11 @@
221222
"typing-privacy": {
222223
"type": "integer",
223224
"format": "int64"
225+
},
226+
"summary-threshold": {
227+
"type": "integer",
228+
"format": "int64",
229+
"minimum": 1
224230
}
225231
}
226232
},

openapi-backend-recording.json

+7-1
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,8 @@
137137
"max-length",
138138
"read-privacy",
139139
"has-translation-providers",
140-
"typing-privacy"
140+
"typing-privacy",
141+
"summary-threshold"
141142
],
142143
"properties": {
143144
"max-length": {
@@ -154,6 +155,11 @@
154155
"typing-privacy": {
155156
"type": "integer",
156157
"format": "int64"
158+
},
159+
"summary-threshold": {
160+
"type": "integer",
161+
"format": "int64",
162+
"minimum": 1
157163
}
158164
}
159165
},

openapi-backend-signaling.json

+7-1
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,8 @@
137137
"max-length",
138138
"read-privacy",
139139
"has-translation-providers",
140-
"typing-privacy"
140+
"typing-privacy",
141+
"summary-threshold"
141142
],
142143
"properties": {
143144
"max-length": {
@@ -154,6 +155,11 @@
154155
"typing-privacy": {
155156
"type": "integer",
156157
"format": "int64"
158+
},
159+
"summary-threshold": {
160+
"type": "integer",
161+
"format": "int64",
162+
"minimum": 1
157163
}
158164
}
159165
},

0 commit comments

Comments
 (0)