fix:AI 설정 모달 JSON 파싱 오류 수정 및 기술문서 추가
- data attribute 방식으로 JSON 전달 변경 - hidden 클래스 CSS 명시적 정의 - 페이지 로드 시 모달 강제 닫기 - showToast 함수 추가 - 명함추출로직.md 기술문서 작성 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
367
claudedocs/명함추출로직.md
Normal file
367
claudedocs/명함추출로직.md
Normal file
@@ -0,0 +1,367 @@
|
||||
# 명함 OCR 추출 로직 기술 문서
|
||||
|
||||
## 개요
|
||||
|
||||
명함 이미지를 업로드하면 Google Gemini Vision API를 통해 자동으로 정보를 추출하여 영업권 등록 폼에 자동 입력하는 시스템입니다.
|
||||
|
||||
---
|
||||
|
||||
## 시스템 아키텍처
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ 클라이언트 │ │ MNG 서버 │ │ Gemini API │
|
||||
│ (Blade View) │ │ (Laravel) │ │ (Google) │
|
||||
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
|
||||
│ │ │
|
||||
│ 1. 이미지 업로드 │ │
|
||||
│ (Base64) │ │
|
||||
├──────────────────────>│ │
|
||||
│ │ 2. Vision API 호출 │
|
||||
│ ├──────────────────────>│
|
||||
│ │ │
|
||||
│ │ 3. JSON 응답 │
|
||||
│ │<──────────────────────┤
|
||||
│ 4. 추출 데이터 반환 │ │
|
||||
│<──────────────────────┤ │
|
||||
│ │ │
|
||||
│ 5. 폼 필드 자동 입력 │ │
|
||||
│ │ │
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 파일 구조
|
||||
|
||||
```
|
||||
/home/aweso/sam/mng/
|
||||
├── app/
|
||||
│ ├── Http/Controllers/
|
||||
│ │ ├── Api/
|
||||
│ │ │ └── BusinessCardOcrController.php # OCR API 엔드포인트
|
||||
│ │ └── System/
|
||||
│ │ └── AiConfigController.php # AI 설정 관리
|
||||
│ ├── Models/System/
|
||||
│ │ └── AiConfig.php # AI API 설정 모델
|
||||
│ └── Services/
|
||||
│ └── BusinessCardOcrService.php # Gemini Vision API 호출 서비스
|
||||
├── resources/views/
|
||||
│ ├── sales/prospects/
|
||||
│ │ └── create.blade.php # 영업권 등록 (드래그앤드롭 UI)
|
||||
│ └── system/ai-config/
|
||||
│ └── index.blade.php # AI 설정 관리 페이지
|
||||
└── routes/
|
||||
└── web.php # 라우트 정의
|
||||
|
||||
/home/aweso/sam/api/
|
||||
└── database/migrations/
|
||||
└── 2026_01_27_100000_create_ai_configs_table.php # AI 설정 테이블
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 데이터베이스 스키마
|
||||
|
||||
### ai_configs 테이블
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | BIGINT | PK |
|
||||
| name | VARCHAR(50) | 설정 이름 |
|
||||
| provider | VARCHAR(30) | 제공자 (gemini, claude, openai) |
|
||||
| api_key | VARCHAR(255) | API 키 (암호화 저장 권장) |
|
||||
| model | VARCHAR(100) | 모델명 (예: gemini-2.0-flash) |
|
||||
| base_url | VARCHAR(255) | API Base URL (NULL이면 기본값 사용) |
|
||||
| description | TEXT | 설명 |
|
||||
| is_active | BOOLEAN | 활성화 여부 (provider당 1개만 활성) |
|
||||
| options | JSON | 추가 옵션 |
|
||||
| created_at | TIMESTAMP | 생성일시 |
|
||||
| updated_at | TIMESTAMP | 수정일시 |
|
||||
| deleted_at | TIMESTAMP | 삭제일시 (소프트삭제) |
|
||||
|
||||
---
|
||||
|
||||
## API 엔드포인트
|
||||
|
||||
### POST /api/business-card-ocr
|
||||
|
||||
명함 이미지에서 정보를 추출합니다.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"image": "data:image/jpeg;base64,/9j/4AAQSkZJRg..."
|
||||
}
|
||||
```
|
||||
|
||||
**Response (성공):**
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"company_name": "주식회사 샘플",
|
||||
"ceo_name": "홍길동",
|
||||
"business_number": "123-45-67890",
|
||||
"contact_phone": "02-1234-5678",
|
||||
"contact_email": "hong@sample.com",
|
||||
"address": "서울시 강남구 테헤란로 123",
|
||||
"position": "대표이사",
|
||||
"department": "경영지원팀"
|
||||
},
|
||||
"raw_response": "{...}"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (실패):**
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": "Gemini API 설정이 없습니다."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 핵심 로직
|
||||
|
||||
### 1. BusinessCardOcrService.php
|
||||
|
||||
```php
|
||||
class BusinessCardOcrService
|
||||
{
|
||||
public function extractFromImage(string $base64Image): array
|
||||
{
|
||||
// 1. 활성화된 Gemini 설정 조회
|
||||
$config = AiConfig::getActiveGemini();
|
||||
|
||||
// 2. Gemini Vision API 호출
|
||||
return $this->callGeminiVisionApi($config, $base64Image);
|
||||
}
|
||||
|
||||
private function callGeminiVisionApi(AiConfig $config, string $base64Image): array
|
||||
{
|
||||
// API URL 구성
|
||||
$url = "{$config->base_url}/models/{$config->model}:generateContent?key={$config->api_key}";
|
||||
|
||||
// Base64 이미지 데이터 처리
|
||||
// data:image/jpeg;base64, 접두사 제거
|
||||
|
||||
// API 호출
|
||||
$response = Http::timeout(30)->post($url, [
|
||||
'contents' => [[
|
||||
'parts' => [
|
||||
['inline_data' => ['mime_type' => $mimeType, 'data' => $imageData]],
|
||||
['text' => $prompt]
|
||||
]
|
||||
]],
|
||||
'generationConfig' => [
|
||||
'temperature' => 0.1,
|
||||
'responseMimeType' => 'application/json'
|
||||
]
|
||||
]);
|
||||
|
||||
// 응답 파싱 및 정규화
|
||||
return $this->normalizeData($parsed);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Gemini Vision API 프롬프트
|
||||
|
||||
```
|
||||
이 명함 이미지에서 다음 정보를 추출해주세요.
|
||||
|
||||
## 추출 항목
|
||||
1. company_name: 회사명/상호
|
||||
2. ceo_name: 대표자명/담당자명
|
||||
3. business_number: 사업자등록번호 (000-00-00000 형식)
|
||||
4. contact_phone: 연락처/전화번호
|
||||
5. contact_email: 이메일
|
||||
6. address: 주소
|
||||
7. position: 직책
|
||||
8. department: 부서
|
||||
|
||||
## 규칙
|
||||
1. 정보가 없으면 빈 문자열("")로 응답
|
||||
2. 사업자번호는 10자리 숫자를 000-00-00000 형식으로 변환
|
||||
3. 전화번호는 하이픈 포함 형식 유지
|
||||
4. 한국어로 된 정보를 우선 추출
|
||||
|
||||
## 출력 형식 (JSON)
|
||||
{
|
||||
"company_name": "",
|
||||
"ceo_name": "",
|
||||
"business_number": "",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 데이터 정규화
|
||||
|
||||
```php
|
||||
private function normalizeData(array $data): array
|
||||
{
|
||||
// 사업자번호 정규화 (10자리 → 000-00-00000)
|
||||
if (!empty($data['business_number'])) {
|
||||
$digits = preg_replace('/\D/', '', $data['business_number']);
|
||||
if (strlen($digits) === 10) {
|
||||
$data['business_number'] = substr($digits, 0, 3) . '-'
|
||||
. substr($digits, 3, 2) . '-'
|
||||
. substr($digits, 5);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'company_name' => trim($data['company_name'] ?? ''),
|
||||
'ceo_name' => trim($data['ceo_name'] ?? ''),
|
||||
// ... 기타 필드
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 프론트엔드 (create.blade.php)
|
||||
|
||||
### 드래그앤드롭 영역
|
||||
|
||||
```html
|
||||
<div id="ocr-drop-zone" class="border-2 border-dashed border-gray-300 rounded-lg p-8">
|
||||
<p>명함 이미지를 드래그하거나 클릭하여 업로드</p>
|
||||
<input type="file" id="ocr-file-input" accept="image/*" class="hidden">
|
||||
</div>
|
||||
<div id="ocr-preview" class="hidden">
|
||||
<img id="ocr-preview-image" class="max-h-48 rounded-lg">
|
||||
</div>
|
||||
```
|
||||
|
||||
### JavaScript 처리 로직
|
||||
|
||||
```javascript
|
||||
// 파일 처리
|
||||
async function handleFile(file) {
|
||||
// 1. 이미지 미리보기
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
// 미리보기 표시
|
||||
document.getElementById('ocr-preview-image').src = e.target.result;
|
||||
|
||||
// 2. OCR API 호출
|
||||
showOcrLoading(true);
|
||||
const response = await fetch('/api/business-card-ocr', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
|
||||
body: JSON.stringify({ image: e.target.result })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// 3. 폼 필드 자동 입력
|
||||
if (result.ok) {
|
||||
fillFormFields(result.data);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
// 폼 필드 자동 입력 (하이라이트 효과 포함)
|
||||
function fillFormFields(data) {
|
||||
const fieldMap = {
|
||||
'company_name': 'name',
|
||||
'ceo_name': 'ceo_name',
|
||||
'business_number': 'business_number',
|
||||
// ...
|
||||
};
|
||||
|
||||
for (const [key, fieldName] of Object.entries(fieldMap)) {
|
||||
if (data[key]) {
|
||||
const input = document.querySelector(`[name="${fieldName}"]`);
|
||||
if (input) {
|
||||
input.value = data[key];
|
||||
// 하이라이트 효과
|
||||
input.classList.add('bg-yellow-100');
|
||||
setTimeout(() => input.classList.remove('bg-yellow-100'), 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AI 설정 관리
|
||||
|
||||
### 라우트
|
||||
|
||||
```php
|
||||
// routes/web.php
|
||||
Route::prefix('system')->name('system.')->group(function () {
|
||||
Route::get('ai-config', [AiConfigController::class, 'index'])->name('ai-config.index');
|
||||
Route::post('ai-config', [AiConfigController::class, 'store'])->name('ai-config.store');
|
||||
Route::put('ai-config/{id}', [AiConfigController::class, 'update'])->name('ai-config.update');
|
||||
Route::delete('ai-config/{id}', [AiConfigController::class, 'destroy'])->name('ai-config.destroy');
|
||||
Route::post('ai-config/{id}/toggle', [AiConfigController::class, 'toggle'])->name('ai-config.toggle');
|
||||
Route::post('ai-config/test', [AiConfigController::class, 'test'])->name('ai-config.test');
|
||||
});
|
||||
|
||||
Route::post('api/business-card-ocr', [BusinessCardOcrController::class, 'process']);
|
||||
```
|
||||
|
||||
### Provider별 기본 설정
|
||||
|
||||
```php
|
||||
// AiConfig.php
|
||||
public const DEFAULT_BASE_URLS = [
|
||||
'gemini' => 'https://generativelanguage.googleapis.com/v1beta',
|
||||
'claude' => 'https://api.anthropic.com/v1',
|
||||
'openai' => 'https://api.openai.com/v1',
|
||||
];
|
||||
|
||||
public const DEFAULT_MODELS = [
|
||||
'gemini' => 'gemini-2.0-flash',
|
||||
'claude' => 'claude-sonnet-4-20250514',
|
||||
'openai' => 'gpt-4o',
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 에러 처리
|
||||
|
||||
| 상황 | 에러 메시지 | 대응 |
|
||||
|------|------------|------|
|
||||
| Gemini 설정 없음 | "Gemini API 설정이 없습니다" | AI 설정 페이지에서 설정 추가 |
|
||||
| API 호출 실패 | "AI API 호출 실패: {status}" | API 키/모델 확인 |
|
||||
| 연결 실패 | "AI API 연결 실패" | 네트워크/Base URL 확인 |
|
||||
| 응답 파싱 실패 | "AI 응답 파싱 실패" | 프롬프트 조정 필요 |
|
||||
| Rate Limit | 429 에러 | 잠시 후 재시도 |
|
||||
|
||||
---
|
||||
|
||||
## 보안 고려사항
|
||||
|
||||
1. **API 키 보호**: `api_key` 컬럼 암호화 저장 권장
|
||||
2. **마스킹**: UI에서 API 키 앞 8자리만 표시
|
||||
3. **CSRF 보호**: 모든 POST 요청에 CSRF 토큰 포함
|
||||
4. **파일 검증**: 이미지 파일만 허용 (accept="image/*")
|
||||
|
||||
---
|
||||
|
||||
## 향후 개선 사항
|
||||
|
||||
1. **Claude/OpenAI Vision 지원**: 현재 Gemini만 지원, 타 provider 확장 가능
|
||||
2. **배치 처리**: 여러 명함 동시 처리
|
||||
3. **OCR 결과 캐싱**: 동일 이미지 재처리 방지
|
||||
4. **API 키 암호화**: Laravel Crypt 활용
|
||||
|
||||
---
|
||||
|
||||
## 참고 자료
|
||||
|
||||
- [Gemini API 문서](https://ai.google.dev/gemini-api/docs)
|
||||
- [Gemini Vision API](https://ai.google.dev/gemini-api/docs/vision)
|
||||
- API 키 파일 위치: `/home/aweso/sam/sales/apikey/gemini_api_key.txt`
|
||||
|
||||
---
|
||||
|
||||
*문서 작성일: 2026-01-27*
|
||||
@@ -55,6 +55,9 @@
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modal-overlay.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@@ -104,7 +107,7 @@
|
||||
<button type="button" onclick="toggleConfig({{ $config->id }})" class="px-3 py-1.5 text-sm {{ $config->is_active ? 'bg-yellow-100 hover:bg-yellow-200 text-yellow-700' : 'bg-green-100 hover:bg-green-200 text-green-700' }} rounded-lg transition">
|
||||
{{ $config->is_active ? '비활성화' : '활성화' }}
|
||||
</button>
|
||||
<button type="button" onclick="editConfig({{ $config->id }}, {{ json_encode($config) }})" class="px-3 py-1.5 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition">
|
||||
<button type="button" data-config='@json($config)' onclick="editConfig(this)" class="px-3 py-1.5 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition">
|
||||
수정
|
||||
</button>
|
||||
<button type="button" onclick="deleteConfig({{ $config->id }}, '{{ $config->name }}')" class="px-3 py-1.5 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition">
|
||||
@@ -212,6 +215,34 @@
|
||||
(function() {
|
||||
const csrfToken = '{{ csrf_token() }}';
|
||||
|
||||
// 토스트 메시지 함수
|
||||
function showToast(message, type = 'info') {
|
||||
// 기존 토스트 제거
|
||||
const existingToast = document.getElementById('custom-toast');
|
||||
if (existingToast) {
|
||||
existingToast.remove();
|
||||
}
|
||||
|
||||
const colors = {
|
||||
success: 'bg-green-500',
|
||||
error: 'bg-red-500',
|
||||
warning: 'bg-yellow-500',
|
||||
info: 'bg-blue-500'
|
||||
};
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.id = 'custom-toast';
|
||||
toast.className = `fixed top-4 right-4 ${colors[type] || colors.info} text-white px-6 py-3 rounded-lg shadow-lg z-[100] transition-opacity duration-300`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// 3초 후 자동 제거
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
const defaultModels = {
|
||||
gemini: 'gemini-2.0-flash',
|
||||
claude: 'claude-sonnet-4-20250514',
|
||||
@@ -249,8 +280,14 @@
|
||||
};
|
||||
|
||||
// 수정
|
||||
window.editConfig = function(id, config) {
|
||||
window.openModal(config);
|
||||
window.editConfig = function(btn) {
|
||||
try {
|
||||
const config = JSON.parse(btn.dataset.config);
|
||||
window.openModal(config);
|
||||
} catch (e) {
|
||||
console.error('Config parse error:', e);
|
||||
showToast('설정 데이터를 불러올 수 없습니다.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// 토글
|
||||
@@ -394,6 +431,12 @@
|
||||
|
||||
// DOM 로드 후 이벤트 리스너 등록
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 페이지 로드 시 모달 강제 닫기
|
||||
const modal = document.getElementById('config-modal');
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Provider 변경 시 기본 모델 업데이트
|
||||
const providerEl = document.getElementById('config-provider');
|
||||
if (providerEl) {
|
||||
|
||||
Reference in New Issue
Block a user