Files
nc-talk-ai/lib/Service/OnboardingService.php
T
Pascal Kienast 0739d3da6a Initial open-source release of Talk AI
Talk AI is a multi-bot AI assistant manager for Nextcloud Talk:
per-bot prompts and models, agentic tool calling (MCP + built-in
tools), RAG over Nextcloud files, room-document search, vision and
speech-to-text attachments, persistent bot wikis, approval workflows,
rate limiting, and multi-provider LLM support (any OpenAI-compatible
endpoint).

Developed within EDUC - the European Digital UniverCity
(https://educalliance.eu), where it runs as the 'EDUC AI' assistant on
the alliance-wide Nextcloud portal. This public repository is the
upstream point of truth; deployment-specific tools plug in via the
tool-provider extension point (docs/TOOL_PROVIDERS.md).

License: AGPL-3.0-or-later.
2026-07-08 21:13:13 +02:00

545 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\EducAI\Service;
use OCA\EducAI\Db\Bot;
use OCA\EducAI\Db\BotMapper;
use OCA\EducAI\Db\ChatRoom;
use OCA\EducAI\Db\ChatRoomMapper;
use OCA\EducAI\Db\ConversationMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use Psr\Log\LoggerInterface;
/**
* Service for managing chat room onboarding flow.
* Handles room state, response mode selection, and custom question trees.
*
* @psalm-import-type OnboardingQuestion from \OCA\EducAI\TypeDefinitions
*/
class OnboardingService {
private ChatRoomMapper $chatRoomMapper;
private BotMapper $botMapper;
private ConversationMapper $conversationMapper;
private LoggerInterface $logger;
public function __construct(
ChatRoomMapper $chatRoomMapper,
BotMapper $botMapper,
ConversationMapper $conversationMapper,
LoggerInterface $logger
) {
$this->chatRoomMapper = $chatRoomMapper;
$this->botMapper = $botMapper;
$this->conversationMapper = $conversationMapper;
$this->logger = $logger;
}
/**
* Get the current room state for a bot in a room.
*
* @param int $botId
* @param string $roomToken
* @return ChatRoom|null
*/
public function getRoomState(int $botId, string $roomToken): ?ChatRoom {
return $this->chatRoomMapper->findByBotAndRoom($botId, $roomToken);
}
/**
* Initialize a new room with onboarding state.
*
* @param int $botId
* @param string $roomToken
* @param string $userId
* @return ChatRoom
*/
public function initializeRoom(int $botId, string $roomToken, string $userId): ChatRoom {
$now = time();
$this->deactivateOtherAlwaysModeBots($roomToken, $botId);
$room = new ChatRoom();
$room->setBotId($botId);
$room->setRoomToken($roomToken);
$room->setActivatedBy($userId);
$room->setOnboardingStatus('mode_selection');
$room->setCreatedAt($now);
$room->setUpdatedAt($now);
return $this->chatRoomMapper->insert($room);
}
/**
* Get the welcome message for starting onboarding.
*
* @param Bot $bot
* @return string
*/
public function getWelcomeMessage(Bot $bot): string {
$mentionName = $bot->getMentionName();
return "You have activated the bot **{$mentionName}**.\n\n" .
"Would you like me to respond to:\n" .
"- **A**: Only when you @mention me\n" .
"- **B**: Every message in this chat\n\n" .
"Reply with **A** or **B**.\n\n" .
"_(You can reset this chat anytime with `((RESET))`)_";
}
/**
* Handle a user's response during onboarding.
* Returns the next message to send, or null if onboarding is complete.
*
* @param ChatRoom $room
* @param Bot $bot
* @param string $message
* @return array{message:?string,completed:bool}
*/
public function handleOnboardingResponse(ChatRoom $room, Bot $bot, string $message): array {
$status = $room->getOnboardingStatus();
$message = trim($message);
$upperMessage = strtoupper($message);
$this->logger->debug('Handling onboarding response', [
'status' => $status,
'message' => $message,
'bot_id' => $bot->getId(),
]);
if ($status === 'mode_selection') {
return $this->handleModeSelection($room, $bot, $upperMessage);
}
if ($status === 'questions') {
// For question flow we may need the raw user input (free-text answers),
// so we pass the trimmed message (not uppercased).
return $this->handleQuestionResponse($room, $bot, $message);
}
// Already completed or unknown status
return ['message' => null, 'completed' => true];
}
/**
* Handle the mode selection phase (A = mention, B = always).
*/
private function handleModeSelection(ChatRoom $room, Bot $bot, string $answer): array {
$validAnswers = ['A', 'B'];
if (!in_array($answer, $validAnswers, true)) {
return [
'message' => "Please reply with **A** (only when @mentioned) or **B** (every message).",
'completed' => false,
];
}
$mode = $answer === 'A' ? 'mention' : 'always';
$modeDescription = $answer === 'A'
? 'only when you @mention me'
: 'every message in this chat';
$room->setResponseMode($mode);
$room->setUpdatedAt(time());
if ($mode === 'always') {
$this->deactivateOtherAlwaysModeBots($room->getRoomToken(), $room->getBotId());
}
// Check if bot has custom onboarding questions
if ($bot->hasOnboardingQuestions()) {
$startQuestion = $bot->getStartingOnboardingQuestion();
if ($startQuestion !== null) {
$room->setOnboardingStatus('questions');
$room->setCurrentQuestionId($startQuestion['id']);
$this->chatRoomMapper->update($room);
$questionMessage = $this->formatQuestion($startQuestion);
return [
'message' => "Got it! I'll respond to {$modeDescription}.\n\n" .
"Before we start, I have a few questions to better assist you:\n\n" .
$questionMessage,
'completed' => false,
];
}
}
// No custom questions - complete onboarding
$room->setOnboardingStatus('completed');
$this->chatRoomMapper->update($room);
return [
'message' => "Got it! I'll respond to {$modeDescription}.\n\n" .
"I'm ready to help! You can reset this conversation anytime with `((RESET))`.",
'completed' => true,
];
}
/**
* Handle a response to a custom onboarding question.
*/
private function handleQuestionResponse(ChatRoom $room, Bot $bot, string $message): array {
$rawMessage = trim($message);
$upperMessage = strtoupper($rawMessage);
$currentQuestionId = $room->getCurrentQuestionId();
if ($currentQuestionId === null) {
// No current question - complete onboarding
$room->setOnboardingStatus('completed');
$room->setCurrentQuestionId(null);
$this->chatRoomMapper->update($room);
return [
'message' => $this->getOnboardingCompleteMessage(),
'completed' => true,
];
}
$question = $bot->getOnboardingQuestion($currentQuestionId);
if ($question === null) {
// Question not found - complete onboarding
$this->logger->warning('Onboarding question not found', [
'question_id' => $currentQuestionId,
'bot_id' => $bot->getId(),
]);
$room->setOnboardingStatus('completed');
$room->setCurrentQuestionId(null);
$this->chatRoomMapper->update($room);
return [
'message' => $this->getOnboardingCompleteMessage(),
'completed' => true,
];
}
$answers = is_array($question['answers'] ?? null) ? $question['answers'] : [];
// Split answers into fixed-choice and free-text
$choiceAnswers = [];
$freeTextAnswer = null;
foreach ($answers as $answerOption) {
$type = strtolower((string)($answerOption['type'] ?? 'choice'));
if ($type === 'free_text') {
$freeTextAnswer = $answerOption;
continue;
}
$choiceAnswers[] = $answerOption;
}
// If this question expects free-text only, accept any non-empty input
$selectedAnswer = null;
$recordedAnswerText = null;
if (count($choiceAnswers) === 0 && $freeTextAnswer !== null) {
if ($rawMessage === '') {
return [
'message' => 'Please reply with your answer in free text.',
'completed' => false,
];
}
$selectedAnswer = $freeTextAnswer;
$recordedAnswerText = $rawMessage;
} else {
// Try to match a fixed-choice answer (A/B/C/…)
foreach ($choiceAnswers as $answerOption) {
if (strtoupper((string)$answerOption['id']) === $upperMessage) {
$selectedAnswer = $answerOption;
$recordedAnswerText = (string)($answerOption['text'] ?? '');
break;
}
}
// If not matched and free-text is allowed, treat the user's message as the answer
if ($selectedAnswer === null && $freeTextAnswer !== null) {
if ($rawMessage === '') {
$options = array_map(fn($a) => strtoupper((string)$a['id']), $choiceAnswers);
$optionsStr = implode(' or ', array_map(fn($o) => "**{$o}**", $options));
$hint = $optionsStr !== '' ? "Please reply with {$optionsStr} or write your answer in free text." : 'Please reply with your answer in free text.';
return [
'message' => $hint,
'completed' => false,
];
}
$selectedAnswer = $freeTextAnswer;
$recordedAnswerText = $rawMessage;
}
if ($selectedAnswer === null) {
// Invalid answer - show options again
$options = array_map(fn($a) => strtoupper((string)$a['id']), $choiceAnswers);
$optionsStr = implode(' or ', array_map(fn($o) => "**{$o}**", $options));
return [
'message' => $optionsStr !== '' ? "Please reply with {$optionsStr}." : 'Please reply with a valid answer.',
'completed' => false,
];
}
}
// Record the answer (for free-text questions, store the user's input)
$room->addOnboardingAnswer(
(string)$question['id'],
(string)$question['text'],
(string)($selectedAnswer['id'] ?? 'free_text'),
(string)($recordedAnswerText ?? '')
);
// Check for next question
$nextQuestionId = $selectedAnswer['next'] ?? null;
if ($nextQuestionId === null) {
// End of this path - complete onboarding
$room->setOnboardingStatus('completed');
$room->setCurrentQuestionId(null);
$this->chatRoomMapper->update($room);
return [
'message' => $this->getOnboardingCompleteMessage(),
'completed' => true,
];
}
// Move to next question
$nextQuestion = $bot->getOnboardingQuestion($nextQuestionId);
if ($nextQuestion === null) {
// Next question not found - complete onboarding
$this->logger->warning('Next onboarding question not found', [
'next_question_id' => $nextQuestionId,
'bot_id' => $bot->getId(),
]);
$room->setOnboardingStatus('completed');
$room->setCurrentQuestionId(null);
$this->chatRoomMapper->update($room);
return [
'message' => $this->getOnboardingCompleteMessage(),
'completed' => true,
];
}
$room->setCurrentQuestionId($nextQuestionId);
$this->chatRoomMapper->update($room);
return [
'message' => $this->formatQuestion($nextQuestion),
'completed' => false,
];
}
/**
* Format a question for display.
*
* @param OnboardingQuestion $question
* @return string
*/
private function formatQuestion(array $question): string {
$text = (string)$question['text'] . "\n";
$answers = is_array($question['answers'] ?? null) ? $question['answers'] : [];
$choiceAnswers = [];
$hasFreeText = false;
foreach ($answers as $answer) {
$type = strtolower((string)($answer['type'] ?? 'choice'));
if ($type === 'free_text') {
$hasFreeText = true;
continue;
}
$choiceAnswers[] = $answer;
}
// Free-text only question
if ($hasFreeText && count($choiceAnswers) === 0) {
$text .= "\nReply with your answer in free text.";
return $text;
}
// Render fixed-choice answers
foreach ($choiceAnswers as $answer) {
$id = strtoupper((string)$answer['id']);
$answerText = (string)($answer['text'] ?? '');
$text .= "- **{$id}**: {$answerText}\n";
}
$options = array_map(fn($a) => strtoupper((string)$a['id']), $choiceAnswers);
$optionsStr = implode(' or ', $options);
if ($optionsStr !== '' && $hasFreeText) {
$text .= "\nReply with {$optionsStr} or write your answer in free text.";
} elseif ($optionsStr !== '') {
$text .= "\nReply with {$optionsStr}.";
} elseif ($hasFreeText) {
$text .= "\nReply with your answer in free text.";
} else {
$text .= "\nReply with your answer.";
}
return $text;
}
/**
* Get the completion message.
*/
private function getOnboardingCompleteMessage(): string {
return "Thanks! I've noted your preferences. I'm ready to help!\n\n" .
"_(Reminder: You can reset this chat with `((RESET))`)_";
}
/**
* Build system prompt context from onboarding answers.
*
* @param ChatRoom $room
* @return string
*/
public function buildOnboardingContext(ChatRoom $room): string {
$answers = $room->getOnboardingAnswersArray();
if (count($answers) === 0) {
return '';
}
$context = "\n\n## User Onboarding Context\n";
$context .= "The user has provided the following information during onboarding:\n";
foreach ($answers as $answer) {
$context .= "- **{$answer['question_text']}** → {$answer['answer_text']}\n";
}
$context .= "\nUse this context to personalize your responses.\n";
return $context;
}
/**
* Reset a room's onboarding state and conversation history.
*
* @param int $botId
* @param string $roomToken
* @return bool True if reset was successful
*/
public function resetRoom(int $botId, string $roomToken): bool {
try {
// Delete room state
$this->chatRoomMapper->deleteByBotAndRoom($botId, $roomToken);
// Delete conversation history for this bot in this room
$this->conversationMapper->deleteByBotAndRoom($botId, $roomToken);
$this->logger->info('Room state reset', [
'bot_id' => $botId,
'room_token' => $roomToken,
]);
return true;
} catch (\Throwable $e) {
$this->logger->error('Failed to reset room state', [
'bot_id' => $botId,
'room_token' => $roomToken,
'exception' => $e,
]);
return false;
}
}
/**
* Get all rooms in "always" mode for a given room token.
* Used to find bots that should respond to any message.
*
* @param string $roomToken
* @return array<int,array{room:ChatRoom,bot:Bot}>
*/
public function getAlwaysModeBotsForRoom(string $roomToken): array {
$rooms = $this->chatRoomMapper->findAlwaysModeByRoom($roomToken);
return $this->resolveActiveBotsForRooms($rooms);
}
/**
* Ensure only one bot remains in always mode for a given room.
*/
private function deactivateOtherAlwaysModeBots(string $roomToken, int $activeBotId): void {
$alwaysModeRooms = $this->chatRoomMapper->findAlwaysModeByRoom($roomToken);
$deactivatedBotIds = [];
foreach ($alwaysModeRooms as $room) {
if ($room->getBotId() === $activeBotId) {
continue;
}
$room->setResponseMode('mention');
$room->setUpdatedAt(time());
$this->chatRoomMapper->update($room);
$deactivatedBotIds[] = $room->getBotId();
}
if ($deactivatedBotIds !== []) {
$this->logger->info('Deactivated existing always-mode bots in room', [
'room_token' => $roomToken,
'active_bot_id' => $activeBotId,
'deactivated_bot_ids' => $deactivatedBotIds,
]);
}
}
/**
* Get all bots with onboarding in progress for a given room token.
* Used to detect onboarding responses without mentions.
*
* @param string $roomToken
* @return array<int,array{room:ChatRoom,bot:Bot}>
*/
public function getOnboardingInProgressBotsForRoom(string $roomToken): array {
$rooms = $this->chatRoomMapper->findOnboardingInProgressByRoom($roomToken);
return $this->resolveActiveBotsForRooms($rooms);
}
/**
* Get all bots with ANY state (any onboarding status) for a given room token.
* Used for reset command to find all bots to reset.
*
* @param string $roomToken
* @return array<int,array{room:ChatRoom,bot:Bot}>
*/
public function getAllBotsWithStateForRoom(string $roomToken): array {
$rooms = $this->chatRoomMapper->findAllByRoom($roomToken);
return $this->resolveActiveBotsForRooms($rooms);
}
/**
* @param ChatRoom[] $rooms
* @return array<int,array{room:ChatRoom,bot:Bot}>
*/
private function resolveActiveBotsForRooms(array $rooms): array {
$result = [];
foreach ($rooms as $room) {
try {
$bot = $this->botMapper->findById($room->getBotId());
if ($bot->getIsActive()) {
$result[] = [
'room' => $room,
'bot' => $bot,
];
}
} catch (DoesNotExistException $e) {
// Bot was deleted - clean up orphaned room state
$this->chatRoomMapper->delete($room);
}
}
return $result;
}
/**
* Check if onboarding is in progress for a bot in a room.
*
* @param int $botId
* @param string $roomToken
* @return bool
*/
public function isOnboardingInProgress(int $botId, string $roomToken): bool {
$room = $this->getRoomState($botId, $roomToken);
if ($room === null) {
return false;
}
return !$room->isOnboardingComplete();
}
}