diff --git a/.env.example b/.env.example index a05f091..276bfb8 100644 --- a/.env.example +++ b/.env.example @@ -2,8 +2,8 @@ NEXT_PUBLIC_API_BASE=http://localhost:8000 # LLM (optional) -OPENAI_API_KEY=your_openai_api_key_here -OPENAI_EMBEDDING_MODEL=text-embedding-3-small -OPENAI_CHAT_MODEL=gpt-4o-mini -OPENAI_REQUEST_TIMEOUT=30 +GEMINI_API_KEY=your_gemini_api_key_here +GEMINI_EMBEDDING_MODEL=text-embedding-004 +GEMINI_CHAT_MODEL=gemini-2.5-flash +GEMINI_REQUEST_TIMEOUT=30 KNOWLEDGE_COPILOT_DATABASE_PATH=api/data/knowledge_copilot.db diff --git a/api/src/config.py b/api/src/config.py index 6397f0b..7bca72c 100644 --- a/api/src/config.py +++ b/api/src/config.py @@ -4,10 +4,10 @@ @dataclass(frozen=True) class Settings: - openai_api_key: str | None + gemini_api_key: str | None embedding_model: str chat_model: str - openai_timeout: int + api_timeout: int cors_origins: list[str] db_path: str @@ -20,10 +20,10 @@ def _parse_cors(origins: str) -> list[str]: def load_settings() -> Settings: return Settings( - openai_api_key=os.getenv("OPENAI_API_KEY"), - embedding_model=os.getenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small"), - chat_model=os.getenv("OPENAI_CHAT_MODEL", "gpt-4o-mini"), - openai_timeout=int(os.getenv("OPENAI_REQUEST_TIMEOUT", "30")), + gemini_api_key=os.getenv("GEMINI_API_KEY"), + embedding_model=os.getenv("GEMINI_EMBEDDING_MODEL", "text-embedding-004"), + chat_model=os.getenv("GEMINI_CHAT_MODEL", "gemini-2.5-flash"), + api_timeout=int(os.getenv("GEMINI_REQUEST_TIMEOUT", "30")), cors_origins=_parse_cors(os.getenv("CORS_ORIGINS", "*")), db_path=os.getenv( "KNOWLEDGE_COPILOT_DATABASE_PATH", diff --git a/api/src/services/rag.py b/api/src/services/rag.py index d29b443..d1989ea 100644 --- a/api/src/services/rag.py +++ b/api/src/services/rag.py @@ -15,6 +15,7 @@ class LLMError(RuntimeError): _EMBED_DIM = 256 +_GEMINI_BASE = "https://generativelanguage.googleapis.com/v1beta" def _normalize_vector(vector: np.ndarray) -> np.ndarray: @@ -54,26 +55,25 @@ def chunk_text(text: str, max_tokens: int = 220, overlap: int = 40) -> list[str] async def embed_text(text: str) -> list[float]: settings = load_settings() - if not settings.openai_api_key: + if not settings.gemini_api_key: return _local_embed(text) - endpoint = "https://api.openai.com/v1/embeddings" + endpoint = f"{_GEMINI_BASE}/models/{settings.embedding_model}:embedContent" headers = { - "Authorization": f"Bearer {settings.openai_api_key}", + "x-goog-api-key": settings.gemini_api_key, "Content-Type": "application/json", } payload = { - "input": text, - "model": settings.embedding_model, + "model": f"models/{settings.embedding_model}", + "content": {"parts": [{"text": text}]}, } try: - async with httpx.AsyncClient(timeout=settings.openai_timeout) as client: + async with httpx.AsyncClient(timeout=settings.api_timeout) as client: response = await client.post(endpoint, json=payload, headers=headers) response.raise_for_status() data = response.json() - return data["data"][0]["embedding"] + return data["embedding"]["values"] except Exception: - # fallback to local mode if provider call is unavailable return _local_embed(text) @@ -112,7 +112,7 @@ def build_prompt(question: str, context_chunks: list[dict[str, Any]]) -> str: async def generate_answer(question: str, context_chunks: list[dict[str, Any]], model: str | None = None) -> tuple[str, int, str]: settings = load_settings() - if not settings.openai_api_key: + if not settings.gemini_api_key: if not context_chunks: return ( "현재 API 키가 없어 데모 모드로 동작 중입니다. 먼저 참고 문서를 업로드하면 키워드 기반 응답을 제공합니다.", @@ -127,29 +127,32 @@ async def generate_answer(question: str, context_chunks: list[dict[str, Any]], m "local-fallback", ) - endpoint = "https://api.openai.com/v1/chat/completions" + selected_model = model or settings.chat_model + endpoint = f"{_GEMINI_BASE}/models/{selected_model}:generateContent" headers = { - "Authorization": f"Bearer {settings.openai_api_key}", + "x-goog-api-key": settings.gemini_api_key, "Content-Type": "application/json", } - selected_model = model or settings.chat_model prompt = build_prompt(question, context_chunks) payload = { - "model": selected_model, - "messages": [ - {"role": "system", "content": "You are a practical engineering assistant."}, - {"role": "user", "content": prompt}, + "systemInstruction": { + "parts": [{"text": "You are a practical engineering assistant."}], + }, + "contents": [ + {"role": "user", "parts": [{"text": prompt}]}, ], - "temperature": 0.2, + "generationConfig": { + "temperature": 0.2, + }, } try: start = time.perf_counter() - async with httpx.AsyncClient(timeout=settings.openai_timeout) as client: + async with httpx.AsyncClient(timeout=settings.api_timeout) as client: response = await client.post(endpoint, json=payload, headers=headers) response.raise_for_status() data = response.json() - answer = data["choices"][0]["message"]["content"] - used_tokens = int(data.get("usage", {}).get("total_tokens", 0)) + answer = data["candidates"][0]["content"]["parts"][0]["text"] + used_tokens = int(data.get("usageMetadata", {}).get("totalTokenCount", 0)) _ = time.perf_counter() - start return answer.strip(), used_tokens, selected_model except Exception as err: diff --git a/app/app/error.tsx b/app/app/error.tsx new file mode 100644 index 0000000..1280588 --- /dev/null +++ b/app/app/error.tsx @@ -0,0 +1,17 @@ +"use client"; + +export default function ErrorBoundary({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+

