Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 6 additions & 6 deletions api/src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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",
Expand Down
43 changes: 23 additions & 20 deletions api/src/services/rag.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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 키가 없어 데모 모드로 동작 중입니다. 먼저 참고 문서를 업로드하면 키워드 기반 응답을 제공합니다.",
Expand All @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions app/app/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"use client";

export default function ErrorBoundary({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<main>
<h1>오류가 발생했습니다</h1>
<p>{error.message || "알 수 없는 오류가 발생했습니다."}</p>
<button onClick={reset}>다시 시도</button>
</main>
);
}
26 changes: 26 additions & 0 deletions app/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
11 changes: 9 additions & 2 deletions app/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<html lang="en">
<html lang="ko">
<body>{children}</body>
</html>
);
Expand Down
24 changes: 24 additions & 0 deletions app/app/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(
url: string,
options?: RequestInit,
): Promise<T> {
const response = await fetch(url, options);
if (!response.ok) {
throw new ApiError(
`${response.status} ${response.statusText}`,
response.status,
);
}
return response.json() as Promise<T>;
}
Loading