- AiTokenHelper 공통 헬퍼 생성 (Gemini/Claude 응답 파서) - BizCertOcrService (Claude) 토큰 기록 추가 - BusinessCardOcrService (Gemini) 토큰 기록 추가 - MeetingLogService (Claude) 토큰 기록 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
248 lines
7.6 KiB
PHP
248 lines
7.6 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Helpers\AiTokenHelper;
|
|
use App\Models\BizCert;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* 사업자등록증 OCR 서비스
|
|
*/
|
|
class BizCertOcrService
|
|
{
|
|
private string $apiKey;
|
|
|
|
private string $apiUrl = 'https://api.anthropic.com/v1/messages';
|
|
|
|
public function __construct()
|
|
{
|
|
$this->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();
|
|
|
|
// 토큰 사용량 저장
|
|
AiTokenHelper::saveClaudeUsage($apiResponse, 'claude-3-haiku-20240307', '사업자등록증OCR');
|
|
|
|
$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();
|
|
}
|
|
}
|