493 lines
17 KiB
PHP
493 lines
17 KiB
PHP
<?php
|
|
|
|
namespace Espo\Modules\SmartAssistant\Services;
|
|
|
|
use Espo\Core\Exceptions\Error;
|
|
use Espo\Core\Exceptions\NotFound;
|
|
use Espo\Core\InjectableFactory;
|
|
use Espo\Core\Utils\Config;
|
|
use Espo\Core\Utils\Log;
|
|
use Espo\ORM\EntityManager;
|
|
use Espo\Entities\Note;
|
|
use Espo\Entities\User;
|
|
|
|
class SmartAssistantService
|
|
{
|
|
public const NOTE_TYPE_REQUEST = 'SmartRequest';
|
|
public const NOTE_TYPE_RESPONSE = 'SmartResponse';
|
|
public const NOTE_TYPE_ACTION = 'SmartAction';
|
|
|
|
private const READ_ONLY_TOOLS = ['list_documents', 'save_memory'];
|
|
|
|
private EntityManager $entityManager;
|
|
private InjectableFactory $injectableFactory;
|
|
private Config $config;
|
|
private Log $log;
|
|
private User $user;
|
|
|
|
private static array $conversations = [];
|
|
|
|
public function __construct(
|
|
EntityManager $entityManager,
|
|
InjectableFactory $injectableFactory,
|
|
Config $config,
|
|
Log $log,
|
|
User $user
|
|
) {
|
|
$this->entityManager = $entityManager;
|
|
$this->injectableFactory = $injectableFactory;
|
|
$this->config = $config;
|
|
$this->log = $log;
|
|
$this->user = $user;
|
|
}
|
|
|
|
private function getConversationRepo(): ConversationRepository
|
|
{
|
|
return $this->injectableFactory->create(ConversationRepository::class);
|
|
}
|
|
|
|
public function chat(string $message, string $mode, ?string $caseId, ?string $conversationId): array
|
|
{
|
|
$userId = $this->user->getId();
|
|
|
|
// Build context based on mode
|
|
if ($mode === 'case' && $caseId) {
|
|
$contextBuilder = $this->injectableFactory->create(CaseContextBuilder::class);
|
|
$context = $contextBuilder->buildContext($caseId, $userId);
|
|
|
|
// Add case memory to context
|
|
$memoryProvider = $this->injectableFactory->create(CaseMemoryContextProvider::class);
|
|
$context['caseMemory'] = $memoryProvider->getContextForCase($caseId);
|
|
$context['mode'] = 'case';
|
|
} else {
|
|
$contextBuilder = $this->injectableFactory->create(OfficeContextBuilder::class);
|
|
$context = $contextBuilder->buildSummaryContext($this->user, $this->getThresholds());
|
|
|
|
// Auto-detect case drill-down
|
|
$drillDown = $this->detectDrillDown($message);
|
|
if ($drillDown) {
|
|
$context['drillDown'] = $drillDown;
|
|
}
|
|
}
|
|
|
|
// Generate conversation ID
|
|
if (!$conversationId) {
|
|
$prefix = $mode === 'case' ? 'conv_' : 'oconv_';
|
|
$conversationId = uniqid($prefix, true);
|
|
}
|
|
|
|
// Persist conversation
|
|
try {
|
|
$convRepo = $this->getConversationRepo();
|
|
$convRepo->startOrResume($mode, $conversationId, $caseId);
|
|
$convRepo->appendMessage($conversationId, 'user', $message);
|
|
} catch (\Exception $e) {
|
|
$this->log->warning('SmartAssistant: Failed to persist conversation: ' . $e->getMessage());
|
|
}
|
|
|
|
// Create stream note (only for case mode)
|
|
if ($mode === 'case' && $caseId) {
|
|
$this->createStreamNote(self::NOTE_TYPE_REQUEST, $message, $caseId, [
|
|
'conversationId' => $conversationId,
|
|
]);
|
|
}
|
|
|
|
// Load conversation history for context
|
|
$conversationHistory = [];
|
|
try {
|
|
$conversationHistory = $this->getConversationRepo()->getMessages($conversationId);
|
|
} catch (\Exception $e) {
|
|
// Ignore
|
|
}
|
|
|
|
// Call webhook
|
|
$response = $this->callWebhook($message, $context, $conversationId, $conversationHistory, $mode);
|
|
|
|
// Process actions
|
|
$actions = $response['actions'] ?? [];
|
|
$responseText = $response['text'] ?? '';
|
|
$inlineResults = [];
|
|
$remainingActions = [];
|
|
|
|
foreach ($actions as $action) {
|
|
$tool = $action['tool'] ?? '';
|
|
if (in_array($tool, self::READ_ONLY_TOOLS, true)) {
|
|
try {
|
|
$executor = $this->injectableFactory->create(ActionExecutor::class);
|
|
$result = $executor->execute($tool, $action['params'] ?? [], $caseId, $userId);
|
|
if (!empty($result['message'])) {
|
|
$inlineResults[] = $result['message'];
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->log->warning("SmartAssistant: Inline execution of {$tool} failed: " . $e->getMessage());
|
|
$inlineResults[] = "שגיאה בביצוע {$tool}: " . $e->getMessage();
|
|
}
|
|
} else {
|
|
$remainingActions[] = $action;
|
|
}
|
|
}
|
|
|
|
if (!empty($inlineResults)) {
|
|
$responseText .= "\n\n" . implode("\n\n", $inlineResults);
|
|
}
|
|
|
|
// Store pending actions
|
|
if (!empty($remainingActions)) {
|
|
self::$conversations[$conversationId] = [
|
|
'caseId' => $caseId,
|
|
'userId' => $userId,
|
|
'actions' => $remainingActions,
|
|
];
|
|
|
|
try {
|
|
$this->getConversationRepo()->storePendingActions($conversationId, $caseId ?? '', $userId, $remainingActions);
|
|
} catch (\Exception $e) {
|
|
$this->log->warning('SmartAssistant: Failed to persist actions: ' . $e->getMessage());
|
|
$this->saveConversationFile($conversationId, $caseId, $userId, $remainingActions);
|
|
}
|
|
}
|
|
|
|
// Persist response
|
|
try {
|
|
$this->getConversationRepo()->appendMessage($conversationId, 'assistant', $responseText, $remainingActions);
|
|
} catch (\Exception $e) {
|
|
$this->log->warning('SmartAssistant: Failed to persist response: ' . $e->getMessage());
|
|
}
|
|
|
|
// Stream note for case mode
|
|
if ($mode === 'case' && $caseId) {
|
|
$this->createStreamNote(self::NOTE_TYPE_RESPONSE, $responseText, $caseId, [
|
|
'conversationId' => $conversationId,
|
|
'actions' => $remainingActions,
|
|
]);
|
|
}
|
|
|
|
return [
|
|
'conversationId' => $conversationId,
|
|
'text' => $responseText,
|
|
'actions' => $remainingActions,
|
|
];
|
|
}
|
|
|
|
public function executeAction(string $conversationId, string $actionId, bool $approved): array
|
|
{
|
|
$conversation = $this->loadConversation($conversationId);
|
|
|
|
if (!$conversation) {
|
|
throw new NotFound("Conversation {$conversationId} not found.");
|
|
}
|
|
|
|
$caseId = $conversation['caseId'];
|
|
$userId = $conversation['userId'];
|
|
$actions = $conversation['actions'] ?? [];
|
|
|
|
$action = null;
|
|
foreach ($actions as $a) {
|
|
if (($a['actionId'] ?? '') === $actionId) {
|
|
$action = $a;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!$action) {
|
|
throw new NotFound("Action {$actionId} not found.");
|
|
}
|
|
|
|
if (!$approved) {
|
|
if ($caseId) {
|
|
$this->createStreamNote(self::NOTE_TYPE_ACTION, 'פעולה נדחתה: ' . ($action['displayText'] ?? ''), $caseId, [
|
|
'conversationId' => $conversationId,
|
|
'actionId' => $actionId,
|
|
'status' => 'rejected',
|
|
]);
|
|
}
|
|
|
|
return ['success' => true, 'status' => 'rejected', 'message' => 'הפעולה נדחתה'];
|
|
}
|
|
|
|
$executor = $this->injectableFactory->create(ActionExecutor::class);
|
|
|
|
try {
|
|
$result = $executor->execute($action['tool'], $action['params'] ?? [], $caseId, $userId);
|
|
|
|
if ($caseId) {
|
|
$this->createStreamNote(self::NOTE_TYPE_ACTION, $result['message'] ?? 'פעולה בוצעה', $caseId, [
|
|
'conversationId' => $conversationId,
|
|
'actionId' => $actionId,
|
|
'status' => 'executed',
|
|
'entityType' => $result['entityType'] ?? null,
|
|
'entityId' => $result['entityId'] ?? null,
|
|
]);
|
|
}
|
|
|
|
return $result;
|
|
} catch (\Exception $e) {
|
|
$this->log->error("SmartAssistant: Action execution failed: " . $e->getMessage());
|
|
|
|
if ($caseId) {
|
|
$this->createStreamNote(self::NOTE_TYPE_ACTION, 'שגיאה: ' . $e->getMessage(), $caseId, [
|
|
'conversationId' => $conversationId,
|
|
'actionId' => $actionId,
|
|
'status' => 'error',
|
|
]);
|
|
}
|
|
|
|
return ['success' => false, 'message' => 'שגיאה בביצוע הפעולה: ' . $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
public function getStatus(): array
|
|
{
|
|
$webhookUrl = $this->getWebhookUrl();
|
|
|
|
return [
|
|
'enabled' => $webhookUrl !== null,
|
|
'webhookConfigured' => !empty($webhookUrl),
|
|
];
|
|
}
|
|
|
|
public function getSummary(): array
|
|
{
|
|
$alertCalc = $this->injectableFactory->create(AlertCalculator::class);
|
|
$thresholds = $this->getThresholds();
|
|
|
|
return [
|
|
'summary' => $alertCalc->getSummary(),
|
|
'alerts' => $alertCalc->calculateAlerts($thresholds),
|
|
];
|
|
}
|
|
|
|
public function getAlerts(): array
|
|
{
|
|
$alertCalc = $this->injectableFactory->create(AlertCalculator::class);
|
|
return $alertCalc->calculateAlerts($this->getThresholds());
|
|
}
|
|
|
|
public function getHistory(?string $caseId): array
|
|
{
|
|
if ($caseId) {
|
|
// Case mode: return stream notes
|
|
$notes = $this->entityManager->getRDBRepository('Note')
|
|
->where([
|
|
'parentType' => 'Case',
|
|
'parentId' => $caseId,
|
|
'type' => [self::NOTE_TYPE_REQUEST, self::NOTE_TYPE_RESPONSE, self::NOTE_TYPE_ACTION],
|
|
])
|
|
->order('createdAt', 'DESC')
|
|
->limit(0, 50)
|
|
->find();
|
|
|
|
$history = [];
|
|
foreach ($notes as $note) {
|
|
$history[] = [
|
|
'id' => $note->get('id'),
|
|
'type' => $note->get('type'),
|
|
'post' => $note->get('post'),
|
|
'data' => $note->get('data') ?? (object) [],
|
|
'createdAt' => $note->get('createdAt'),
|
|
'createdByName' => $note->get('createdByName'),
|
|
];
|
|
}
|
|
|
|
return ['list' => array_reverse($history), 'total' => count($history)];
|
|
}
|
|
|
|
// Office mode: return latest conversation from today
|
|
$conversations = $this->getConversationRepo()->getRecentConversations('office', null, 1);
|
|
|
|
if (empty($conversations)) return [];
|
|
|
|
$latest = $conversations[0];
|
|
$messages = $this->getConversationRepo()->getMessages($latest['conversationKey']);
|
|
|
|
return [
|
|
'conversationId' => $latest['conversationKey'],
|
|
'messages' => $messages,
|
|
'updatedAt' => $latest['lastMessageAt'],
|
|
];
|
|
}
|
|
|
|
public function getConversations(?string $type): array
|
|
{
|
|
return $this->getConversationRepo()->getRecentConversations($type, null, 50);
|
|
}
|
|
|
|
public function getConversationMessages(string $key): array
|
|
{
|
|
return [
|
|
'conversationId' => $key,
|
|
'messages' => $this->getConversationRepo()->getMessages($key),
|
|
];
|
|
}
|
|
|
|
public function searchHistory(string $query): array
|
|
{
|
|
return $this->getConversationRepo()->searchConversations($query);
|
|
}
|
|
|
|
private function detectDrillDown(string $message): ?array
|
|
{
|
|
$cases = $this->entityManager->getRDBRepository('Case')
|
|
->select(['id', 'name'])
|
|
->where([
|
|
'status!=' => ['ClosedSettlement', 'ClosedJudgment', 'Closed', 'Rejected'],
|
|
'deleted' => false,
|
|
])
|
|
->find();
|
|
|
|
foreach ($cases as $case) {
|
|
if (mb_stripos($message, $case->get('name')) !== false) {
|
|
$contextBuilder = $this->injectableFactory->create(OfficeContextBuilder::class);
|
|
return $contextBuilder->buildCaseDrillDown($case->get('id'), $this->user);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function callWebhook(string $message, array $context, string $conversationId, array $history, string $mode): array
|
|
{
|
|
$webhookUrl = $this->getWebhookUrl();
|
|
|
|
if (!$webhookUrl) {
|
|
throw new Error('SmartAssistant webhook URL is not configured. Go to Admin > Integrations > Smart Assistant.');
|
|
}
|
|
|
|
$payload = json_encode([
|
|
'message' => $message,
|
|
'context' => $context,
|
|
'conversationId' => $conversationId,
|
|
'conversationHistory' => $history,
|
|
'mode' => $mode,
|
|
]);
|
|
|
|
$headers = ['Content-Type: application/json', 'Accept: application/json'];
|
|
|
|
$apiKey = $this->getApiKey();
|
|
if ($apiKey) {
|
|
$headers[] = 'X-Api-Key: ' . $apiKey;
|
|
}
|
|
|
|
$ch = curl_init($webhookUrl);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => $payload,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_HTTPHEADER => $headers,
|
|
CURLOPT_TIMEOUT => 120,
|
|
CURLOPT_CONNECTTIMEOUT => 10,
|
|
]);
|
|
|
|
$responseBody = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$error = curl_error($ch);
|
|
curl_close($ch);
|
|
|
|
if ($error) {
|
|
$this->log->error("SmartAssistant: Webhook error: {$error}");
|
|
throw new Error("Failed to connect to assistant: {$error}");
|
|
}
|
|
|
|
if ($httpCode !== 200) {
|
|
$this->log->error("SmartAssistant: Webhook HTTP {$httpCode}: {$responseBody}");
|
|
throw new Error("Assistant returned error (HTTP {$httpCode})");
|
|
}
|
|
|
|
$response = json_decode($responseBody, true);
|
|
if (!$response) {
|
|
throw new Error('Invalid response from assistant');
|
|
}
|
|
|
|
return $response;
|
|
}
|
|
|
|
private function createStreamNote(string $type, string $content, string $caseId, array $data = []): void
|
|
{
|
|
$note = $this->entityManager->getNewEntity('Note');
|
|
$note->set([
|
|
'type' => $type,
|
|
'post' => $content,
|
|
'parentType' => 'Case',
|
|
'parentId' => $caseId,
|
|
'data' => (object) $data,
|
|
]);
|
|
$this->entityManager->saveEntity($note);
|
|
}
|
|
|
|
private function getWebhookUrl(): ?string
|
|
{
|
|
// Try SmartAssistant first, then fallback to old integrations
|
|
foreach (['SmartAssistant', 'CrmAssistant', 'OfficeAssistant'] as $name) {
|
|
$integration = $this->entityManager->getEntityById('Integration', $name);
|
|
if ($integration && $integration->get('enabled')) {
|
|
$data = $integration->get('data') ?? (object) [];
|
|
if (!empty($data->webhookUrl)) {
|
|
return $data->webhookUrl;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private function getApiKey(): ?string
|
|
{
|
|
foreach (['SmartAssistant', 'CrmAssistant', 'OfficeAssistant'] as $name) {
|
|
$integration = $this->entityManager->getEntityById('Integration', $name);
|
|
if ($integration && $integration->get('enabled')) {
|
|
$data = $integration->get('data') ?? (object) [];
|
|
if (!empty($data->apiKey)) {
|
|
return $data->apiKey;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private function getThresholds(): array
|
|
{
|
|
foreach (['SmartAssistant', 'OfficeAssistant'] as $name) {
|
|
$integration = $this->entityManager->getEntityById('Integration', $name);
|
|
if ($integration && $integration->get('enabled')) {
|
|
$data = (array) ($integration->get('data') ?? []);
|
|
return [
|
|
'inactivityWarningDays' => $data['inactivityWarningDays'] ?? 14,
|
|
'inactivityCriticalDays' => $data['inactivityCriticalDays'] ?? 30,
|
|
'upcomingHearingDays' => $data['upcomingHearingDays'] ?? 7,
|
|
];
|
|
}
|
|
}
|
|
return [];
|
|
}
|
|
|
|
private function saveConversationFile(string $conversationId, ?string $caseId, string $userId, array $actions): void
|
|
{
|
|
$dir = 'data/tmp/assistant-conversations';
|
|
if (!is_dir($dir)) mkdir($dir, 0755, true);
|
|
file_put_contents($dir . '/' . $conversationId . '.json', json_encode([
|
|
'caseId' => $caseId,
|
|
'userId' => $userId,
|
|
'actions' => $actions,
|
|
'createdAt' => date('Y-m-d H:i:s'),
|
|
]));
|
|
}
|
|
|
|
private function loadConversation(string $conversationId): ?array
|
|
{
|
|
if (isset(self::$conversations[$conversationId])) {
|
|
return self::$conversations[$conversationId];
|
|
}
|
|
|
|
$file = 'data/tmp/assistant-conversations/' . basename($conversationId) . '.json';
|
|
if (file_exists($file)) {
|
|
$data = json_decode(file_get_contents($file), true);
|
|
if (isset($data['createdAt']) && (time() - strtotime($data['createdAt'])) > 86400) {
|
|
unlink($file);
|
|
return null;
|
|
}
|
|
return $data;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|