초기 커밋: 5130 레거시 시스템
- URL 하드코딩 → .env APP_URL 기반 동적 URL로 변경 - DB 연결 하드코딩 → .env 기반으로 변경 - MySQL strict mode DATE 오류 수정
This commit is contained in:
180
chatbot/api.php
Normal file
180
chatbot/api.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// POST 요청만 처리
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['error' => 'Invalid request method']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// JSON 입력 받기
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$userMessage = $input['message'] ?? '';
|
||||
$history = $input['history'] ?? [];
|
||||
|
||||
if (empty($userMessage)) {
|
||||
echo json_encode(['error' => 'Empty message']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// API 키 설정
|
||||
$notionApiKeyPath = $_SERVER['DOCUMENT_ROOT'] . "/apikey/notion.txt";
|
||||
$googleApiKeyPath = $_SERVER['DOCUMENT_ROOT'] . "/apikey/google_vertex_api.txt";
|
||||
|
||||
if (!file_exists($notionApiKeyPath) || !file_exists($googleApiKeyPath)) {
|
||||
echo json_encode(['reply' => "시스템 설정 오류: API 키 파일을 찾을 수 없습니다."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$notionApiKey = trim(file_get_contents($notionApiKeyPath));
|
||||
$googleApiKey = trim(file_get_contents($googleApiKeyPath));
|
||||
|
||||
require_once 'notion_client.php';
|
||||
|
||||
try {
|
||||
// 0. 검색어 정제 및 확장 (Query Refinement)
|
||||
// 항상 Gemini를 이용해 오타 수정 및 Notion 검색에 적합한 키워드로 변환합니다.
|
||||
$refineSystemInstruction = "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.";
|
||||
|
||||
$historyText = "";
|
||||
if (!empty($history)) {
|
||||
foreach ($history as $msg) {
|
||||
$role = $msg['role'] === 'user' ? "User" : "Assistant";
|
||||
$text = is_array($msg['parts']) ? $msg['parts'][0]['text'] : $msg['parts'];
|
||||
$historyText .= "$role: $text\n";
|
||||
}
|
||||
}
|
||||
|
||||
$refineData = [
|
||||
'contents' => [
|
||||
['parts' => [['text' => "Conversation History:\n$historyText\n\nCurrent Question: $userMessage"]]]
|
||||
],
|
||||
'systemInstruction' => [
|
||||
'parts' => [['text' => $refineSystemInstruction]]
|
||||
]
|
||||
];
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=" . $googleApiKey);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($refineData));
|
||||
$refineResponse = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$refineData = json_decode($refineResponse, true);
|
||||
$refinedQuery = $userMessage; // 기본값
|
||||
if (isset($refineData['candidates'][0]['content']['parts'][0]['text'])) {
|
||||
$refinedQuery = trim($refineData['candidates'][0]['content']['parts'][0]['text']);
|
||||
}
|
||||
|
||||
// 1. Notion 검색 및 컨텍스트 확보 (정제된 쿼리 사용)
|
||||
$notion = new NotionClient($notionApiKey);
|
||||
$searchResults = $notion->search($refinedQuery);
|
||||
|
||||
$context = "";
|
||||
if ($searchResults && isset($searchResults['results'])) {
|
||||
foreach ($searchResults['results'] as $page) {
|
||||
$title = "제목 없음";
|
||||
if (isset($page['properties']['Name']['title'][0]['plain_text'])) {
|
||||
$title = $page['properties']['Name']['title'][0]['plain_text'];
|
||||
} elseif (isset($page['properties']['title']['title'][0]['plain_text'])) {
|
||||
$title = $page['properties']['title']['title'][0]['plain_text'];
|
||||
}
|
||||
|
||||
$pageContent = $notion->getPageContent($page['id']);
|
||||
$url = $page['url'] ?? '';
|
||||
$context .= "문서 제목: [{$title}]\nURL: {$url}\n내용:\n{$pageContent}\n---\n";
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($context)) {
|
||||
$context = "관련된 내부 문서를 찾을 수 없습니다.";
|
||||
}
|
||||
|
||||
// 2. Gemini API 호출
|
||||
$url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=" . $googleApiKey;
|
||||
|
||||
$headers = [
|
||||
'Content-Type: application/json'
|
||||
];
|
||||
|
||||
$systemInstruction = "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)) {
|
||||
$systemInstruction .= "\n[Conversation History]\n" . $historyText;
|
||||
}
|
||||
|
||||
$systemInstruction .= "\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;
|
||||
|
||||
$data = [
|
||||
'contents' => [
|
||||
[
|
||||
'parts' => [
|
||||
['text' => $userMessage]
|
||||
]
|
||||
]
|
||||
],
|
||||
'systemInstruction' => [
|
||||
'parts' => [
|
||||
['text' => $systemInstruction]
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
if (curl_errno($ch)) {
|
||||
throw new Exception(curl_error($ch));
|
||||
}
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
throw new Exception("API Error: " . $response);
|
||||
}
|
||||
|
||||
$responseData = json_decode($response, true);
|
||||
$reply = $responseData['candidates'][0]['content']['parts'][0]['text'] ?? "죄송합니다. 답변을 생성하지 못했습니다.";
|
||||
|
||||
echo json_encode([
|
||||
'reply' => $reply,
|
||||
'debug' => [
|
||||
'refinedQuery' => $refinedQuery,
|
||||
'context' => $context,
|
||||
'systemInstruction' => $systemInstruction,
|
||||
'rawResponse' => $responseData
|
||||
]
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo json_encode(['reply' => "오류가 발생했습니다: " . $e->getMessage()]);
|
||||
}
|
||||
?>
|
||||
59
chatbot/docs/advanced_rag_guide.md
Normal file
59
chatbot/docs/advanced_rag_guide.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Notion API를 이용한 RAG 구축: 현재 방식 vs 고도화(Vector) 방식
|
||||
|
||||
현재 구축된 챗봇은 **"검색 기반(Search-Based) RAG"**입니다. Notion의 실시간 검색 기능을 활용해 가볍고 빠르게 구축한 버전입니다.
|
||||
질문하신 대로, 더 높은 정확도와 의미 기반 검색이 가능한 **"임베딩(Vector) 기반 RAG"**(=순수 RAG)를 구축하려면 훨씬 더 복잡한 파이프라인이 필요합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 현재 방식: Search-Based RAG (Live Search)
|
||||
Notion이 제공하는 '검색 창' 기능을 API로 대신 누르는 것과 같습니다.
|
||||
|
||||
### 구조
|
||||
1. **User**: "오타 수정 기능 있어?"
|
||||
2. **AI (Keyword Gen)**: 질문을 Notion 검색어로 변경 -> "오타 수정"
|
||||
3. **Notion API**: 해당 단어가 포함된 제목/본문을 찾아서 리턴
|
||||
4. **AI (Answer)**: 찾은 내용을 읽고 답변 생성
|
||||
|
||||
> **장점**:
|
||||
> - 구축이 매우 빠름 (별도 DB 필요 없음).
|
||||
> - Notion에서 수정하면 즉시 반영됨 (실시간).
|
||||
>
|
||||
> **단점**:
|
||||
> - **의미 검색 불가**: "급여"를 검색할 때 "월급"이라는 단어만 있는 문서는 못 찾을 수 있음 (단어 일치 기반).
|
||||
> - **속도/양 제한**: Notion API의 검색 속도에 의존하며, 가져올 수 있는 문서 양에 한계가 있음.
|
||||
|
||||
---
|
||||
|
||||
## 2. 고도화 방식: Vector RAG (Standard RAG)
|
||||
AI가 문장의 '의미'를 숫자로 변환(임베딩)하여 저장해두고 찾는 방식입니다.
|
||||
|
||||
### 필요 단계 (구축 파이프라인)
|
||||
RAG를 제대로 구현하려면 아래 5단계를 수행하는 **별도의 서버와 DB**를 구축해야 합니다.
|
||||
|
||||
1. **데이터 추출 (Extract)**
|
||||
* Notion API를 통해 주기적으로 모든 페이지를 긁어옵니다. (매일 밤 or 변경 시마다)
|
||||
2. **청킹 (Chunking)**
|
||||
* 긴 문서를 문단 단위나 의미 단위로 잘게 쪼갭니다. (예: 500자 단위)
|
||||
3. **임베딩 (Embedding)**
|
||||
* 쪼갠 텍스트를 AI 모델(OpenAI Embeddings, Google Vertex Embeddings 등)에 넣어 **숫자 벡터(Vector)**로 변환합니다.
|
||||
* 예: "사과" -> `[0.1, 0.5, -0.3, ...]`
|
||||
4. **벡터 저장소 적재 (Vector Vector)**
|
||||
* 변환된 숫자를 검색할 수 있는 전용 DB(Pinecone, Milvus, Elasticsearch 등)에 저장합니다.
|
||||
5. **검색 및 생성 (Retrieval & Generation)**
|
||||
* 사용자가 질문하면, 질문도 `[0.2, ...]` 숫자로 변환합니다.
|
||||
* DB에서 **숫자가 가장 비슷한**(의미가 가까운) 문서를 찾아냅니다. (이때 "급여"로 검색해도 "월급" 문서를 찾습니다)
|
||||
* 찾은 문서를 AI에게 주어 답변을 생성합니다.
|
||||
|
||||
### 비교 요약
|
||||
|
||||
| 구분 | 현재 방식 (Live Search) | 고도화 방식 (Vector RAG) |
|
||||
| :--- | :--- | :--- |
|
||||
| **핵심 기술** | Notion 키워드 검색 API | Vector 임베딩 & Similarity Search |
|
||||
| **준비물** | API 키만 있으면 됨 | 별도 DB, 임베딩 모델, 동기화 서버 |
|
||||
| **검색 품질** | 키워드 일치 여부에 의존 | **의미(문맥) 기반 추론 가능** |
|
||||
| **최신성** | 실시간 (즉시 반영) | 동기화 주기(예: 1시간)에 따라 지연 발생 |
|
||||
| **구축 난이도** | 하 (반나절 소요) | **상 (최소 1~2주 소요)** |
|
||||
|
||||
### 결론
|
||||
현재는 **"구축 비용 대비 효율"**이 가장 좋은 방식을 채택했습니다.
|
||||
만약 사내 문서가 수천 개 이상으로 늘어나고, "단어는 다르지만 의미가 같은" 문서들을 찾아야 하는 니즈가 커진다면, 그때 **벡터 DB 도입(2번 방식)**을 고려하시는 것이 좋습니다.
|
||||
51
chatbot/docs/google_cloud_rag_guide.md
Normal file
51
chatbot/docs/google_cloud_rag_guide.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Google Cloud 기반 Vector RAG 구축 가이드
|
||||
|
||||
"순수 RAG" 즉, **Vector Search** 기반의 고도화된 시스템을 Google Cloud Platform(GCP)에서 구축하기 위한 가이드입니다.
|
||||
|
||||
## 1. 핵심 구글 서비스 (필요한 도구들)
|
||||
|
||||
고도화된 RAG를 구축하려면 다음 Vertex AI 서비스들을 조합해야 합니다.
|
||||
|
||||
| 단계 | 역할 | Google 서비스명 | 설명 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **두뇌 (LLM)** | 답변 생성 | **Vertex AI Gemini** | 최종 답변을 작성하는 생성형 AI 모델 (Flash/Pro) |
|
||||
| **번역기 (Embedding)** | 텍스트 → 숫자 변환 | **Vertex AI Embeddings** | 문서를 AI가 이해하는 숫자(Vector)로 변환 (`text-embedding-004`) |
|
||||
| **저장소 (DB)** | 숫자 검색 (Vector DB) | **Vertex AI Vector Search** | 수억 개의 벡터 중 가장 유사한 것을 0.1초 내에 찾는 엔진 |
|
||||
| **저장소 (Raw)** | 원본 파일 저장 | **Cloud Storage (GCS)** | Notion에서 추출한 텍스트/JSON 파일을 저장하는 공간 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 구축 과정 (Step-by-Step)
|
||||
|
||||
### Phase 1: 데이터 파이프라인 구축 (Data Ingestion)
|
||||
매일 밤 또는 주기적으로 문서를 최신화하는 자동화 시스템입니다.
|
||||
|
||||
1. **Notion 데이터 추출**:
|
||||
* Python 스크립트(Cloud Functions)가 Notion API를 호출해 모든 페이지를 텍스트로 가져옵니다.
|
||||
* 데이터를 500~1000자 단위로 자릅니다(Chunking).
|
||||
2. **임베딩 생성 (Embedding)**:
|
||||
* 자른 텍스트 조각들을 **Vertex AI Embeddings API**에 보냅니다.
|
||||
* API가 각 조각을 768차원(혹은 그 이상)의 숫자 배열(Vector)로 변환해 줍니다.
|
||||
3. **Vector DB 적재**:
|
||||
* 변환된 Vector와 원본 텍스트 ID를 **Vertex AI Vector Search** 인덱스에 업로드(Upsert)하여 저장합니다.
|
||||
|
||||
### Phase 2: 검색 및 답변 시스템 (RAG Application)
|
||||
실제 사용자가 채팅할 때 일어나는 실시간 프로세스입니다.
|
||||
|
||||
1. **질문 임베딩**:
|
||||
* 사용자: "SaaS 용어가 뭐야?"
|
||||
* 시스템: 이 질문을 **Embeddings API**에 보내 숫자로 변환합니다.
|
||||
2. **유사도 검색 (Retrieval)**:
|
||||
* 시스템: 변환된 숫자를 **Vector Search**에 던져 "가장 거리가 가까운(의미가 비슷한) 문서 조각 5개 줘"라고 요청합니다.
|
||||
* Vector Search: Notion에 없는 단어라도 의미가 비슷하면 찾아냅니다. (예: 'SaaS' ↔ '클라우드 소프트웨어')
|
||||
3. **답변 생성 (Generation)**:
|
||||
* 시스템: 찾아낸 문서 조각 5개 + 사용자 질문을 모아 **Gemini**에게 보냅니다.
|
||||
* Gemini: 근거 자료를 바탕으로 정확한 답변을 작성합니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. (참고) 더 쉬운 방법: Vertex AI Agent Builder
|
||||
위 과정을 일일이 개발하기 부담스럽다면, Google의 완전 관리형 서비스인 **Vertex AI Agent Builder (구 Search & Conversation)**를 사용할 수 있습니다.
|
||||
|
||||
* **방식**: Notion 데이터(PDF/HTML 등)를 Google Cloud Storage에 넣고 "이거 검색기 만들어줘"라고 클릭 몇 번 하면, **위의 1~3번 과정(임베딩, 벡터 DB, 검색 로직)을 구글이 알아서 처리**해 줍니다.
|
||||
* **추천**: 개발 리소스가 부족하고 빠른 구축이 필요하다면 이 방식이 효율적입니다. "순수 RAG"를 직접 코딩으로 제어하고 싶다면 위의 Vector Search 방식을 씁니다.
|
||||
34
chatbot/docs/notion검색설정방법.md
Normal file
34
chatbot/docs/notion검색설정방법.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# 챗봇 전체 문서 검색을 위한 Notion 설정 가이드
|
||||
|
||||
본 문서는 사내 챗봇이 회사 Notion의 모든 주요 문서를 검색하여 답변할 수 있도록 권한을 설정하는 방법을 설명합니다.
|
||||
|
||||
## 핵심 원리
|
||||
Notion API는 보안을 위해 **"사용자가 명시적으로 허용한 페이지"**만 챗봇이 읽을 수 있습니다.
|
||||
따라서, 회사의 가장 상위 페이지(Root Page)에 챗봇 권한을 부여하면, 그 하위에 있는 모든 문서와 데이터베이스까지 자동으로 권한이 상속되어 검색이 가능해집니다.
|
||||
|
||||
---
|
||||
|
||||
## 설정 방법 (3단계)
|
||||
|
||||
### 1단계: 최상위 페이지(Root Page) 이동
|
||||
1. Notion에 로그인합니다.
|
||||
2. 회사의 모든 문서가 담겨 있는 **가장 상위 폴더** 또는 **워크스페이스 홈 페이지**로 이동합니다.
|
||||
- 예: `OOO 회사 홈`, `Team Space`, `공용 문서함` 등
|
||||
|
||||
### 2단계: 연결(Connection) 메뉴 진입
|
||||
1. 페이지 우측 상단의 **`...` (더보기 아이콘)**을 클릭합니다.
|
||||
2. 메뉴 목록 아래쪽의 **`연결(Connections)`** 또는 **`Connect to`** 항목을 클릭합니다.
|
||||
*(이미 연결된 앱이 있다면 해당 앱 이름이 보일 수 있습니다.)*
|
||||
|
||||
### 3단계: 챗봇 앱 추가
|
||||
1. 검색창에 우리 회사의 챗봇 통합 앱 이름(예: `CodeBridge Chatbot` 또는 API 발급 시 설정한 이름)을 검색합니다.
|
||||
2. 해당 앱을 선택하고 **`초대(Confirm)`** 버튼을 클릭합니다.
|
||||
|
||||
---
|
||||
|
||||
## 확인 사항
|
||||
- **권한 상속**: 위 설정을 완료하면, 해당 페이지 내부에 있는 모든 하위 페이지도 챗봇이 읽을 수 있게 됩니다.
|
||||
- **제외하고 싶은 문서**: 만약 특정 문서를 챗봇이 읽지 못하게 하려면, 해당 하위 페이지로 이동하여 `연결` 메뉴에서 챗봇 앱을 **`연결 해제(Disconnect)`** 하거나, 별도의 비공개 페이지로 이동시켜야 합니다.
|
||||
- **검색 테스트**: 설정 후 약 1~5분 뒤부터 챗봇에서 해당 문서의 내용을 질문하면 답변을 받을 수 있습니다.
|
||||
|
||||
> **참고**: 챗봇은 문서의 **제목**과 **텍스트 내용**을 기반으로 검색을 수행합니다. 문서 제목이 명확할수록 검색 정확도가 높아집니다.
|
||||
50
chatbot/docs/vertex.md
Normal file
50
chatbot/docs/vertex.md
Normal file
@@ -0,0 +1,50 @@
|
||||
GCP Vertex AI(구체적으로는 Vertex AI Agent Builder 또는 Vertex AI Search)를 사용하여 Notion의 데이터를 읽어들이고, 이를 기반으로 답변하는 검색 엔진이나 챗봇을 구축할 수 있습니다.
|
||||
다만, Google Drive나 Jira처럼 '버튼 하나로 연결되는(Native Connector)' 방식이 아직 Notion에 대해서는 완전하게 제공되지 않을 수 있으므로, 질문하신 대로 API를 활용한 커스텀 연동 방식이 가장 일반적입니다.
|
||||
이 과정을 "RAG (검색 증강 생성) 파이프라인 구축" 관점에서 정리해 드리겠습니다.
|
||||
1. 전체적인 데이터 흐름 (아키텍처)
|
||||
Notion의 데이터를 Vertex AI가 이해하기 위해서는 **[Notion] -> [중간 처리] -> [Vertex AI 저장소]**의 과정을 거쳐야 합니다.
|
||||
2. 구체적인 구현 방법: API 기법
|
||||
가장 확실하고 유연한 방법은 Notion API와 Vertex AI Discovery Engine API를 결합하는 것입니다.
|
||||
단계 1: Notion에서 데이터 추출 (Extraction)
|
||||
Notion에 있는 페이지와 텍스트를 가져오기 위해 Notion API를 사용합니다.
|
||||
API: Notion REST API (Retrieve block children, Retrieve a page 등)
|
||||
기능: 특정 데이터베이스나 페이지의 텍스트, 표, 속성(메타데이터)을 긁어옵니다.
|
||||
형식: 추출된 데이터는 보통 Markdown이나 JSON 형태로 받아집니다.
|
||||
단계 2: 데이터 전처리 (Transformation)
|
||||
Vertex AI가 읽기 좋은 형태로 데이터를 가공합니다.
|
||||
JSONL 변환: Vertex AI Search는 JSONL(JSON Lines) 형식을 매우 선호합니다.
|
||||
구조화: 각 Notion 페이지를 하나의 JSON 객체로 만들고, id(페이지ID), content(본문 내용), title(제목), url(Notion 링크) 등의 필드로 정리합니다.
|
||||
예시 데이터 포맷 (JSONL):
|
||||
{"id": "notion_page_123", "jsonData": "{\"title\": \"프로젝트 기획안\", \"content\": \"이 프로젝트의 목적은...\"}"}
|
||||
단계 3: Vertex AI로 데이터 수집 (Ingestion)
|
||||
가공된 데이터를 Vertex AI의 **Data Store(데이터 저장소)**에 넣습니다. 두 가지 방법이 있습니다.
|
||||
Cloud Storage (GCS) 경유: JSONL 파일을 Google Cloud Storage 버킷에 업로드하고, Vertex AI 콘솔에서 해당 버킷을 바라보게 설정합니다. (가장 쉬움)
|
||||
Discovery Engine API 직접 호출: 실시간성이 중요하다면, 코드를 짜서 Discovery Engine API의 userEvents:write 또는 documents:import 메서드를 통해 데이터를 직접 밀어넣습니다.
|
||||
3. 대안: 타사 ETL 도구 활용 (No-Code 접근)
|
||||
개발 리소스를 줄이고 싶다면 Airbyte나 Fivetran 같은 데이터 파이프라인 도구를 사용할 수 있습니다.
|
||||
방식: [Notion] -> Airbyte(커넥터) -> [Google BigQuery] -> [Vertex AI Search]
|
||||
장점: Notion API를 직접 코딩하지 않아도 데이터를 주기적으로 동기화할 수 있습니다. Vertex AI는 BigQuery에 있는 데이터를 바로 소스로 사용할 수 있습니다.
|
||||
4. Vertex AI를 통한 활용 (결과물)
|
||||
위 과정을 통해 데이터가 Vertex AI에 색인(Indexing)되면 다음과 같은 작업이 가능합니다.
|
||||
시맨틱 검색 (Semantic Search): 키워드가 정확히 일치하지 않아도, 문맥을 이해하여 Notion 내의 관련 문서를 찾아줍니다.
|
||||
요약 및 답변 생성: "이번 달 마케팅 회의록 요약해 줘"라고 물으면, Notion의 회의록 내용을 읽고 LLM(Gemini)이 답변을 생성합니다.
|
||||
출처 표기: 답변과 함께 해당 정보가 있는 Notion 페이지 링크를 제공할 수 있습니다.
|
||||
요약 표
|
||||
구분
|
||||
API 직접 개발 (Custom)
|
||||
ETL 도구 활용 (BigQuery)
|
||||
난이도
|
||||
높음 (Python 등 코딩 필요)
|
||||
중간 (설정 위주)
|
||||
실시간성
|
||||
높음 (API 호출 즉시 반영 가능)
|
||||
보통 (스케줄링 주기에 따름)
|
||||
비용
|
||||
API 호출 비용 + GCS 비용
|
||||
도구 라이선스 비용 + BigQuery 비용
|
||||
추천 대상
|
||||
정교한 전처리가 필요한 경우
|
||||
빠른 구축과 유지보수 편의성 중시
|
||||
|
||||
제가 사용자를 위해 할 수 있는 다음 단계:
|
||||
현재 Notion 데이터의 양이 어느 정도인지, 혹은 개발자(엔지니어)가 직접 API 코딩이 가능한 상황인지 알려주시면, "Python을 사용한 Notion to Vertex AI 데이터 파이프라인 예제 코드" 구조를 잡아드릴까요?
|
||||
40
chatbot/implementation_plan.md
Normal file
40
chatbot/implementation_plan.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Implementation Plan - Advanced Vector RAG (Parallel Deployment)
|
||||
|
||||
We will build the "Advanced Vector RAG" system as a separate module to compare with the existing "Live Search RAG".
|
||||
|
||||
## User Review Required
|
||||
> [!NOTE]
|
||||
> **New Menu Item**: "운영자 vertex RAG 챗봇" will be added to the SAM menu.
|
||||
> **Initial Setup**: You must run `chatbot/rag/ingest.php` once to populate the vector database.
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### 1. New UI & Entry Point
|
||||
#### [MODIFY] [myheader.php](file:///c:/Project/5130/myheader.php)
|
||||
- Add new menu item: "운영자 vertex RAG 챗봇" linking to `chatbot/rag_index.php`.
|
||||
|
||||
#### [NEW] [chatbot/rag_index.php](file:///c:/Project/5130/chatbot/rag_index.php)
|
||||
- Copied from `index.php`.
|
||||
- Updated to call `rag_api.php` instead of `api.php`.
|
||||
- Updated title/branding to indicate "RAG Version".
|
||||
|
||||
### 2. Vector Backend
|
||||
#### [NEW] [chatbot/rag_api.php](file:///c:/Project/5130/chatbot/rag_api.php)
|
||||
- Copied from `api.php`.
|
||||
- logic: `NotionClient->search()` replaced with `VectorSearch->search()`.
|
||||
|
||||
### 3. Vector Engine (Core Logic)
|
||||
#### [NEW] [chatbot/rag/ingest.php](file:///c:/Project/5130/chatbot/rag/ingest.php)
|
||||
- EXTRACT: Fetch all Notion pages.
|
||||
- EMBED: Generate vectors using Gemini `text-embedding-004`.
|
||||
- STORE: Save to `chatbot/rag/data/vectors.json`.
|
||||
|
||||
#### [NEW] [chatbot/rag/search.php](file:///c:/Project/5130/chatbot/rag/search.php)
|
||||
- LOAD: Read `vectors.json`.
|
||||
- SEARCH: Calculate Cosine Similarity between Query Vector and Stored Vectors.
|
||||
- RETRIEVE: Return top 5 matches.
|
||||
|
||||
## Verification Plan
|
||||
1. **Menu Check**: Verify new menu item appears and leads to the new page.
|
||||
2. **Ingestion**: Run `ingest.php` (via browser or CLI) and check `vectors.json` size.
|
||||
3. **Comparison Test**: Ask the same question to both chatbots and compare answers.
|
||||
488
chatbot/index.php
Normal file
488
chatbot/index.php
Normal file
@@ -0,0 +1,488 @@
|
||||
<?php
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>운영자용 챗봇 - codebridge-x.com</title>
|
||||
|
||||
<!-- Fonts: Pretendard -->
|
||||
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.8/dist/web/static/pretendard.css" />
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Pretendard', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
background: 'rgb(250, 250, 250)',
|
||||
primary: {
|
||||
DEFAULT: '#2563eb', // blue-600
|
||||
foreground: '#ffffff',
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'blob': 'blob 7s infinite',
|
||||
'fade-in-up': 'fadeInUp 0.5s ease-out forwards',
|
||||
},
|
||||
keyframes: {
|
||||
blob: {
|
||||
'0%': { transform: 'translate(0px, 0px) scale(1)' },
|
||||
'33%': { transform: 'translate(30px, -50px) scale(1.1)' },
|
||||
'66%': { transform: 'translate(-20px, 20px) scale(0.9)' },
|
||||
'100%': { transform: 'translate(0px, 0px) scale(1)' },
|
||||
},
|
||||
fadeInUp: {
|
||||
'0%': { opacity: '0', transform: 'translateY(10px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- React & ReactDOM -->
|
||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
|
||||
|
||||
<!-- Babel for JSX -->
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
|
||||
<!-- Icons: Lucide React -->
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
</head>
|
||||
<body class="bg-background text-slate-800 antialiased overflow-hidden">
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect, useRef } = React;
|
||||
|
||||
// --- Types ---
|
||||
const Sender = {
|
||||
USER: 'user',
|
||||
BOT: 'bot',
|
||||
};
|
||||
|
||||
// --- Services ---
|
||||
const sendMessageToGemini = async (userMessage, history = []) => {
|
||||
try {
|
||||
const response = await fetch('api.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: userMessage,
|
||||
history: history
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.debug) {
|
||||
console.group("🤖 Chatbot Debug Info");
|
||||
console.log("Refined Query:", data.debug.refinedQuery); // New: Log refined query
|
||||
console.log("Context from Notion:", data.debug.context);
|
||||
console.log("System Instruction:", data.debug.systemInstruction);
|
||||
console.log("Raw Response from Gemini:", data.debug.rawResponse);
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
return data.reply || "죄송합니다. 응답을 받을 수 없습니다.";
|
||||
} catch (error) {
|
||||
console.error("API Error:", error);
|
||||
return "오류가 발생했습니다. 잠시 후 다시 시도해주세요.";
|
||||
}
|
||||
};
|
||||
|
||||
// --- Icons ---
|
||||
const ChatMultipleIcon = ({ className = "w-6 h-6" }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M14 9a2 2 0 0 1-2 2H6l-4 4V4c0-1.1.9-2 2-2h8a2 2 0 0 1 2 2v5Z"/>
|
||||
<path d="M18 9h2a2 2 0 0 1 2 2v11l-4-4h-6a2 2 0 0 1-2-2v-1"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CloseIcon = ({ className = "w-6 h-6" }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className={className}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SendIcon = ({ className = "w-6 h-6" }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||
<path d="M3.478 2.405a.75.75 0 00-.926.94l2.432 7.905H13.5a.75.75 0 010 1.5H4.984l-2.432 7.905a.75.75 0 00.926.94 60.519 60.519 0 0018.445-8.986.75.75 0 000-1.218A60.517 60.517 0 003.478 2.405z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// --- Header Component ---
|
||||
const Header = ({ onOpenHelp }) => {
|
||||
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
|
||||
const profileMenuRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (profileMenuRef.current && !profileMenuRef.current.contains(event.target)) {
|
||||
setIsProfileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isProfileMenuOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isProfileMenuOpen]);
|
||||
|
||||
return (
|
||||
<header className="bg-white border-b border-gray-100 sticky top-0 z-40">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-lg font-semibold text-slate-900">운영자용 챗봇</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<a href="../index.php" className="text-sm text-slate-500 hover:text-slate-900 flex items-center gap-1">
|
||||
<i data-lucide="home" className="w-4 h-4"></i>
|
||||
홈으로
|
||||
</a>
|
||||
<button onClick={onOpenHelp} className="text-sm text-slate-500 hover:text-slate-900 flex items-center gap-1">
|
||||
<i data-lucide="help-circle" className="w-4 h-4"></i>
|
||||
도움말
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Chat Components ---
|
||||
const BotAvatar = ({ size = 'md', className = '' }) => {
|
||||
const sizeClasses = {
|
||||
sm: 'w-8 h-8 text-xs',
|
||||
md: 'w-10 h-10 text-sm',
|
||||
lg: 'w-12 h-12 text-base',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${sizeClasses[size]} rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white font-bold shadow-sm ${className}`}>
|
||||
CB
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AvatarGroup = ({ size = 'md', borderColor = 'border-white' }) => {
|
||||
return (
|
||||
<div className="flex -space-x-3 relative z-10">
|
||||
<BotAvatar size={size} className={`border-2 ${borderColor}`} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ChatTooltip = ({ onClose, onClick }) => {
|
||||
return (
|
||||
<div className="relative mb-4 group cursor-pointer animate-fade-in-up" onClick={onClick}>
|
||||
<div className="absolute -top-5 left-1/2 transform -translate-x-1/2 flex justify-center w-full pointer-events-none">
|
||||
<AvatarGroup size="md" borderColor="border-white" />
|
||||
</div>
|
||||
|
||||
<div className="bg-white border-2 border-blue-600 rounded-[2rem] p-6 pt-8 pr-10 shadow-lg max-w-[320px] relative">
|
||||
<p className="text-gray-700 text-sm leading-relaxed font-medium">
|
||||
운영자 문서를 탐색합니다. 궁금한 것을 검색하세요! 😉
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="absolute -top-3 -right-2 bg-gray-600 hover:bg-gray-700 text-white rounded-full p-1 shadow-md transition-colors z-20"
|
||||
aria-label="Close tooltip"
|
||||
>
|
||||
<CloseIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ChatWindow = () => {
|
||||
const [messages, setMessages] = useState([
|
||||
{
|
||||
id: 'welcome',
|
||||
text: "운영자 문서를 탐색합니다. 궁금한 것을 검색하세요! 😉",
|
||||
sender: Sender.BOT,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
]);
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const messagesEndRef = useRef(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
const handleSendMessage = async (e) => {
|
||||
e?.preventDefault();
|
||||
if (!inputText.trim() || isLoading) return;
|
||||
|
||||
const userMsg = {
|
||||
id: Date.now().toString(),
|
||||
text: inputText,
|
||||
sender: Sender.USER,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setInputText('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Pass existing messages as history (limit to last 10 for context)
|
||||
const history = messages.slice(-10).map(msg => ({
|
||||
role: msg.sender === Sender.USER ? 'user' : 'model',
|
||||
parts: [{ text: msg.text }]
|
||||
}));
|
||||
|
||||
const responseText = await sendMessageToGemini(userMsg.text, history);
|
||||
const botMsg = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
text: responseText,
|
||||
sender: Sender.BOT,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages(prev => [...prev, botMsg]);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full bg-white rounded-lg overflow-hidden shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="bg-blue-600 p-4 pt-6 pb-6 text-white shrink-0 relative">
|
||||
<div className="flex items-center space-x-3">
|
||||
<AvatarGroup size="md" borderColor="border-blue-600" />
|
||||
<div>
|
||||
<h2 className="font-bold text-lg leading-tight"> codebridge-x.com</h2>
|
||||
<p className="text-xs text-blue-100 opacity-90 mt-0.5">
|
||||
일반적으로 몇 분 내에 답변을 드립니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message List */}
|
||||
<div className="flex-1 overflow-y-auto p-4 bg-gray-50 space-y-4 scrollbar-hide">
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex ${msg.sender === Sender.USER ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
{msg.sender === Sender.BOT && (
|
||||
<div className="mr-2 mt-1 flex-shrink-0">
|
||||
<BotAvatar size="sm" className="border border-gray-200" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[80%] rounded-2xl px-4 py-3 text-sm leading-relaxed shadow-sm whitespace-pre-wrap ${
|
||||
msg.sender === Sender.USER
|
||||
? 'bg-blue-600 text-white rounded-tr-none'
|
||||
: 'bg-white text-gray-800 border border-gray-100 rounded-tl-none'
|
||||
}`}
|
||||
>
|
||||
{(() => {
|
||||
const parseTextWithLinks = (text) => {
|
||||
const parts = [];
|
||||
let lastIndex = 0;
|
||||
const regex = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.substring(lastIndex, match.index));
|
||||
}
|
||||
parts.push(
|
||||
<a
|
||||
key={lastIndex}
|
||||
href={match[2]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`underline font-medium break-all ${
|
||||
msg.sender === Sender.USER
|
||||
? 'text-blue-200 hover:text-white'
|
||||
: 'text-blue-600 hover:text-blue-800'
|
||||
}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{match[1]}
|
||||
</a>
|
||||
);
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.substring(lastIndex));
|
||||
}
|
||||
return parts;
|
||||
};
|
||||
|
||||
return parseTextWithLinks(msg.text);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="flex justify-start animate-pulse">
|
||||
<div className="mr-2 mt-1 flex-shrink-0">
|
||||
<BotAvatar size="sm" className="border border-gray-200" />
|
||||
</div>
|
||||
<div className="bg-white text-gray-400 border border-gray-100 rounded-2xl rounded-tl-none px-4 py-3 text-sm shadow-sm">
|
||||
답변 작성 중...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="p-4 bg-white border-t border-gray-100 shrink-0">
|
||||
<form
|
||||
onSubmit={handleSendMessage}
|
||||
className="flex items-center w-full border border-gray-300 rounded-full px-4 py-2 focus-within:border-blue-600 focus-within:ring-1 focus-within:ring-blue-600 transition-all shadow-sm"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 outline-none text-sm text-gray-700 placeholder-gray-400 bg-transparent"
|
||||
placeholder="무엇이든 물어보세요..."
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!inputText.trim() || isLoading}
|
||||
className={`ml-2 p-1 ${!inputText.trim() ? 'text-gray-300' : 'text-blue-600 hover:text-blue-700'} transition-colors`}
|
||||
>
|
||||
<SendIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</form>
|
||||
<div className="text-center mt-2">
|
||||
<a href="https://codebridge-x.com" target="_blank" className="text-[10px] text-gray-400 hover:underline">Powered by codebridge-x.com</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ChatWidget = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isTooltipVisible, setIsTooltipVisible] = useState(true);
|
||||
|
||||
const toggleChat = () => {
|
||||
setIsOpen(!isOpen);
|
||||
if (!isOpen) {
|
||||
setIsTooltipVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeTooltip = () => {
|
||||
setIsTooltipVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end gap-3 font-sans">
|
||||
<div
|
||||
className={`
|
||||
transition-all duration-300 ease-in-out origin-bottom-right
|
||||
${isOpen ? 'scale-100 opacity-100' : 'scale-90 opacity-0 pointer-events-none absolute bottom-16 right-0'}
|
||||
w-[380px] h-[600px] max-w-[calc(100vw-48px)] max-h-[calc(100vh-120px)]
|
||||
`}
|
||||
>
|
||||
<ChatWindow />
|
||||
</div>
|
||||
|
||||
{!isOpen && isTooltipVisible && (
|
||||
<ChatTooltip onClose={closeTooltip} onClick={toggleChat} />
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={toggleChat}
|
||||
className={`
|
||||
w-16 h-16 rounded-full shadow-xl flex items-center justify-center transition-all duration-300
|
||||
${isOpen ? 'bg-blue-600 rotate-90' : 'bg-blue-600 hover:bg-blue-700 hover:scale-105'}
|
||||
`}
|
||||
aria-label={isOpen ? "Close chat" : "Open chat"}
|
||||
>
|
||||
{isOpen ? (
|
||||
<CloseIcon className="w-8 h-8 text-white" />
|
||||
) : (
|
||||
<ChatMultipleIcon className="w-8 h-8 text-white" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Main App ---
|
||||
const App = () => {
|
||||
useEffect(() => {
|
||||
lucide.createIcons();
|
||||
}, []);
|
||||
|
||||
const handleOpenHelp = () => {
|
||||
alert("도움말 기능은 준비중입니다.");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-screen relative bg-gray-50">
|
||||
<Header onOpenHelp={handleOpenHelp} />
|
||||
|
||||
{/* Background Content */}
|
||||
<div className="w-full h-full flex items-center justify-center p-20 min-h-[80vh]">
|
||||
<div className="text-center opacity-30 select-none">
|
||||
<h1 className="text-9xl font-bold text-gray-300 mb-8">codebridge-x.com</h1>
|
||||
<p className="text-4xl text-gray-400">Host Website Content</p>
|
||||
</div>
|
||||
<div className="absolute left-10 top-1/2 w-96 h-96 bg-blue-300 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob"></div>
|
||||
<div className="absolute right-10 top-1/3 w-96 h-96 bg-purple-300 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob animation-delay-2000"></div>
|
||||
</div>
|
||||
|
||||
{/* The Widget */}
|
||||
<ChatWidget />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
186
chatbot/md_rag/api.php
Normal file
186
chatbot/md_rag/api.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
// chatbot/md_rag/api.php
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
||||
// Error handling for API
|
||||
error_reporting(0);
|
||||
ini_set('display_errors', 0);
|
||||
ini_set('memory_limit', '512M');
|
||||
ob_start();
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
ob_clean();
|
||||
echo json_encode(['error' => 'Invalid request method']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$userMessage = $input['message'] ?? '';
|
||||
$history = $input['history'] ?? [];
|
||||
|
||||
if (empty($userMessage)) {
|
||||
ob_clean();
|
||||
echo json_encode(['error' => 'Empty message']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$projectRoot = dirname(__DIR__, 2);
|
||||
$googleApiKeyPath = $projectRoot . "/apikey/google_vertex_api.txt";
|
||||
$googleApiKey = trim(file_get_contents($googleApiKeyPath));
|
||||
|
||||
// Reuse VectorSearch class but override file path?
|
||||
// Or just instantiate it and inject path if possible?
|
||||
// The current VectorSearch class has hardcoded path.
|
||||
// We should modify VectorSearch to accept a path in constructor,
|
||||
// OR simpler: we just write a mini-search logic here or a subclass.
|
||||
// Let's use a modified Include logic or subclassing.
|
||||
// Actually, modifying `rag/search.php` to accept an optional $customFile path is the BEST architecture.
|
||||
// But to avoid touching working code during this task, I will clone the logic lightly here or simpler: define a new class in this file.
|
||||
|
||||
class MDSearch {
|
||||
private $vectors;
|
||||
|
||||
public function __construct() {
|
||||
$dataFile = __DIR__ . '/data/vectors.json';
|
||||
if (file_exists($dataFile)) {
|
||||
$this->vectors = json_decode(file_get_contents($dataFile), true);
|
||||
} else {
|
||||
$this->vectors = [];
|
||||
}
|
||||
}
|
||||
|
||||
private function cosineSimilarity($vecA, $vecB) {
|
||||
$dotProduct = 0;
|
||||
$normA = 0;
|
||||
$normB = 0;
|
||||
foreach ($vecA as $i => $val) {
|
||||
$dotProduct += $val * $vecB[$i];
|
||||
$normA += $val * $val;
|
||||
$normB += $val * $val;
|
||||
}
|
||||
return ($normA * $normB) == 0 ? 0 : $dotProduct / (sqrt($normA) * sqrt($normB));
|
||||
}
|
||||
|
||||
public function search($query, $apiKey, $topK = 3) {
|
||||
if (empty($this->vectors)) return [];
|
||||
|
||||
// Embed Query
|
||||
$url = "https://generativelanguage.googleapis.com/v1beta/models/text-embedding-004:embedContent?key=" . $apiKey;
|
||||
$data = ['model' => 'models/text-embedding-004', 'content' => ['parts' => [['text' => $query]]]];
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||
$response = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$result = json_decode($response, true);
|
||||
$queryVector = $result['embedding']['values'] ?? null;
|
||||
|
||||
if (!$queryVector) return [];
|
||||
|
||||
// Search
|
||||
$scores = [];
|
||||
foreach ($this->vectors as $doc) {
|
||||
$score = $this->cosineSimilarity($queryVector, $doc['vector']);
|
||||
$scores[] = [
|
||||
'id' => $doc['id'],
|
||||
'score' => $score,
|
||||
'text' => $doc['text'],
|
||||
'title' => $doc['title'],
|
||||
'url' => $doc['url']
|
||||
];
|
||||
}
|
||||
|
||||
usort($scores, function($a, $b) { return $b['score'] <=> $a['score']; });
|
||||
return array_slice($scores, 0, $topK);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Search
|
||||
$searcher = new MDSearch();
|
||||
$results = $searcher->search($userMessage, $googleApiKey, 5);
|
||||
|
||||
$context = "";
|
||||
if (empty($results)) {
|
||||
$context = "관련된 도움말 문서를 찾을 수 없습니다.";
|
||||
} else {
|
||||
// Simple Dedupe
|
||||
$processed = [];
|
||||
foreach ($results as $doc) {
|
||||
if ($doc['score'] < 0.5) continue; // Threshold
|
||||
$context .= "문서: {$doc['title']}\n내용:\n{$doc['text']}\n---\n";
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Generation
|
||||
$url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=" . $googleApiKey;
|
||||
$headers = ['Content-Type: application/json'];
|
||||
|
||||
// Debug Data Collection
|
||||
$debugInfo = [];
|
||||
$context = "";
|
||||
|
||||
if (empty($results)) {
|
||||
$context = "관련된 도움말 문서를 찾을 수 없습니다.";
|
||||
$debugInfo['message'] = "No results found from vector search.";
|
||||
} else {
|
||||
// Simple Dedupe
|
||||
$processed = [];
|
||||
$debugInfo['candidates'] = [];
|
||||
|
||||
foreach ($results as $doc) {
|
||||
// Log all candidates to debug
|
||||
$debugInfo['candidates'][] = [
|
||||
'title' => $doc['title'],
|
||||
'score' => round($doc['score'], 4),
|
||||
'text_preview' => mb_substr($doc['text'], 0, 100) . "..."
|
||||
];
|
||||
|
||||
// Threshold Check (Lowered to 0.4)
|
||||
if ($doc['score'] < 0.4) continue;
|
||||
|
||||
$context .= "문서: {$doc['title']}\n내용:\n{$doc['text']}\n---\n";
|
||||
}
|
||||
|
||||
if (empty($context)) {
|
||||
$context = "검색된 문서들의 유사도가 너무 낮습니다. (Threshold < 0.4)";
|
||||
}
|
||||
}
|
||||
|
||||
// Construct Prompt
|
||||
$systemInstruction = "You are a helpful assistant for 'Tenant Knowledge Base'.
|
||||
Answer the user's question using ONLY the provided [Context].
|
||||
If the answer is not in the context, say '죄송합니다. 제공된 도움말 문서에 해당 내용이 없습니다.'
|
||||
|
||||
[Context]
|
||||
$context";
|
||||
|
||||
$data = [
|
||||
'contents' => [['parts' => [['text' => $userMessage]]]],
|
||||
'systemInstruction' => ['parts' => [['text' => $systemInstruction]]]
|
||||
];
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
$response = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$responseData = json_decode($response, true);
|
||||
$reply = $responseData['candidates'][0]['content']['parts'][0]['text'] ?? "죄송합니다. 응답 생성 중 오류가 발생했습니다.";
|
||||
|
||||
ob_clean();
|
||||
echo json_encode(['reply' => $reply, 'debug' => $debugInfo]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
ob_clean();
|
||||
echo json_encode(['reply' => "System Error: " . $e->getMessage()]);
|
||||
}
|
||||
?>
|
||||
52
chatbot/md_rag/files/linux_roadmap.md
Normal file
52
chatbot/md_rag/files/linux_roadmap.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# 리눅스 명령어 학습 로드맵
|
||||
|
||||
## 1단계: 기본기 (이미 배우신 영역)
|
||||
- 파일 관리: `ls`, `cp`, `mv`, `rm`
|
||||
- 내용 확인: `cat`, `less`, `head`, `tail`
|
||||
- 검색/처리: `grep`, `awk`, `sed`
|
||||
- 👉 로그 분석, 간단 자동화 가능
|
||||
|
||||
---
|
||||
|
||||
## 2단계: 시스템/환경 다루기
|
||||
- 디렉토리/파일: `find`, `du`, `df`, `stat`
|
||||
- 권한/사용자: `chmod`, `chown`, `whoami`, `groups`
|
||||
- 프로세스 관리: `ps`, `top`, `htop`, `kill`
|
||||
- 네트워크: `ping`, `curl`, `wget`, `netstat`, `ss`
|
||||
- 아카이브/압축: `tar`, `zip`, `unzip`, `gzip`
|
||||
|
||||
---
|
||||
|
||||
## 3단계: 자동화와 스크립트
|
||||
- 셸 스크립트 작성: 조건문(`if`, `case`), 반복문(`for`, `while`), 함수
|
||||
- 입출력 리다이렉션: `>`, `>>`, `<`, `|`
|
||||
- cron 작업 예약(`crontab`)
|
||||
- 예시:
|
||||
```bash
|
||||
for f in *.log; do
|
||||
grep "ERROR" $f >> error_summary.txt
|
||||
done
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4단계: 개발자에게 중요한 영역
|
||||
- Git: 버전 관리
|
||||
- Docker: 컨테이너 환경
|
||||
- CI/CD: Jenkins, GitHub Actions
|
||||
- DB CLI: `mysql`, `psql`, `sqlite3`
|
||||
|
||||
---
|
||||
|
||||
## 5단계: 고수로 가는 길
|
||||
- 리눅스 내부 구조: 프로세스, 파일 시스템, systemd
|
||||
- 고급 도구: `strace`, `lsof`, `tcpdump`
|
||||
- 보안: SSH 키, 방화벽(`ufw`, `iptables`)
|
||||
- 고급 스크립트 언어: Python, Perl
|
||||
- 클라우드 환경: AWS CLI, `kubectl`
|
||||
|
||||
---
|
||||
|
||||
## ✅ 정리
|
||||
- `ls, cp, mv, rm, cat, grep, awk, sed` → 기초 체력
|
||||
- 그 위에 **시스템 관리, 자동화, 개발 툴, 보안/클라우드**까지 익히면 진짜 고수!
|
||||
231
chatbot/md_rag/index.php
Normal file
231
chatbot/md_rag/index.php
Normal file
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
// chatbot/md_rag/index.php
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>테넌트 지식관리 챗봇 - codebridge-x.com</title>
|
||||
|
||||
<!-- Fonts: Pretendard -->
|
||||
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.8/dist/web/static/pretendard.css" />
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Pretendard', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
background: 'rgb(250, 250, 250)',
|
||||
primary: {
|
||||
DEFAULT: '#059669', // emerald-600 (Green for Tenant)
|
||||
foreground: '#ffffff',
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'blob': 'blob 7s infinite',
|
||||
'fade-in-up': 'fadeInUp 0.5s ease-out forwards',
|
||||
},
|
||||
keyframes: {
|
||||
blob: {
|
||||
'0%': { transform: 'translate(0px, 0px) scale(1)' },
|
||||
'33%': { transform: 'translate(30px, -50px) scale(1.1)' },
|
||||
'66%': { transform: 'translate(-20px, 20px) scale(0.9)' },
|
||||
'100%': { transform: 'translate(0px, 0px) scale(1)' },
|
||||
},
|
||||
fadeInUp: {
|
||||
'0%': { opacity: '0', transform: 'translateY(10px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.scrollbar-hide::-webkit-scrollbar { display: none; }
|
||||
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
</style>
|
||||
|
||||
<!-- React & ReactDOM -->
|
||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
|
||||
<!-- Babel for JSX -->
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
<!-- Icons: Lucide React -->
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
</head>
|
||||
<body class="bg-background text-slate-800 antialiased overflow-hidden">
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect, useRef } = React;
|
||||
const Sender = { USER: 'user', BOT: 'bot' };
|
||||
|
||||
// Point to local api.php
|
||||
const sendMessageToGemini = async (userMessage, history = []) => {
|
||||
try {
|
||||
const response = await fetch('api.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: userMessage, history: history }),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Network response was not ok');
|
||||
const data = await response.json();
|
||||
|
||||
// Debug log (Hidden from UI but visible in console for admin)
|
||||
if (data.debug) console.log("Debug:", data.debug);
|
||||
|
||||
return data.reply || "죄송합니다. 응답을 받을 수 없습니다.";
|
||||
} catch (error) {
|
||||
console.error("API Error:", error);
|
||||
return "오류가 발생했습니다. 잠시 후 다시 시도해주세요.";
|
||||
}
|
||||
};
|
||||
|
||||
// Icons
|
||||
const ChatMultipleIcon = ({ className }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="M14 9a2 2 0 0 1-2 2H6l-4 4V4c0-1.1.9-2 2-2h8a2 2 0 0 1 2 2v5Z"/><path d="M18 9h2a2 2 0 0 1 2 2v11l-4-4h-6a2 2 0 0 1-2-2v-1"/></svg>
|
||||
);
|
||||
const CloseIcon = ({ className }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className={className}><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
);
|
||||
const SendIcon = ({ className }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className={className}><path d="M3.478 2.405a.75.75 0 00-.926.94l2.432 7.905H13.5a.75.75 0 010 1.5H4.984l-2.432 7.905a.75.75 0 00.926.94 60.519 60.519 0 0018.445-8.986.75.75 0 000-1.218A60.517 60.517 0 003.478 2.405z" /></svg>
|
||||
);
|
||||
|
||||
// Header
|
||||
const Header = () => (
|
||||
<header className="bg-white border-b border-gray-100 sticky top-0 z-40">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-lg font-semibold text-slate-900">테넌트 지식관리 챗봇</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<a href="/" className="text-sm text-slate-500 hover:text-slate-900 flex items-center gap-1">
|
||||
<i data-lucide="home" className="w-4 h-4"></i> 홈으로
|
||||
</a>
|
||||
<a href="upload.php" className="text-sm text-emerald-600 hover:text-emerald-800 flex items-center gap-1 font-medium">
|
||||
<i data-lucide="upload" className="w-4 h-4"></i> 지식 관리
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
// Chat Components
|
||||
const BotAvatar = ({ size = 'md', className = '' }) => {
|
||||
const sizeClasses = { sm: 'w-8 h-8 text-xs', md: 'w-10 h-10 text-sm' };
|
||||
return <div className={`${sizeClasses[size]} rounded-full bg-gradient-to-br from-emerald-500 to-teal-600 flex items-center justify-center text-white font-bold shadow-sm ${className}`}>Help</div>;
|
||||
};
|
||||
|
||||
const ChatWindow = () => {
|
||||
const [messages, setMessages] = useState([{
|
||||
id: 'welcome',
|
||||
text: "안녕하세요! 테넌트 서비스 이용 중 궁금한 점이 있으신가요? 📄\n업로드된 지식 문서를 바탕으로 답변해드립니다.",
|
||||
sender: Sender.BOT,
|
||||
timestamp: new Date(),
|
||||
}]);
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const messagesEndRef = useRef(null);
|
||||
|
||||
useEffect(() => messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }), [messages]);
|
||||
|
||||
const handleSendMessage = async (e) => {
|
||||
e?.preventDefault();
|
||||
if (!inputText.trim() || isLoading) return;
|
||||
|
||||
const userMsg = { id: Date.now().toString(), text: inputText, sender: Sender.USER };
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setInputText('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const history = messages.slice(-5).map(msg => ({
|
||||
role: msg.sender === Sender.USER ? 'user' : 'model',
|
||||
parts: [{ text: msg.text }]
|
||||
}));
|
||||
const responseText = await sendMessageToGemini(userMsg.text, history);
|
||||
setMessages(prev => [...prev, { id: 'bot-'+Date.now(), text: responseText, sender: Sender.BOT }]);
|
||||
} catch (error) { console.error(error); }
|
||||
finally { setIsLoading(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full bg-white rounded-lg overflow-hidden shadow-2xl font-sans">
|
||||
<div className="bg-emerald-600 p-4 pt-6 pb-6 text-white shrink-0 relative">
|
||||
<div className="flex items-center space-x-3">
|
||||
<BotAvatar size="md" className="border-2 border-emerald-400" />
|
||||
<div>
|
||||
<h2 className="font-bold text-lg leading-tight">Tenant Support Bot</h2>
|
||||
<p className="text-xs text-emerald-100 opacity-90 mt-0.5">지식 문서 기반 AI 상담원</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 bg-gray-50 space-y-4 scrollbar-hide">
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className={`flex ${msg.sender === Sender.USER ? 'justify-end' : 'justify-start'}`}>
|
||||
{msg.sender === Sender.BOT && <div className="mr-2 mt-1 flex-shrink-0"><BotAvatar size="sm" /></div>}
|
||||
<div className={`max-w-[80%] rounded-2xl px-4 py-3 text-sm leading-relaxed shadow-sm whitespace-pre-wrap ${
|
||||
msg.sender === Sender.USER ? 'bg-emerald-600 text-white rounded-tr-none' : 'bg-white text-gray-800 border border-gray-100 rounded-tl-none'
|
||||
}`}>{msg.text}</div>
|
||||
</div>
|
||||
))}
|
||||
{isLoading && <div className="flex justify-start animate-pulse"><div className="mr-2 mt-1"><BotAvatar size="sm" /></div><div className="bg-white text-gray-400 border border-gray-100 rounded-2xl px-4 py-3 text-sm shadow-sm">답변 찾는 중...</div></div>}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<div className="p-4 bg-white border-t border-gray-100 shrink-0">
|
||||
<form onSubmit={handleSendMessage} className="flex items-center w-full border border-gray-300 rounded-full px-4 py-2 focus-within:border-emerald-600 focus-within:ring-1 focus-within:ring-emerald-600 transition-all shadow-sm">
|
||||
<input type="text" className="flex-1 outline-none text-sm bg-transparent" placeholder="질문을 입력하세요..." value={inputText} onChange={(e) => setInputText(e.target.value)} />
|
||||
<button type="submit" disabled={!inputText.trim() || isLoading} className={`ml-2 p-1 ${!inputText.trim() ? 'text-gray-300' : 'text-emerald-600'}`}><SendIcon className="w-5 h-5" /></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ChatWidget = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end gap-3 font-sans">
|
||||
<div className={`transition-all duration-300 ease-in-out origin-bottom-right ${isOpen ? 'scale-100 opacity-100' : 'scale-90 opacity-0 pointer-events-none absolute bottom-16 right-0'} w-[380px] h-[600px] max-w-[calc(100vw-48px)] max-h-[calc(100vh-120px)]`}>
|
||||
<ChatWindow />
|
||||
</div>
|
||||
<button onClick={() => setIsOpen(!isOpen)} className={`w-16 h-16 rounded-full shadow-xl flex items-center justify-center transition-all duration-300 ${isOpen ? 'bg-emerald-600 rotate-90' : 'bg-emerald-600 hover:bg-emerald-700 hover:scale-105'}`}>
|
||||
{isOpen ? <CloseIcon className="w-8 h-8 text-white" /> : <ChatMultipleIcon className="w-8 h-8 text-white" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
useEffect(() => lucide.createIcons(), []);
|
||||
return (
|
||||
<div className="w-full min-h-screen relative bg-gray-50">
|
||||
<Header />
|
||||
<div className="w-full h-full flex items-center justify-center p-20 min-h-[80vh]">
|
||||
<div className="text-center opacity-30 select-none">
|
||||
<h1 className="text-6xl font-bold text-gray-300 mb-8">Tenant Knowledge Base</h1>
|
||||
<p className="text-2xl text-gray-400">MD File Based RAG System</p>
|
||||
</div>
|
||||
<div className="absolute left-10 top-1/2 w-96 h-96 bg-emerald-300 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob"></div>
|
||||
<div className="absolute right-10 top-1/3 w-96 h-96 bg-teal-300 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob animation-delay-2000"></div>
|
||||
</div>
|
||||
<ChatWidget />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
142
chatbot/md_rag/ingest.php
Normal file
142
chatbot/md_rag/ingest.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
// chatbot/md_rag/ingest.php
|
||||
|
||||
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . '/vendor/autoload.php'); // Load Google Client
|
||||
|
||||
ini_set('max_execution_time', 0); // No time limit
|
||||
ini_set('memory_limit', '512M');
|
||||
|
||||
$projectRoot = dirname(__DIR__, 2);
|
||||
$googleApiKey = trim(file_get_contents($projectRoot . "/apikey/google_vertex_api.txt"));
|
||||
|
||||
// --- GCS Configuration ---
|
||||
$credentialsPath = $projectRoot . "/apikey/google_service_account.json";
|
||||
$bucketName = 'codebridge-speech-audio-files';
|
||||
$folderPrefix = 'tenant_knowledge_base/';
|
||||
|
||||
// Initialize Google Client
|
||||
$client = new Google_Client();
|
||||
$client->setAuthConfig($credentialsPath);
|
||||
$client->addScope(Google_Service_Storage::CLOUD_PLATFORM);
|
||||
$storage = new Google_Service_Storage($client);
|
||||
|
||||
// Directories
|
||||
// $filesDir = __DIR__ . '/files'; // No longer used
|
||||
$dataDir = __DIR__ . '/data';
|
||||
if (!is_dir($dataDir)) mkdir($dataDir, 0777, true);
|
||||
|
||||
$vectorsFile = $dataDir . '/vectors.json';
|
||||
$progressFile = $dataDir . '/progress.json';
|
||||
|
||||
// Helper: Update Progress
|
||||
function updateProgress($file, $current, $total, $lastTitle, $startTime) {
|
||||
file_put_contents($file, json_encode([
|
||||
'current' => $current,
|
||||
'total' => $total,
|
||||
'last_title' => $lastTitle,
|
||||
'start_time' => $startTime
|
||||
]));
|
||||
}
|
||||
|
||||
// 1. Scan MD Files from GCS
|
||||
$mdFiles = [];
|
||||
try {
|
||||
$objects = $storage->objects->listObjects($bucketName, ['prefix' => $folderPrefix]);
|
||||
if ($objects->getItems()) {
|
||||
foreach ($objects->getItems() as $object) {
|
||||
$name = $object->getName();
|
||||
// Filter out the folder itself
|
||||
if ($name === $folderPrefix) continue;
|
||||
// Only process .md files
|
||||
if (substr($name, -3) !== '.md') continue;
|
||||
|
||||
$mdFiles[] = $name;
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
die("Error listing GCS files: " . $e->getMessage());
|
||||
}
|
||||
|
||||
$total = count($mdFiles);
|
||||
$startTime = time();
|
||||
|
||||
updateProgress($progressFile, 0, $total, "Initialization...", $startTime);
|
||||
|
||||
$vectors = [];
|
||||
$count = 0;
|
||||
|
||||
foreach ($mdFiles as $gcsObjectName) {
|
||||
$fileName = basename($gcsObjectName); // Display name
|
||||
$count++;
|
||||
updateProgress($progressFile, $count, $total, "Processing: $fileName", $startTime);
|
||||
|
||||
// Download Content from GCS
|
||||
try {
|
||||
// Use 'alt' => 'media' to download the actual file content
|
||||
// This returns the content string directly (or a Guzzle Response depending on client config,
|
||||
// but typically string in simple usage or via getBody() if it returns response.
|
||||
// However, standard Google_Service_Storage usage for media download:
|
||||
$content = $storage->objects->get($bucketName, $gcsObjectName, ['alt' => 'media']);
|
||||
|
||||
// If it returns a GuzzleHttp\Psr7\Response object (unlikely with default config but possible):
|
||||
if (is_object($content) && method_exists($content, 'getBody')) {
|
||||
$content = $content->getBody()->getContents();
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// Skip on error
|
||||
updateProgress($progressFile, $count, $total, "Error downloading $fileName: " . $e->getMessage(), $startTime);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Chunking Logic
|
||||
// Simple strategy: Split by H1/H2 headers (#, ##)
|
||||
// If no headers, treat whole file as one or split by length.
|
||||
$chunks = preg_split('/^(?=#{1,3}\s)/m', $content); // Split at #, ##, ### at start of line
|
||||
|
||||
foreach ($chunks as $chunkIndex => $chunkText) {
|
||||
$chunkText = trim($chunkText);
|
||||
if (mb_strlen($chunkText) < 10) continue; // Skip empty/tiny chunks
|
||||
|
||||
// Extract Title from Header if exists, else use Filename
|
||||
$lines = explode("\n", $chunkText);
|
||||
$sectionTitle = $fileName;
|
||||
if (preg_match('/^#{1,3}\s+(.*)$/', $lines[0], $matches)) {
|
||||
$sectionTitle = $matches[1] . " (" . $fileName . ")";
|
||||
}
|
||||
|
||||
// 3. Embed (Vertex AI)
|
||||
$url = "https://generativelanguage.googleapis.com/v1beta/models/text-embedding-004:embedContent?key=" . $googleApiKey;
|
||||
$data = ['model' => 'models/text-embedding-004', 'content' => ['parts' => [['text' => $chunkText]]]];
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode == 200) {
|
||||
$respData = json_decode($response, true);
|
||||
if (isset($respData['embedding']['values'])) {
|
||||
$vectors[] = [
|
||||
'id' => md5($fileName . $chunkIndex),
|
||||
'title' => $sectionTitle,
|
||||
'url' => $fileName, // Using filename as URL ID
|
||||
'text' => $chunkText,
|
||||
'vector' => $respData['embedding']['values']
|
||||
];
|
||||
}
|
||||
}
|
||||
usleep(100000); // 0.1s rate limit
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Save Vectors
|
||||
file_put_contents($vectorsFile, json_encode($vectors));
|
||||
updateProgress($progressFile, $total, $total, "Complete! (Local Saved)", $startTime);
|
||||
|
||||
echo "Processing Complete. " . count($vectors) . " vectors created.";
|
||||
?>
|
||||
330
chatbot/md_rag/upload.php
Normal file
330
chatbot/md_rag/upload.php
Normal file
@@ -0,0 +1,330 @@
|
||||
<?php
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/load_header.php");
|
||||
|
||||
// chatbot/md_rag/upload.php
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/myheader.php");
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . '/vendor/autoload.php'); // Load Google Client
|
||||
|
||||
// --- GCS Configuration ---
|
||||
$projectRoot = $_SERVER['DOCUMENT_ROOT']; // Adjust if needed
|
||||
$credentialsPath = $projectRoot . "/apikey/google_service_account.json";
|
||||
// Read Bucket Name from config or hardcode as per plan
|
||||
$bucketName = 'codebridge-speech-audio-files';
|
||||
$folderPrefix = 'tenant_knowledge_base/';
|
||||
|
||||
// Initialize Google Client
|
||||
$client = new Google_Client();
|
||||
$client->setAuthConfig($credentialsPath);
|
||||
$client->addScope(Google_Service_Storage::CLOUD_PLATFORM);
|
||||
$storage = new Google_Service_Storage($client);
|
||||
|
||||
$msg = "";
|
||||
|
||||
// Handle File Upload
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['md_file'])) {
|
||||
$file = $_FILES['md_file'];
|
||||
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||||
|
||||
if ($ext !== 'md') {
|
||||
$msg = "오직 .md 파일만 업로드 가능합니다.";
|
||||
} else {
|
||||
$gcsObjectName = $folderPrefix . basename($file['name']);
|
||||
|
||||
try {
|
||||
// Upload to GCS
|
||||
$fileContent = file_get_contents($file['tmp_name']);
|
||||
$postBody = new Google_Service_Storage_StorageObject();
|
||||
$postBody->setName($gcsObjectName);
|
||||
|
||||
$storage->objects->insert($bucketName, $postBody, [
|
||||
'data' => $fileContent,
|
||||
'mimeType' => 'text/markdown',
|
||||
'uploadType' => 'media'
|
||||
]);
|
||||
|
||||
$msg = "GCS 업로드 성공: " . htmlspecialchars($file['name']);
|
||||
} catch (Exception $e) {
|
||||
$msg = "GCS 업로드 실패: " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle File Delete
|
||||
if (isset($_GET['del'])) {
|
||||
$delFileName = $_GET['del']; // This should be the simple filename
|
||||
$gcsObjectName = $folderPrefix . basename($delFileName);
|
||||
|
||||
try {
|
||||
$storage->objects->delete($bucketName, $gcsObjectName);
|
||||
$msg = "GCS 파일 삭제 완료: " . htmlspecialchars($delFileName);
|
||||
} catch (Exception $e) {
|
||||
$msg = "GCS 삭제 실패: " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// List Files from GCS
|
||||
$files = [];
|
||||
try {
|
||||
$objects = $storage->objects->listObjects($bucketName, ['prefix' => $folderPrefix]);
|
||||
// The list includes the folder itself sometimes, and full paths
|
||||
if ($objects->getItems()) {
|
||||
foreach ($objects->getItems() as $object) {
|
||||
$name = $object->getName();
|
||||
// Filter out the folder itself "tenant_knowledge_base/"
|
||||
if ($name === $folderPrefix) continue;
|
||||
|
||||
// Extract pure filename
|
||||
$pureName = str_replace($folderPrefix, '', $name);
|
||||
if(empty($pureName)) continue;
|
||||
|
||||
$size = $object->getSize(); // bytes
|
||||
|
||||
$files[] = [
|
||||
'name' => $pureName,
|
||||
'size' => $size
|
||||
];
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$msg = "파일 목록 로드 실패: " . $e->getMessage();
|
||||
}
|
||||
?>
|
||||
|
||||
|
||||
<!-- Fonts: Pretendard -->
|
||||
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.8/dist/web/static/pretendard.css" />
|
||||
|
||||
<!-- Tailwind CSS (Prefixed to avoid Bootstrap conflict) -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
prefix: 'tw-',
|
||||
corePlugins: {
|
||||
preflight: false, // Disable base reset to protect Bootstrap navbar
|
||||
},
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Pretendard', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
primary: '#2563eb', // blue-600
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Local Scoped Styles for smoother integration */
|
||||
.tw-wrapper {
|
||||
font-family: 'Pretendard', sans-serif;
|
||||
background-color: #f8fafc; /* slate-50 */
|
||||
}
|
||||
.tw-card {
|
||||
background-color: white;
|
||||
border-radius: 1rem; /* rounded-2xl */
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); /* shadow-md */
|
||||
border: 1px solid #f1f5f9; /* border-slate-100 */
|
||||
}
|
||||
.tw-btn-primary {
|
||||
background-color: #2563eb;
|
||||
color: white;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.tw-btn-primary:hover {
|
||||
background-color: #1d4ed8;
|
||||
}
|
||||
/* Input file styling override */
|
||||
input[type=file]::file-selector-button {
|
||||
background-color: #f1f5f9;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
margin-right: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
transition: background .2s;
|
||||
}
|
||||
input[type=file]::file-selector-button:hover {
|
||||
background-color: #e2e8f0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="page-wrapper tw-wrapper tw-p-6">
|
||||
<div class="page-content">
|
||||
<!-- Breadcrumb (Styled to fit quietly) -->
|
||||
<div class="tw-mb-6 tw-flex tw-items-center tw-justify-between">
|
||||
<div>
|
||||
<h1 class="tw-text-2xl tw-font-bold tw-text-slate-800">챗봇 지식 관리 (GCS)</h1>
|
||||
<p class="tw-text-slate-500 tw-text-sm tw-mt-1">Tenant Knowledge Base (MD) - Google Storage</p>
|
||||
</div>
|
||||
<nav class="tw-text-sm tw-text-slate-400">
|
||||
<ol class="tw-flex tw-gap-2">
|
||||
<li><a href="/" class="hover:tw-text-blue-600"><i class="bx bx-home-alt"></i></a></li>
|
||||
<li>/</li>
|
||||
<li>챗봇 관리</li>
|
||||
<li>/</li>
|
||||
<li class="tw-text-slate-600 tw-font-medium">지식관리</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<?php if($msg): ?>
|
||||
<div class="tw-mb-6 tw-bg-blue-50 tw-border tw-border-blue-100 tw-text-blue-700 tw-px-4 tw-py-3 tw-rounded-xl tw-flex tw-items-center tw-justify-between">
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
<i class="bi bi-info-circle-fill"></i>
|
||||
<span><?= $msg ?></span>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="row tw-gap-y-6">
|
||||
<!-- 1. File Upload -->
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="tw-card tw-p-6 tw-h-full">
|
||||
<div class="tw-flex tw-items-center tw-gap-3 tw-mb-4">
|
||||
<div class="tw-w-10 tw-h-10 tw-rounded-full tw-bg-blue-100 tw-flex tw-items-center tw-justify-center tw-text-blue-600">
|
||||
<i class="bi bi-cloud-upload tw-text-xl"></i>
|
||||
</div>
|
||||
<h2 class="tw-text-lg tw-font-bold tw-text-slate-800 tw-m-0">파일 업로드</h2>
|
||||
</div>
|
||||
|
||||
<form method="post" enctype="multipart/form-data" class="tw-space-y-4">
|
||||
<div>
|
||||
<label for="md_file" class="tw-block tw-text-sm tw-font-medium tw-text-slate-700 tw-mb-2">Markdown 파일 (.md)</label>
|
||||
<input class="form-control tw-block tw-w-full tw-text-sm tw-text-slate-500
|
||||
file:tw-mr-4 file:tw-py-2 file:tw-px-4
|
||||
file:tw-rounded-full file:tw-border-0
|
||||
file:tw-text-sm file:tw-font-semibold
|
||||
file:tw-bg-blue-50 file:tw-text-blue-700
|
||||
hover:file:tw-bg-blue-100
|
||||
tw-border-gray-200 tw-rounded-xl tw-p-2"
|
||||
type="file" id="md_file" name="md_file" accept=".md" required>
|
||||
</div>
|
||||
|
||||
<div class="tw-bg-slate-50 tw-p-4 tw-rounded-xl tw-text-xs tw-text-slate-500 tw-space-y-1">
|
||||
<p class="tw-flex tw-gap-2"><i class="bi bi-check-circle tw-text-emerald-500"></i> Notion 등에서 Export한 Markdown(.md) 파일을 업로드하세요.</p>
|
||||
<p class="tw-flex tw-gap-2"><i class="bi bi-check-circle tw-text-emerald-500"></i> 파일명은 문서의 제목으로 사용됩니다.</p>
|
||||
<p class="tw-flex tw-gap-2"><i class="bi bi-google tw-text-blue-500"></i> Google Cloud Storage에 저장됩니다.</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="tw-w-full tw-btn-primary tw-py-3 tw-rounded-xl tw-font-semibold tw-flex tw-items-center tw-justify-center tw-gap-2 tw-shadow-blue-200 tw-shadow-lg transform active:tw-scale-[0.98] tw-transition-all">
|
||||
<i class="bi bi-upload"></i> GCS로 업로드 하기
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. File List -->
|
||||
<div class="col-12 col-lg-6">
|
||||
<!-- Ingest Status Card (Sticky Top of Column) -->
|
||||
<div class="tw-card tw-p-6 tw-mb-6 tw-bg-gradient-to-br tw-from-slate-800 tw-to-slate-900 tw-text-white tw-border-0">
|
||||
<div class="tw-flex tw-justify-between tw-items-start tw-mb-4">
|
||||
<div>
|
||||
<h2 class="tw-text-lg tw-font-bold tw-text-white tw-m-0">AI 학습 데이터 생성</h2>
|
||||
<p class="tw-text-slate-400 tw-text-sm tw-mt-1">GCS 파일을 분석하여 벡터 DB를 갱신합니다.</p>
|
||||
</div>
|
||||
<div class="tw-w-10 tw-h-10 tw-rounded-full tw-bg-white/10 tw-flex tw-items-center tw-justify-center tw-backdrop-blur-sm">
|
||||
<i class="bi bi-cpu tw-text-xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onclick="startIngestion()" class="tw-w-full tw-bg-white tw-text-slate-900 hover:tw-bg-blue-50 tw-py-3 tw-rounded-xl tw-font-bold tw-flex tw-items-center tw-justify-center tw-gap-2 tw-shadow-lg transform active:tw-scale-[0.98] tw-transition-all">
|
||||
<i class="bi bi-lightning-charge-fill tw-text-yellow-500"></i> 학습 시작 (Vectorize)
|
||||
</button>
|
||||
|
||||
<div id="ingestStatus" class="tw-mt-4" style="display:none;">
|
||||
<div class="progress tw-h-2 tw-bg-white/20 tw-rounded-full tw-overflow-hidden">
|
||||
<div id="progressBar" class="progress-bar tw-bg-blue-500"
|
||||
role="progressbar" style="width: 0%"></div>
|
||||
</div>
|
||||
<small id="statusText" class="tw-block tw-text-center tw-text-blue-300 tw-mt-2 tw-text-xs">대기중...</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File List Table -->
|
||||
<div class="tw-card tw-p-0 tw-overflow-hidden">
|
||||
<div class="tw-p-5 tw-border-b tw-border-slate-100 tw-flex tw-justify-between tw-items-center">
|
||||
<h5 class="tw-font-bold tw-text-slate-800 tw-m-0">GCS 파일 목록 <span class="tw-text-blue-600 tw-text-sm tw-font-normal tw-ml-1">(Total: <?= count($files) ?>)</span></h5>
|
||||
</div>
|
||||
<div class="tw-max-h-[400px] tw-overflow-y-auto custom-scrollbar">
|
||||
<table class="tw-w-full tw-text-sm tw-text-left">
|
||||
<thead class="tw-bg-slate-50 tw-text-slate-500 tw-uppercase tw-text-xs tw-font-semibold tw-sticky tw-top-0">
|
||||
<tr>
|
||||
<th class="tw-px-6 tw-py-3">파일명</th>
|
||||
<th class="tw-px-6 tw-py-3">크기</th>
|
||||
<th class="tw-px-6 tw-py-3 tw-text-right">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="tw-divide-y tw-divide-slate-100">
|
||||
<?php if(empty($files)): ?>
|
||||
<tr><td colspan="3" class="tw-px-6 tw-py-8 tw-text-center tw-text-slate-400">GCS 버킷에 파일이 없습니다.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach($files as $f):
|
||||
$fName = $f['name'];
|
||||
$size = round($f['size']/1024, 1) . ' KB';
|
||||
?>
|
||||
<tr class="hover:tw-bg-slate-50 tw-transition-colors">
|
||||
<td class="tw-px-6 tw-py-3 tw-font-medium tw-text-slate-700">
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
<i class="bi bi-file-text-fill tw-text-blue-400"></i>
|
||||
<?= htmlspecialchars($fName) ?>
|
||||
</div>
|
||||
</td>
|
||||
<td class="tw-px-6 tw-py-3 tw-text-slate-500"><?= $size ?></td>
|
||||
<td class="tw-px-6 tw-py-3 tw-text-right">
|
||||
<a href="?del=<?= urlencode($fName) ?>" onclick="return confirm('GCS에서 영구 삭제하시겠습니까?');"
|
||||
class="tw-text-red-400 hover:tw-text-red-600 tw-transition-colors tw-p-2 hover:tw-bg-red-50 tw-rounded-full" title="삭제"><i class="bi bi-trash"></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function startIngestion() {
|
||||
if(!confirm('GCS의 모든 파일을 분석하여 벡터 데이터를 갱신하시겠습니까?')) return;
|
||||
|
||||
document.getElementById('ingestStatus').style.display = 'block';
|
||||
const pBar = document.getElementById('progressBar');
|
||||
const pText = document.getElementById('statusText');
|
||||
|
||||
pBar.style.width = '10%';
|
||||
pText.innerText = '학습 스크립트 실행 중...';
|
||||
|
||||
// Call ingest.php via AJAX
|
||||
fetch('ingest.php')
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
pBar.style.width = '100%';
|
||||
// Tailwind class update
|
||||
pBar.classList.remove('bg-blue-500');
|
||||
pBar.classList.add('bg-green-500');
|
||||
pBar.style.backgroundColor = '#10b981';
|
||||
|
||||
pText.innerText = '완료! 결과: ' + data;
|
||||
pText.classList.remove('tw-text-blue-300');
|
||||
pText.classList.add('tw-text-green-300');
|
||||
|
||||
alert('학습이 완료되었습니다.');
|
||||
})
|
||||
.catch(err => {
|
||||
pBar.style.backgroundColor = '#ef4444'; // red-500
|
||||
pText.innerText = '오류 발생: ' + err;
|
||||
pText.classList.remove('tw-text-blue-300');
|
||||
pText.classList.add('tw-text-red-300');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php require_once($_SERVER['DOCUMENT_ROOT'] . "/footer.php"); ?>
|
||||
96
chatbot/notion_client.php
Normal file
96
chatbot/notion_client.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
class NotionClient {
|
||||
private $apiKey;
|
||||
private $version = '2025-09-03';
|
||||
private $baseUrl = 'https://api.notion.com/v1';
|
||||
|
||||
public function __construct($apiKey) {
|
||||
$this->apiKey = trim($apiKey);
|
||||
}
|
||||
|
||||
private function makeRequest($endpoint, $method = 'GET', $data = null) {
|
||||
$ch = curl_init();
|
||||
$url = $this->baseUrl . $endpoint;
|
||||
|
||||
$headers = [
|
||||
'Authorization: Bearer ' . $this->apiKey,
|
||||
'Notion-Version: ' . $this->version,
|
||||
'Content-Type: application/json'
|
||||
];
|
||||
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
|
||||
if ($method === 'POST') {
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
if ($data) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
}
|
||||
}
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
if (curl_errno($ch)) {
|
||||
throw new Exception("Curl Error: " . curl_error($ch));
|
||||
}
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode >= 400) {
|
||||
// For debugging purposes, you might want to log the response
|
||||
error_log("Notion API Error ($httpCode): $response");
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_decode($response, true);
|
||||
}
|
||||
|
||||
public function search($query) {
|
||||
$data = [
|
||||
'query' => $query,
|
||||
'page_size' => 3, // Limit to top 3 results
|
||||
'filter' => [
|
||||
'value' => 'page',
|
||||
'property' => 'object'
|
||||
],
|
||||
'sort' => [
|
||||
'direction' => 'descending',
|
||||
'timestamp' => 'last_edited_time'
|
||||
]
|
||||
];
|
||||
|
||||
return $this->makeRequest('/search', 'POST', $data);
|
||||
}
|
||||
|
||||
public function getPageContent($pageId) {
|
||||
$response = $this->makeRequest("/blocks/$pageId/children", 'GET');
|
||||
|
||||
if (!$response || !isset($response['results'])) {
|
||||
return "";
|
||||
}
|
||||
|
||||
$content = "";
|
||||
$maxLength = 1500; // Limit content length to avoid token limits
|
||||
|
||||
foreach ($response['results'] as $block) {
|
||||
if (strlen($content) >= $maxLength) {
|
||||
$content .= "...(내용 생략됨)...";
|
||||
break;
|
||||
}
|
||||
|
||||
$type = $block['type'];
|
||||
if (isset($block[$type]) && isset($block[$type]['rich_text'])) {
|
||||
foreach ($block[$type]['rich_text'] as $text) {
|
||||
$content .= $text['plain_text'];
|
||||
}
|
||||
$content .= "\n";
|
||||
}
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
}
|
||||
?>
|
||||
39
chatbot/rag/debug_search.php
Normal file
39
chatbot/rag/debug_search.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
// chatbot/rag/debug_search.php
|
||||
require_once __DIR__ . '/search.php';
|
||||
require_once dirname(__DIR__) . '/../apikey/google_vertex_api.txt'; // Path verify
|
||||
|
||||
$apiKeyPath = dirname(__DIR__, 2) . '/apikey/google_vertex_api.txt';
|
||||
$apiKey = trim(file_get_contents($apiKeyPath));
|
||||
|
||||
echo "=== RAG Debug Tool ===\n";
|
||||
echo "API Key Path: $apiKeyPath\n";
|
||||
echo "API Key Length: " . strlen($apiKey) . "\n";
|
||||
|
||||
$search = new VectorSearch();
|
||||
|
||||
// Reflect into the object to check vector count
|
||||
$reflection = new ReflectionClass($search);
|
||||
$property = $reflection->getProperty('vectors');
|
||||
$property->setAccessible(true);
|
||||
$vectors = $property->getValue($search);
|
||||
|
||||
echo "Loaded Vectors Count: " . count($vectors) . "\n";
|
||||
|
||||
if (empty($vectors)) {
|
||||
echo "CRITICAL ERROR: No vectors loaded. Check vectors.json format or path.\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
$query = "개발 dump";
|
||||
echo "Testing Query: '$query'\n";
|
||||
|
||||
// Run Search
|
||||
try {
|
||||
$results = $search->search($query, $apiKey, 3);
|
||||
echo "Search Result Count: " . count($results) . "\n";
|
||||
print_r($results);
|
||||
} catch (Exception $e) {
|
||||
echo "Search Error: " . $e->getMessage() . "\n";
|
||||
}
|
||||
?>
|
||||
132
chatbot/rag/gcs_helper.php
Normal file
132
chatbot/rag/gcs_helper.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
// chatbot/rag/gcs_helper.php
|
||||
|
||||
class GCSHelper {
|
||||
private $bucketName;
|
||||
private $serviceAccountPath;
|
||||
private $accessToken = null;
|
||||
|
||||
public function __construct() {
|
||||
// Load Bucket Name
|
||||
$configFile = dirname(__DIR__, 2) . '/apikey/gcs_config.txt';
|
||||
if (file_exists($configFile)) {
|
||||
$config = parse_ini_file($configFile);
|
||||
$this->bucketName = $config['bucket_name'] ?? null;
|
||||
}
|
||||
|
||||
// Load Service Account Path
|
||||
$this->serviceAccountPath = dirname(__DIR__, 2) . '/apikey/google_service_account.json';
|
||||
}
|
||||
|
||||
public function getBucketName() {
|
||||
return $this->bucketName;
|
||||
}
|
||||
|
||||
private function getAccessToken() {
|
||||
if ($this->accessToken) return $this->accessToken;
|
||||
|
||||
if (!file_exists($this->serviceAccountPath)) {
|
||||
throw new Exception("Service account file not found: " . $this->serviceAccountPath);
|
||||
}
|
||||
|
||||
$serviceAccount = json_decode(file_get_contents($this->serviceAccountPath), true);
|
||||
if (!$serviceAccount) {
|
||||
throw new Exception("Invalid service account JSON");
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$jwtHeader = base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
|
||||
$jwtClaim = base64_encode(json_encode([
|
||||
'iss' => $serviceAccount['client_email'],
|
||||
'scope' => 'https://www.googleapis.com/auth/devstorage.full_control',
|
||||
'aud' => 'https://oauth2.googleapis.com/token',
|
||||
'exp' => $now + 3600,
|
||||
'iat' => $now
|
||||
]));
|
||||
|
||||
$privateKey = openssl_pkey_get_private($serviceAccount['private_key']);
|
||||
if (!$privateKey) throw new Exception("Failed to load private key");
|
||||
|
||||
openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256);
|
||||
openssl_free_key($privateKey);
|
||||
|
||||
$jwt = $jwtHeader . '.' . $jwtClaim . '.' . base64_encode($signature);
|
||||
|
||||
$ch = curl_init('https://oauth2.googleapis.com/token');
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
|
||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
'assertion' => $jwt
|
||||
]));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
throw new Exception("Failed to get OAuth token: " . $response);
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
$this->accessToken = $data['access_token'];
|
||||
return $this->accessToken;
|
||||
}
|
||||
|
||||
public function upload($filePath, $objectName) {
|
||||
$token = $this->getAccessToken();
|
||||
|
||||
$fileContent = file_get_contents($filePath);
|
||||
$mimeType = 'application/json';
|
||||
|
||||
$url = 'https://storage.googleapis.com/upload/storage/v1/b/' .
|
||||
urlencode($this->bucketName) . '/o?uploadType=media&name=' .
|
||||
urlencode($objectName);
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Authorization: Bearer ' . $token,
|
||||
'Content-Type: ' . $mimeType,
|
||||
'Content-Length: ' . strlen($fileContent)
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $fileContent);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode == 200) {
|
||||
return true;
|
||||
} else {
|
||||
throw new Exception("GCS Upload Failed ($httpCode): " . $response);
|
||||
}
|
||||
}
|
||||
|
||||
public function download($objectName, $savePath) {
|
||||
$token = $this->getAccessToken();
|
||||
|
||||
$url = 'https://storage.googleapis.com/storage/v1/b/' .
|
||||
urlencode($this->bucketName) . '/o/' .
|
||||
urlencode($objectName) . '?alt=media';
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Authorization: Bearer ' . $token
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
|
||||
$content = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode == 200) {
|
||||
file_put_contents($savePath, $content);
|
||||
return true;
|
||||
} else {
|
||||
throw new Exception("GCS Download Failed ($httpCode)");
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
177
chatbot/rag/ingest.php
Normal file
177
chatbot/rag/ingest.php
Normal file
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
// chatbot/rag/ingest.php
|
||||
// CLI Environment Check
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
// If run from browser, detach process? For now, we assume user runs or we trigger via CLI.
|
||||
}
|
||||
|
||||
ini_set('max_execution_time', 0); // 무제한
|
||||
ini_set('memory_limit', '512M');
|
||||
|
||||
$projectRoot = dirname(__DIR__, 2);
|
||||
$notionApiKey = trim(file_get_contents($projectRoot . "/apikey/notion.txt"));
|
||||
$googleApiKey = trim(file_get_contents($projectRoot . "/apikey/google_vertex_api.txt"));
|
||||
|
||||
require_once dirname(__DIR__) . '/notion_client.php';
|
||||
|
||||
// Data Directories
|
||||
$dataDir = __DIR__ . '/data';
|
||||
if (!is_dir($dataDir)) mkdir($dataDir, 0777, true);
|
||||
|
||||
$vectorsFile = $dataDir . '/vectors.json';
|
||||
$progressFile = $dataDir . '/progress.json';
|
||||
|
||||
// Load existing vectors if any (for resume)
|
||||
$vectors = [];
|
||||
if (file_exists($vectorsFile)) {
|
||||
$vectors = json_decode(file_get_contents($vectorsFile), true);
|
||||
}
|
||||
$processedIds = array_map(function($v) { return explode('_', $v['id'])[0]; }, $vectors);
|
||||
$processedIds = array_unique($processedIds);
|
||||
|
||||
// Helper to update progress
|
||||
function updateProgress($file, $current, $total, $lastTitle, $startTime) {
|
||||
file_put_contents($file, json_encode([
|
||||
'current' => $current,
|
||||
'total' => $total,
|
||||
'last_title' => $lastTitle,
|
||||
'start_time' => $startTime
|
||||
]));
|
||||
}
|
||||
|
||||
// 1. Fetch Pages
|
||||
function fetchAllNotionPages($apiKey) {
|
||||
$pages = [];
|
||||
$hasMore = true;
|
||||
$nextCursor = null;
|
||||
while ($hasMore) {
|
||||
$url = "https://api.notion.com/v1/search";
|
||||
$data = [
|
||||
'filter' => ['value' => 'page', 'property' => 'object'],
|
||||
'sort' => ['direction' => 'descending', 'timestamp' => 'last_edited_time'],
|
||||
'page_size' => 100
|
||||
];
|
||||
if ($nextCursor) $data['start_cursor'] = $nextCursor;
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
'Notion-Version: 2022-06-28',
|
||||
'Content-Type: application/json'
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$result = json_decode($response, true);
|
||||
if (isset($result['results'])) $pages = array_merge($pages, $result['results']);
|
||||
|
||||
$hasMore = $result['has_more'] ?? false;
|
||||
$nextCursor = $result['next_cursor'] ?? null;
|
||||
|
||||
// Rate limit guard
|
||||
usleep(500000);
|
||||
}
|
||||
return $pages;
|
||||
}
|
||||
|
||||
// Start
|
||||
$startTime = time();
|
||||
updateProgress($progressFile, 0, 0, "Fetching Page List...", $startTime);
|
||||
|
||||
$notionpages = fetchAllNotionPages($notionApiKey);
|
||||
$total = count($notionpages);
|
||||
updateProgress($progressFile, 0, $total, "Starting Processing...", $startTime);
|
||||
|
||||
$notionClient = new NotionClient($notionApiKey);
|
||||
$count = 0;
|
||||
|
||||
foreach ($notionpages as $index => $page) {
|
||||
$pageId = $page['id'];
|
||||
|
||||
// Resume Logic: Skip if already processed
|
||||
// if (in_array($pageId, $processedIds)) {
|
||||
// $count++;
|
||||
// continue;
|
||||
// }
|
||||
// (Simpler: just overwrite or append? For now, let's process all to ensure freshness,
|
||||
// unless we strictly want to resume. Given the timeout previously, maybe safest to re-process but save often.)
|
||||
|
||||
// Title
|
||||
$title = "Untitled";
|
||||
if (isset($page['properties']['Name']['title'][0]['plain_text'])) {
|
||||
$title = $page['properties']['Name']['title'][0]['plain_text'];
|
||||
} elseif (isset($page['properties']['title']['title'][0]['plain_text'])) {
|
||||
$title = $page['properties']['title']['title'][0]['plain_text'];
|
||||
}
|
||||
|
||||
// Update Progress
|
||||
$count++;
|
||||
updateProgress($progressFile, $count, $total, $title, $startTime);
|
||||
|
||||
// Content
|
||||
$content = $notionClient->getPageContent($pageId);
|
||||
$fullText = "Title: $title\n\n$content";
|
||||
|
||||
// Chunking
|
||||
$chunks = function_exists('mb_str_split') ? mb_str_split($fullText, 500) : str_split($fullText, 500);
|
||||
|
||||
foreach ($chunks as $chunkIndex => $chunkText) {
|
||||
if (mb_strlen(trim($chunkText)) < 10) continue;
|
||||
|
||||
// Embed
|
||||
$url = "https://generativelanguage.googleapis.com/v1beta/models/text-embedding-004:embedContent?key=" . $googleApiKey;
|
||||
$data = ['model' => 'models/text-embedding-004', 'content' => ['parts' => [['text' => $chunkText]]]];
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode == 200) {
|
||||
$respData = json_decode($response, true);
|
||||
if (isset($respData['embedding']['values'])) {
|
||||
$vectors[] = [
|
||||
'id' => $pageId . "_" . $chunkIndex,
|
||||
'title' => $title,
|
||||
'url' => $page['url'] ?? '',
|
||||
'text' => $chunkText,
|
||||
'vector' => $respData['embedding']['values']
|
||||
];
|
||||
}
|
||||
}
|
||||
usleep(100000); // 0.1s delay
|
||||
}
|
||||
|
||||
// Save periodically (every 10 pages) to prevent total loss
|
||||
if ($count % 10 == 0) {
|
||||
file_put_contents($vectorsFile, json_encode($vectors));
|
||||
}
|
||||
}
|
||||
|
||||
// Final Save
|
||||
file_put_contents($vectorsFile, json_encode($vectors));
|
||||
updateProgress($progressFile, $total, $total, "Uploading to Google Cloud Storage...", $startTime);
|
||||
|
||||
// GCS Upload
|
||||
require_once 'gcs_helper.php';
|
||||
try {
|
||||
$gcs = new GCSHelper();
|
||||
if ($gcs->getBucketName()) {
|
||||
$gcs->upload($vectorsFile, 'chatbot/vectors.json');
|
||||
updateProgress($progressFile, $total, $total, "Complete! (Saved to GCS)", $startTime);
|
||||
echo "Successfully uploaded to GCS: " . $gcs->getBucketName() . "/chatbot/vectors.json";
|
||||
} else {
|
||||
updateProgress($progressFile, $total, $total, "Complete! (Local Only - No Bucket Config)", $startTime);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo "GCS Upload Error: " . $e->getMessage();
|
||||
updateProgress($progressFile, $total, $total, "Complete (Local Saved, GCS Error)", $startTime);
|
||||
}
|
||||
?>
|
||||
16
chatbot/rag/run_update.bat
Normal file
16
chatbot/rag/run_update.bat
Normal file
@@ -0,0 +1,16 @@
|
||||
@echo off
|
||||
:: RAG 데이터 자동 갱신 스크립트
|
||||
:: Windows 작업 스케줄러(Task Scheduler)에 등록하여 사용하세요.
|
||||
|
||||
:: 1. 프로젝트 폴더로 이동 (경로가 다르면 수정 필요)
|
||||
cd /d C:\Project\5130
|
||||
|
||||
:: 2. 날짜 기록
|
||||
echo [DATE: %date% %time%] Starting Auto-Ingestion... >> chatbot\rag\data\auto_log.txt
|
||||
|
||||
:: 3. PHP 스크립트 실행 (PHP 경로가 다르면 수정 필요)
|
||||
:: ingest.php는 GCS 업로드까지 자동으로 수행합니다.
|
||||
C:\xampp\php\php.exe chatbot\rag\ingest.php >> chatbot\rag\data\auto_log.txt 2>&1
|
||||
|
||||
echo [DATE: %date% %time%] Finished. >> chatbot\rag\data\auto_log.txt
|
||||
exit
|
||||
118
chatbot/rag/search.php
Normal file
118
chatbot/rag/search.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
// chatbot/rag/search.php
|
||||
class VectorSearch {
|
||||
private $vectors;
|
||||
private $dataFile;
|
||||
private $lastError = null;
|
||||
|
||||
public function __construct() {
|
||||
$this->dataFile = __DIR__ . '/data/vectors.json';
|
||||
|
||||
// GCS Sync (If local file missing)
|
||||
if (!file_exists($this->dataFile)) {
|
||||
require_once 'gcs_helper.php';
|
||||
try {
|
||||
$gcs = new GCSHelper();
|
||||
if ($gcs->getBucketName()) {
|
||||
$gcs->download('chatbot/vectors.json', $this->dataFile);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// Ignore download error, start with empty
|
||||
$this->lastError = "GCS Download Failed: " . $e->getMessage();
|
||||
error_log($this->lastError);
|
||||
}
|
||||
}
|
||||
|
||||
if (file_exists($this->dataFile)) {
|
||||
// Increase memory limit for large JSON
|
||||
ini_set('memory_limit', '512M');
|
||||
$content = file_get_contents($this->dataFile);
|
||||
$this->vectors = json_decode($content, true);
|
||||
|
||||
if ($this->vectors === null) {
|
||||
$this->lastError = "JSON Decode Error: " . json_last_error_msg();
|
||||
error_log("RAG Error: " . $this->lastError);
|
||||
$this->vectors = [];
|
||||
} else {
|
||||
// Success
|
||||
}
|
||||
} else {
|
||||
$this->lastError = "File Not Found: " . $this->dataFile;
|
||||
error_log("RAG Error: " . $this->lastError);
|
||||
$this->vectors = [];
|
||||
}
|
||||
}
|
||||
|
||||
public function getLastError() {
|
||||
return $this->lastError;
|
||||
}
|
||||
|
||||
// 코사인 유사도 계산
|
||||
private function cosineSimilarity($vecA, $vecB) {
|
||||
$dotProduct = 0;
|
||||
$normA = 0;
|
||||
$normB = 0;
|
||||
|
||||
// 벡터 크기가 다르면 0 반환 (예외처리)
|
||||
if (count($vecA) !== count($vecB)) return 0;
|
||||
|
||||
for ($i = 0; $i < count($vecA); $i++) {
|
||||
$dotProduct += $vecA[$i] * $vecB[$i];
|
||||
$normA += $vecA[$i] * $vecA[$i];
|
||||
$normB += $vecB[$i] * $vecB[$i];
|
||||
}
|
||||
|
||||
if ($normA == 0 || $normB == 0) return 0;
|
||||
|
||||
return $dotProduct / (sqrt($normA) * sqrt($normB));
|
||||
}
|
||||
|
||||
public function getVectorCount() {
|
||||
return count($this->vectors);
|
||||
}
|
||||
|
||||
public function search($query, $apiKey, $limit = 5) {
|
||||
// 1. 쿼리 임베딩
|
||||
$url = "https://generativelanguage.googleapis.com/v1beta/models/text-embedding-004:embedContent?key=" . $apiKey;
|
||||
$data = [
|
||||
'model' => 'models/text-embedding-004',
|
||||
'content' => ['parts' => [['text' => $query]]]
|
||||
];
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||
$response = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$responseData = json_decode($response, true);
|
||||
|
||||
if (!isset($responseData['embedding']['values'])) {
|
||||
error_log("RAG embedding failed: " . json_encode($responseData));
|
||||
return [];
|
||||
}
|
||||
|
||||
$queryVector = $responseData['embedding']['values'];
|
||||
|
||||
// 2. 유사도 계산
|
||||
$scored = [];
|
||||
foreach ($this->vectors as $doc) {
|
||||
$score = $this->cosineSimilarity($queryVector, $doc['vector']);
|
||||
$doc['score'] = $score;
|
||||
// 벡터 데이터는 결과에서 제외 (용량 절약)
|
||||
unset($doc['vector']);
|
||||
$scored[] = $doc;
|
||||
}
|
||||
|
||||
// 3. 정렬 (유사도 내림차순)
|
||||
usort($scored, function($a, $b) {
|
||||
return $b['score'] <=> $a['score'];
|
||||
});
|
||||
|
||||
// 4. 상위 N개 반환
|
||||
return array_slice($scored, 0, $limit);
|
||||
}
|
||||
}
|
||||
?>
|
||||
80
chatbot/rag/status.php
Normal file
80
chatbot/rag/status.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RAG Ingestion Status</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<meta http-equiv="refresh" content="3"> <!-- 3초마다 새로고침 -->
|
||||
</head>
|
||||
<body class="bg-gray-100 p-10">
|
||||
<div class="max-w-2xl mx-auto bg-white rounded-xl shadow-lg p-8">
|
||||
<h1 class="text-2xl font-bold mb-6 text-gray-800">Vector Search 데이터 구축 현황</h1>
|
||||
|
||||
<?php
|
||||
$statusFile = __DIR__ . '/data/progress.json';
|
||||
if (file_exists($statusFile)) {
|
||||
$status = json_decode(file_get_contents($statusFile), true);
|
||||
$current = $status['current'] ?? 0;
|
||||
$total = $status['total'] ?? 100;
|
||||
$percent = $total > 0 ? round(($current / $total) * 100) : 0;
|
||||
$lastTitle = $status['last_title'] ?? 'Initializing...';
|
||||
$startTime = $status['start_time'] ?? time();
|
||||
$elapsed = time() - $startTime;
|
||||
|
||||
// 예상 남은 시간
|
||||
$remaining = "Calculating...";
|
||||
if ($current > 0) {
|
||||
$rate = $elapsed / $current; // 초/개
|
||||
$remSecs = ($total - $current) * $rate;
|
||||
$remaining = round($remSecs / 60) . "분";
|
||||
}
|
||||
} else {
|
||||
$current = 0;
|
||||
$total = 0;
|
||||
$percent = 0;
|
||||
$lastTitle = "작업 대기 중...";
|
||||
$remaining = "-";
|
||||
}
|
||||
?>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between mb-1">
|
||||
<span class="text-sm font-medium text-blue-700">진행률 (<?=$current?> / <?=$total?> Pages)</span>
|
||||
<span class="text-sm font-medium text-blue-700"><?=$percent?>%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-4">
|
||||
<div class="bg-blue-600 h-4 rounded-full transition-all duration-500" style="width: <?=$percent?>%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<p class="text-sm text-gray-500">현재 작업 중인 문서</p>
|
||||
<p class="font-semibold text-gray-800 truncate"><?=$lastTitle?></p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="p-4 bg-gray-50 rounded-lg border border-gray-200 text-center">
|
||||
<p class="text-sm text-gray-500">경과 시간</p>
|
||||
<p class="font-mono text-xl"><?=gmdate("i:s", $elapsed ?? 0)?></p>
|
||||
</div>
|
||||
<div class="p-4 bg-gray-50 rounded-lg border border-gray-200 text-center">
|
||||
<p class="text-sm text-gray-500">예상 남은 시간</p>
|
||||
<p class="font-mono text-xl"><?=$remaining?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 text-center">
|
||||
<?php if ($percent >= 100): ?>
|
||||
<a href="../rag_index.php" class="inline-block px-6 py-3 bg-green-600 text-white rounded-lg font-bold hover:bg-green-700 transition">
|
||||
데이터 구축 완료! 챗봇 시작하기
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<p class="text-gray-400 text-sm">작업이 완료되면 버튼이 나타납니다.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
161
chatbot/rag_api.php
Normal file
161
chatbot/rag_api.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
// chatbot/rag_api.php
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
||||
// JSON 응답을 위해 에러 출력 방지
|
||||
error_reporting(0);
|
||||
ini_set('display_errors', 0);
|
||||
// Increase memory limit for API context
|
||||
ini_set('memory_limit', '512M');
|
||||
ob_start();
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
ob_clean();
|
||||
echo json_encode(['error' => 'Invalid request method']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$userMessage = $input['message'] ?? '';
|
||||
$history = $input['history'] ?? [];
|
||||
|
||||
if (empty($userMessage)) {
|
||||
ob_clean();
|
||||
echo json_encode(['error' => 'Empty message']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$googleApiKeyPath = $_SERVER['DOCUMENT_ROOT'] . "/apikey/google_vertex_api.txt";
|
||||
if (!file_exists($googleApiKeyPath)) {
|
||||
ob_clean();
|
||||
echo json_encode(['reply' => "API Key not found."]);
|
||||
exit;
|
||||
}
|
||||
$googleApiKey = trim(file_get_contents($googleApiKeyPath));
|
||||
|
||||
require_once __DIR__ . '/rag/search.php';
|
||||
|
||||
try {
|
||||
// 1. Vector Search (Semantic Search)
|
||||
// 기존 NotionClient->search() 대신 VectorSearch 사용
|
||||
$vectorSearch = new VectorSearch();
|
||||
$results = $vectorSearch->search($userMessage, $googleApiKey, 5); // 상위 5개
|
||||
|
||||
$context = "";
|
||||
if (empty($results)) {
|
||||
$context = "관련된 내부 문서를 찾을 수 없습니다. (벡터 데이터가 없거나 매칭 실패)";
|
||||
} else {
|
||||
// Deduplicate documents (Group text by URL)
|
||||
$processedDocs = [];
|
||||
foreach ($results as $doc) {
|
||||
$url = $doc['url'];
|
||||
if (!isset($processedDocs[$url])) {
|
||||
$processedDocs[$url] = [
|
||||
'title' => $doc['title'],
|
||||
'score' => $doc['score'],
|
||||
'text' => $doc['text']
|
||||
];
|
||||
} else {
|
||||
// Determine if this text chunk is already in the compiled text to avoid exact duplication
|
||||
// (Simple check, can be improved)
|
||||
if (strpos($processedDocs[$url]['text'], $doc['text']) === false) {
|
||||
$processedDocs[$url]['text'] .= "\n[...추가 내용...]\n" . $doc['text'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($processedDocs as $url => $doc) {
|
||||
$score = round($doc['score'] * 100, 1);
|
||||
$context .= "문서 제목: [{$doc['title']}] (유사도: {$score}%)\nURL: {$url}\n내용:\n{$doc['text']}\n---\n";
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Gemini Answer Generation
|
||||
$url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=" . $googleApiKey;
|
||||
$headers = ['Content-Type: application/json'];
|
||||
|
||||
$historyText = "";
|
||||
if (!empty($history)) {
|
||||
foreach ($history as $msg) {
|
||||
$role = $msg['role'] === 'user' ? "User" : "Assistant";
|
||||
$text = is_array($msg['parts']) ? $msg['parts'][0]['text'] : $msg['parts'];
|
||||
$historyText .= "$role: $text\n";
|
||||
}
|
||||
}
|
||||
|
||||
$systemInstruction = "You are a helpful customer support agent for 'codebridge-x.com'.
|
||||
Use the provided [Context] (retrieved via Semantic Vector Search) to answer the user's question.
|
||||
If you cannot find the answer in the context, say so.
|
||||
|
||||
[Conversation History]
|
||||
$historyText
|
||||
|
||||
IMPORTANT: List the used documents at the bottom.
|
||||
|
||||
Format:
|
||||
[Answer]
|
||||
|
||||
관련 문서 (Vector Search Result):
|
||||
- [Title](URL)
|
||||
|
||||
[Context]
|
||||
$context";
|
||||
$data = [
|
||||
'contents' => [['parts' => [['text' => $userMessage]]]],
|
||||
'systemInstruction' => ['parts' => [['text' => $systemInstruction]]]
|
||||
];
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
$response = curl_exec($ch);
|
||||
|
||||
if (curl_errno($ch)) throw new Exception(curl_error($ch));
|
||||
curl_close($ch);
|
||||
|
||||
$responseData = json_decode($response, true);
|
||||
|
||||
// Check for standard text response
|
||||
$reply = $responseData['candidates'][0]['content']['parts'][0]['text'] ?? null;
|
||||
|
||||
// If empty, check for finishReason
|
||||
if (!$reply) {
|
||||
$finishReason = $responseData['candidates'][0]['finishReason'] ?? 'UNKNOWN';
|
||||
if ($finishReason === 'SAFETY') {
|
||||
$reply = "죄송합니다. 해당 질문에 대한 답변은 안전 정책상 제공해드릴 수 없습니다.";
|
||||
} else {
|
||||
$reply = "답변 생성 실패 (Finish Reason: $finishReason). Raw Response를 확인해주세요.";
|
||||
}
|
||||
}
|
||||
|
||||
// Debug File Status
|
||||
$vectorFile = __DIR__ . '/rag/data/vectors.json';
|
||||
$fileStatus = [
|
||||
'path' => $vectorFile,
|
||||
'exists' => file_exists($vectorFile),
|
||||
'size' => file_exists($vectorFile) ? filesize($vectorFile) : -1,
|
||||
'memory_limit' => ini_get('memory_limit')
|
||||
];
|
||||
|
||||
ob_clean();
|
||||
echo json_encode([
|
||||
'reply' => $reply,
|
||||
'debug' => [
|
||||
'refinedQuery' => $userMessage,
|
||||
'vectorCount' => $vectorSearch->getVectorCount(),
|
||||
'loadError' => $vectorSearch->getLastError(),
|
||||
'fileStatus' => $fileStatus,
|
||||
'context' => $context,
|
||||
'systemInstruction' => $systemInstruction,
|
||||
'rawResponse' => $responseData
|
||||
]
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
ob_clean();
|
||||
echo json_encode(['reply' => "Error: " . $e->getMessage()]);
|
||||
}
|
||||
?>
|
||||
472
chatbot/rag_index.php
Normal file
472
chatbot/rag_index.php
Normal file
@@ -0,0 +1,472 @@
|
||||
<?php
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>운영자 Vertex RAG 챗봇 - codebridge-x.com</title>
|
||||
|
||||
<!-- Fonts: Pretendard -->
|
||||
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.8/dist/web/static/pretendard.css" />
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Pretendard', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
background: 'rgb(250, 250, 250)',
|
||||
primary: {
|
||||
DEFAULT: '#2563eb', // blue-600
|
||||
foreground: '#ffffff',
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'blob': 'blob 7s infinite',
|
||||
'fade-in-up': 'fadeInUp 0.5s ease-out forwards',
|
||||
},
|
||||
keyframes: {
|
||||
blob: {
|
||||
'0%': { transform: 'translate(0px, 0px) scale(1)' },
|
||||
'33%': { transform: 'translate(30px, -50px) scale(1.1)' },
|
||||
'66%': { transform: 'translate(-20px, 20px) scale(0.9)' },
|
||||
'100%': { transform: 'translate(0px, 0px) scale(1)' },
|
||||
},
|
||||
fadeInUp: {
|
||||
'0%': { opacity: '0', transform: 'translateY(10px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- React & ReactDOM -->
|
||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
|
||||
|
||||
<!-- Babel for JSX -->
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
|
||||
<!-- Icons: Lucide React -->
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
</head>
|
||||
<body class="bg-background text-slate-800 antialiased overflow-hidden">
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect, useRef } = React;
|
||||
|
||||
// --- Types ---
|
||||
const Sender = {
|
||||
USER: 'user',
|
||||
BOT: 'bot',
|
||||
};
|
||||
|
||||
// --- Services ---
|
||||
const sendMessageToGemini = async (userMessage, history = []) => {
|
||||
try {
|
||||
// Pointing to the new RAG API
|
||||
const response = await fetch('rag_api.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: userMessage,
|
||||
history: history
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.debug) {
|
||||
console.group("🤖 RAG Chatbot Debug Info");
|
||||
console.log("Refined Query:", data.debug.refinedQuery);
|
||||
console.log("Vector Count:", data.debug.vectorCount);
|
||||
if (data.debug.loadError) {
|
||||
console.error("⚠️ VECTOR LOAD ERROR:", data.debug.loadError);
|
||||
}
|
||||
console.log("File Status:", data.debug.fileStatus);
|
||||
console.log("Vector Context:", data.debug.context);
|
||||
console.log("System Instruction:", data.debug.systemInstruction);
|
||||
console.log("Raw Response:", data.debug.rawResponse);
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
return data.reply || "죄송합니다. 응답을 받을 수 없습니다.";
|
||||
} catch (error) {
|
||||
console.error("API Error:", error);
|
||||
return "오류가 발생했습니다. 잠시 후 다시 시도해주세요.";
|
||||
}
|
||||
};
|
||||
|
||||
// --- Icons ---
|
||||
const ChatMultipleIcon = ({ className = "w-6 h-6" }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M14 9a2 2 0 0 1-2 2H6l-4 4V4c0-1.1.9-2 2-2h8a2 2 0 0 1 2 2v5Z"/>
|
||||
<path d="M18 9h2a2 2 0 0 1 2 2v11l-4-4h-6a2 2 0 0 1-2-2v-1"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CloseIcon = ({ className = "w-6 h-6" }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className={className}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SendIcon = ({ className = "w-6 h-6" }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||
<path d="M3.478 2.405a.75.75 0 00-.926.94l2.432 7.905H13.5a.75.75 0 010 1.5H4.984l-2.432 7.905a.75.75 0 00.926.94 60.519 60.519 0 0018.445-8.986.75.75 0 000-1.218A60.517 60.517 0 003.478 2.405z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// --- Header Component ---
|
||||
const Header = ({ onOpenHelp }) => {
|
||||
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
|
||||
const profileMenuRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (profileMenuRef.current && !profileMenuRef.current.contains(event.target)) {
|
||||
setIsProfileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
if (isProfileMenuOpen) document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isProfileMenuOpen]);
|
||||
|
||||
return (
|
||||
<header className="bg-white border-b border-gray-100 sticky top-0 z-40">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-lg font-semibold text-slate-900">운영자 Vertex RAG 챗봇</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<a href="../index.php" className="text-sm text-slate-500 hover:text-slate-900 flex items-center gap-1">
|
||||
<i data-lucide="home" className="w-4 h-4"></i>
|
||||
홈으로
|
||||
</a>
|
||||
<button onClick={onOpenHelp} className="text-sm text-slate-500 hover:text-slate-900 flex items-center gap-1">
|
||||
<i data-lucide="help-circle" className="w-4 h-4"></i>
|
||||
도움말
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Chat Components ---
|
||||
const BotAvatar = ({ size = 'md', className = '' }) => {
|
||||
const sizeClasses = {
|
||||
sm: 'w-8 h-8 text-xs',
|
||||
md: 'w-10 h-10 text-sm',
|
||||
lg: 'w-12 h-12 text-base',
|
||||
};
|
||||
return (
|
||||
<div className={`${sizeClasses[size]} rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-white font-bold shadow-sm ${className}`}>
|
||||
VR
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AvatarGroup = ({ size = 'md', borderColor = 'border-white' }) => {
|
||||
return (
|
||||
<div className="flex -space-x-3 relative z-10">
|
||||
<BotAvatar size={size} className={`border-2 ${borderColor}`} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ChatTooltip = ({ onClose, onClick }) => {
|
||||
return (
|
||||
<div className="relative mb-4 group cursor-pointer animate-fade-in-up" onClick={onClick}>
|
||||
<div className="absolute -top-5 left-1/2 transform -translate-x-1/2 flex justify-center w-full pointer-events-none">
|
||||
<AvatarGroup size="md" borderColor="border-white" />
|
||||
</div>
|
||||
|
||||
<div className="bg-white border-2 border-indigo-600 rounded-[2rem] p-6 pt-8 pr-10 shadow-lg max-w-[320px] relative">
|
||||
<p className="text-gray-700 text-sm leading-relaxed font-medium">
|
||||
Vertex RAG(Vector Search) 엔진을 테스트합니다. 질문을 입력하세요! 🚀
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="absolute -top-3 -right-2 bg-gray-600 hover:bg-gray-700 text-white rounded-full p-1 shadow-md transition-colors z-20"
|
||||
>
|
||||
<CloseIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ChatWindow = () => {
|
||||
const [messages, setMessages] = useState([
|
||||
{
|
||||
id: 'welcome',
|
||||
text: "Vertex RAG(Vector Search) 엔진을 테스트합니다. 무엇을 도와드릴까요? 🚀",
|
||||
sender: Sender.BOT,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
]);
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const messagesEndRef = useRef(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
const handleSendMessage = async (e) => {
|
||||
e?.preventDefault();
|
||||
if (!inputText.trim() || isLoading) return;
|
||||
|
||||
const userMsg = {
|
||||
id: Date.now().toString(),
|
||||
text: inputText,
|
||||
sender: Sender.USER,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setInputText('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const history = messages.slice(-10).map(msg => ({
|
||||
role: msg.sender === Sender.USER ? 'user' : 'model',
|
||||
parts: [{ text: msg.text }]
|
||||
}));
|
||||
|
||||
const responseText = await sendMessageToGemini(userMsg.text, history);
|
||||
const botMsg = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
text: responseText,
|
||||
sender: Sender.BOT,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages(prev => [...prev, botMsg]);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full bg-white rounded-lg overflow-hidden shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="bg-indigo-600 p-4 pt-6 pb-6 text-white shrink-0 relative">
|
||||
<div className="flex items-center space-x-3">
|
||||
<AvatarGroup size="md" borderColor="border-indigo-600" />
|
||||
<div>
|
||||
<h2 className="font-bold text-lg leading-tight"> Vertex RAG Bot</h2>
|
||||
<p className="text-xs text-indigo-100 opacity-90 mt-0.5">
|
||||
고도화된 벡터 검색 엔진입니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message List */}
|
||||
<div className="flex-1 overflow-y-auto p-4 bg-gray-50 space-y-4 scrollbar-hide">
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex ${msg.sender === Sender.USER ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
{msg.sender === Sender.BOT && (
|
||||
<div className="mr-2 mt-1 flex-shrink-0">
|
||||
<BotAvatar size="sm" className="border border-gray-200" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[80%] rounded-2xl px-4 py-3 text-sm leading-relaxed shadow-sm whitespace-pre-wrap ${
|
||||
msg.sender === Sender.USER
|
||||
? 'bg-indigo-600 text-white rounded-tr-none'
|
||||
: 'bg-white text-gray-800 border border-gray-100 rounded-tl-none'
|
||||
}`}
|
||||
>
|
||||
{(() => {
|
||||
const parseTextWithLinks = (text) => {
|
||||
const parts = [];
|
||||
let lastIndex = 0;
|
||||
const regex = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.substring(lastIndex, match.index));
|
||||
}
|
||||
parts.push(
|
||||
<a
|
||||
key={lastIndex}
|
||||
href={match[2]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`underline font-medium break-all ${
|
||||
msg.sender === Sender.USER
|
||||
? 'text-indigo-200 hover:text-white'
|
||||
: 'text-indigo-600 hover:text-indigo-800'
|
||||
}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{match[1]}
|
||||
</a>
|
||||
);
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.substring(lastIndex));
|
||||
}
|
||||
return parts;
|
||||
};
|
||||
|
||||
return parseTextWithLinks(msg.text);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="flex justify-start animate-pulse">
|
||||
<div className="mr-2 mt-1 flex-shrink-0">
|
||||
<BotAvatar size="sm" className="border border-gray-200" />
|
||||
</div>
|
||||
<div className="bg-white text-gray-400 border border-gray-100 rounded-2xl rounded-tl-none px-4 py-3 text-sm shadow-sm">
|
||||
답변 작성 중...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="p-4 bg-white border-t border-gray-100 shrink-0">
|
||||
<form
|
||||
onSubmit={handleSendMessage}
|
||||
className="flex items-center w-full border border-gray-300 rounded-full px-4 py-2 focus-within:border-indigo-600 focus-within:ring-1 focus-within:ring-indigo-600 transition-all shadow-sm"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 outline-none text-sm text-gray-700 placeholder-gray-400 bg-transparent"
|
||||
placeholder="무엇이든 물어보세요..."
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!inputText.trim() || isLoading}
|
||||
className={`ml-2 p-1 ${!inputText.trim() ? 'text-gray-300' : 'text-indigo-600 hover:text-indigo-700'} transition-colors`}
|
||||
>
|
||||
<SendIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</form>
|
||||
<div className="text-center mt-2">
|
||||
<a href="https://codebridge-x.com" target="_blank" className="text-[10px] text-gray-400 hover:underline">Powered by Vertex AI Vector Search</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ChatWidget = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isTooltipVisible, setIsTooltipVisible] = useState(true);
|
||||
|
||||
const toggleChat = () => {
|
||||
setIsOpen(!isOpen);
|
||||
if (!isOpen) setIsTooltipVisible(false);
|
||||
};
|
||||
|
||||
const closeTooltip = () => setIsTooltipVisible(false);
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end gap-3 font-sans">
|
||||
<div
|
||||
className={`
|
||||
transition-all duration-300 ease-in-out origin-bottom-right
|
||||
${isOpen ? 'scale-100 opacity-100' : 'scale-90 opacity-0 pointer-events-none absolute bottom-16 right-0'}
|
||||
w-[380px] h-[600px] max-w-[calc(100vw-48px)] max-h-[calc(100vh-120px)]
|
||||
`}
|
||||
>
|
||||
<ChatWindow />
|
||||
</div>
|
||||
|
||||
{!isOpen && isTooltipVisible && (
|
||||
<ChatTooltip onClose={closeTooltip} onClick={toggleChat} />
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={toggleChat}
|
||||
className={`
|
||||
w-16 h-16 rounded-full shadow-xl flex items-center justify-center transition-all duration-300
|
||||
${isOpen ? 'bg-indigo-600 rotate-90' : 'bg-indigo-600 hover:bg-indigo-700 hover:scale-105'}
|
||||
`}
|
||||
>
|
||||
{isOpen ? (
|
||||
<CloseIcon className="w-8 h-8 text-white" />
|
||||
) : (
|
||||
<ChatMultipleIcon className="w-8 h-8 text-white" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
useEffect(() => {
|
||||
lucide.createIcons();
|
||||
}, []);
|
||||
const handleOpenHelp = () => alert("도움말 기능은 준비중입니다.");
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-screen relative bg-gray-50">
|
||||
<Header onOpenHelp={handleOpenHelp} />
|
||||
<div className="w-full h-full flex items-center justify-center p-20 min-h-[80vh]">
|
||||
<div className="text-center opacity-30 select-none">
|
||||
<h1 className="text-9xl font-bold text-gray-300 mb-8">Vector RAG</h1>
|
||||
<p className="text-4xl text-gray-400">Vertex AI Search Mode</p>
|
||||
</div>
|
||||
<div className="absolute left-10 top-1/2 w-96 h-96 bg-indigo-300 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob"></div>
|
||||
<div className="absolute right-10 top-1/3 w-96 h-96 bg-purple-300 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob animation-delay-2000"></div>
|
||||
</div>
|
||||
<ChatWidget />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
32
chatbot/test_embedding.php
Normal file
32
chatbot/test_embedding.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
// Test script for Google Vertex AI Embedding API
|
||||
$apiKeyPath = '../apikey/google_vertex_api.txt';
|
||||
if (!file_exists($apiKeyPath)) {
|
||||
die("API Key file not found");
|
||||
}
|
||||
$apiKey = trim(file_get_contents($apiKeyPath));
|
||||
|
||||
$url = "https://generativelanguage.googleapis.com/v1beta/models/text-embedding-004:embedContent?key=" . $apiKey;
|
||||
|
||||
$data = [
|
||||
'model' => 'models/text-embedding-004',
|
||||
'content' => [
|
||||
'parts' => [
|
||||
['text' => 'Hello, this is a test for embedding.']
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
echo "HTTP Code: $httpCode\n";
|
||||
echo "Response: $response\n";
|
||||
?>
|
||||
57
chatbot/test_load.php
Normal file
57
chatbot/test_load.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
// chatbot/test_load.php
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
|
||||
$root = $_SERVER['DOCUMENT_ROOT'];
|
||||
echo "Checking Environment...\n";
|
||||
echo "DOCUMENT_ROOT: $root\n";
|
||||
echo "__DIR__ (here): " . __DIR__ . "\n";
|
||||
|
||||
$targetFile = __DIR__ . '/rag/data/vectors.json';
|
||||
echo "Target File: $targetFile\n";
|
||||
|
||||
if (file_exists($targetFile)) {
|
||||
echo "Status: FOUND\n";
|
||||
echo "Size: " . filesize($targetFile) . " bytes\n";
|
||||
echo "Permissions: " . substr(sprintf('%o', fileperms($targetFile)), -4) . "\n";
|
||||
|
||||
// Try reading
|
||||
$start = microtime(true);
|
||||
$content = file_get_contents($targetFile);
|
||||
$readTime = microtime(true) - $start;
|
||||
echo "Read Time: " . number_format($readTime, 4) . "s\n";
|
||||
|
||||
// Try decoding
|
||||
ini_set('memory_limit', '512M');
|
||||
echo "Memory Limit: " . ini_get('memory_limit') . "\n";
|
||||
|
||||
$start = microtime(true);
|
||||
$data = json_decode($content, true);
|
||||
$decodeTime = microtime(true) - $start;
|
||||
|
||||
if ($data === null) {
|
||||
echo "JSON Decode: FAILED. Error: " . json_last_error_msg() . "\n";
|
||||
} else {
|
||||
echo "JSON Decode: SUCCESS. Items: " . count($data) . "\n";
|
||||
echo "Decode Time: " . number_format($decodeTime, 4) . "s\n";
|
||||
}
|
||||
|
||||
// Check directory list
|
||||
echo "\n=== Testing VectorSearch Class ===\n";
|
||||
$searchPhp = __DIR__ . '/rag/search.php';
|
||||
if (!file_exists($searchPhp)) {
|
||||
echo "rag/search.php NOT FOUND at $searchPhp\n";
|
||||
} else {
|
||||
require_once $searchPhp;
|
||||
try {
|
||||
$vs = new VectorSearch();
|
||||
echo "VectorSearch Instantiated.\n";
|
||||
echo "Vector Count from Class: " . $vs->getVectorCount() . "\n";
|
||||
|
||||
// Debug path inside class? We can't see private var, but getVectorCount result is enough.
|
||||
} catch (Exception $e) {
|
||||
echo "VectorSearch Exception: " . $e->getMessage() . "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
Reference in New Issue
Block a user