Certificate manager built on Laravel 13, Livewire 4, and Flux. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
117 lines
3.5 KiB
PHP
117 lines
3.5 KiB
PHP
<?php
|
|
|
|
namespace App\Ocr\Providers;
|
|
|
|
use App\Ocr\OcrProviderInterface;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Mistral (FR) Pixtral vision API — extracts structured fields from an
|
|
* image of a certificate and returns them as JSON.
|
|
*
|
|
* @see https://docs.mistral.ai/capabilities/vision/
|
|
*/
|
|
class MistralProvider implements OcrProviderInterface
|
|
{
|
|
private const ENDPOINT = 'https://api.mistral.ai/v1/chat/completions';
|
|
|
|
private const MODEL = 'pixtral-12b-2409';
|
|
|
|
private const PROMPT = 'You are analyzing an image of a certificate or diploma. '
|
|
.'Return ONLY a JSON object with two keys: "title" (a concise name for the certificate) '
|
|
.'and "expires_at" (the expiry or valid-until date in YYYY-MM-DD format, or null if none is visible).';
|
|
|
|
/**
|
|
* @return array{title?: string|null, expires_at?: string|null}
|
|
*/
|
|
public function analyze(string $filePath, ?string $apiKey): array
|
|
{
|
|
if (blank($apiKey) || ! is_readable($filePath)) {
|
|
return [];
|
|
}
|
|
|
|
$dataUri = $this->toDataUri($filePath);
|
|
|
|
if ($dataUri === null) {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
$response = Http::withToken($apiKey)
|
|
->timeout(45)
|
|
->post(self::ENDPOINT, [
|
|
'model' => self::MODEL,
|
|
'response_format' => ['type' => 'json_object'],
|
|
'messages' => [[
|
|
'role' => 'user',
|
|
'content' => [
|
|
['type' => 'text', 'text' => self::PROMPT],
|
|
['type' => 'image_url', 'image_url' => $dataUri],
|
|
],
|
|
]],
|
|
]);
|
|
|
|
if ($response->failed()) {
|
|
Log::warning('Mistral OCR request failed.', ['status' => $response->status()]);
|
|
|
|
return [];
|
|
}
|
|
|
|
$content = $response->json('choices.0.message.content');
|
|
|
|
return $this->parseContent(is_string($content) ? $content : '');
|
|
} catch (Throwable $e) {
|
|
Log::warning('Mistral OCR analysis threw an exception.', ['message' => $e->getMessage()]);
|
|
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private function toDataUri(string $filePath): ?string
|
|
{
|
|
$mime = mime_content_type($filePath) ?: 'image/jpeg';
|
|
|
|
// Pixtral is a vision model and only accepts images, not PDFs.
|
|
if (! str_starts_with($mime, 'image/')) {
|
|
return null;
|
|
}
|
|
|
|
return 'data:'.$mime.';base64,'.base64_encode((string) file_get_contents($filePath));
|
|
}
|
|
|
|
/**
|
|
* @return array{title?: string|null, expires_at?: string|null}
|
|
*/
|
|
private function parseContent(string $content): array
|
|
{
|
|
$decoded = json_decode($content, true);
|
|
|
|
if (! is_array($decoded)) {
|
|
return [];
|
|
}
|
|
|
|
$title = data_get($decoded, 'title');
|
|
$expiresAt = data_get($decoded, 'expires_at');
|
|
|
|
return array_filter([
|
|
'title' => filled($title) && is_string($title) ? trim($title) : null,
|
|
'expires_at' => $this->normalizeDate($expiresAt),
|
|
], fn ($value) => $value !== null);
|
|
}
|
|
|
|
private function normalizeDate(mixed $value): ?string
|
|
{
|
|
if (! filled($value) || ! is_string($value)) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return CarbonImmutable::parse($value)->toDateString();
|
|
} catch (Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|