Skip to content

Commit 4186802

Browse files
hamza221backportbot[bot]
authored andcommitted
fix(ai): reduce summary tasks queued per user
Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Hamza <hamzamahjoubi221@gmail.com>
1 parent c649b0d commit 4186802

6 files changed

Lines changed: 261 additions & 27 deletions

File tree

‎lib/Listener/FollowUpClassifierListener.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ public function handle(Event $event): void {
5252

5353
// Do not process emails older than 14D to save some processing power
5454
$notBefore = (new DateTimeImmutable('now'))
55-
->sub(new DateInterval('P14D'));
55+
->sub(new DateInterval(AiIntegrationsService::RECENT_MESSAGE_MAX_AGE));
5656
$userId = $event->getAccount()->getUserId();
5757
foreach ($event->getMessages() as $message) {
5858
if ($message->getSentAt() < $notBefore->getTimestamp()) {

‎lib/Listener/NewMessagesSummarizeListener.php‎

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,44 @@
99

1010
namespace OCA\Mail\Listener;
1111

12+
use DateInterval;
13+
use Horde_Imap_Client;
14+
use OCA\Mail\Account;
1215
use OCA\Mail\ConfigLexicon;
16+
use OCA\Mail\Db\Mailbox;
17+
use OCA\Mail\Db\Message;
1318
use OCA\Mail\Events\NewMessagesSynchronized;
1419
use OCA\Mail\Exception\ServiceException;
1520
use OCA\Mail\Service\AiIntegrations\AiIntegrationsService;
1621
use OCP\AppFramework\Services\IAppConfig;
22+
use OCP\AppFramework\Utility\ITimeFactory;
1723
use OCP\EventDispatcher\Event;
1824
use OCP\EventDispatcher\IEventListener;
1925
use Psr\Log\LoggerInterface;
26+
use function array_filter;
27+
use function array_values;
28+
use function in_array;
2029

2130
/**
2231
* @template-implements IEventListener<Event>
2332
*/
2433
class NewMessagesSummarizeListener implements IEventListener {
2534

35+
private const SKIPPED_SPECIAL_USES = [
36+
Horde_Imap_Client::SPECIALUSE_ALL,
37+
Horde_Imap_Client::SPECIALUSE_ARCHIVE,
38+
Horde_Imap_Client::SPECIALUSE_DRAFTS,
39+
Horde_Imap_Client::SPECIALUSE_FLAGGED,
40+
Horde_Imap_Client::SPECIALUSE_JUNK,
41+
Horde_Imap_Client::SPECIALUSE_SENT,
42+
Horde_Imap_Client::SPECIALUSE_TRASH,
43+
];
44+
2645
public function __construct(
2746
private LoggerInterface $logger,
2847
private AiIntegrationsService $aiService,
2948
private IAppConfig $appConfig,
49+
private ITimeFactory $timeFactory,
3050
) {
3151
}
3252

@@ -38,16 +58,48 @@ public function handle(Event $event): void {
3858
if (!($event instanceof NewMessagesSynchronized)) {
3959
return;
4060
}
61+
if ($this->isSkippedMailbox($event->getAccount(), $event->getMailbox())) {
62+
return;
63+
}
64+
65+
$notBefore = $this->timeFactory->getDateTime()
66+
->sub(new DateInterval(AiIntegrationsService::RECENT_MESSAGE_MAX_AGE))
67+
->getTimestamp();
68+
$messages = array_values(array_filter(
69+
$event->getMessages(),
70+
static fn (Message $message) => $message->getSentAt() >= $notBefore,
71+
));
72+
if ($messages === []) {
73+
return;
74+
}
4175

4276
try {
4377
$this->aiService->summarizeMessages(
4478
$event->getAccount(),
45-
$event->getMessages(),
79+
$messages,
4680
);
4781
} catch (ServiceException $e) {
4882
$this->logger->error('Could not initiate a message summarize task(s): ' . $e->getMessage(), [
4983
'exception' => $e,
5084
]);
5185
}
5286
}
87+
88+
private function isSkippedMailbox(Account $account, Mailbox $mailbox): bool {
89+
foreach (self::SKIPPED_SPECIAL_USES as $specialUse) {
90+
if ($mailbox->isSpecialUse($specialUse)) {
91+
return true;
92+
}
93+
}
94+
95+
$mailAccount = $account->getMailAccount();
96+
return in_array($mailbox->getId(), [
97+
$mailAccount->getSentMailboxId(),
98+
$mailAccount->getTrashMailboxId(),
99+
$mailAccount->getJunkMailboxId(),
100+
$mailAccount->getArchiveMailboxId(),
101+
$mailAccount->getDraftsMailboxId(),
102+
$mailAccount->getSnoozeMailboxId(),
103+
], true);
104+
}
53105
}

‎lib/Service/AiIntegrations/AiIntegrationsService.php‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@
3939

4040
class AiIntegrationsService {
4141

42+
public const RECENT_MESSAGE_MAX_AGE = 'P14D';
43+
4244
public function __construct(
4345
private LoggerInterface $logger,
4446
private Cache $cache,
@@ -140,10 +142,11 @@ public function summarizeMessages(Account $account, array $messages): void {
140142
*/
141143
public function summarizeThread(Account $account, string $threadId, array $messages, string $currentUserId): ?string {
142144
if (isset($this->taskProcessingManager->getAvailableTaskTypes()[TextToTextSummary::ID])) {
143-
$messageIds = array_map(fn ($message) => $message->getMessageId(), $messages);
144-
$cachedSummary = $this->cache->getValue($this->cache->buildUrlKey($messageIds));
145-
if ($cachedSummary) {
146-
return $cachedSummary;
145+
$messageIds = array_map(static fn (Message $message) => $message->getId(), $messages);
146+
$cacheKey = 'threadSummary_' . $this->cache->buildUrlKey($messageIds);
147+
$cachedSummary = $this->cache->getValue($cacheKey);
148+
if (is_string($cachedSummary)) {
149+
return $cachedSummary === Cache::FAILURE_MARKER ? null : $cachedSummary;
147150
}
148151
$client = $this->clientFactory->getClient($account);
149152
try {
@@ -174,13 +177,22 @@ public function summarizeThread(Account $account, string $threadId, array $messa
174177
$currentUserId,
175178
$threadId,
176179
);
177-
$summaryTask = $this->runTask($summaryTask);
180+
try {
181+
$summaryTask = $this->runTask($summaryTask);
182+
} catch (ServiceException $e) {
183+
$this->cache->addFailure($cacheKey);
184+
throw $e;
185+
}
178186
$output = $summaryTask->getOutput()['output'] ?? null;
179187
// output could be array<array<numeric|string>|numeric|string>|null depending on task type
180188
// We expect Text in TextToTextSummary so should always resolve to (string)$output
181189
$summary = $output !== null && !is_array($output) ? (string)$output : null;
190+
if ($summary === null || trim($summary) === '') {
191+
$this->cache->addFailure($cacheKey);
192+
return null;
193+
}
182194

183-
$this->cache->addValue($this->cache->buildUrlKey($messageIds), $summary);
195+
$this->cache->addValue($cacheKey, $summary);
184196

185197
return $summary;
186198
} else {

‎lib/Service/AiIntegrations/Cache.php‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
class Cache {
1616
// Cache for one week
1717
public const CACHE_TTL = 7 * 24 * 60 * 60;
18+
// Failed or empty AI results are retried after one hour
19+
public const FAILURE_CACHE_TTL = 60 * 60;
20+
public const FAILURE_MARKER = '';
1821

1922
/** @var ICache */
2023
private $cache;
@@ -52,8 +55,12 @@ public function getValue(string $key) {
5255
*
5356
* @return void
5457
*/
55-
public function addValue(string $key, ?string $value): void {
56-
$this->cache->set($key, $value ?? false, self::CACHE_TTL);
58+
public function addValue(string $key, ?string $value, int $ttl = self::CACHE_TTL): void {
59+
$this->cache->set($key, $value ?? false, $ttl);
60+
}
61+
62+
public function addFailure(string $key): void {
63+
$this->addValue($key, self::FAILURE_MARKER, self::FAILURE_CACHE_TTL);
5764
}
5865

5966
/**

‎tests/Unit/Listener/NewMessagesSummarizeListenerTest.php‎

Lines changed: 119 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,52 +10,83 @@
1010
namespace Unit\Listener;
1111

1212
use ChristophWurst\Nextcloud\Testing\TestCase;
13+
use DateInterval;
14+
use DateTime;
1315
use OCA\Mail\Account;
1416
use OCA\Mail\Db\MailAccount;
17+
use OCA\Mail\Db\Mailbox;
18+
use OCA\Mail\Db\Message;
1519
use OCA\Mail\Events\NewMessagesSynchronized;
1620
use OCA\Mail\Listener\NewMessagesSummarizeListener;
1721
use OCA\Mail\Service\AiIntegrations\AiIntegrationsService;
1822
use OCP\AppFramework\Services\IAppConfig;
23+
use OCP\AppFramework\Utility\ITimeFactory;
1924
use PHPUnit\Framework\MockObject\MockObject;
2025
use Psr\Log\LoggerInterface;
2126
use Psr\Log\Test\TestLogger;
2227

2328
class NewMessagesSummarizeListenerTest extends TestCase {
29+
private const NOW = 1_750_000_000;
30+
2431
private LoggerInterface $logger;
2532
private NewMessagesSummarizeListener $listener;
26-
private AiIntegrationsService|MockObject $aiService;
27-
private IAppConfig|MockObject $appConfig;
33+
private AiIntegrationsService&MockObject $aiService;
34+
private IAppConfig&MockObject $appConfig;
35+
private ITimeFactory&MockObject $timeFactory;
2836

2937
protected function setUp(): void {
3038
parent::setUp();
3139

3240
$this->logger = new TestLogger();
3341
$this->aiService = $this->createMock(AiIntegrationsService::class);
3442
$this->appConfig = $this->createMock(IAppConfig::class);
43+
$this->timeFactory = $this->createMock(ITimeFactory::class);
44+
$this->timeFactory->method('getDateTime')
45+
->willReturnCallback(static fn () => new DateTime('@' . self::NOW));
3546

3647
$this->listener = new NewMessagesSummarizeListener(
3748
$this->logger,
3849
$this->aiService,
39-
$this->appConfig
50+
$this->appConfig,
51+
$this->timeFactory,
4052
);
4153
}
4254

43-
public function testLlmEnabled(): void {
44-
$event = $this->createMock(NewMessagesSynchronized::class);
45-
$account = new Account(new MailAccount());
46-
$event->expects($this->once())
47-
->method('getAccount')
48-
->willReturn($account);
49-
$event->expects($this->once())
50-
->method('getMessages')
51-
->willReturn([]);
55+
private function createMailbox(int $id, string $specialUse = '[]'): Mailbox {
56+
$mailbox = new Mailbox();
57+
$mailbox->setId($id);
58+
$mailbox->setSpecialUse($specialUse);
59+
return $mailbox;
60+
}
61+
62+
private function createMessage(int $sentAt): Message {
63+
$message = new Message();
64+
$message->setSentAt($sentAt);
65+
return $message;
66+
}
67+
68+
private function enableLlm(): void {
5269
$this->appConfig->expects($this->once())
5370
->method('getAppValueBool')
5471
->with('llm_processing', false)
5572
->willReturn(true);
73+
}
74+
75+
private function maxAgeCutoff(): int {
76+
return (new DateTime('@' . self::NOW))
77+
->sub(new DateInterval(AiIntegrationsService::RECENT_MESSAGE_MAX_AGE))
78+
->getTimestamp();
79+
}
80+
81+
public function testLlmEnabled(): void {
82+
$account = new Account(new MailAccount());
83+
$message = $this->createMessage(self::NOW);
84+
$event = new NewMessagesSynchronized($account, $this->createMailbox(1), [$message]);
85+
$this->enableLlm();
5686
$this->aiService->expects($this->once())
5787
->method('summarizeMessages')
58-
->with($account, []);
88+
->with($account, [$message]);
89+
5990
$this->listener->handle($event);
6091
}
6192

@@ -67,7 +98,82 @@ public function testLlmDisabled(): void {
6798
->willReturn(false);
6899
$this->aiService->expects($this->never())
69100
->method('summarizeMessages');
101+
70102
$this->listener->handle($event);
71103
}
72104

105+
public static function provideSkippedSpecialUses(): array {
106+
return [
107+
'all' => ['["all"]'],
108+
'archive' => ['["archive"]'],
109+
'drafts' => ['["drafts"]'],
110+
'flagged' => ['["flagged"]'],
111+
'junk' => ['["junk"]'],
112+
'sent' => ['["sent"]'],
113+
'trash' => ['["trash"]'],
114+
];
115+
}
116+
117+
/**
118+
* @dataProvider provideSkippedSpecialUses
119+
*/
120+
public function testSkipsSpecialUseMailbox(string $specialUse): void {
121+
$account = new Account(new MailAccount());
122+
$event = new NewMessagesSynchronized($account, $this->createMailbox(1, $specialUse), [$this->createMessage(self::NOW)]);
123+
$this->enableLlm();
124+
$this->aiService->expects($this->never())
125+
->method('summarizeMessages');
126+
127+
$this->listener->handle($event);
128+
}
129+
130+
public static function provideAccountMailboxSetters(): array {
131+
return [
132+
'archive' => ['setArchiveMailboxId'],
133+
'drafts' => ['setDraftsMailboxId'],
134+
'junk' => ['setJunkMailboxId'],
135+
'sent' => ['setSentMailboxId'],
136+
'snooze' => ['setSnoozeMailboxId'],
137+
'trash' => ['setTrashMailboxId'],
138+
];
139+
}
140+
141+
/**
142+
* @dataProvider provideAccountMailboxSetters
143+
*/
144+
public function testSkipsAccountConfiguredMailbox(string $setter): void {
145+
$mailAccount = new MailAccount();
146+
$mailAccount->$setter(7);
147+
$account = new Account($mailAccount);
148+
$event = new NewMessagesSynchronized($account, $this->createMailbox(7), [$this->createMessage(self::NOW)]);
149+
$this->enableLlm();
150+
$this->aiService->expects($this->never())
151+
->method('summarizeMessages');
152+
153+
$this->listener->handle($event);
154+
}
155+
156+
public function testOnlySummarizesRecentMessages(): void {
157+
$account = new Account(new MailAccount());
158+
$recent = $this->createMessage(self::NOW);
159+
$atCutoff = $this->createMessage($this->maxAgeCutoff());
160+
$old = $this->createMessage($this->maxAgeCutoff() - 1);
161+
$event = new NewMessagesSynchronized($account, $this->createMailbox(1), [$old, $recent, $atCutoff]);
162+
$this->enableLlm();
163+
$this->aiService->expects($this->once())
164+
->method('summarizeMessages')
165+
->with($account, [$recent, $atCutoff]);
166+
167+
$this->listener->handle($event);
168+
}
169+
170+
public function testSkipsWhenAllMessagesAreOld(): void {
171+
$account = new Account(new MailAccount());
172+
$event = new NewMessagesSynchronized($account, $this->createMailbox(1), [$this->createMessage($this->maxAgeCutoff() - 1)]);
173+
$this->enableLlm();
174+
$this->aiService->expects($this->never())
175+
->method('summarizeMessages');
176+
177+
$this->listener->handle($event);
178+
}
73179
}

0 commit comments

Comments
 (0)