초기 커밋: 5130 레거시 시스템

- URL 하드코딩 → .env APP_URL 기반 동적 URL로 변경
- DB 연결 하드코딩 → .env 기반으로 변경
- MySQL strict mode DATE 오류 수정
This commit is contained in:
2025-12-10 20:14:31 +09:00
commit aca1767eb9
6728 changed files with 1863265 additions and 0 deletions

180
chatbot/api.php Normal file
View 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()]);
}
?>

View 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번 방식)**을 고려하시는 것이 좋습니다.

View 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 방식을 씁니다.

View 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
View 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 데이터 파이프라인 예제 코드" 구조를 잡아드릴까요?

View 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
View 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
View 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()]);
}
?>

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

View 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
View 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
View 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);
}
?>

View 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
View 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
View 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
View 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
View 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>

View 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
View 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";
}
}
}
?>