Laravel 11 & OpenAI API でPDFファイルのテキストと画像を解析する(AmazonS3からファイル取得)

コード 生成AI

ファイルのアップロードはこちら(Laravel 11でAmazon S3に画像を保存し、表示する手順)から。

Attachment.php

<?php

namespace App\Models;

class Attachment extends Model
{
    // s3 署名付きURLを取得するメソッド
    public function getFileUrl($filePath)
    {
        if (!$filePath) {
            return null;
        }
    
        $cacheKey = "s3_url_{$filePath}";
    
        // キャッシュに保存(有効期限はURLの期限に合わせる)
        return Cache::remember($cacheKey, now()->addMinutes(15), function () use ($filePath) {
            return Storage::disk('s3')->temporaryUrl($filePath, now()->addMinutes(15));
        });
    }

}

Web.php

// test
use App\Http\Controllers\Test\FileController;
Route::prefix('test')->group(function () {
    Route::get('/analyze/{id}', [FileController::class, 'show'])->name('test.analyze');
});

FileController

<?php

namespace App\Http\Controllers\Test;

use App\Http\Controllers\Controller;
use OpenAI\Laravel\Facades\OpenAI;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log;
use Smalot\PdfParser\Parser;

// attachmentsのfileに保存されているデータ
use App\Models\Attachment;

class FileController extends Controller
{
    public function show($id)
    {
        try {
            // ファイルの取得
            $attachment = Attachment::findOrFail($id);
            $fileUrl = $attachment->getFileUrl($attachment->file);
            $fileName = basename($attachment->file);
            
            // 一時ファイルのパスを設定
            $tempPath = storage_path('app/temp/' . uniqid() . '_' . $fileName);
            
            // ディレクトリが存在することを確認
            if (!file_exists(dirname($tempPath))) {
                mkdir(dirname($tempPath), 0755, true);
            }

            // ファイルをコピー
            if (!copy($fileUrl, $tempPath)) {
                throw new \Exception('ファイルのコピーに失敗しました。');
            }

            try {
                // MIMEタイプの取得を修正
                $finfo = new \finfo(FILEINFO_MIME_TYPE);
                $mimeType = $finfo->file($tempPath);
                
                Log::info('File MIME type: ' . $mimeType); // デバッグ用ログ
                
                // ファイル拡張子からもチェック
                $extension = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
                
                // MIMEタイプとファイル拡張子の両方をチェック
                if (strpos($mimeType, 'image/') === 0 || in_array($extension, ['jpg', 'jpeg', 'png'])) {
                    $analysis = $this->analyzeImage($tempPath);
                } elseif ($mimeType === 'application/pdf' || $extension === 'pdf') {
                    $analysis = $this->analyzePdf($tempPath);
                } else {
                    throw new \Exception("未対応のファイル形式です。(MIME: {$mimeType}, 拡張子: {$extension})");
                }

                // 分析結果を返す
                return view('test.analysis', [
                    'fileName' => $fileName,
                    'analysis' => $analysis,
                    'error' => null
                ]);

            } finally {
                // 一時ファイルの削除を確実に実行
                if (file_exists($tempPath)) {
                    unlink($tempPath);
                }
            }

        } catch (\Exception $e) {
            Log::error('分析エラー: ' . $e->getMessage());
            // エラー時も同じビューを使用し、エラーメッセージを表示
            return view('test.analysis', [
                'fileName' => $fileName ?? null,
                'analysis' => null,
                'error' => '分析中にエラーが発生しました: ' . $e->getMessage()
            ]);
        }
    }

    private function analyzeImage($path)
    {
        $imageData = base64_encode(file_get_contents($path));

        $result = OpenAI::chat()->create([
            'model' => 'gpt-4o',
            'messages' => [
                [
                    'role' => 'system',
                    'content' => '画像の内容を詳細に分析し、日本語で説明してください。'
                ],
                [
                    'role' => 'user',
                    'content' => [
                        [
                            'type' => 'text',
                            'text' => '画像について以下の点を説明してください:
                                    1. 画像の主な内容は?
                                    2. 画像の特徴は?
                                    3. 画像から読み取れる情報や意図'
                        ],
                        [
                            'type' => 'image_url',
                            'image_url' => [
                                'url' => "data:image/jpeg;base64,{$imageData}"
                            ]
                        ]
                    ]
                ]
            ],
            'max_tokens' => 1000
        ]);

        return $result->choices[0]->message->content;
    }

