feat: AI usage logging (AiLogger, ai_logs table, admin view)
- AiLogger.php: new service, inserts into ai_logs on every AI call - ai_logs table: added to migrate.php (provider, model, call_type, tokens, duration_ms) - AiBot: logs chat, media_response, nlu calls with timing and token counts - MediaTranscriber: logs whisper, geminiAudio, openaiVision, geminiVision, claudeVision - /admin/ai-logs: paginated table with provider/company filter - Layout.php: added IA Logs nav link - index.php: require AiLogger before AiBot; added GET /admin/ai-logs route Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
1d9484a1e0
commit
5506ebd2af
@@ -5691,4 +5691,114 @@ HTML;
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function aiLogs(): void
|
||||
{
|
||||
SessionAuth::require();
|
||||
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||
$limit = 50;
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$provider = $_GET['provider'] ?? '';
|
||||
$company = (int)($_GET['company'] ?? 0);
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
if ($provider !== '') { $where[] = 'provider = ?'; $params[] = $provider; }
|
||||
if ($company > 0) { $where[] = 'company_id = ?'; $params[] = $company; }
|
||||
$wSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
$total = (int)db()->prepare("SELECT COUNT(*) FROM ai_logs $wSql")->execute($params) ? db()->prepare("SELECT COUNT(*) FROM ai_logs $wSql")->execute($params) && 0 : 0;
|
||||
$stmt = db()->prepare("SELECT COUNT(*) FROM ai_logs $wSql");
|
||||
$stmt->execute($params);
|
||||
$total = (int)$stmt->fetchColumn();
|
||||
|
||||
$stmt2 = db()->prepare("SELECT * FROM ai_logs $wSql ORDER BY id DESC LIMIT $limit OFFSET $offset");
|
||||
$stmt2->execute($params);
|
||||
$rows = $stmt2->fetchAll(\PDO::FETCH_ASSOC);
|
||||
|
||||
$companies = db()->query("SELECT id, name FROM companies ORDER BY name")->fetchAll(\PDO::FETCH_ASSOC);
|
||||
$companyMap = [];
|
||||
foreach ($companies as $c) $companyMap[(int)$c['id']] = self::h($c['name']);
|
||||
|
||||
$pages = (int)ceil($total / $limit);
|
||||
|
||||
$companyOptions = '<option value="">Todas las empresas</option>';
|
||||
foreach ($companies as $c) {
|
||||
$sel = ($company === (int)$c['id']) ? ' selected' : '';
|
||||
$companyOptions .= '<option value="' . (int)$c['id'] . '"' . $sel . '>' . self::h($c['name']) . '</option>';
|
||||
}
|
||||
|
||||
$providerOptions = '';
|
||||
foreach (['', 'openai', 'gemini', 'claude'] as $p) {
|
||||
$label = $p === '' ? 'Todos' : ucfirst($p);
|
||||
$sel = $provider === $p ? ' selected' : '';
|
||||
$providerOptions .= "<option value=\"{$p}\"{$sel}>{$label}</option>";
|
||||
}
|
||||
|
||||
$rows_html = '';
|
||||
foreach ($rows as $r) {
|
||||
$cid = (int)($r['company_id'] ?? 0);
|
||||
$cname = $companyMap[$cid] ?? '<span style="color:#aaa">—</span>';
|
||||
$badge = match($r['provider'] ?? '') {
|
||||
'openai' => 'background:#10a37f;color:#fff',
|
||||
'gemini' => 'background:#4285f4;color:#fff',
|
||||
'claude' => 'background:#d97706;color:#fff',
|
||||
default => 'background:#6b7280;color:#fff',
|
||||
};
|
||||
$tok = ($r['tokens_in'] ?? null) !== null
|
||||
? self::h($r['tokens_in']) . '+' . self::h($r['tokens_out'])
|
||||
: '—';
|
||||
$rows_html .= '<tr>'
|
||||
. '<td style="color:#9ca3af;font-size:11px">' . self::h(substr($r['created_at'] ?? '', 0, 16)) . '</td>'
|
||||
. '<td>' . $cname . '</td>'
|
||||
. '<td>' . self::h($r['phone_number'] ?? '') . '</td>'
|
||||
. '<td><span style="' . $badge . ';border-radius:4px;padding:2px 7px;font-size:11px">' . self::h($r['provider']) . '</span></td>'
|
||||
. '<td style="font-size:11px">' . self::h($r['model'] ?? '') . '</td>'
|
||||
. '<td><span style="background:#f3f4f6;border-radius:4px;padding:2px 6px;font-size:11px">' . self::h($r['call_type'] ?? '') . '</span></td>'
|
||||
. '<td style="font-size:11px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' . self::h($r['input_preview'] ?? '') . '">' . self::h(mb_substr($r['input_preview'] ?? '', 0, 60)) . '</td>'
|
||||
. '<td style="font-size:11px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' . self::h($r['output_preview'] ?? '') . '">' . self::h(mb_substr($r['output_preview'] ?? '', 0, 60)) . '</td>'
|
||||
. '<td style="text-align:right;font-size:11px">' . $tok . '</td>'
|
||||
. '<td style="text-align:right;font-size:11px">' . number_format((int)($r['duration_ms'] ?? 0)) . 'ms</td>'
|
||||
. '</tr>';
|
||||
}
|
||||
|
||||
$pager = '';
|
||||
for ($i = 1; $i <= $pages; $i++) {
|
||||
$active = $i === $page ? ' style="font-weight:bold"' : '';
|
||||
$url = '/admin/ai-logs?page=' . $i . ($provider ? '&provider=' . urlencode($provider) : '') . ($company ? '&company=' . $company : '');
|
||||
$pager .= "<a href=\"{$url}\"{$active} class=\"btn-sm\">{$i}</a> ";
|
||||
}
|
||||
|
||||
echo Layout::page('IA Logs', 'ai-logs', '
|
||||
<style>
|
||||
.filter-bar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:16px}
|
||||
.filter-bar select{padding:5px 10px;border:1px solid #d1d5db;border-radius:6px;font-size:13px}
|
||||
.log-table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
.log-table th{background:#f9fafb;padding:8px 10px;text-align:left;border-bottom:2px solid #e5e7eb;font-size:11px;text-transform:uppercase;color:#6b7280;white-space:nowrap}
|
||||
.log-table td{padding:7px 10px;border-bottom:1px solid #f3f4f6;vertical-align:middle}
|
||||
.log-table tr:hover td{background:#fafafa}
|
||||
@media(prefers-color-scheme:dark){.log-table th{background:#1f2937;border-color:#374151;color:#9ca3af}.log-table td{border-color:#1f2937}.log-table tr:hover td{background:#111827}}
|
||||
</style>
|
||||
<div class="card" style="padding:20px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||
<h2 style="margin:0">🤖 IA Logs <span style="font-size:14px;color:#9ca3af;font-weight:400">(' . number_format($total) . ' registros)</span></h2>
|
||||
</div>
|
||||
<form class="filter-bar" method="get">
|
||||
<select name="provider" onchange="this.form.submit()">' . $providerOptions . '</select>
|
||||
<select name="company" onchange="this.form.submit()">' . $companyOptions . '</select>
|
||||
</form>
|
||||
<div style="overflow-x:auto">
|
||||
<table class="log-table">
|
||||
<thead><tr>
|
||||
<th>Fecha</th><th>Empresa</th><th>Teléfono</th><th>Proveedor</th><th>Modelo</th>
|
||||
<th>Tipo</th><th>Entrada</th><th>Salida</th><th>Tokens</th><th>Duración</th>
|
||||
</tr></thead>
|
||||
<tbody>' . ($rows_html ?: '<tr><td colspan="10" style="text-align:center;color:#9ca3af;padding:30px">Sin registros</td></tr>') . '</tbody>
|
||||
</table>
|
||||
</div>
|
||||
' . ($pages > 1 ? '<div style="margin-top:12px">' . $pager . '</div>' : '') . '
|
||||
</div>
|
||||
');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ class Layout
|
||||
['bot-config', '/admin/bot-config', 'bot-config', 'Bot'],
|
||||
['conv-flow', '/admin/conversation-flow', 'conv-flow', 'Flujos'],
|
||||
['settings', '/admin/settings', 'settings', 'Ajustes'],
|
||||
['ai-logs', '/admin/ai-logs', 'live', 'IA Logs'],
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ require_once __DIR__ . '/../services/OutboundWorker.php';
|
||||
require_once __DIR__ . '/../services/ErpSync.php';
|
||||
require_once __DIR__ . '/../services/ConversationContext.php';
|
||||
require_once __DIR__ . '/../services/NormalBot.php';
|
||||
require_once __DIR__ . '/../services/AiLogger.php';
|
||||
require_once __DIR__ . '/../services/AiBot.php';
|
||||
require_once __DIR__ . '/../services/MediaTranscriber.php';
|
||||
require_once __DIR__ . '/../services/BotRouter.php';
|
||||
@@ -674,6 +675,7 @@ $routes = [
|
||||
|
||||
// ─── Admin: configuración general ──────────────────────────────────────
|
||||
['GET', '/admin/settings', fn() => DashboardController::settings()],
|
||||
['GET', '/admin/ai-logs', fn() => DashboardController::aiLogs()],
|
||||
|
||||
['POST', '/admin/settings/save', fn() => (function () {
|
||||
SessionAuth::require();
|
||||
|
||||
+35
-5
@@ -3,6 +3,11 @@ declare(strict_types=1);
|
||||
|
||||
class AiBot
|
||||
{
|
||||
// Context forwarded to provider methods so AiLogger has phone + call type
|
||||
private static string $logPhone = '';
|
||||
private static string $logType = 'chat';
|
||||
private static int $logCompany = 0;
|
||||
|
||||
public static function processMedia(array $company, array $context, string $mediaType, string $caption): ?array
|
||||
{
|
||||
$permType = (int)($context['permission_type'] ?? 1);
|
||||
@@ -36,7 +41,7 @@ class AiBot
|
||||
$ctxId = (int)$botCtx['id'];
|
||||
|
||||
ConversationContext::addAiMessage($ctxId, ['role' => 'user', 'content' => $userMessage]);
|
||||
$result = self::callLlm($systemPrompt, $ctxId, $company);
|
||||
$result = self::callLlm($systemPrompt, $ctxId, $company, 'media_response', $context['from']);
|
||||
if ($result === null) {
|
||||
return null;
|
||||
}
|
||||
@@ -61,7 +66,7 @@ class AiBot
|
||||
|
||||
ConversationContext::addAiMessage($ctxId, ['role' => 'user', 'content' => $input]);
|
||||
|
||||
$result = self::callLlm($systemPrompt, $ctxId, $company);
|
||||
$result = self::callLlm($systemPrompt, $ctxId, $company, 'chat', $context['from']);
|
||||
|
||||
if ($result === null) {
|
||||
return null;
|
||||
@@ -77,8 +82,12 @@ class AiBot
|
||||
];
|
||||
}
|
||||
|
||||
private static function callLlm(string $systemPrompt, int $ctxId, array $company): ?array
|
||||
private static function callLlm(string $systemPrompt, int $ctxId, array $company, string $callType = 'chat', string $phone = ''): ?array
|
||||
{
|
||||
self::$logType = $callType;
|
||||
self::$logPhone = $phone;
|
||||
self::$logCompany = (int)($company['id'] ?? 0);
|
||||
|
||||
$cfg = self::getConfig($company);
|
||||
// Per-company provider overrides global; empty = use global
|
||||
$provider = $cfg['ai_provider'] ?? env('AI_PROVIDER', 'openai');
|
||||
@@ -121,6 +130,7 @@ class AiBot
|
||||
'temperature' => 0.7,
|
||||
]);
|
||||
|
||||
$t0 = (int)(microtime(true) * 1000);
|
||||
$ch = curl_init('https://api.openai.com/v1/chat/completions');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
@@ -137,6 +147,7 @@ class AiBot
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
$ms = (int)(microtime(true) * 1000) - $t0;
|
||||
|
||||
if ($httpCode !== 200 || $response === false) {
|
||||
WpWebhook::log('ERROR', "OpenAI API error: {$error} HTTP:{$httpCode}");
|
||||
@@ -146,6 +157,9 @@ class AiBot
|
||||
$data = json_decode($response, true);
|
||||
$text = $data['choices'][0]['message']['content'] ?? null;
|
||||
|
||||
$lastUser = end($history)['content'] ?? '';
|
||||
AiLogger::log(self::$logCompany, 'openai', $model, self::$logType, $lastUser, $text ?? '', $ms, self::$logPhone, $data['usage']['prompt_tokens'] ?? null, $data['usage']['completion_tokens'] ?? null);
|
||||
|
||||
return $text !== null ? ['content' => $text] : null;
|
||||
}
|
||||
|
||||
@@ -175,6 +189,7 @@ class AiBot
|
||||
'generationConfig' => ['maxOutputTokens' => $maxTokens, 'temperature' => 0.7],
|
||||
]);
|
||||
|
||||
$t0 = (int)(microtime(true) * 1000);
|
||||
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}";
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
@@ -189,6 +204,7 @@ class AiBot
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
$ms = (int)(microtime(true) * 1000) - $t0;
|
||||
|
||||
if ($httpCode !== 200 || $response === false) {
|
||||
WpWebhook::log('ERROR', "Gemini API error: {$error} HTTP:{$httpCode}");
|
||||
@@ -198,6 +214,11 @@ class AiBot
|
||||
$data = json_decode($response, true);
|
||||
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
|
||||
|
||||
$lastUser = end($history)['content'] ?? '';
|
||||
$tokIn = $data['usageMetadata']['promptTokenCount'] ?? null;
|
||||
$tokOut = $data['usageMetadata']['candidatesTokenCount'] ?? null;
|
||||
AiLogger::log(self::$logCompany, 'gemini', $model, self::$logType, $lastUser, $text ?? '', $ms, self::$logPhone, $tokIn, $tokOut);
|
||||
|
||||
return $text !== null ? ['content' => $text] : null;
|
||||
}
|
||||
|
||||
@@ -225,6 +246,7 @@ class AiBot
|
||||
'messages' => $messages,
|
||||
]);
|
||||
|
||||
$t0 = (int)(microtime(true) * 1000);
|
||||
$ch = curl_init('https://api.anthropic.com/v1/messages');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
@@ -242,6 +264,7 @@ class AiBot
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
$ms = (int)(microtime(true) * 1000) - $t0;
|
||||
|
||||
if ($httpCode !== 200 || $response === false) {
|
||||
WpWebhook::log('ERROR', "Claude API error: {$error} HTTP:{$httpCode}");
|
||||
@@ -251,6 +274,9 @@ class AiBot
|
||||
$data = json_decode($response, true);
|
||||
$text = $data['content'][0]['text'] ?? null;
|
||||
|
||||
$lastUser = end($history)['content'] ?? '';
|
||||
AiLogger::log(self::$logCompany, 'claude', $model, self::$logType, $lastUser, $text ?? '', $ms, self::$logPhone, $data['usage']['input_tokens'] ?? null, $data['usage']['output_tokens'] ?? null);
|
||||
|
||||
return $text !== null ? ['content' => $text] : null;
|
||||
}
|
||||
|
||||
@@ -324,7 +350,7 @@ PROMPT;
|
||||
$permType
|
||||
);
|
||||
|
||||
$raw = self::callLlmOnce($systemPrompt, $input, $company);
|
||||
$raw = self::callLlmOnce($systemPrompt, $input, $company, 'nlu', $context['from'] ?? '');
|
||||
if ($raw === null) {
|
||||
return ['action' => 'chat', 'text' => ''];
|
||||
}
|
||||
@@ -420,8 +446,12 @@ PROMPT;
|
||||
}
|
||||
|
||||
/** Llamada de 1 turno sin historial — para NLU routing. */
|
||||
private static function callLlmOnce(string $systemPrompt, string $userMessage, array $company): ?string
|
||||
private static function callLlmOnce(string $systemPrompt, string $userMessage, array $company, string $callType = 'nlu', string $phone = ''): ?string
|
||||
{
|
||||
self::$logType = $callType;
|
||||
self::$logPhone = $phone;
|
||||
self::$logCompany = (int)($company['id'] ?? 0);
|
||||
|
||||
$cfg = self::getConfig($company);
|
||||
$provider = $cfg['ai_provider'] ?? env('AI_PROVIDER', 'openai');
|
||||
$history = [['role' => 'user', 'content' => $userMessage]];
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
class AiLogger
|
||||
{
|
||||
public static function log(
|
||||
?int $companyId,
|
||||
string $provider,
|
||||
string $model,
|
||||
string $callType,
|
||||
string $inputPreview,
|
||||
string $outputPreview,
|
||||
int $durationMs,
|
||||
string $phone = '',
|
||||
?int $tokensIn = null,
|
||||
?int $tokensOut = null
|
||||
): void {
|
||||
try {
|
||||
db()->prepare("
|
||||
INSERT INTO ai_logs
|
||||
(company_id, phone_number, provider, model, call_type,
|
||||
input_preview, output_preview, tokens_in, tokens_out, duration_ms)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||
")->execute([
|
||||
$companyId ?: null,
|
||||
$phone !== '' ? $phone : null,
|
||||
$provider,
|
||||
$model,
|
||||
$callType,
|
||||
mb_substr($inputPreview, 0, 500),
|
||||
mb_substr($outputPreview, 0, 500),
|
||||
$tokensIn,
|
||||
$tokensOut,
|
||||
$durationMs,
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
// Never break the bot for a log failure
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ class MediaTranscriber
|
||||
public static function transcribeAudio(string $mediaId, array $company): ?string
|
||||
{
|
||||
$cfg = self::getConfig($company);
|
||||
$cfg['__company_id'] = (int)($company['id'] ?? 0);
|
||||
$provider = $cfg['ai_provider'] ?? env('AI_PROVIDER', 'openai');
|
||||
|
||||
$media = self::downloadMedia($mediaId);
|
||||
@@ -75,6 +76,7 @@ class MediaTranscriber
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'wa_audio_') . '.' . $ext;
|
||||
file_put_contents($tmpFile, $bytes);
|
||||
|
||||
$t0 = (int)(microtime(true) * 1000);
|
||||
$ch = curl_init('https://api.openai.com/v1/audio/transcriptions');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
@@ -90,12 +92,17 @@ class MediaTranscriber
|
||||
$resp = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
$ms = (int)(microtime(true) * 1000) - $t0;
|
||||
@unlink($tmpFile);
|
||||
|
||||
if ($code !== 200 || !$resp) return null;
|
||||
|
||||
$data = json_decode($resp, true);
|
||||
return isset($data['text']) ? trim($data['text']) : null;
|
||||
$text = isset($data['text']) ? trim($data['text']) : null;
|
||||
if ($text !== null) {
|
||||
AiLogger::log($cfg['__company_id'] ?? null, 'openai', 'whisper-1', 'transcribe', '[audio]', $text, $ms);
|
||||
}
|
||||
return $text;
|
||||
}
|
||||
|
||||
private static function geminiAudio(string $bytes, string $mime, array $cfg): ?string
|
||||
@@ -115,6 +122,7 @@ class MediaTranscriber
|
||||
]);
|
||||
|
||||
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}";
|
||||
$t0 = (int)(microtime(true) * 1000);
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
@@ -126,12 +134,17 @@ class MediaTranscriber
|
||||
$resp = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
$ms = (int)(microtime(true) * 1000) - $t0;
|
||||
|
||||
if ($code !== 200 || !$resp) return null;
|
||||
|
||||
$data = json_decode($resp, true);
|
||||
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
|
||||
return $text !== null ? trim($text) : null;
|
||||
$text = $text !== null ? trim($text) : null;
|
||||
if ($text !== null) {
|
||||
AiLogger::log($cfg['__company_id'] ?? null, 'gemini', $model, 'transcribe', '[audio]', $text, $ms);
|
||||
}
|
||||
return $text;
|
||||
}
|
||||
|
||||
// ── Imagen → intención/texto ──────────────────────────────────────────────
|
||||
@@ -139,6 +152,7 @@ class MediaTranscriber
|
||||
public static function extractFromImage(string $mediaId, array $company): ?string
|
||||
{
|
||||
$cfg = self::getConfig($company);
|
||||
$cfg['__company_id'] = (int)($company['id'] ?? 0);
|
||||
$provider = $cfg['ai_provider'] ?? env('AI_PROVIDER', 'openai');
|
||||
|
||||
$media = self::downloadMedia($mediaId);
|
||||
@@ -178,6 +192,7 @@ class MediaTranscriber
|
||||
]],
|
||||
]);
|
||||
|
||||
$t0 = (int)(microtime(true) * 1000);
|
||||
$ch = curl_init('https://api.openai.com/v1/chat/completions');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
@@ -189,12 +204,17 @@ class MediaTranscriber
|
||||
$resp = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
$ms = (int)(microtime(true) * 1000) - $t0;
|
||||
|
||||
if ($code !== 200 || !$resp) return null;
|
||||
|
||||
$data = json_decode($resp, true);
|
||||
$text = $data['choices'][0]['message']['content'] ?? null;
|
||||
return $text !== null ? trim($text) : null;
|
||||
$text = $text !== null ? trim($text) : null;
|
||||
if ($text !== null) {
|
||||
AiLogger::log($cfg['__company_id'] ?? null, 'openai', $model, 'vision', '[image]', $text, $ms);
|
||||
}
|
||||
return $text;
|
||||
}
|
||||
|
||||
private static function geminiVision(string $b64, string $mime, string $prompt, array $cfg): ?string
|
||||
@@ -213,6 +233,7 @@ class MediaTranscriber
|
||||
]);
|
||||
|
||||
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}";
|
||||
$t0 = (int)(microtime(true) * 1000);
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
@@ -224,12 +245,17 @@ class MediaTranscriber
|
||||
$resp = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
$ms = (int)(microtime(true) * 1000) - $t0;
|
||||
|
||||
if ($code !== 200 || !$resp) return null;
|
||||
|
||||
$data = json_decode($resp, true);
|
||||
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
|
||||
return $text !== null ? trim($text) : null;
|
||||
$text = $text !== null ? trim($text) : null;
|
||||
if ($text !== null) {
|
||||
AiLogger::log($cfg['__company_id'] ?? null, 'gemini', $model, 'vision', '[image]', $text, $ms);
|
||||
}
|
||||
return $text;
|
||||
}
|
||||
|
||||
private static function claudeVision(string $b64, string $mime, string $prompt, array $cfg): ?string
|
||||
@@ -250,6 +276,7 @@ class MediaTranscriber
|
||||
]],
|
||||
]);
|
||||
|
||||
$t0 = (int)(microtime(true) * 1000);
|
||||
$ch = curl_init('https://api.anthropic.com/v1/messages');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
@@ -261,12 +288,17 @@ class MediaTranscriber
|
||||
$resp = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
$ms = (int)(microtime(true) * 1000) - $t0;
|
||||
|
||||
if ($code !== 200 || !$resp) return null;
|
||||
|
||||
$data = json_decode($resp, true);
|
||||
$text = $data['content'][0]['text'] ?? null;
|
||||
return $text !== null ? trim($text) : null;
|
||||
$text = $text !== null ? trim($text) : null;
|
||||
if ($text !== null) {
|
||||
AiLogger::log($cfg['__company_id'] ?? null, 'claude', $model, 'vision', '[image]', $text, $ms);
|
||||
}
|
||||
return $text;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -257,6 +257,27 @@ $db->exec("
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
");
|
||||
|
||||
// ─── AI usage logs ───────────────────────────────────────────────────────────
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS ai_logs (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
company_id INT DEFAULT NULL,
|
||||
phone_number VARCHAR(20) DEFAULT NULL,
|
||||
provider VARCHAR(20) NOT NULL,
|
||||
model VARCHAR(60) NOT NULL DEFAULT '',
|
||||
call_type VARCHAR(30) NOT NULL DEFAULT 'chat',
|
||||
input_preview VARCHAR(500) DEFAULT NULL,
|
||||
output_preview VARCHAR(500) DEFAULT NULL,
|
||||
tokens_in INT DEFAULT NULL,
|
||||
tokens_out INT DEFAULT NULL,
|
||||
duration_ms INT DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_company (company_id),
|
||||
INDEX idx_provider (provider),
|
||||
INDEX idx_created (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
");
|
||||
|
||||
// ─── Campo erp_active_users en companies ──────────────────────────────────────
|
||||
try {
|
||||
$db->exec("ALTER TABLE companies ADD COLUMN erp_active_users INT DEFAULT 0 AFTER is_active");
|
||||
|
||||
Reference in New Issue
Block a user