This repository has been archived on 2026-07-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
SmartAssistant/files/custom/Espo/Modules/SmartAssistant/Services/SmartAssistantService.php
T
chaim 0dc26f3b15 feat: SmartAssistant v2.4.0 — create_call tool + behavioral rules system
- Add create_call tool: records phone calls as Call entity (not Meeting)
- Add AssistantRule entity: persistent behavioral rules the AI follows
- Add save_rule tool: AI can save new rules when user requests them
- Add systemInstructions to webhook: call vs meeting guidance, open question tracking
- Add recentCalls to case context for AI awareness
- Add AssistantRuleContextProvider: injects active rules per mode into context

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 06:20:23 +00:00

550 lines
20 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';
// All tools execute inline — Shira has full permissions
private const TOOLS_REQUIRING_CASE = ['change_status', 'schedule_hearing', 'list_documents', 'analyze_document', 'upload_document', 'generate_document', 'merge_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;
}
}
// Load behavioral rules
$ruleProvider = $this->injectableFactory->create(AssistantRuleContextProvider::class);
$context['assistantRules'] = $ruleProvider->getRulesForMode($mode === 'case' ? 'case' : 'office');
// 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 — execute all inline (Shira has full permissions)
$actions = $response['actions'] ?? [];
$responseText = $response['text'] ?? '';
$inlineResults = [];
$executedActions = [];
// Resolve effective caseId: explicit case mode, or drill-down detection in office mode
$effectiveCaseId = $caseId;
if (!$effectiveCaseId && !empty($context['drillDown']['case']['id'])) {
$effectiveCaseId = $context['drillDown']['case']['id'];
}
$executor = $this->injectableFactory->create(ActionExecutor::class);
foreach ($actions as $action) {
$tool = $action['tool'] ?? '';
$actionCaseId = $effectiveCaseId;
// Resolve case from action params if not available
if (!$actionCaseId && !empty($action['params']['caseId'])) {
$actionCaseId = $action['params']['caseId'];
}
// Skip tools that require a case if no case context available
if (!$actionCaseId && in_array($tool, self::TOOLS_REQUIRING_CASE, true)) {
$inlineResults[] = "⚠️ לא ניתן לבצע {$tool} — לא זוהה תיק. נסה מתוך מסך התיק.";
continue;
}
try {
$result = $executor->execute($tool, $action['params'] ?? [], $actionCaseId, $userId);
if (!empty($result['message'])) {
$inlineResults[] = $result['message'];
}
$executedActions[] = [
'tool' => $tool,
'status' => 'executed',
'entityType' => $result['entityType'] ?? null,
'entityId' => $result['entityId'] ?? null,
];
// Create stream note for executed action in case context
if ($actionCaseId) {
$this->createStreamNote(self::NOTE_TYPE_ACTION, $result['message'] ?? 'פעולה בוצעה', $actionCaseId, [
'conversationId' => $conversationId,
'status' => 'executed',
'entityType' => $result['entityType'] ?? null,
'entityId' => $result['entityId'] ?? null,
]);
}
} catch (\Exception $e) {
$this->log->warning("SmartAssistant: Execution of {$tool} failed: " . $e->getMessage());
$inlineResults[] = "שגיאה בביצוע {$tool}: " . $e->getMessage();
}
}
if (!empty($inlineResults)) {
$responseText .= "\n\n" . implode("\n\n", $inlineResults);
}
// Persist response
try {
$this->getConversationRepo()->appendMessage($conversationId, 'assistant', $responseText, $executedActions);
} 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' => $executedActions,
]);
}
return [
'conversationId' => $conversationId,
'text' => $responseText,
'actions' => [],
'executedActions' => $executedActions,
];
}
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();
$hearingDays = $thresholds['upcomingHearingDays'] ?? 7;
return [
'summary' => $alertCalc->getSummary(),
'alerts' => $alertCalc->calculateAlerts($thresholds),
'openCases' => $alertCalc->getOpenCaseItems(),
'upcomingHearings' => $alertCalc->getUpcomingHearingItems($hearingDays),
];
}
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.');
}
$siteUrl = $this->config->get('siteUrl') ?? '';
$payload = json_encode([
'message' => $message,
'context' => $context,
'conversationId' => $conversationId,
'conversationHistory' => $history,
'mode' => $mode,
'systemInstructions' => $this->getSystemInstructions(),
'espocrm' => [
'url' => rtrim($siteUrl, '/'),
'apiKey' => $this->getEspocrmApiKey(),
],
]);
$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
{
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
if ($integration && $integration->get('enabled')) {
$data = $integration->get('data') ?? (object) [];
if (!empty($data->webhookUrl)) {
return $data->webhookUrl;
}
}
return null;
}
private function getApiKey(): ?string
{
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
if ($integration && $integration->get('enabled')) {
$data = $integration->get('data') ?? (object) [];
if (!empty($data->apiKey)) {
return $data->apiKey;
}
}
return null;
}
private function getEspocrmApiKey(): ?string
{
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
if ($integration && $integration->get('enabled')) {
$data = $integration->get('data') ?? (object) [];
if (!empty($data->espocrmApiKey)) {
return $data->espocrmApiKey;
}
}
return null;
}
private function getSystemInstructions(): array
{
return [
'call_vs_meeting' => implode(' ', [
'כשהמשתמש מדווח ששוחח, דיבר, התקשר, או שוחחה עם לקוח/ה — השתמש בכלי create_call (לא create_meeting).',
'כשהמשתמש מדווח שנפגש, קבע פגישה, או תיאם מפגש — השתמש בכלי create_meeting.',
'שיחת טלפון = create_call. פגישה פיזית/זום = create_meeting.',
]),
'open_questions' => implode(' ', [
'אם שאלת את המשתמש שאלה והוא לא ענה עליה בתגובה הבאה — חזור על השאלה בתחילת התשובה הבאה.',
'עקוב אחרי שאלות פתוחות ואל תוותר עליהן עד שהמשתמש עונה או אומר לך לוותר.',
]),
'behavioral_rules' => implode(' ', [
'אם context.assistantRules מכיל כללים — יש ליישם אותם בכל שיחה.',
'כללים גלובליים תקפים תמיד. כללי "case" תקפים רק במצב תיק. כללי "office" תקפים רק במצב משרד.',
'אם המשתמש מבקש להגדיר כלל חדש (למשל "תמיד תעשה X כש-Y") — השתמש בכלי save_rule כדי לשמור אותו.',
]),
];
}
private function getThresholds(): array
{
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
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;
}
}