This commit is contained in:
2026-07-12 19:21:35 +02:00
parent f2d944b117
commit 41cb412cb0
49 changed files with 3170 additions and 52 deletions
+176
View File
@@ -0,0 +1,176 @@
<?php
namespace App\Libraries;
use RuntimeException;
/**
* Parsowanie paragonu przez OpenAI Responses API (/v1/responses).
* Wejscie: obraz (input_image), PDF (input_file — OpenAI renderuje strony), JSON/tekst (input_text).
* Wyjscie: merchant, date, total, items[] z proponowana kategoria (nazwa lub null).
*
* UWAGA: chat-completions z file_data NIE dostarcza tresci PDF (model halucynuje).
* Dziala dopiero Responses API + input_file. gpt-4o czyta gesty paragon lepiej niz mini.
* ponytail: jeden POST przez \Config\Services::curlrequest() — bez SDK.
*/
class ReceiptParser
{
private string $apiKey;
private string $model;
public function __construct()
{
$this->apiKey = (string) env('OPEN_AI_API');
// o4-mini (model rozumujacy) czyta gesty paragon fiskalny najdokladniej — lepiej niz gpt-4o/4.1
// i tansze; poprawnie liczy rabaty (Opust). Podmiana przez env('openai.model').
$this->model = (string) (env('openai.model') ?: 'o4-mini');
if ($this->apiKey === '') {
throw new RuntimeException('Brak klucza OPEN_AI_API w .env.');
}
}
/**
* @param string $filePath sciezka do wgranego pliku
* @param string $mime typ MIME pliku
* @param string[] $categories dozwolone nazwy kategorii wydatkow (do dopasowania)
*
* @return array{merchant:?string,date:?string,total:?float,items:array<int,array{name:string,qty:?float,amount:float,category:?string}>}
*/
public function parse(string $filePath, string $mime, array $categories): array
{
$content = file_get_contents($filePath);
if ($content === false) {
throw new RuntimeException('Nie udalo sie odczytac pliku paragonu.');
}
$parts = [['type' => 'input_text', 'text' => $this->prompt($categories)]];
if (str_starts_with($mime, 'image/')) {
$parts[] = ['type' => 'input_image', 'image_url' => 'data:' . $mime . ';base64,' . base64_encode($content)];
} elseif ($mime === 'application/pdf') {
$parts[] = [
'type' => 'input_file',
'filename' => 'paragon.pdf',
'file_data' => 'data:application/pdf;base64,' . base64_encode($content),
];
} else {
// JSON (lub inny tekst) — przekaz tresc do interpretacji.
$parts[] = ['type' => 'input_text', 'text' => "Dane paragonu (surowe):\n" . $content];
}
$payload = [
'model' => $this->model,
'text' => ['format' => ['type' => 'json_object']],
'input' => [['role' => 'user', 'content' => $parts]],
];
$client = \Config\Services::curlrequest(['timeout' => 120]);
$response = $client->post('https://api.openai.com/v1/responses', [
'headers' => [
'Authorization' => 'Bearer ' . $this->apiKey,
'Content-Type' => 'application/json',
],
'body' => json_encode($payload),
'http_errors' => false,
]);
$status = $response->getStatusCode();
$body = (string) $response->getBody();
if ($status < 200 || $status >= 300) {
throw new RuntimeException('OpenAI API blad (HTTP ' . $status . '): ' . mb_substr($body, 0, 300));
}
$decoded = json_decode($body, true);
$text = $this->extractText($decoded);
if ($text === '') {
throw new RuntimeException('Nieoczekiwana odpowiedz OpenAI (brak tresci).');
}
$parsed = json_decode($text, true);
if (! is_array($parsed)) {
throw new RuntimeException('OpenAI zwrocilo niepoprawny JSON paragonu.');
}
return $this->normalize($parsed);
}
/**
* Wyciaga tekst z odpowiedzi Responses API (output_text lub output[].content[].text).
*/
private function extractText(?array $d): string
{
if (! is_array($d)) {
return '';
}
if (! empty($d['output_text']) && is_string($d['output_text'])) {
return $d['output_text'];
}
$txt = '';
foreach ($d['output'] ?? [] as $o) {
foreach ($o['content'] ?? [] as $c) {
if (isset($c['text']) && is_string($c['text'])) {
$txt .= $c['text'];
}
}
}
return $txt;
}
private function prompt(array $categories): string
{
$list = implode(', ', $categories);
return <<<TXT
To jest polski PARAGON FISKALNY (kolumny tabeli pozycji: Nazwa, PTU, Ilosc, Cena, Wartosc).
Zwroc WYLACZNIE obiekt JSON o strukturze:
{"merchant":string|null,"date":"YYYY-MM-DD"|null,"total":number|null,"items":[{"name":string,"display":string,"qty":number|null,"amount":number,"category":string|null}]}
Zasady:
- "name" = nazwa DOKLADNIE jak na paragonie (skrocona, np. "WinogrJasneBezpLuz") — sluzy do mapowania.
- "display" = ladna, czytelna nazwa produktu po polsku dla czlowieka (np. "Winogrona jasne bezpestkowe").
- "amount" = kwota z kolumny WARTOSC (skrajnie prawa, laczna wartosc pozycji), NIE cena jednostkowa.
- Linia "Opust"/"Rabat" ze znakiem minus: odejmij ja od wartosci poprzedniej pozycji (podaj amount po rabacie).
- Ujmij pozycje z sekcji "Opakowania zwrotne"/kaucja, jesli wystepuja.
- POMIN wiersze podsumowan: Sprzedaz opodatkowana, PTU, Suma PLN, DO ZAPLATY.
- "date" = data zakupu z paragonu. "total" = kwota DO ZAPLATY.
- "category" dobierz TYLKO z tej listy (dokladna nazwa) albo null: {$list}
Nie dodawaj zadnego tekstu poza obiektem JSON.
TXT;
}
/**
* Sanityzacja i rzutowanie typow z odpowiedzi modelu.
*/
private function normalize(array $p): array
{
$items = [];
foreach ($p['items'] ?? [] as $it) {
$name = trim((string) ($it['name'] ?? ''));
if ($name === '') {
continue;
}
$display = trim((string) ($it['display'] ?? ''));
$items[] = [
'name' => $name,
'display' => $display !== '' ? $display : $name,
'qty' => isset($it['qty']) && $it['qty'] !== null ? (float) $it['qty'] : null,
'amount' => round((float) ($it['amount'] ?? 0), 2),
'category' => isset($it['category']) && $it['category'] !== null && $it['category'] !== ''
? (string) $it['category'] : null,
];
}
$date = isset($p['date']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $p['date']) ? (string) $p['date'] : null;
return [
'merchant' => isset($p['merchant']) && $p['merchant'] !== '' ? (string) $p['merchant'] : null,
'date' => $date,
'total' => isset($p['total']) && $p['total'] !== null ? round((float) $p['total'], 2) : null,
'items' => $items,
];
}
}