Skip to content

Commit

Permalink
Merge pull request #9366 from nextcloud/feat/integration/llm-events
Browse files Browse the repository at this point in the history
feat(integration): Use LLM to fill event details
  • Loading branch information
ChristophWurst authored Feb 27, 2024
2 parents c934d35 + eab26c4 commit a537687
Show file tree
Hide file tree
Showing 11 changed files with 446 additions and 2 deletions.
5 changes: 5 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,11 @@
'url' => '/api/thread/{id}/summary',
'verb' => 'GET'
],
[
'name' => 'thread#generateEventData',
'url' => '/api/thread/{id}/eventdata',
'verb' => 'GET'
],
[
'name' => 'outbox#send',
'url' => '/api/outbox/{id}',
Expand Down
23 changes: 23 additions & 0 deletions lib/Controller/ThreadController.php
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,29 @@ public function summarize(int $id): JSONResponse {
return new JSONResponse(['data' => $summary]);
}

public function generateEventData(int $id): JSONResponse {
try {
$message = $this->mailManager->getMessage($this->currentUserId, $id);
$mailbox = $this->mailManager->getMailbox($this->currentUserId, $message->getMailboxId());
$account = $this->accountService->find($this->currentUserId, $mailbox->getAccountId());
} catch (DoesNotExistException $e) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
if (empty($message->getThreadRootId())) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
$thread = $this->mailManager->getThread($account, $message->getThreadRootId());
$data = $this->aiIntergrationsService->generateEventData(
$account,
$mailbox,
$message->getThreadRootId(),
$thread,
$this->currentUserId,
);

return new JSONResponse(['data' => $data]);
}

/**
* @NoAdminRequired
*
Expand Down
52 changes: 52 additions & 0 deletions lib/Model/EventData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);

/*
* @copyright 2024 Christoph Wurst <[email protected]>
*
* @author 2024 Christoph Wurst <[email protected]>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

namespace OCA\Mail\Model;

use JsonSerializable;
use ReturnTypeWillChange;

class EventData implements JsonSerializable {

public function __construct(private string $summary,
private string $description) {
}

public function getSummary(): string {
return $this->summary;
}

public function getDescription(): string {
return $this->description;
}

#[ReturnTypeWillChange]
public function jsonSerialize() {
return [
'summary' => $this->summary,
'description' => $this->description,
];
}
}
56 changes: 56 additions & 0 deletions lib/Service/AiIntegrations/AiIntegrationsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,25 @@

namespace OCA\Mail\Service\AiIntegrations;

use JsonException;
use OCA\Mail\Account;
use OCA\Mail\Contracts\IMailManager;
use OCA\Mail\Db\Mailbox;
use OCA\Mail\Db\Message;
use OCA\Mail\Exception\ServiceException;
use OCA\Mail\IMAP\IMAPClientFactory;
use OCA\Mail\Model\EventData;
use OCA\Mail\Model\IMAPMessage;
use OCP\TextProcessing\FreePromptTaskType;
use OCP\TextProcessing\IManager;
use OCP\TextProcessing\SummaryTaskType;
use OCP\TextProcessing\Task;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\ContainerInterface;
use function array_map;
use function implode;
use function in_array;
use function json_decode;

class AiIntegrationsService {

Expand All @@ -51,6 +57,12 @@ class AiIntegrationsService {
/** @var IMailManager */
private IMailManager $mailManager;

private const EVENT_DATA_PROMPT_PREAMBLE = <<<PROMPT
I am scheduling an event based on an email thread and need an event title and agenda. Provide the result as JSON with keys for "title" and "agenda". For example ```{ "title": "Project kick-off meeting", "agenda": "* Introduction\\n* Project goals\\n* Next steps" }```.
The email contents are:
PROMPT;

public function __construct(ContainerInterface $container, Cache $cache, IMAPClientFactory $clientFactory, IMailManager $mailManager) {
$this->container = $container;
Expand Down Expand Up @@ -110,6 +122,50 @@ public function summarizeThread(Account $account, Mailbox $mailbox, string $thre
}
}

/**
* @param Message[] $messages
*/
public function generateEventData(Account $account, Mailbox $mailbox, string $threadId, array $messages, string $currentUserId): ?EventData {
try {
/** @var IManager $manager */
$manager = $this->container->get(IManager::class);
} catch (ContainerExceptionInterface $e) {
return null;
}
if (!in_array(FreePromptTaskType::class, $manager->getAvailableTaskTypes(), true)) {
return null;
}
$client = $this->clientFactory->getClient($account);
try {
$messageBodies = array_map(function ($message) use ($client, $account, $mailbox) {
$imapMessage = $this->mailManager->getImapMessage(
$client,
$account,
$mailbox,
$message->getUid(), true
);
return $imapMessage->getPlainBody();
}, $messages);
} finally {
$client->logout();
}

$task = new Task(
FreePromptTaskType::class,
self::EVENT_DATA_PROMPT_PREAMBLE . implode("\n\n---\n\n", $messageBodies),
"mail",
$currentUserId,
"event_data_$threadId",
);
$result = $manager->runTask($task);
try {
$decoded = json_decode($result, true, 512, JSON_THROW_ON_ERROR);
return new EventData($decoded['title'], $decoded['agenda']);
} catch (JsonException $e) {
return null;
}
}

/**
* @return string[]
*/
Expand Down
25 changes: 23 additions & 2 deletions src/components/EventModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<div class="modal-content">
<h2>{{ t('mail', 'Create event') }}</h2>
<div class="eventTitle">
<input v-model="eventTitle" type="text">
<input v-model="eventTitle" :disabled="generatingData" type="text">
</div>
<div class="dateTimePicker">
<DatetimePicker v-model="startDate"
Expand Down Expand Up @@ -48,6 +48,7 @@
<label for="description">{{ t('mail', 'Description') }}</label>
<textarea id="description"
v-model="description"
:disabled="generatingData"
class="modal-content__description-input"
rows="7" />
<br>
Expand All @@ -67,6 +68,8 @@ import { getUserCalendars, importCalendarEvent } from '../service/DAVService.js'
import logger from '../logger.js'
import CalendarPickerOption from './CalendarPickerOption.vue'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { generateEventData } from '../service/AiIntergrationsService.js'
export default {
name: 'EventModal',
Expand Down Expand Up @@ -98,6 +101,8 @@ export default {
saving: false,
selectedCalendar: undefined,
description: this.envelope.previewText,
generatingData: false,
enabledThreadSummary: loadState('mail', 'enabled_thread_summary', false),
}
},
computed: {
Expand All @@ -108,10 +113,12 @@ export default {
return this.isAllDay ? 'date' : 'datetime'
},
},
created() {
async created() {
logger.debug('creating event from envelope', {
envelope: this.envelope,
})
await this.generateEventData()
},
async mounted() {
this.calendars = (await getUserCalendars()).filter(c => c.writable)
Expand All @@ -121,6 +128,20 @@ export default {
}
},
methods: {
async generateEventData() {
if (!this.enabledThreadSummary) {
return
}
try {
this.generatingData = true
const { summary, description } = await generateEventData(this.envelope.databaseId)
this.eventTitle = summary
this.description = description
} finally {
this.generatingData = false
}
},
onClose() {
this.$emit('close')
},
Expand Down
14 changes: 14 additions & 0 deletions src/service/AiIntergrationsService.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,20 @@ export const summarizeThread = async (threadId) => {
throw convertAxiosError(e)
}
}

export const generateEventData = async (threadId) => {
const url = generateUrl('/apps/mail/api/thread/{threadId}/eventdata', {
threadId,
})

try {
const resp = await axios.get(url)
return resp.data.data
} catch (e) {
throw convertAxiosError(e)
}
}

export const smartReply = async (messageId) => {
const url = generateUrl('/apps/mail/api/messages/{messageId}/smartreply', {
messageId,
Expand Down
49 changes: 49 additions & 0 deletions src/tests/unit/components/EventModal.vue.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* @copyright 2024 Christoph Wurst <[email protected]>
*
* @author 2024 Christoph Wurst <[email protected]>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import { createLocalVue, shallowMount } from '@vue/test-utils'
import { loadState } from '@nextcloud/initial-state'

import Nextcloud from '../../../mixins/Nextcloud.js'
import EventModal from '../../../components/EventModal.vue'

const localVue = createLocalVue()

localVue.mixin(Nextcloud)

describe('EventModal', () => {

it('renders default values', () => {
const view = shallowMount(EventModal, {
localVue,
propsData: {
envelope: {
subject: 'Sub?',
previewText: 'prev',
},
},
})

expect(view.vm.enabledThreadSummary).toBe(false)
expect(view.vm.eventTitle).toBe('Sub?')
expect(view.vm.description).toBe('prev')
})
})
40 changes: 40 additions & 0 deletions tests/Unit/Controller/ThreadControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
use OCA\Mail\Db\MailAccount;
use OCA\Mail\Db\Mailbox;
use OCA\Mail\Db\Message;
use OCA\Mail\Model\EventData;
use OCA\Mail\Service\AccountService;
use OCA\Mail\Service\AiIntegrations\AiIntegrationsService;
use OCA\Mail\Service\SnoozeService;
Expand Down Expand Up @@ -288,4 +289,43 @@ public function testSummarizeThread(): void {

}

public function testGenerateEventData(): void {
$mailAccount = new MailAccount();
$mailAccount->setId(1);
$this->accountService->expects(self::once())
->method('find')
->with('john', 1)
->willReturn(new Account($mailAccount));
$mailbox = new Mailbox();
$mailbox->setId(20);
$mailbox->setAccountId($mailAccount->getId());
$this->mailManager->expects(self::once())
->method('getMailbox')
->willReturn($mailbox);
$message1 = new Message();
$message1->setId(300);
$message1->setMailboxId($mailbox->getId());
$message1->setPreviewText('message1');
$message1->setThreadRootId('some-thread-root-id-1');
$message2 = new Message();
$message2->setId(301);
$message2->setMailboxId($mailbox->getId());
$message2->setThreadRootId('some-thread-root-id-1');
$message3 = new Message();
$message3->setId(302);
$message3->setMailboxId($mailbox->getId());
$message3->setThreadRootId('some-thread-root-id-1');
$this->mailManager->expects(self::once())
->method('getMessage')
->willReturn($message1);
$this->aiIntergrationsService
->expects(self::once())
->method('generateEventData')
->willReturn(new EventData("S", "D"));

$response = $this->controller->generateEventData(300);

$this->assertEquals(Http::STATUS_OK, $response->getStatus());
}

}
Loading

0 comments on commit a537687

Please sign in to comment.