diff --git a/app/Http/Controllers/Api/BizCertController.php b/app/Http/Controllers/Api/BizCertController.php new file mode 100644 index 00000000..8c31443d --- /dev/null +++ b/app/Http/Controllers/Api/BizCertController.php @@ -0,0 +1,89 @@ +validate([ + 'image' => 'required|string', + 'raw_text' => 'nullable|string', + ]); + + $result = $this->service->processWithClaude( + $request->input('image'), + $request->input('raw_text') + ); + + if (! $result['ok']) { + return response()->json($result, 400); + } + + return response()->json($result); + } + + /** + * 저장된 목록 조회 + * + * GET /api/biz-cert + */ + public function index(): JsonResponse + { + $list = $this->service->list(); + + return response()->json([ + 'ok' => true, + 'data' => $list, + ]); + } + + /** + * 사업자등록증 데이터 저장 + * + * POST /api/biz-cert + */ + public function store(BizCertStoreRequest $request): JsonResponse + { + $bizCert = $this->service->store($request->validated()); + + return response()->json([ + 'ok' => true, + 'message' => '저장되었습니다.', + 'data' => $bizCert, + ]); + } + + /** + * 삭제 + * + * DELETE /api/biz-cert/{id} + */ + public function destroy(int $id): JsonResponse + { + $this->service->delete($id); + + return response()->json([ + 'ok' => true, + 'message' => '삭제되었습니다.', + ]); + } +} diff --git a/app/Http/Requests/BizCertStoreRequest.php b/app/Http/Requests/BizCertStoreRequest.php new file mode 100644 index 00000000..2695bb4c --- /dev/null +++ b/app/Http/Requests/BizCertStoreRequest.php @@ -0,0 +1,37 @@ + 'required|string|max:12', + 'company_name' => 'required|string|max:100', + 'representative' => 'nullable|string|max:50', + 'open_date' => 'nullable|date', + 'address' => 'nullable|string|max:255', + 'biz_type' => 'nullable|string|max:100', + 'biz_item' => 'nullable|string|max:255', + 'issue_date' => 'nullable|date', + 'raw_text' => 'nullable|string', + 'ocr_method' => 'nullable|string|max:20', + ]; + } + + public function messages(): array + { + return [ + 'biz_no.required' => '사업자등록번호는 필수입니다.', + 'company_name.required' => '상호명은 필수입니다.', + ]; + } +} diff --git a/app/Models/BizCert.php b/app/Models/BizCert.php new file mode 100644 index 00000000..60f8df65 --- /dev/null +++ b/app/Models/BizCert.php @@ -0,0 +1,47 @@ + 'date', + 'issue_date' => 'date', + ]; + } + + /** + * 사업자번호 포맷팅 (XXX-XX-XXXXX) + */ + public function getFormattedBizNoAttribute(): string + { + $no = preg_replace('/[^0-9]/', '', $this->biz_no); + if (strlen($no) === 10) { + return substr($no, 0, 3).'-'.substr($no, 3, 2).'-'.substr($no, 5, 5); + } + + return $this->biz_no; + } +} diff --git a/app/Services/BizCertOcrService.php b/app/Services/BizCertOcrService.php new file mode 100644 index 00000000..8140aeb7 --- /dev/null +++ b/app/Services/BizCertOcrService.php @@ -0,0 +1,242 @@ +apiKey = config('services.claude.api_key') ?? ''; + } + + /** + * Claude Vision API를 사용한 OCR 처리 + */ + public function processWithClaude(string $imageBase64, ?string $rawText = null): array + { + if (empty($this->apiKey)) { + return [ + 'ok' => false, + 'error' => 'Claude API 키가 설정되지 않았습니다.', + ]; + } + + // 이미지 데이터 파싱 + $imageData = $imageBase64; + $mediaType = 'image/png'; + + if (preg_match('/^data:image\/(\w+);base64,(.+)$/', $imageBase64, $matches)) { + $mediaType = 'image/'.$matches[1]; + $imageData = $matches[2]; + } + + // 프롬프트 생성 + $promptText = $this->buildPrompt($rawText); + + // API 요청 + $requestBody = [ + 'model' => 'claude-3-haiku-20240307', + 'max_tokens' => 4096, + 'messages' => [ + [ + 'role' => 'user', + 'content' => [ + [ + 'type' => 'image', + 'source' => [ + 'type' => 'base64', + 'media_type' => $mediaType, + 'data' => $imageData, + ], + ], + [ + 'type' => 'text', + 'text' => $promptText, + ], + ], + ], + ], + ]; + + try { + $response = Http::withHeaders([ + 'x-api-key' => $this->apiKey, + 'anthropic-version' => '2023-06-01', + ])->post($this->apiUrl, $requestBody); + + if (! $response->successful()) { + Log::error('Claude API Error', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + return [ + 'ok' => false, + 'error' => 'Claude API 호출 실패 (HTTP '.$response->status().')', + ]; + } + + $apiResponse = $response->json(); + $claudeText = $apiResponse['content'][0]['text'] ?? ''; + + if (empty($claudeText)) { + return [ + 'ok' => false, + 'error' => 'Claude API 응답이 비어있습니다.', + ]; + } + + // JSON 추출 + $extractedData = $this->parseClaudeResponse($claudeText); + + if (! $extractedData) { + return [ + 'ok' => false, + 'error' => 'Claude 응답 JSON 파싱 실패', + 'raw_response' => $claudeText, + ]; + } + + return [ + 'ok' => true, + 'data' => $extractedData, + 'raw_response' => $claudeText, + ]; + } catch (\Exception $e) { + Log::error('Claude API Exception', ['message' => $e->getMessage()]); + + return [ + 'ok' => false, + 'error' => 'Claude API 호출 중 오류 발생: '.$e->getMessage(), + ]; + } + } + + /** + * OCR 프롬프트 생성 + */ + private function buildPrompt(?string $rawText = null): string + { + $prompt = "제공된 사업자등록증 이미지를 직접 분석하여 아래 필드를 정확하게 추출해주세요.\n\n"; + + if ($rawText) { + $prompt .= "참고: OCR 텍스트가 제공되었지만 부정확할 수 있으니, 이미지를 직접 읽어서 정확한 정보를 추출해주세요.\n"; + $prompt .= "OCR 텍스트(참고용): {$rawText}\n\n"; + } + + $prompt .= <<<'EOT' +추출할 필드: +1. 사업자등록번호 (10자리 숫자, 형식: 000-00-00000) +2. 상호명 (법인명 또는 단체명) +3. 대표자명 (한글 이름) +4. 개업일자 (YYYY-MM-DD 형식) +5. 본점 소재지 (주소) +6. 업태 +7. 종목 +8. 발급일자 (YYYY-MM-DD 형식) + +**중요 지침:** +- 이미지를 직접 읽어서 정확한 텍스트를 추출하세요. +- 사업자등록번호는 정확히 10자리 숫자여야 하며, 하이픈을 포함하여 000-00-00000 형식으로 반환하세요. +- 날짜는 YYYY-MM-DD 형식으로 변환하세요 (예: 2015년 06월 02일 → 2015-06-02). +- 대표자명은 2-4자의 한글 이름이어야 합니다. +- 이미지가 흐리거나 화질이 좋지 않아도 최대한 정확하게 읽어주세요. +- 특수문자나 공백을 정리해주세요. + +**응답 형식 (JSON만 반환, 설명 없이):** +{ + "biz_no": "123-45-67890", + "company_name": "주식회사 예시", + "representative": "홍길동", + "open_date": "2015-06-02", + "address": "서울특별시 강남구 ...", + "type": "제조업", + "item": "엘리베이터부장품", + "issue_date": "2024-01-15" +} + +데이터를 찾을 수 없으면 빈 문자열("")로 반환하세요. +EOT; + + return $prompt; + } + + /** + * Claude 응답에서 JSON 추출 + */ + private function parseClaudeResponse(string $claudeText): ?array + { + // JSON 부분만 추출 + if (preg_match('/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/s', $claudeText, $matches)) { + $jsonText = $matches[0]; + } else { + $jsonText = $claudeText; + } + + $data = json_decode($jsonText, true); + + if (! $data) { + return null; + } + + // 필드명 정규화 (type -> biz_type, item -> biz_item) + return [ + 'biz_no' => $data['biz_no'] ?? '', + 'company_name' => $data['company_name'] ?? '', + 'representative' => $data['representative'] ?? '', + 'open_date' => $data['open_date'] ?? '', + 'address' => $data['address'] ?? '', + 'biz_type' => $data['type'] ?? $data['biz_type'] ?? '', + 'biz_item' => $data['item'] ?? $data['biz_item'] ?? '', + 'issue_date' => $data['issue_date'] ?? '', + ]; + } + + /** + * 사업자등록증 데이터 저장 + */ + public function store(array $data): BizCert + { + return BizCert::create([ + 'biz_no' => preg_replace('/[^0-9]/', '', $data['biz_no'] ?? ''), + 'company_name' => $data['company_name'] ?? '', + 'representative' => $data['representative'] ?? null, + 'open_date' => $data['open_date'] ?: null, + 'address' => $data['address'] ?? null, + 'biz_type' => $data['biz_type'] ?? null, + 'biz_item' => $data['biz_item'] ?? null, + 'issue_date' => $data['issue_date'] ?: null, + 'raw_text' => $data['raw_text'] ?? null, + 'ocr_method' => $data['ocr_method'] ?? 'claude', + ]); + } + + /** + * 목록 조회 + */ + public function list(): \Illuminate\Database\Eloquent\Collection + { + return BizCert::orderBy('created_at', 'desc')->get(); + } + + /** + * 삭제 + */ + public function delete(int $id): bool + { + $bizCert = BizCert::findOrFail($id); + + return $bizCert->delete(); + } +} diff --git a/config/services.php b/config/services.php index 5c2002d5..5c605d71 100644 --- a/config/services.php +++ b/config/services.php @@ -40,4 +40,8 @@ 'project_id' => env('GEMINI_PROJECT_ID', 'codebridge-chatbot'), ], + 'claude' => [ + 'api_key' => env('CLAUDE_API_KEY'), + ], + ]; diff --git a/resources/views/lab/ai/business-ocr.blade.php b/resources/views/lab/ai/business-ocr.blade.php index fc2a824b..f4ec4295 100644 --- a/resources/views/lab/ai/business-ocr.blade.php +++ b/resources/views/lab/ai/business-ocr.blade.php @@ -1,62 +1,714 @@ -@extends('layouts.presentation') +@extends('layouts.app') @section('title', '사업자등록증 OCR') @push('styles') @endpush @section('content') -
- 사업자등록증 이미지를 업로드하면 AI가 자동으로 텍스트를 추출하고 - 사업자 정보를 구조화된 데이터로 변환합니다. -
-이미지나 PDF를 업로드하면 자동으로 정보를 추출합니다
+클릭하거나 파일을 드래그하세요
+PNG, JPG, PDF 지원
+| 사업자번호 | +상호 | +대표자 | +개업일 | +OCR | +등록일 | ++ |
|---|