- config/services.php fallback 기본값 변경 - AiConfig DEFAULT_MODELS 상수 + getActiveGemini() fallback 변경 - NotionService fallback 변경 - AI 설정 관리 UI placeholder/기본값 변경 - Google Cloud AI 가이드 서비스 현황 모델명 변경 - 환경변수 관리 아카데미 예시 변경
296 lines
10 KiB
PHP
296 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Helpers\AiTokenHelper;
|
|
use App\Models\System\AiConfig;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class NotionService
|
|
{
|
|
private string $apiKey;
|
|
|
|
private string $version;
|
|
|
|
private string $baseUrl;
|
|
|
|
public function __construct()
|
|
{
|
|
$apiKey = config('services.notion.api_key');
|
|
if (! $apiKey) {
|
|
throw new \RuntimeException('Notion API 키가 설정되지 않았습니다. .env 파일의 NOTION_API_KEY를 확인하세요.');
|
|
}
|
|
|
|
$this->apiKey = $apiKey;
|
|
$this->version = config('services.notion.version', '2025-09-03');
|
|
$this->baseUrl = config('services.notion.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.5-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()) {
|
|
$errorBody = $response->json();
|
|
$errorMsg = $errorBody['error']['message'] ?? ('HTTP '.$response->status());
|
|
|
|
Log::error('Gemini API 오류', [
|
|
'status' => $response->status(),
|
|
'body' => $response->body(),
|
|
]);
|
|
|
|
return [
|
|
'reply' => "Gemini API 오류: {$errorMsg}\n\nAI 설정 페이지에서 Gemini API 키를 확인해주세요.",
|
|
'debug' => ['refinedQuery' => $refinedQuery, 'error' => $errorMsg],
|
|
];
|
|
}
|
|
|
|
AiTokenHelper::saveGeminiUsage($response->json(), $geminiModel, 'Notion 검색 > AI 답변');
|
|
|
|
$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()) {
|
|
AiTokenHelper::saveGeminiUsage($response->json(), $model, 'Notion 검색 > 검색어 정제');
|
|
|
|
$refined = $response->json('candidates.0.content.parts.0.text');
|
|
if ($refined) {
|
|
return trim($refined);
|
|
}
|
|
} else {
|
|
$errorBody = $response->json();
|
|
$errorMsg = $errorBody['error']['message'] ?? ('HTTP '.$response->status());
|
|
Log::warning('Gemini 검색어 정제 실패, 원본 쿼리 사용', ['error' => $errorMsg]);
|
|
|
|
throw new \RuntimeException("Gemini API 오류: {$errorMsg}\n\nAI 설정 페이지에서 Gemini API 키를 확인해주세요.");
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|