오류가 발생했습니다

+

{error.message || "알 수 없는 오류가 발생했습니다."}

+ +
+ ); +} diff --git a/app/app/globals.css b/app/app/globals.css index 59ef8fa..c7ff145 100644 --- a/app/app/globals.css +++ b/app/app/globals.css @@ -101,3 +101,29 @@ pre { padding: 0.75rem 1rem; margin-top: 1rem; } + +.meta { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.25rem 0.75rem; + margin: 0.5rem 0; +} + +.meta dt { + font-weight: 600; + color: #4b5563; +} + +.meta dd { + margin: 0; +} + +.citations { + padding-left: 1.25rem; + margin: 0.5rem 0; +} + +.citations li { + margin-bottom: 0.5rem; + line-height: 1.5; +} diff --git a/app/app/layout.tsx b/app/app/layout.tsx index df33620..7cea191 100644 --- a/app/app/layout.tsx +++ b/app/app/layout.tsx @@ -3,12 +3,19 @@ import "./globals.css"; export const metadata: Metadata = { title: "Knowledge Copilot", - description: "Knowledge Copilot portfolio project with RAG and Agent workflow", + description: "AI document copilot with RAG-based Q&A, actions, and metrics tracking", + openGraph: { + title: "Knowledge Copilot", + description: "AI document copilot with RAG-based Q&A, actions, and metrics tracking", + type: "website", + locale: "ko_KR", + }, + robots: { index: true, follow: true }, }; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + {children} ); diff --git a/app/app/lib.ts b/app/app/lib.ts index 07ed9fc..3f7b298 100644 --- a/app/app/lib.ts +++ b/app/app/lib.ts @@ -7,3 +7,27 @@ export const ENDPOINT = { metrics: `${API_BASE}/api/v1/metrics`, actions: `${API_BASE}/api/v1/agent/actions`, }; + +export class ApiError extends Error { + constructor( + message: string, + public status: number, + ) { + super(message); + this.name = "ApiError"; + } +} + +export async function fetchApi( + url: string, + options?: RequestInit, +): Promise { + const response = await fetch(url, options); + if (!response.ok) { + throw new ApiError( + `${response.status} ${response.statusText}`, + response.status, + ); + } + return response.json() as Promise; +} diff --git a/app/app/page.tsx b/app/app/page.tsx index 1c1e5b2..cfa761c 100644 --- a/app/app/page.tsx +++ b/app/app/page.tsx @@ -1,7 +1,7 @@ "use client"; import { FormEvent, useEffect, useState } from "react"; -import { ENDPOINT } from "./lib"; +import { ENDPOINT, fetchApi, ApiError } from "./lib"; type DocumentItem = { id: string; @@ -36,6 +36,13 @@ type Metric = { avg_feedback_rating: number | null; }; +function errorMessage(error: unknown): string { + if (error instanceof ApiError) { + return `서버 오류 (${error.status})`; + } + return "네트워크 오류가 발생했습니다. 잠시 후 다시 시도하세요."; +} + export default function HomePage() { const [documents, setDocuments] = useState([]); const [metrics, setMetrics] = useState(null); @@ -53,12 +60,10 @@ export default function HomePage() { async function refreshAll() { try { - const [docsRes, metricsRes] = await Promise.all([ - fetch(`${ENDPOINT.documents}?project_id=${projectId}`), - fetch(`${ENDPOINT.metrics}?project_id=${projectId}`), + const [docs, metricPayload] = await Promise.all([ + fetchApi(`${ENDPOINT.documents}?project_id=${projectId}`), + fetchApi(`${ENDPOINT.metrics}?project_id=${projectId}`), ]); - const docs = await docsRes.json(); - const metricPayload = await metricsRes.json(); setDocuments(docs); setMetrics(metricPayload); } catch { @@ -79,18 +84,12 @@ export default function HomePage() { form.set("source_text", sourceText); try { - const response = await fetch(ENDPOINT.documents, { - method: "POST", - body: form, - }); - if (!response.ok) { - throw new Error("upload failed"); - } + await fetchApi(ENDPOINT.documents, { method: "POST", body: form }); setSourceText(""); setMessage("문서 업로드 완료"); await refreshAll(); - } catch { - setMessage("문서 업로드 실패"); + } catch (error) { + setMessage(errorMessage(error)); } finally { setLoading(false); } @@ -106,26 +105,16 @@ export default function HomePage() { setLoading(true); try { - const response = await fetch(ENDPOINT.queries, { + const payload = await fetchApi(ENDPOINT.queries, { method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - project_id: projectId, - question, - }), + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ project_id: projectId, question }), }); - if (!response.ok) { - setMessage("질의 처리 실패"); - return; - } - const payload: QueryResponse = await response.json(); setQueryResult(payload); setQuestion(""); await refreshAll(); - } catch { - setMessage("네트워크 오류가 발생했습니다. 잠시 후 다시 시도하세요."); + } catch (error) { + setMessage(errorMessage(error)); } finally { setLoading(false); } @@ -136,27 +125,18 @@ export default function HomePage() { setActionResult(""); try { - const response = await fetch(ENDPOINT.actions, { + const payload = await fetchApi<{ result: string }>(ENDPOINT.actions, { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ project_id: projectId, type: "summary", - payload: { - documents: documents.map((doc) => doc.id), - }, + payload: { documents: documents.map((doc) => doc.id) }, }), }); - if (!response.ok) { - setActionResult("액션 실행 실패"); - return; - } - const payload = await response.json(); setActionResult(payload.result); - } catch { - setActionResult("네트워크 오류가 발생했습니다. 잠시 후 다시 시도하세요."); + } catch (error) { + setActionResult(errorMessage(error)); } finally { setLoading(false); } @@ -223,10 +203,20 @@ export default function HomePage() {

질의 응답

{queryResult.answer}

-

- 모델: {queryResult.model} / 지연: {queryResult.latency_ms}ms -

-
{queryResult.citations.map((c) => `- [${c.document_id}] ${c.text}`).join("\n")}
+
+
모델
+
{queryResult.model}
+
지연
+
{queryResult.latency_ms}ms
+
+

인용

+
    + {queryResult.citations.map((c) => ( +
  1. + {c.document_id} {c.text} +
  2. + ))} +
) : null} @@ -241,9 +231,20 @@ export default function HomePage() {

4) 문서/메트릭

{metrics ? ( -
-{`문서: ${metrics.documents}\n청크: ${metrics.chunks}\n질의: ${metrics.queries}\n평균 응답: ${metrics.avg_query_latency_ms}ms\n피드백: ${metrics.feedback_count}건 / 평점 ${metrics.avg_feedback_rating ?? "N/A"}`}
-          
+
+
문서
+
{metrics.documents}
+
청크
+
{metrics.chunks}
+
질의
+
{metrics.queries}
+
평균 응답
+
{metrics.avg_query_latency_ms}ms
+
피드백
+
{metrics.feedback_count}건
+
평점
+
{metrics.avg_feedback_rating ?? "N/A"}
+
) : null}
    {documents.map((doc) => ( diff --git a/app/tsconfig.json b/app/tsconfig.json index c133409..b52a47b 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -19,7 +19,7 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": ["./app/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],