feat: [additional] RAG 검색 기능 추가
- RagSearchService: docs 폴더 키워드 검색 + Gemini API 컨텍스트 기반 답변 - RagSearchController: 검색 페이지 및 HTMX 비동기 검색 API - 검색 UI: 통계 바, 예시 질문, Markdown 렌더링, 참조 문서 표시 - AiTokenHelper 연동으로 토큰 사용량 자동 추적
This commit is contained in:
60
app/Http/Controllers/Additional/RagSearchController.php
Normal file
60
app/Http/Controllers/Additional/RagSearchController.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Additional;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\RagSearchService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 추가기능 > RAG 검색 컨트롤러
|
||||
* SAM 프로젝트 문서를 Gemini AI로 검색/답변
|
||||
*/
|
||||
class RagSearchController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RagSearchService $ragService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* RAG 검색 메인 페이지
|
||||
*/
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('additional.rag.index'));
|
||||
}
|
||||
|
||||
$stats = $this->ragService->getDocStats();
|
||||
|
||||
return view('additional.rag.index', compact('stats'));
|
||||
}
|
||||
|
||||
/**
|
||||
* RAG 검색 실행 (HTMX)
|
||||
*/
|
||||
public function search(Request $request)
|
||||
{
|
||||
$query = trim($request->input('query', ''));
|
||||
|
||||
if (empty($query)) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'error' => '검색어를 입력해주세요.',
|
||||
]);
|
||||
}
|
||||
|
||||
if (mb_strlen($query) < 2) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'error' => '검색어는 2자 이상 입력해주세요.',
|
||||
]);
|
||||
}
|
||||
|
||||
$result = $this->ragService->search($query);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
}
|
||||
424
app/Services/RagSearchService.php
Normal file
424
app/Services/RagSearchService.php
Normal file
@@ -0,0 +1,424 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Helpers\AiTokenHelper;
|
||||
use App\Models\System\AiConfig;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class RagSearchService
|
||||
{
|
||||
/**
|
||||
* 문서 검색 기본 경로
|
||||
*/
|
||||
private string $docsBasePath;
|
||||
|
||||
/**
|
||||
* 검색 대상 확장자
|
||||
*/
|
||||
private array $extensions = ['md', 'php', 'js', 'json'];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->docsBasePath = base_path('../docs');
|
||||
}
|
||||
|
||||
/**
|
||||
* RAG 검색 수행
|
||||
*
|
||||
* 1. docs 폴더에서 키워드 기반 문서 검색
|
||||
* 2. 관련 문서 내용을 Gemini API에 컨텍스트로 전달
|
||||
* 3. 답변 생성 및 반환
|
||||
*/
|
||||
public function search(string $query): array
|
||||
{
|
||||
$config = AiConfig::getActiveGemini();
|
||||
|
||||
if (! $config) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'error' => 'Gemini API 설정이 없습니다. 시스템설정 > AI 설정에서 Gemini를 추가해주세요.',
|
||||
];
|
||||
}
|
||||
|
||||
// 1. 관련 문서 검색
|
||||
$documents = $this->findRelevantDocuments($query);
|
||||
|
||||
if (empty($documents)) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'error' => '관련 문서를 찾지 못했습니다. 다른 키워드로 검색해보세요.',
|
||||
'searched_count' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
// 2. 컨텍스트 구성
|
||||
$context = $this->buildContext($documents);
|
||||
|
||||
// 3. Gemini API 호출
|
||||
return $this->callGemini($config, $query, $context, $documents);
|
||||
}
|
||||
|
||||
/**
|
||||
* docs 폴더에서 키워드 기반으로 관련 문서 검색
|
||||
*/
|
||||
private function findRelevantDocuments(string $query): array
|
||||
{
|
||||
$keywords = $this->extractKeywords($query);
|
||||
$files = $this->getAllDocFiles();
|
||||
$scoredFiles = [];
|
||||
|
||||
foreach ($files as $filePath) {
|
||||
$content = @file_get_contents($filePath);
|
||||
if ($content === false || empty(trim($content))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$score = $this->calculateRelevance($content, $filePath, $keywords);
|
||||
|
||||
if ($score > 0) {
|
||||
$relativePath = str_replace($this->docsBasePath.'/', '', $filePath);
|
||||
$scoredFiles[] = [
|
||||
'path' => $relativePath,
|
||||
'full_path' => $filePath,
|
||||
'score' => $score,
|
||||
'content' => $content,
|
||||
'size' => strlen($content),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 점수 내림차순 정렬
|
||||
usort($scoredFiles, fn ($a, $b) => $b['score'] <=> $a['score']);
|
||||
|
||||
// 상위 문서 선택 (토큰 제한 고려: ~100KB 이내)
|
||||
$selected = [];
|
||||
$totalSize = 0;
|
||||
$maxSize = 100 * 1024; // 100KB
|
||||
|
||||
foreach ($scoredFiles as $file) {
|
||||
if ($totalSize + $file['size'] > $maxSize) {
|
||||
continue;
|
||||
}
|
||||
$selected[] = $file;
|
||||
$totalSize += $file['size'];
|
||||
|
||||
if (count($selected) >= 10) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $selected;
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿼리에서 키워드 추출
|
||||
*/
|
||||
private function extractKeywords(string $query): array
|
||||
{
|
||||
// 한글/영문/숫자 토큰 분리
|
||||
preg_match_all('/[\p{L}\p{N}]+/u', mb_strtolower($query), $matches);
|
||||
$tokens = $matches[0] ?? [];
|
||||
|
||||
// 불용어 제거
|
||||
$stopWords = ['은', '는', '이', '가', '을', '를', '에', '의', '에서', '으로', '로', '와', '과', '도', '하다', '있다', '없다', '되다', '것', '수', '등', '및', '때', '중', '더', '안', '해', '이런', '저런', '그런', '어떤', '무엇', '어떻게', '왜', '뭐', '좀', 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'how', 'what', 'why', 'when', 'where'];
|
||||
|
||||
return array_values(array_filter($tokens, function ($token) use ($stopWords) {
|
||||
return mb_strlen($token) >= 2 && ! in_array($token, $stopWords);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서 관련도 점수 계산
|
||||
*/
|
||||
private function calculateRelevance(string $content, string $filePath, array $keywords): float
|
||||
{
|
||||
$score = 0.0;
|
||||
$lowerContent = mb_strtolower($content);
|
||||
$lowerPath = mb_strtolower($filePath);
|
||||
|
||||
foreach ($keywords as $keyword) {
|
||||
// 본문 매칭
|
||||
$count = mb_substr_count($lowerContent, $keyword);
|
||||
$score += min($count, 10) * 1.0;
|
||||
|
||||
// 파일 경로 매칭 (가중치 높음)
|
||||
if (mb_strpos($lowerPath, $keyword) !== false) {
|
||||
$score += 5.0;
|
||||
}
|
||||
}
|
||||
|
||||
// 파일 유형 가중치
|
||||
if (str_ends_with($filePath, '.md')) {
|
||||
$score *= 1.2; // Markdown 문서 우선
|
||||
}
|
||||
|
||||
// INDEX.md, README.md 가중치
|
||||
$basename = basename($filePath);
|
||||
if (in_array($basename, ['INDEX.md', 'README.md'])) {
|
||||
$score *= 1.1;
|
||||
}
|
||||
|
||||
return $score;
|
||||
}
|
||||
|
||||
/**
|
||||
* docs 폴더의 모든 대상 파일 목록
|
||||
*/
|
||||
private function getAllDocFiles(): array
|
||||
{
|
||||
$files = [];
|
||||
$basePath = realpath($this->docsBasePath);
|
||||
|
||||
if (! $basePath || ! is_dir($basePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($basePath, \RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::LEAVES_ONLY
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if (! $file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ext = strtolower($file->getExtension());
|
||||
if (! in_array($ext, $this->extensions)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// node_modules, vendor, .git 등 제외
|
||||
$path = $file->getPathname();
|
||||
if (preg_match('#/(node_modules|vendor|\.git|pptx-output|assets)/#', $path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 파일 크기 제한 (50KB 이하만)
|
||||
if ($file->getSize() > 50 * 1024) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$files[] = $path;
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색된 문서들로 Gemini 컨텍스트 구성
|
||||
*/
|
||||
private function buildContext(array $documents): string
|
||||
{
|
||||
$context = '';
|
||||
|
||||
foreach ($documents as $doc) {
|
||||
$context .= "=== 문서: {$doc['path']} ===\n";
|
||||
$context .= $doc['content'];
|
||||
$context .= "\n\n";
|
||||
}
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini API 호출
|
||||
*/
|
||||
private function callGemini(AiConfig $config, string $query, string $context, array $documents): array
|
||||
{
|
||||
$systemPrompt = <<<'PROMPT'
|
||||
당신은 SAM(Smart Automation Management) 프로젝트의 기술 문서 어시스턴트입니다.
|
||||
|
||||
역할:
|
||||
- 제공된 문서 컨텍스트를 기반으로 사용자의 질문에 정확히 답변합니다.
|
||||
- 문서에 없는 내용은 "해당 정보는 현재 문서에서 찾을 수 없습니다"라고 답변합니다.
|
||||
- 답변은 한글로 작성하며, 코드/경로/기술 용어는 원문 그대로 사용합니다.
|
||||
- 관련 파일 경로가 있으면 함께 안내합니다.
|
||||
|
||||
답변 형식:
|
||||
- Markdown 형식으로 작성합니다.
|
||||
- 코드 블록에는 언어를 지정합니다.
|
||||
- 핵심을 먼저 말하고, 상세 설명은 뒤에 배치합니다.
|
||||
PROMPT;
|
||||
|
||||
$userMessage = "## 질문\n{$query}\n\n## 참조 문서\n{$context}";
|
||||
|
||||
$parts = [
|
||||
['text' => $systemPrompt],
|
||||
['text' => $userMessage],
|
||||
];
|
||||
|
||||
$body = [
|
||||
'contents' => [
|
||||
['parts' => $parts],
|
||||
],
|
||||
'generationConfig' => [
|
||||
'temperature' => 0.3,
|
||||
'maxOutputTokens' => 4096,
|
||||
],
|
||||
];
|
||||
|
||||
try {
|
||||
if ($config->isVertexAi()) {
|
||||
$response = $this->callVertexAi($config, $body);
|
||||
} else {
|
||||
$response = $this->callGoogleAiStudio($config, $body);
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('RagSearchService: Gemini API 호출 실패', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'error' => 'AI 응답 생성에 실패했습니다. (HTTP '.$response->status().')',
|
||||
];
|
||||
}
|
||||
|
||||
$result = $response->json();
|
||||
$answer = $result['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
||||
|
||||
// 토큰 사용량 저장
|
||||
AiTokenHelper::saveGeminiUsage($result, $result['modelVersion'] ?? $config->model, 'RAG검색');
|
||||
|
||||
// 참조 문서 목록 추출
|
||||
$references = array_map(fn ($doc) => [
|
||||
'path' => $doc['path'],
|
||||
'score' => round($doc['score'], 1),
|
||||
], $documents);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'answer' => $answer,
|
||||
'references' => $references,
|
||||
'searched_count' => count($documents),
|
||||
'model' => $config->model,
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('RagSearchService: 예외 발생', ['error' => $e->getMessage()]);
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'error' => 'AI 호출 중 오류가 발생했습니다: '.$e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Google AI Studio API 호출 (API 키 인증)
|
||||
*/
|
||||
private function callGoogleAiStudio(AiConfig $config, array $body): \Illuminate\Http\Client\Response
|
||||
{
|
||||
$model = $config->model;
|
||||
$apiKey = $config->api_key;
|
||||
$baseUrl = $config->base_url ?? 'https://generativelanguage.googleapis.com/v1beta';
|
||||
|
||||
$url = "{$baseUrl}/models/{$model}:generateContent?key={$apiKey}";
|
||||
|
||||
return Http::timeout(120)->post($url, $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertex AI API 호출 (서비스 계정 인증)
|
||||
*/
|
||||
private function callVertexAi(AiConfig $config, array $body): \Illuminate\Http\Client\Response
|
||||
{
|
||||
$model = $config->model;
|
||||
$projectId = $config->getProjectId();
|
||||
$region = $config->getRegion();
|
||||
|
||||
$accessToken = $this->getAccessToken($config);
|
||||
if (! $accessToken) {
|
||||
throw new \RuntimeException('Google Cloud 인증 실패');
|
||||
}
|
||||
|
||||
$url = "https://{$region}-aiplatform.googleapis.com/v1/projects/{$projectId}/locations/{$region}/publishers/google/models/{$model}:generateContent";
|
||||
|
||||
// Vertex AI는 role 필수
|
||||
$body['contents'][0]['role'] = 'user';
|
||||
|
||||
return Http::withToken($accessToken)->timeout(120)->post($url, $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertex AI 용 OAuth 액세스 토큰 발급
|
||||
*/
|
||||
private function getAccessToken(AiConfig $config): ?string
|
||||
{
|
||||
$serviceAccountPath = $config->getServiceAccountPath();
|
||||
if (! $serviceAccountPath || ! file_exists($serviceAccountPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$serviceAccount = json_decode(file_get_contents($serviceAccountPath), true);
|
||||
if (! $serviceAccount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$now = time();
|
||||
$header = base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
|
||||
$claim = base64_encode(json_encode([
|
||||
'iss' => $serviceAccount['client_email'],
|
||||
'scope' => 'https://www.googleapis.com/auth/cloud-platform',
|
||||
'aud' => 'https://oauth2.googleapis.com/token',
|
||||
'exp' => $now + 3600,
|
||||
'iat' => $now,
|
||||
]));
|
||||
|
||||
$privateKey = openssl_pkey_get_private($serviceAccount['private_key']);
|
||||
openssl_sign("{$header}.{$claim}", $signature, $privateKey, OPENSSL_ALGO_SHA256);
|
||||
|
||||
$jwt = "{$header}.{$claim}.".base64_encode($signature);
|
||||
|
||||
$response = Http::asForm()->post('https://oauth2.googleapis.com/token', [
|
||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
'assertion' => $jwt,
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json('access_token');
|
||||
}
|
||||
|
||||
Log::error('RagSearchService: OAuth 토큰 발급 실패', [
|
||||
'status' => $response->status(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('RagSearchService: OAuth 토큰 발급 예외', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 가능한 문서 통계 조회
|
||||
*/
|
||||
public function getDocStats(): array
|
||||
{
|
||||
$files = $this->getAllDocFiles();
|
||||
$totalSize = 0;
|
||||
$byExtension = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$size = filesize($file);
|
||||
$totalSize += $size;
|
||||
$ext = pathinfo($file, PATHINFO_EXTENSION);
|
||||
$byExtension[$ext] = ($byExtension[$ext] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return [
|
||||
'total_files' => count($files),
|
||||
'total_size_kb' => round($totalSize / 1024),
|
||||
'by_extension' => $byExtension,
|
||||
];
|
||||
}
|
||||
}
|
||||
277
resources/views/additional/rag/index.blade.php
Normal file
277
resources/views/additional/rag/index.blade.php
Normal file
@@ -0,0 +1,277 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'RAG 검색')
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
.rag { max-width: 900px; margin: 0 auto; padding: 32px 20px 48px; }
|
||||
.rag-header { margin-bottom: 28px; }
|
||||
.rag-header h1 { font-size: 1.5rem; font-weight: 700; color: #1e293b; margin-bottom: 6px; }
|
||||
.rag-header p { color: #64748b; font-size: 0.85rem; }
|
||||
|
||||
/* 통계 바 */
|
||||
.rag-stats { display: flex; gap: 16px; margin-bottom: 24px; }
|
||||
.rag-stat { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 16px; display: flex; align-items: center; gap: 8px; }
|
||||
.rag-stat-icon { width: 28px; height: 28px; border-radius: 6px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.rag-stat-icon svg { width: 14px; height: 14px; }
|
||||
.rag-stat-label { font-size: 0.75rem; color: #94a3b8; }
|
||||
.rag-stat-value { font-size: 0.95rem; font-weight: 600; color: #1e293b; }
|
||||
|
||||
/* 검색 입력 */
|
||||
.rag-search { position: relative; margin-bottom: 28px; }
|
||||
.rag-search-input { width: 100%; padding: 14px 52px 14px 18px; border: 2px solid #e2e8f0; border-radius: 12px; font-size: 0.95rem; color: #1e293b; background: #fff; transition: border-color 0.2s, box-shadow 0.2s; outline: none; box-sizing: border-box; }
|
||||
.rag-search-input:focus { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,0.1); }
|
||||
.rag-search-input::placeholder { color: #94a3b8; }
|
||||
.rag-search-btn { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); width: 36px; height: 36px; border: none; background: #3b82f6; color: #fff; border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background 0.15s; }
|
||||
.rag-search-btn:hover { background: #2563eb; }
|
||||
.rag-search-btn:disabled { background: #94a3b8; cursor: not-allowed; }
|
||||
.rag-search-btn svg { width: 16px; height: 16px; }
|
||||
|
||||
/* 로딩 */
|
||||
.rag-loading { display: none; text-align: center; padding: 40px 20px; }
|
||||
.rag-loading.active { display: block; }
|
||||
.rag-spinner { width: 32px; height: 32px; border: 3px solid #e2e8f0; border-top: 3px solid #3b82f6; border-radius: 50%; animation: spin 0.8s linear infinite; margin: 0 auto 12px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.rag-loading p { color: #64748b; font-size: 0.85rem; }
|
||||
|
||||
/* 결과 영역 */
|
||||
.rag-result { display: none; }
|
||||
.rag-result.active { display: block; }
|
||||
|
||||
/* 에러 */
|
||||
.rag-error { background: #fef2f2; border: 1px solid #fecaca; border-radius: 10px; padding: 16px 20px; color: #dc2626; font-size: 0.85rem; display: flex; align-items: flex-start; gap: 10px; }
|
||||
.rag-error svg { width: 18px; height: 18px; flex-shrink: 0; margin-top: 1px; }
|
||||
|
||||
/* 답변 */
|
||||
.rag-answer { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 24px; margin-bottom: 16px; }
|
||||
.rag-answer-header { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding-bottom: 12px; border-bottom: 1px solid #f1f5f9; }
|
||||
.rag-answer-header svg { width: 18px; height: 18px; color: #3b82f6; }
|
||||
.rag-answer-header span { font-size: 0.8rem; font-weight: 600; color: #3b82f6; }
|
||||
.rag-answer-header .model { margin-left: auto; font-size: 0.7rem; color: #94a3b8; font-weight: 400; }
|
||||
.rag-answer-body { font-size: 0.88rem; line-height: 1.8; color: #334155; }
|
||||
.rag-answer-body h1, .rag-answer-body h2, .rag-answer-body h3 { font-weight: 600; color: #1e293b; margin-top: 16px; margin-bottom: 8px; }
|
||||
.rag-answer-body h1 { font-size: 1.15rem; }
|
||||
.rag-answer-body h2 { font-size: 1.05rem; }
|
||||
.rag-answer-body h3 { font-size: 0.95rem; }
|
||||
.rag-answer-body p { margin-bottom: 10px; }
|
||||
.rag-answer-body code { background: #f1f5f9; padding: 2px 6px; border-radius: 4px; font-size: 0.82rem; color: #e11d48; }
|
||||
.rag-answer-body pre { background: #1e293b; color: #e2e8f0; border-radius: 8px; padding: 16px; margin: 12px 0; overflow-x: auto; font-size: 0.8rem; line-height: 1.6; }
|
||||
.rag-answer-body pre code { background: none; color: inherit; padding: 0; }
|
||||
.rag-answer-body ul, .rag-answer-body ol { padding-left: 20px; margin-bottom: 10px; }
|
||||
.rag-answer-body li { margin-bottom: 4px; }
|
||||
.rag-answer-body table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 0.82rem; }
|
||||
.rag-answer-body th, .rag-answer-body td { border: 1px solid #e2e8f0; padding: 8px 12px; text-align: left; }
|
||||
.rag-answer-body th { background: #f8fafc; font-weight: 600; }
|
||||
.rag-answer-body blockquote { border-left: 3px solid #3b82f6; padding-left: 14px; margin: 12px 0; color: #64748b; }
|
||||
.rag-answer-body strong { font-weight: 600; color: #1e293b; }
|
||||
|
||||
/* 참조 문서 */
|
||||
.rag-refs { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 16px 20px; }
|
||||
.rag-refs-title { font-size: 0.78rem; font-weight: 600; color: #64748b; margin-bottom: 10px; display: flex; align-items: center; gap: 6px; }
|
||||
.rag-refs-title svg { width: 14px; height: 14px; }
|
||||
.rag-ref-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.rag-ref-item { display: flex; align-items: center; gap: 8px; font-size: 0.78rem; color: #475569; padding: 5px 10px; background: #fff; border-radius: 6px; border: 1px solid #e2e8f0; }
|
||||
.rag-ref-item .score { margin-left: auto; font-size: 0.7rem; color: #94a3b8; background: #f1f5f9; padding: 2px 8px; border-radius: 4px; }
|
||||
.rag-ref-item svg { width: 12px; height: 12px; color: #94a3b8; flex-shrink: 0; }
|
||||
|
||||
/* 예시 질문 */
|
||||
.rag-examples { margin-top: 24px; }
|
||||
.rag-examples-title { font-size: 0.82rem; font-weight: 600; color: #64748b; margin-bottom: 12px; }
|
||||
.rag-examples-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
||||
.rag-example { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px 16px; cursor: pointer; transition: border-color 0.15s, box-shadow 0.15s; font-size: 0.82rem; color: #475569; text-align: left; }
|
||||
.rag-example:hover { border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.08); }
|
||||
.rag-example .label { font-size: 0.7rem; color: #94a3b8; margin-bottom: 4px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.rag-stats { flex-direction: column; }
|
||||
.rag-examples-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="rag">
|
||||
<div class="rag-header">
|
||||
<h1>RAG 검색</h1>
|
||||
<p>SAM 프로젝트 문서를 AI가 분석하여 답변합니다. Gemini API + 문서 컨텍스트 기반 검색.</p>
|
||||
</div>
|
||||
|
||||
{{-- 통계 --}}
|
||||
<div class="rag-stats">
|
||||
<div class="rag-stat">
|
||||
<div class="rag-stat-icon" style="background:#dbeafe; color:#2563eb;">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="rag-stat-label">검색 가능 문서</div>
|
||||
<div class="rag-stat-value">{{ $stats['total_files'] }}개</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rag-stat">
|
||||
<div class="rag-stat-icon" style="background:#dcfce7; color:#16a34a;">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="rag-stat-label">총 문서 크기</div>
|
||||
<div class="rag-stat-value">{{ number_format($stats['total_size_kb']) }}KB</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rag-stat">
|
||||
<div class="rag-stat-icon" style="background:#fef3c7; color:#d97706;">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="rag-stat-label">AI 엔진</div>
|
||||
<div class="rag-stat-value">Gemini</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 검색 --}}
|
||||
<div class="rag-search">
|
||||
<input type="text" class="rag-search-input" id="ragQuery" placeholder="SAM 프로젝트에 대해 질문하세요... (예: API 개발 규칙, 데이터베이스 구조)" autocomplete="off">
|
||||
<button type="button" class="rag-search-btn" id="ragSearchBtn" onclick="ragSearch()">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- 로딩 --}}
|
||||
<div class="rag-loading" id="ragLoading">
|
||||
<div class="rag-spinner"></div>
|
||||
<p>문서를 검색하고 AI가 답변을 생성하고 있습니다...</p>
|
||||
</div>
|
||||
|
||||
{{-- 결과 영역 --}}
|
||||
<div class="rag-result" id="ragResult"></div>
|
||||
|
||||
{{-- 예시 질문 --}}
|
||||
<div class="rag-examples" id="ragExamples">
|
||||
<div class="rag-examples-title">예시 질문</div>
|
||||
<div class="rag-examples-grid">
|
||||
<button type="button" class="rag-example" onclick="setQuery('SAM 프로젝트의 API 개발 규칙은?')">
|
||||
<div class="label">개발 표준</div>
|
||||
SAM 프로젝트의 API 개발 규칙은?
|
||||
</button>
|
||||
<button type="button" class="rag-example" onclick="setQuery('데이터베이스 스키마 구조를 설명해줘')">
|
||||
<div class="label">기술 스펙</div>
|
||||
데이터베이스 스키마 구조를 설명해줘
|
||||
</button>
|
||||
<button type="button" class="rag-example" onclick="setQuery('견적 시스템의 BOM 10단계 로직은 어떻게 작동하나?')">
|
||||
<div class="label">기능 분석</div>
|
||||
견적 시스템의 BOM 10단계 로직은 어떻게 작동하나?
|
||||
</button>
|
||||
<button type="button" class="rag-example" onclick="setQuery('Git 커밋 메시지 규칙과 브랜치 전략은?')">
|
||||
<div class="label">Git 규칙</div>
|
||||
Git 커밋 메시지 규칙과 브랜치 전략은?
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script>
|
||||
const ragInput = document.getElementById('ragQuery');
|
||||
const ragBtn = document.getElementById('ragSearchBtn');
|
||||
const ragLoading = document.getElementById('ragLoading');
|
||||
const ragResult = document.getElementById('ragResult');
|
||||
const ragExamples = document.getElementById('ragExamples');
|
||||
|
||||
// Enter 키 검색
|
||||
ragInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter' && !e.isComposing) {
|
||||
e.preventDefault();
|
||||
ragSearch();
|
||||
}
|
||||
});
|
||||
|
||||
function setQuery(text) {
|
||||
ragInput.value = text;
|
||||
ragSearch();
|
||||
}
|
||||
|
||||
async function ragSearch() {
|
||||
const query = ragInput.value.trim();
|
||||
if (!query) return;
|
||||
|
||||
// UI 상태 변경
|
||||
ragBtn.disabled = true;
|
||||
ragLoading.classList.add('active');
|
||||
ragResult.classList.remove('active');
|
||||
ragExamples.style.display = 'none';
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ route("additional.rag.search") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
},
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.ok) {
|
||||
renderAnswer(data);
|
||||
} else {
|
||||
renderError(data.error || '검색에 실패했습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
renderError('네트워크 오류가 발생했습니다. 다시 시도해주세요.');
|
||||
} finally {
|
||||
ragBtn.disabled = false;
|
||||
ragLoading.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
function renderAnswer(data) {
|
||||
let html = `
|
||||
<div class="rag-answer">
|
||||
<div class="rag-answer-header">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" /></svg>
|
||||
<span>AI 답변</span>
|
||||
<span class="model">${data.model || 'Gemini'} · ${data.searched_count || 0}개 문서 참조</span>
|
||||
</div>
|
||||
<div class="rag-answer-body">${marked.parse(data.answer || '')}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 참조 문서
|
||||
if (data.references && data.references.length > 0) {
|
||||
html += `
|
||||
<div class="rag-refs">
|
||||
<div class="rag-refs-title">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg>
|
||||
참조 문서 (관련도 순)
|
||||
</div>
|
||||
<div class="rag-ref-list">
|
||||
${data.references.map(ref => `
|
||||
<div class="rag-ref-item">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
|
||||
<span>${ref.path}</span>
|
||||
<span class="score">관련도 ${ref.score}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
ragResult.innerHTML = html;
|
||||
ragResult.classList.add('active');
|
||||
}
|
||||
|
||||
function renderError(message) {
|
||||
ragResult.innerHTML = `
|
||||
<div class="rag-error">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" /></svg>
|
||||
<span>${message}</span>
|
||||
</div>
|
||||
`;
|
||||
ragResult.classList.add('active');
|
||||
ragExamples.style.display = 'block';
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Http\Controllers\Additional\KioskController;
|
||||
use App\Http\Controllers\Additional\NotionSearchController;
|
||||
use App\Http\Controllers\Additional\RagSearchController;
|
||||
use App\Http\Controllers\Api\BusinessCardOcrController;
|
||||
use App\Http\Controllers\ApiLogController;
|
||||
use App\Http\Controllers\AppVersionController;
|
||||
@@ -714,6 +715,11 @@
|
||||
Route::get('/', [NotionSearchController::class, 'index'])->name('index');
|
||||
Route::post('/search', [NotionSearchController::class, 'search'])->name('search');
|
||||
});
|
||||
|
||||
Route::prefix('rag')->name('rag.')->group(function () {
|
||||
Route::get('/', [RagSearchController::class, 'index'])->name('index');
|
||||
Route::post('/search', [RagSearchController::class, 'search'])->name('search');
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user