    private function analyzePdf($path)
    {
        $parser = new Parser();
        $pdf = $parser->parseFile($path);
        
        // テキストの分析
        $text = $this->sanitizeText($pdf->getText());
        $textAnalysis = '';
        
        if (!empty(trim($text))) {
            $result = OpenAI::chat()->create([
                'model' => 'gpt-4o',
                'messages' => [
                    [
                        'role' => 'system',
                        'content' => 'PDFのテキスト内容を分析して日本語で要約してください。'
                    ],
                    [
                        'role' => 'user',
                        'content' => $text
                    ]
                ],
            ]);
            $textAnalysis = $result->choices[0]->message->content;
        }
        
        // 画像の分析(必要な場合)
        $imageAnalyses = $this->analyzePdfImages($path);
        
        // 結果の統合
        return $this->combinedAnalysis($textAnalysis, $imageAnalyses);
    }
    
    private function analyzePdfImages($path)
    {
        try {
            if (!extension_loaded('imagick')) {
                return ["Imagick拡張モジュールが利用できないため、画像分析はスキップされました。"];
            }

            $imagick = new \Imagick();
            $imagick->readImage($path);
            $imageAnalyses = [];

            for ($i = 0; $i < $imagick->getNumberImages(); $i++) {
                $imagick->setIteratorIndex($i);
                $imagick->setImageFormat('jpeg');
                
                $imageData = base64_encode($imagick->getImageBlob());

                $result = OpenAI::chat()->create([
                    'model' => 'gpt-4o',
                    'messages' => [
                        [
                            'role' => 'system',
                            'content' => 'PDFに含まれる画像の内容を分析して日本語で説明してください。'
                        ],
                        [
                            'role' => 'user',
                            'content' => [
                                [
                                    'type' => 'text',
                                    'text' => 'この画像の内容を解説してください。'
                                ],
                                [
                                    'type' => 'image_url',
                                    'image_url' => [
                                        'url' => "data:image/jpeg;base64,{$imageData}"
                                    ]
                                ]
                            ]
                        ]
                    ],
                    'max_tokens' => 500
                ]);

                $imageAnalyses[] = "画像 " . ($i + 1) . ":\n" . $result->choices[0]->message->content;
            }

            $imagick->clear();
            $imagick->destroy();

            return $imageAnalyses;

        } catch (\Exception $e) {
            Log::error('PDF画像分析エラー: ' . $e->getMessage());
            return ["画像の抽出または分析中にエラーが発生しました: " . $e->getMessage()];
        }
    }

    private function combinedAnalysis($textAnalysis, $imageAnalyses)
    {
        if (empty($textAnalysis) && empty($imageAnalyses)) {
            return "分析可能なコンテンツが見つかりませんでした。";
        }

        $imageAnalysisText = empty($imageAnalyses) ? 
            "PDFから画像は抽出されませんでした。" : 
            "PDF内の画像分析:\n" . implode("\n\n", $imageAnalyses);

        if (empty(trim($textAnalysis))) {
            return $imageAnalysisText;
        }

        $result = OpenAI::chat()->create([
            'model' => 'gpt-4o',
            'messages' => [
                [
                    'role' => 'system',
                    'content' => 'PDFのテキストと画像の分析結果を統合して、総合的な要約を作成してください。'
                ],
                [
                    'role' => 'user',
                    'content' => "テキスト分析結果:\n{$textAnalysis}\n\n画像分析結果:\n{$imageAnalysisText}"
                ]
            ],
        ]);

        return $result->choices[0]->message->content;
    }

    private function sanitizeText($text)
    {
        $encoding = mb_detect_encoding($text, ['ASCII', 'UTF-8', 'EUC-JP', 'SJIS'], true);
        if ($encoding) {
            $text = mb_convert_encoding($text, 'UTF-8', $encoding);
        }

        $text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text);
        $text = iconv('UTF-8', 'UTF-8//IGNORE', $text);
        $text = preg_replace('/\s+/', ' ', $text);
        $text = preg_replace('/\n\s*\n/', "\n\n", $text);

        return trim($text);
    }
}

analysis.blade.php

<div class="container">
    @if ($error)
        <div class="alert alert-danger">
            {{ $error }}
        </div>
    @endif

    @if ($fileName && $analysis)
        <div class="card">
            <div class="card-body">
                <h2 class="card-title">分析結果</h2>
                
                <div class="mb-4">
                    <h3>ファイル名:</h3>
                    <p>{{ $fileName }}</p>
                </div>

                <div class="mb-4">
                    <h3>総合分析:</h3>
                    <div class="analysis-content">
                        {!! nl2br(e($analysis)) !!}
                    </div>
                </div>
            </div>
        </div>
    @endif
</div>

サーバーにもよりますが、重いファイルは処理ができないかもしれません。一定以上の大きいファイルを処理する場合は、バックグラウンドで処理させると良いと思います。