update
This commit is contained in:
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user