Files
sam-manage/resources/views/additional/rag/index.blade.php
김보곤 576b1d9f6b feat: [additional] RAG 검색에 토큰 비용 안내 및 사용량 표시 추가
- 검색 전 비용 안내 문구 (건당 약 3~10원, AI 토큰 사용량 기록 안내)
- 검색 결과에 토큰 사용량 바 표시 (입력/출력/합계/비용)
- AiTokenHelper + AiPricingConfig 연동으로 정확한 비용 계산
2026-02-22 23:33:15 +09:00

325 lines
17 KiB
PHP

@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-token-bar { display: flex; align-items: center; gap: 14px; background: #fffbeb; border: 1px solid #fde68a; border-radius: 8px; padding: 10px 16px; margin-bottom: 16px; flex-wrap: wrap; }
.rag-token-item { display: flex; align-items: center; gap: 5px; font-size: 0.75rem; color: #92400e; }
.rag-token-item .label { color: #a16207; }
.rag-token-item .value { font-weight: 600; }
.rag-token-sep { width: 1px; height: 14px; background: #fde68a; }
/* 비용 안내 */
.rag-cost-notice { background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 8px; padding: 10px 16px; margin-bottom: 24px; display: flex; align-items: flex-start; gap: 8px; font-size: 0.78rem; color: #0369a1; line-height: 1.5; }
.rag-cost-notice svg { width: 16px; height: 16px; flex-shrink: 0; margin-top: 1px; }
/* 참조 문서 */
.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-cost-notice">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>검색 1회당 Gemini API 토큰이 소모됩니다. 관련 문서(최대 100KB) 컨텍스트로 전달하므로 <strong>25,000~50,000 토큰</strong> 사용되며, 예상 비용은 <strong>건당 3~10</strong>입니다. 사용 내역은 시스템설정 > AI 토큰 사용량에 기록됩니다.</span>
</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 = '';
// 토큰 사용량 바
if (data.token_usage) {
const t = data.token_usage;
html += `
<div class="rag-token-bar">
<div class="rag-token-item">
<span class="label">입력</span>
<span class="value">${Number(t.prompt_tokens).toLocaleString()}</span>
</div>
<div class="rag-token-sep"></div>
<div class="rag-token-item">
<span class="label">출력</span>
<span class="value">${Number(t.completion_tokens).toLocaleString()}</span>
</div>
<div class="rag-token-sep"></div>
<div class="rag-token-item">
<span class="label">합계</span>
<span class="value">${Number(t.total_tokens).toLocaleString()} tokens</span>
</div>
<div class="rag-token-sep"></div>
<div class="rag-token-item">
<span class="label">비용</span>
<span class="value">$${t.cost_usd.toFixed(4)} (${Math.round(t.cost_krw).toLocaleString()}원)</span>
</div>
</div>
`;
}
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'} &middot; ${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