Certified/app/Ocr/Providers/MistralProvider.php
Joël van de Wouw ac340d125c
Some checks failed
linter / quality (push) Failing after 13s
tests / ci (8.3) (push) Failing after 2s
tests / ci (8.4) (push) Failing after 2s
tests / ci (8.5) (push) Failing after 3s
Initial commit
Certificate manager built on Laravel 13, Livewire 4, and Flux.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 14:44:58 +02:00

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;
}
}
}