From 22487bf7cc7e8eb2a49ab968ffdc7e873695b193 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=9D=80=EC=84=9D?= Date: Sat, 28 Feb 2026 18:20:17 +0900 Subject: [PATCH 1/2] refactor(app): semantic HTML, API wrapper, SEO, error boundary - Fix tsconfig path alias @/* to ./app/* (was incorrectly ./src/*) - Add OpenGraph meta tags, lang="ko" to layout - Add Next.js error.tsx error boundary - Extract fetchApi() wrapper with typed ApiError in lib.ts - Replace citations
 with semantic 
    list - Replace metrics
     with 
    definition list - Add .meta and .citations styles to globals.css Co-Authored-By: Claude Opus 4.6 --- app/app/error.tsx | 17 ++++++++ app/app/globals.css | 26 +++++++++++ app/app/layout.tsx | 11 ++++- app/app/lib.ts | 24 +++++++++++ app/app/page.tsx | 103 ++++++++++++++++++++++---------------------- app/tsconfig.json | 2 +- 6 files changed, 129 insertions(+), 54 deletions(-) create mode 100644 app/app/error.tsx 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"], From 97867a233bffce045e4543280f71de80d37eef23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=9D=80=EC=84=9D?= Date: Sat, 28 Feb 2026 18:25:46 +0900 Subject: [PATCH 2/2] feat(api): switch LLM provider from OpenAI to Google Gemini - Replace OpenAI API calls with Gemini API (generativelanguage.googleapis.com) - Embedding: text-embedding-004, Chat: gemini-2.5-flash - Update config.py settings from openai_* to gemini_* - Update .env.example with new GEMINI_* variables - Preserve local fallback behavior when no API key is set Co-Authored-By: Claude Opus 4.6 --- .env.example | 8 ++++---- api/src/config.py | 12 ++++++------ api/src/services/rag.py | 43 ++++++++++++++++++++++------------------- 3 files changed, 33 insertions(+), 30 deletions(-) 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: