Update: 2026-07-30 02:27:45
This commit is contained in:
+71
-14
@@ -14,10 +14,52 @@ class AI
|
||||
|
||||
private static int $maxRetries = 3;
|
||||
|
||||
/** Tenant the current extraction belongs to, for per-office cost attribution. */
|
||||
private static ?string $tenantContext = null;
|
||||
|
||||
/**
|
||||
* Extract Data from Invoice Image or PDF (Base64)
|
||||
* Set the tenant whose quota/cost the next extraction(s) belong to.
|
||||
* Workers should call this before extracting so ai_usage_log is attributable.
|
||||
*/
|
||||
public static function setTenantContext(?string $tenantId): void
|
||||
{
|
||||
self::$tenantContext = $tenantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress output that is safe to call from a web request.
|
||||
* Writing to stdout mid-request would corrupt the JSON response body.
|
||||
*/
|
||||
private static function progress(string $msg): void
|
||||
{
|
||||
if (php_sapi_name() === 'cli') {
|
||||
echo $msg . "\n";
|
||||
}
|
||||
@file_put_contents(
|
||||
STORAGE_PATH . '/logs/worker.log',
|
||||
'[' . date('Y-m-d H:i:s') . '] [AI] ' . $msg . "\n",
|
||||
FILE_APPEND
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the FIRST invoice found in an image or PDF (Base64).
|
||||
*
|
||||
* Kept for callers that only ever expect one invoice. Prefer
|
||||
* extractInvoices() when an image might contain more than one.
|
||||
*/
|
||||
public static function extractInvoiceData(string $base64Data, string $mimeType): ?array
|
||||
{
|
||||
$invoices = self::extractInvoices($base64Data, $mimeType);
|
||||
return $invoices[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract EVERY invoice found in an image or PDF (Base64).
|
||||
*
|
||||
* @return array<int, array>|null
|
||||
*/
|
||||
public static function extractInvoices(string $base64Data, string $mimeType): ?array
|
||||
{
|
||||
$apiKey = env('GEMINI_API_KEY');
|
||||
if (!$apiKey) {
|
||||
@@ -64,7 +106,7 @@ class AI
|
||||
error_log("AI Error: cURL failed (attempt $attempt): $curlError");
|
||||
if ($attempt < self::$maxRetries) {
|
||||
$wait = pow(2, $attempt) + rand(1, 3);
|
||||
echo " Retrying in {$wait}s (cURL error)...\n";
|
||||
self::progress(" Retrying in {$wait}s (cURL error)...");
|
||||
sleep($wait);
|
||||
continue;
|
||||
}
|
||||
@@ -78,7 +120,7 @@ class AI
|
||||
// Retry on 503 (overloaded) or 429 (rate limit)
|
||||
if (in_array($httpCode, [503, 429]) && $attempt < self::$maxRetries) {
|
||||
$wait = pow(2, $attempt) + rand(1, 3);
|
||||
echo " Gemini $httpCode — retrying in {$wait}s (attempt $attempt/" . self::$maxRetries . ")...\n";
|
||||
self::progress(" Gemini $httpCode - retrying in {$wait}s (attempt $attempt/" . self::$maxRetries . ")...");
|
||||
sleep($wait);
|
||||
continue;
|
||||
}
|
||||
@@ -106,18 +148,22 @@ class AI
|
||||
return null;
|
||||
}
|
||||
|
||||
// If the AI returns an array of invoices, extract the first one
|
||||
if (isset($data['invoices']) && is_array($data['invoices']) && count($data['invoices']) > 0) {
|
||||
$data = $data['invoices'][0];
|
||||
}
|
||||
|
||||
// Track token usage from Gemini response
|
||||
$usage = $result['usageMetadata'] ?? [];
|
||||
if (!empty($usage)) {
|
||||
self::logTokenUsage($usage);
|
||||
}
|
||||
|
||||
return $data;
|
||||
// The prompt asks for {"invoices": [...]}. Return the whole list; a single
|
||||
// photo can legitimately hold two receipts, and the old code kept only
|
||||
// $data['invoices'][0] and discarded the rest without a trace.
|
||||
if (isset($data['invoices']) && is_array($data['invoices'])) {
|
||||
$invoices = array_values(array_filter($data['invoices'], 'is_array'));
|
||||
return empty($invoices) ? null : $invoices;
|
||||
}
|
||||
|
||||
// Model answered with a bare invoice object rather than the wrapper.
|
||||
return empty($data) ? null : [$data];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,16 +184,27 @@ class AI
|
||||
$totalCostUsd = $inputCost + $outputCost;
|
||||
$totalCostJod = $totalCostUsd * 0.709; // 1 USD ≈ 0.709 JOD
|
||||
|
||||
$db->prepare("
|
||||
INSERT INTO ai_usage_log (id, input_tokens, output_tokens, total_tokens, cost_usd, cost_jod, model, created_at)
|
||||
VALUES (UUID(), ?, ?, ?, ?, ?, 'gemini-flash-lite', NOW())
|
||||
")->execute([
|
||||
$params = [
|
||||
$inputTokens,
|
||||
$outputTokens,
|
||||
$totalTokens,
|
||||
round($totalCostUsd, 8),
|
||||
round($totalCostJod, 8),
|
||||
]);
|
||||
];
|
||||
|
||||
try {
|
||||
// Preferred: attribute the cost to a tenant.
|
||||
$db->prepare("
|
||||
INSERT INTO ai_usage_log (id, tenant_id, input_tokens, output_tokens, total_tokens, cost_usd, cost_jod, model, created_at)
|
||||
VALUES (UUID(), ?, ?, ?, ?, ?, ?, 'gemini-flash-lite', NOW())
|
||||
")->execute(array_merge([self::$tenantContext], $params));
|
||||
} catch (\PDOException $e) {
|
||||
// Older deployments have no tenant_id column yet - fall back.
|
||||
$db->prepare("
|
||||
INSERT INTO ai_usage_log (id, input_tokens, output_tokens, total_tokens, cost_usd, cost_jod, model, created_at)
|
||||
VALUES (UUID(), ?, ?, ?, ?, ?, 'gemini-flash-lite', NOW())
|
||||
")->execute($params);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Never crash the main flow for logging
|
||||
error_log("[AI] Token usage log failed: " . $e->getMessage());
|
||||
|
||||
Reference in New Issue
Block a user