From aa3c9f4c3b6c7a88a89366305b60aae8bdb01c5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Sun, 22 Feb 2026 23:04:16 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[additional]=20Notion=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NotionService: Notion API 검색 + Gemini AI 답변 - AiConfig에 notion provider 추가 - 추가기능 > Notion 검색 채팅 UI --- .../Additional/NotionSearchController.php | 51 ++++ .../Controllers/System/AiConfigController.php | 46 +-- app/Models/System/AiConfig.php | 18 +- app/Services/NotionService.php | 281 ++++++++++++++++++ .../additional/notion-search/index.blade.php | 214 +++++++++++++ routes/web.php | 23 +- 6 files changed, 600 insertions(+), 33 deletions(-) create mode 100644 app/Http/Controllers/Additional/NotionSearchController.php create mode 100644 app/Services/NotionService.php create mode 100644 resources/views/additional/notion-search/index.blade.php diff --git a/app/Http/Controllers/Additional/NotionSearchController.php b/app/Http/Controllers/Additional/NotionSearchController.php new file mode 100644 index 00000000..871bf622 --- /dev/null +++ b/app/Http/Controllers/Additional/NotionSearchController.php @@ -0,0 +1,51 @@ +header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('additional.notion-search.index')); + } + + return view('additional.notion-search.index'); + } + + /** + * AJAX 검색 API + */ + public function search(Request $request): JsonResponse + { + $validated = $request->validate([ + 'message' => 'required|string|max:1000', + 'history' => 'nullable|array', + ]); + + try { + $service = new NotionService; + $result = $service->searchWithAi( + $validated['message'], + $validated['history'] ?? [] + ); + + return response()->json($result); + } catch (\RuntimeException $e) { + return response()->json([ + 'reply' => $e->getMessage(), + 'debug' => null, + ]); + } + } +} diff --git a/app/Http/Controllers/System/AiConfigController.php b/app/Http/Controllers/System/AiConfigController.php index b3b174c3..b4a6f932 100644 --- a/app/Http/Controllers/System/AiConfigController.php +++ b/app/Http/Controllers/System/AiConfigController.php @@ -48,7 +48,7 @@ public function store(Request $request): JsonResponse { $validated = $request->validate([ 'name' => 'required|string|max:50', - 'provider' => 'required|string|in:gemini,claude,openai,gcs', + 'provider' => 'required|string|in:gemini,claude,openai,gcs,notion', 'api_key' => 'nullable|string|max:255', 'model' => 'nullable|string|max:100', 'base_url' => 'nullable|string|max:255', @@ -108,7 +108,7 @@ public function update(Request $request, int $id): JsonResponse $validated = $request->validate([ 'name' => 'required|string|max:50', - 'provider' => 'required|string|in:gemini,claude,openai,gcs', + 'provider' => 'required|string|in:gemini,claude,openai,gcs,notion', 'api_key' => 'nullable|string|max:255', 'model' => 'nullable|string|max:100', 'base_url' => 'nullable|string|max:255', @@ -203,7 +203,7 @@ public function toggle(int $id): JsonResponse public function test(Request $request): JsonResponse { $validated = $request->validate([ - 'provider' => 'required|string|in:gemini,claude,openai', + 'provider' => 'required|string|in:gemini,claude,openai,notion', 'api_key' => 'nullable|string', 'model' => 'required|string', 'base_url' => 'nullable|string', @@ -279,7 +279,7 @@ private function testGemini(string $baseUrl, string $model, string $apiKey): arr return [ 'ok' => false, - 'error' => 'API 응답 오류: ' . $response->status(), + 'error' => 'API 응답 오류: '.$response->status(), ]; } @@ -297,19 +297,19 @@ private function testGeminiVertexAi(string $model, string $projectId, string $re return ['ok' => false, 'error' => '서비스 계정 파일 경로가 필요합니다.']; } - if (!file_exists($serviceAccountPath)) { + if (! file_exists($serviceAccountPath)) { return ['ok' => false, 'error' => "서비스 계정 파일을 찾을 수 없습니다: {$serviceAccountPath}"]; } // 서비스 계정 JSON 로드 $serviceAccount = json_decode(file_get_contents($serviceAccountPath), true); - if (!$serviceAccount || empty($serviceAccount['client_email']) || empty($serviceAccount['private_key'])) { + if (! $serviceAccount || empty($serviceAccount['client_email']) || empty($serviceAccount['private_key'])) { return ['ok' => false, 'error' => '서비스 계정 파일 형식이 올바르지 않습니다.']; } // OAuth 토큰 획득 $accessToken = $this->getVertexAiAccessToken($serviceAccount); - if (!$accessToken) { + if (! $accessToken) { return ['ok' => false, 'error' => 'OAuth 토큰 획득 실패. 서비스 계정 권한을 확인하세요.']; } @@ -318,7 +318,7 @@ private function testGeminiVertexAi(string $model, string $projectId, string $re $response = \Illuminate\Support\Facades\Http::timeout(30) ->withHeaders([ - 'Authorization' => 'Bearer ' . $accessToken, + 'Authorization' => 'Bearer '.$accessToken, 'Content-Type' => 'application/json', ]) ->post($url, [ @@ -345,7 +345,7 @@ private function testGeminiVertexAi(string $model, string $projectId, string $re // 상세 오류 메시지 추출 $errorBody = $response->json(); - $errorMsg = $errorBody['error']['message'] ?? ('HTTP ' . $response->status()); + $errorMsg = $errorBody['error']['message'] ?? ('HTTP '.$response->status()); return [ 'ok' => false, @@ -369,16 +369,16 @@ private function getVertexAiAccessToken(array $serviceAccount): ?string ])); $privateKey = openssl_pkey_get_private($serviceAccount['private_key']); - if (!$privateKey) { + if (! $privateKey) { return null; } - openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256); + openssl_sign($jwtHeader.'.'.$jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256); if (PHP_VERSION_ID < 80000) { openssl_free_key($privateKey); } - $jwt = $jwtHeader . '.' . $jwtClaim . '.' . $this->base64UrlEncode($signature); + $jwt = $jwtHeader.'.'.$jwtClaim.'.'.$this->base64UrlEncode($signature); $response = \Illuminate\Support\Facades\Http::asForm()->post('https://oauth2.googleapis.com/token', [ 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', @@ -408,13 +408,13 @@ public function testGcs(Request $request): JsonResponse $serviceAccount = null; // 서비스 계정 로드 (JSON 직접 입력 또는 파일 경로) - if (!empty($validated['service_account_json'])) { + if (! empty($validated['service_account_json'])) { $serviceAccount = $validated['service_account_json']; - } elseif (!empty($validated['service_account_path']) && file_exists($validated['service_account_path'])) { + } elseif (! empty($validated['service_account_path']) && file_exists($validated['service_account_path'])) { $serviceAccount = json_decode(file_get_contents($validated['service_account_path']), true); } - if (!$serviceAccount) { + if (! $serviceAccount) { return response()->json([ 'ok' => false, 'error' => '서비스 계정 정보를 찾을 수 없습니다.', @@ -423,7 +423,7 @@ public function testGcs(Request $request): JsonResponse // OAuth 토큰 획득 $accessToken = $this->getGcsAccessToken($serviceAccount); - if (!$accessToken) { + if (! $accessToken) { return response()->json([ 'ok' => false, 'error' => 'OAuth 토큰 획득 실패', @@ -432,7 +432,7 @@ public function testGcs(Request $request): JsonResponse // 버킷 존재 확인 $response = \Illuminate\Support\Facades\Http::timeout(10) - ->withHeaders(['Authorization' => 'Bearer ' . $accessToken]) + ->withHeaders(['Authorization' => 'Bearer '.$accessToken]) ->get("https://storage.googleapis.com/storage/v1/b/{$bucketName}"); if ($response->successful()) { @@ -444,7 +444,7 @@ public function testGcs(Request $request): JsonResponse return response()->json([ 'ok' => false, - 'error' => '버킷 접근 실패: ' . $response->status(), + 'error' => '버킷 접근 실패: '.$response->status(), ]); } catch (\Exception $e) { @@ -467,24 +467,24 @@ private function getGcsAccessToken(array $serviceAccount): ?string 'scope' => 'https://www.googleapis.com/auth/devstorage.full_control', 'aud' => 'https://oauth2.googleapis.com/token', 'exp' => $now + 3600, - 'iat' => $now + 'iat' => $now, ])); $privateKey = openssl_pkey_get_private($serviceAccount['private_key']); - if (!$privateKey) { + if (! $privateKey) { return null; } - openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256); + openssl_sign($jwtHeader.'.'.$jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256); if (PHP_VERSION_ID < 80000) { openssl_free_key($privateKey); } - $jwt = $jwtHeader . '.' . $jwtClaim . '.' . $this->base64UrlEncode($signature); + $jwt = $jwtHeader.'.'.$jwtClaim.'.'.$this->base64UrlEncode($signature); $response = \Illuminate\Support\Facades\Http::asForm()->post('https://oauth2.googleapis.com/token', [ 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', - 'assertion' => $jwt + 'assertion' => $jwt, ]); if ($response->successful()) { diff --git a/app/Models/System/AiConfig.php b/app/Models/System/AiConfig.php index 36ff541f..66f575ac 100644 --- a/app/Models/System/AiConfig.php +++ b/app/Models/System/AiConfig.php @@ -52,6 +52,7 @@ class AiConfig extends Model 'claude' => 'https://api.anthropic.com/v1', 'openai' => 'https://api.openai.com/v1', 'gcs' => 'https://storage.googleapis.com', + 'notion' => 'https://api.notion.com/v1', ]; /** @@ -62,12 +63,13 @@ class AiConfig extends Model 'claude' => 'claude-sonnet-4-20250514', 'openai' => 'gpt-4o', 'gcs' => '-', + 'notion' => '2025-09-03', ]; /** * AI Provider 목록 (GCS 제외) */ - public const AI_PROVIDERS = ['gemini', 'claude', 'openai']; + public const AI_PROVIDERS = ['gemini', 'claude', 'openai', 'notion']; /** * 스토리지 Provider 목록 @@ -94,6 +96,16 @@ public static function getActiveClaude(): ?self ->first(); } + /** + * 활성화된 Notion 설정 조회 + */ + public static function getActiveNotion(): ?self + { + return self::where('provider', 'notion') + ->where('is_active', true) + ->first(); + } + /** * Provider별 활성 설정 조회 */ @@ -122,6 +134,7 @@ public function getProviderLabelAttribute(): string 'claude' => 'Anthropic Claude', 'openai' => 'OpenAI', 'gcs' => 'Google Cloud Storage', + 'notion' => 'Notion', default => $this->provider, }; } @@ -193,7 +206,7 @@ public function getMaskedApiKeyAttribute(): string return $this->api_key; } - return substr($this->api_key, 0, 8) . str_repeat('*', 8) . '...'; + return substr($this->api_key, 0, 8).str_repeat('*', 8).'...'; } /** @@ -236,6 +249,7 @@ public function getAuthTypeLabelAttribute(): string if ($this->isVertexAi()) { return 'Vertex AI (서비스 계정)'; } + return 'API 키'; } } diff --git a/app/Services/NotionService.php b/app/Services/NotionService.php new file mode 100644 index 00000000..793a56b4 --- /dev/null +++ b/app/Services/NotionService.php @@ -0,0 +1,281 @@ +apiKey = $config->api_key; + $this->version = $config->model ?: '2025-09-03'; + $this->baseUrl = $config->base_url ?: 'https://api.notion.com/v1'; + } + + /** + * Notion 검색 API 호출 + */ + public function search(string $query, int $limit = 3): ?array + { + $response = Http::withHeaders([ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Notion-Version' => $this->version, + ])->timeout(15)->post($this->baseUrl.'/search', [ + 'query' => $query, + 'page_size' => $limit, + 'filter' => [ + 'value' => 'page', + 'property' => 'object', + ], + 'sort' => [ + 'direction' => 'descending', + 'timestamp' => 'last_edited_time', + ], + ]); + + if ($response->failed()) { + Log::error('Notion API 검색 오류', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + return null; + } + + return $response->json(); + } + + /** + * 페이지 블록 내용 추출 + */ + public function getPageContent(string $pageId): string + { + $response = Http::withHeaders([ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Notion-Version' => $this->version, + ])->timeout(15)->get($this->baseUrl."/blocks/{$pageId}/children"); + + if ($response->failed() || ! $response->json('results')) { + return ''; + } + + $content = ''; + $maxLength = 1500; + + foreach ($response->json('results') as $block) { + if (strlen($content) >= $maxLength) { + $content .= '...(내용 생략됨)...'; + break; + } + + $type = $block['type'] ?? ''; + if (isset($block[$type]['rich_text'])) { + foreach ($block[$type]['rich_text'] as $text) { + $content .= $text['plain_text'] ?? ''; + } + $content .= "\n"; + } + } + + return $content; + } + + /** + * Gemini 쿼리 정제 → Notion 검색 → Gemini 답변 (통합) + */ + public function searchWithAi(string $userMessage, array $history = []): array + { + $gemini = AiConfig::getActiveGemini(); + if (! $gemini) { + return [ + 'reply' => 'Gemini AI 설정이 없거나 비활성화 상태입니다.', + 'debug' => null, + ]; + } + + $geminiApiKey = $gemini->api_key; + $geminiModel = $gemini->model ?: 'gemini-2.0-flash'; + $geminiBaseUrl = $gemini->base_url ?: 'https://generativelanguage.googleapis.com/v1beta'; + + // 대화 이력 텍스트 변환 + $historyText = $this->buildHistoryText($history); + + // 1. 검색어 정제 + $refinedQuery = $this->refineQuery($userMessage, $historyText, $geminiBaseUrl, $geminiModel, $geminiApiKey); + + // 2. Notion 검색 및 컨텍스트 확보 + $searchResults = $this->search($refinedQuery); + $context = $this->buildContext($searchResults); + + // 3. Gemini AI 답변 생성 + $systemInstruction = $this->buildSystemInstruction($historyText, $context); + + $response = Http::timeout(30)->post( + "{$geminiBaseUrl}/models/{$geminiModel}:generateContent?key={$geminiApiKey}", + [ + 'contents' => [ + ['parts' => [['text' => $userMessage]]], + ], + 'systemInstruction' => [ + 'parts' => [['text' => $systemInstruction]], + ], + ] + ); + + if ($response->failed()) { + Log::error('Gemini API 오류', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + return [ + 'reply' => '오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + 'debug' => ['refinedQuery' => $refinedQuery], + ]; + } + + $reply = $response->json('candidates.0.content.parts.0.text') + ?? '죄송합니다. 답변을 생성하지 못했습니다.'; + + return [ + 'reply' => $reply, + 'debug' => [ + 'refinedQuery' => $refinedQuery, + 'context' => $context, + ], + ]; + } + + /** + * Gemini로 검색어 정제 + */ + private function refineQuery(string $userMessage, string $historyText, string $baseUrl, string $model, string $apiKey): string + { + $systemInstruction = "You are a detailed search query generator for a Notion database. +Analyze the [Current Question] and [Conversation History] (if any). +1. Correct any typos (e.g., '프론트엔디' -> '프론트엔드'). +2. Identify the core topic or entities. +3. IGNORE standard greetings (e.g., 'Hello', '운영자 문서 탐색'). +4. Convert the intent into a precise search query likely to match Notion page titles or content. +RETURN ONLY THE SEARCH QUERY STRING. Do not explain."; + + $response = Http::timeout(15)->post( + "{$baseUrl}/models/{$model}:generateContent?key={$apiKey}", + [ + 'contents' => [ + ['parts' => [['text' => "Conversation History:\n{$historyText}\n\nCurrent Question: {$userMessage}"]]], + ], + 'systemInstruction' => [ + 'parts' => [['text' => $systemInstruction]], + ], + ] + ); + + if ($response->successful()) { + $refined = $response->json('candidates.0.content.parts.0.text'); + if ($refined) { + return trim($refined); + } + } + + return $userMessage; + } + + /** + * 대화 이력을 텍스트로 변환 + */ + private function buildHistoryText(array $history): string + { + $text = ''; + foreach ($history as $msg) { + $role = ($msg['role'] ?? '') === 'user' ? 'User' : 'Assistant'; + $parts = $msg['parts'] ?? []; + $msgText = is_array($parts) && isset($parts[0]['text']) ? $parts[0]['text'] : (is_string($parts) ? $parts : ''); + $text .= "{$role}: {$msgText}\n"; + } + + return $text; + } + + /** + * 검색 결과에서 컨텍스트 구성 + */ + private function buildContext(?array $searchResults): string + { + if (! $searchResults || empty($searchResults['results'])) { + return '관련된 내부 문서를 찾을 수 없습니다.'; + } + + $context = ''; + foreach ($searchResults['results'] as $page) { + $title = $this->extractPageTitle($page); + $pageContent = $this->getPageContent($page['id']); + $url = $page['url'] ?? ''; + $context .= "문서 제목: [{$title}]\nURL: {$url}\n내용:\n{$pageContent}\n---\n"; + } + + return $context ?: '관련된 내부 문서를 찾을 수 없습니다.'; + } + + /** + * 페이지에서 제목 추출 + */ + private function extractPageTitle(array $page): string + { + if (isset($page['properties']['Name']['title'][0]['plain_text'])) { + return $page['properties']['Name']['title'][0]['plain_text']; + } + if (isset($page['properties']['title']['title'][0]['plain_text'])) { + return $page['properties']['title']['title'][0]['plain_text']; + } + + // 다른 title 속성 탐색 + foreach ($page['properties'] ?? [] as $prop) { + if (isset($prop['title'][0]['plain_text'])) { + return $prop['title'][0]['plain_text']; + } + } + + return '제목 없음'; + } + + /** + * 시스템 인스트럭션 구성 + */ + private function buildSystemInstruction(string $historyText, string $context): string + { + $instruction = "You are a helpful, friendly, and professional customer support agent for 'codebridge-x.com'. Your tone should be polite and efficient, similar to Korean customer service standards. Use the provided [Context] to answer the user's question. If the context doesn't contain the answer, say you don't have that information in the internal documents. Reply in Korean.\n"; + + if (! empty($historyText)) { + $instruction .= "\n[Conversation History]\n".$historyText; + } + + $instruction .= "\nIMPORTANT: Even if you cannot find the direct answer in the Context, you MUST list the documents provided in the Context as '관련 문서' at the bottom. + +If the document content is long or partial (ends with '...'), summarize the key points available and encourage the user to click the link for full details. + +Format: +[Answer / Summary] + +관련 문서: +- [문서 제목](URL) + +[Context]\n".$context; + + return $instruction; + } +} diff --git a/resources/views/additional/notion-search/index.blade.php b/resources/views/additional/notion-search/index.blade.php new file mode 100644 index 00000000..8c99ddfb --- /dev/null +++ b/resources/views/additional/notion-search/index.blade.php @@ -0,0 +1,214 @@ +@extends('layouts.app') + +@section('title', 'Notion 검색') + +@push('styles') + +@endpush + +@section('content') + +
+@endsection + +@push('scripts') +@include('partials.react-cdn') +@verbatim + +@endverbatim +@endpush diff --git a/routes/web.php b/routes/web.php index 11c2ace5..d96a4024 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,11 +1,10 @@ header('HX-Request')) { return response('', 200)->header('HX-Redirect', route('dashboard')); } + return view('dashboard.index'); })->name('dashboard'); @@ -707,6 +709,11 @@ Route::get('/showroom', [KioskController::class, 'showroom'])->name('showroom'); Route::get('/factory', [KioskController::class, 'factory'])->name('factory'); }); + + Route::prefix('notion-search')->name('notion-search.')->group(function () { + Route::get('/', [NotionSearchController::class, 'index'])->name('index'); + Route::post('/search', [NotionSearchController::class, 'search'])->name('search'); + }); }); /*