preparer->prepare($absolutePath); $response = Http::withToken($apiKey) ->connectTimeout(min(5, max(1, $timeout))) ->timeout($timeout) ->retry( [200, 500], when: static fn (Throwable $exception): bool => $exception instanceof ConnectionException || ($exception instanceof RequestException && in_array($exception->response->status(), [429, 500, 502, 503, 504], true)), throw: false, ) ->acceptJson() ->post($config['url'] ?? 'https://api.openai.com/v1/chat/completions', [ 'model' => $model, 'response_format' => ['type' => 'json_object'], 'messages' => [ ['role' => 'system', 'content' => $this->systemPrompt()], [ 'role' => 'user', 'content' => [ ['type' => 'text', 'text' => 'Sichte dieses Bild nach den Kategorien im Schema und antworte nur mit dem JSON-Objekt.'], [ 'type' => 'image_url', 'image_url' => [ 'url' => 'data:image/jpeg;base64,'.base64_encode($prepared['bytes']), 'detail' => 'auto', ], ], ], ], ], ]); if ($response->failed()) { throw new RuntimeException("OpenAI-Bildsichtung fehlgeschlagen (HTTP {$response->status()})."); } $content = $response->json('choices.0.message.content'); if (! is_string($content) || trim($content) === '') { throw new RuntimeException('OpenAI-Bildsichtung lieferte keinen Inhalt.'); } $decoded = json_decode($content, true); if (! is_array($decoded)) { throw new RuntimeException('OpenAI-Bildsichtung lieferte kein JSON-Objekt.'); } $missing = array_filter(ImagePrecheckCategory::cases(), fn (ImagePrecheckCategory $c): bool => ! array_key_exists($c->value, $decoded)); if (count($missing) > 0) { throw new RuntimeException('OpenAI-Bildsichtung lieferte ein unvollständiges Schema.'); } $this->assertSchema($decoded); return ImagePrecheckResult::ok($decoded, 'openai', $model, null, [ 'prompt_version' => (string) config('images.precheck.prompt_version'), 'sent_size' => [$prepared['width'], $prepared['height']], 'usage' => $response->json('usage'), ]); } /** * Strikte Prüfung der Wertstruktur (Audit N20): Ein Schlüssel allein * reicht nicht — alle Kategorien auf `null`, leere Objekte, String- * Booleans („false" ist in PHP wahr) oder Konfidenzen außerhalb 0…1 * ergäben sonst ein scheinbar belastbares `ok` ohne Treffer. Formal * unbrauchbare Antworten gehen als Ausnahme in den bestehenden * `error`-Pfad (Block K.1: durchlassen mit Vermerk, nie blockieren). * * @param array $decoded */ private function assertSchema(array $decoded): void { foreach (ImagePrecheckCategory::cases() as $category) { $entry = $decoded[$category->value]; if (! is_array($entry)) { throw new RuntimeException("OpenAI-Bildsichtung lieferte ein ungültiges Schema: {$category->value} ist kein Objekt."); } if (! array_key_exists('hit', $entry) || ! is_bool($entry['hit'])) { throw new RuntimeException("OpenAI-Bildsichtung lieferte ein ungültiges Schema: {$category->value}.hit ist kein Boolean."); } $confidence = $entry['confidence'] ?? null; if (! (is_int($confidence) || is_float($confidence)) || ! is_finite($confidence) || $confidence < 0 || $confidence > 1) { throw new RuntimeException("OpenAI-Bildsichtung lieferte ein ungültiges Schema: {$category->value}.confidence liegt nicht in 0…1."); } if (array_key_exists('note', $entry) && $entry['note'] !== null && ! is_string($entry['note'])) { throw new RuntimeException("OpenAI-Bildsichtung lieferte ein ungültiges Schema: {$category->value}.note ist kein Text."); } } } private function systemPrompt(): string { $keys = implode(', ', array_map(fn (ImagePrecheckCategory $c): string => $c->value, ImagePrecheckCategory::cases())); return <<