Files
sam-manage/app/Services/NotionService.php
김보곤 099d08e49e chore: [ai] Gemini 모델 gemini-2.0-flash → gemini-2.5-flash 마이그레이션
- config/services.php fallback 기본값 변경
- AiConfig DEFAULT_MODELS 상수 + getActiveGemini() fallback 변경
- NotionService fallback 변경
- AI 설정 관리 UI placeholder/기본값 변경
- Google Cloud AI 가이드 서비스 현황 모델명 변경
- 환경변수 관리 아카데미 예시 변경
2026-03-03 08:09:28 +09:00

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;
}
}