Update: 2026-07-30 02:27:45

This commit is contained in:
Hamza-Ayed
2026-07-30 02:27:45 +03:00
parent 5f62455113
commit ca4a7c2e70
56 changed files with 3391 additions and 709 deletions
+71 -14
View File
@@ -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());