From 0741907c915b74c8a2be3a77f13415ab05f82625 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Dec 2025 22:34:38 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B0=88=E6=A1=88=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E6=80=A7=E8=88=87=E5=93=81=E8=B3=AA=E5=85=A8=E9=9D=A2=E6=8F=90?= =?UTF-8?q?=E5=8D=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 安全修復 (P0) - 修復 eval() 代碼注入漏洞,改用安全的 AST 解析器 - 修復過於寬鬆的 CORS 配置,改用環境變量白名單 - 修復 API key 時序攻擊漏洞,使用 secrets.compare_digest - 升級 axios 至 1.7.9 修復已知安全漏洞 ## 新增功能 - 新增速率限制中間件 (滑動窗口算法) - 新增 React Error Boundary 組件 - 新增可重用 UI 組件 (Button, Card, Section) - 新增 API 文檔入口 - 新增故障排除指南 ## 測試 - 新增速率限制中間件完整測試 --- .../projects/fastapi-llm-api/app/main.py" | 10 +- .../examples/basic_apis/01_openai_basic.py" | 49 +- .../fastapi_backend/main.py" | 23 +- .../RAG-ChatBot/main.py" | 34 +- .../RAG-ChatBot/middleware/__init__.py" | 19 + .../RAG-ChatBot/middleware/rate_limiter.py" | 282 ++++++++++ .../RAG-ChatBot/tests/test_rate_limiter.py" | 281 ++++++++++ .../web-ui/app/layout.tsx" | 7 +- .../web-ui/components/ErrorBoundary.tsx" | 153 ++++++ .../web-ui/components/index.ts" | 13 + .../web-ui/components/ui/Button.tsx" | 78 +++ .../web-ui/components/ui/Card.tsx" | 111 ++++ .../web-ui/components/ui/Section.tsx" | 63 +++ .../web-ui/package.json" | 2 +- docs/API_DOCUMENTATION.md | 371 +++++++++++++ docs/TROUBLESHOOTING.md | 512 ++++++++++++++++++ 16 files changed, 1989 insertions(+), 19 deletions(-) create mode 100644 "5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/RAG-ChatBot/middleware/__init__.py" create mode 100644 "5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/RAG-ChatBot/middleware/rate_limiter.py" create mode 100644 "5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/RAG-ChatBot/tests/test_rate_limiter.py" create mode 100644 "5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/web-ui/components/ErrorBoundary.tsx" create mode 100644 "5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/web-ui/components/index.ts" create mode 100644 "5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/web-ui/components/ui/Button.tsx" create mode 100644 "5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/web-ui/components/ui/Card.tsx" create mode 100644 "5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/web-ui/components/ui/Section.tsx" create mode 100644 docs/API_DOCUMENTATION.md create mode 100644 docs/TROUBLESHOOTING.md diff --git "a/3.LLM\346\207\211\347\224\250\345\267\245\347\250\213/1.LLM \351\203\250\347\275\262/projects/fastapi-llm-api/app/main.py" "b/3.LLM\346\207\211\347\224\250\345\267\245\347\250\213/1.LLM \351\203\250\347\275\262/projects/fastapi-llm-api/app/main.py" index d80f8f4..e48a48f 100644 --- "a/3.LLM\346\207\211\347\224\250\345\267\245\347\250\213/1.LLM \351\203\250\347\275\262/projects/fastapi-llm-api/app/main.py" +++ "b/3.LLM\346\207\211\347\224\250\345\267\245\347\250\213/1.LLM \351\203\250\347\275\262/projects/fastapi-llm-api/app/main.py" @@ -61,12 +61,16 @@ async def lifespan(app: FastAPI): ) # CORS 中間件 +# 從環境變量讀取允許的來源,生產環境應設置具體域名 +import os +ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000,http://localhost:8080").split(",") + app.add_middleware( CORSMiddleware, - allow_origins=["*"], # 生產環境應設置具體域名 + allow_origins=ALLOWED_ORIGINS, allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["Content-Type", "Authorization", "X-Requested-With"], ) diff --git "a/3.LLM\346\207\211\347\224\250\345\267\245\347\250\213/2.LLM as API/examples/basic_apis/01_openai_basic.py" "b/3.LLM\346\207\211\347\224\250\345\267\245\347\250\213/2.LLM as API/examples/basic_apis/01_openai_basic.py" index 7bd4c14..4331f55 100644 --- "a/3.LLM\346\207\211\347\224\250\345\267\245\347\250\213/2.LLM as API/examples/basic_apis/01_openai_basic.py" +++ "b/3.LLM\346\207\211\347\224\250\345\267\245\347\250\213/2.LLM as API/examples/basic_apis/01_openai_basic.py" @@ -130,12 +130,51 @@ def get_weather(location: str, unit: str = "celsius") -> Dict[str, Any]: return data def calculate(expression: str) -> float: - """安全地計算數學表達式""" + """安全地計算數學表達式 + + 使用 ast.literal_eval 和 operator 模組的安全方法, + 避免 eval() 的安全風險。 + """ + import ast + import operator + + # 支援的安全運算符 + operators = { + ast.Add: operator.add, + ast.Sub: operator.sub, + ast.Mult: operator.mul, + ast.Div: operator.truediv, + ast.Pow: operator.pow, + ast.USub: operator.neg, + ast.UAdd: operator.pos, + } + + def safe_eval(node): + """遞迴安全計算 AST 節點""" + if isinstance(node, ast.Constant): # 數字 + return node.value + elif isinstance(node, ast.BinOp): # 二元運算 + left = safe_eval(node.left) + right = safe_eval(node.right) + op = operators.get(type(node.op)) + if op is None: + raise ValueError(f"不支援的運算符: {type(node.op).__name__}") + return op(left, right) + elif isinstance(node, ast.UnaryOp): # 一元運算 + operand = safe_eval(node.operand) + op = operators.get(type(node.op)) + if op is None: + raise ValueError(f"不支援的運算符: {type(node.op).__name__}") + return op(operand) + else: + raise ValueError(f"不支援的表達式類型: {type(node).__name__}") + try: - # 注意:在生產環境中應該使用更安全的方法 - return eval(expression, {"__builtins__": {}}) - except: - return "計算錯誤" + # 解析表達式為 AST + tree = ast.parse(expression, mode='eval') + return safe_eval(tree.body) + except Exception as e: + return f"計算錯誤: {e}" # 可用的函數映射 available_functions = { diff --git "a/3.LLM\346\207\211\347\224\250\345\267\245\347\250\213/2.LLM as API/examples/frontend_integration/fastapi_backend/main.py" "b/3.LLM\346\207\211\347\224\250\345\267\245\347\250\213/2.LLM as API/examples/frontend_integration/fastapi_backend/main.py" index ff886af..ecb926a 100644 --- "a/3.LLM\346\207\211\347\224\250\345\267\245\347\250\213/2.LLM as API/examples/frontend_integration/fastapi_backend/main.py" +++ "b/3.LLM\346\207\211\347\224\250\345\267\245\347\250\213/2.LLM as API/examples/frontend_integration/fastapi_backend/main.py" @@ -177,11 +177,26 @@ def init_clients(): def verify_api_key(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str: - """驗證 API Key""" - expected_key = os.getenv("API_KEY", "your-secret-key") + """驗證 API Key - if credentials.credentials != expected_key: - logger.warning(f"無效的 API Key 嘗試") + 使用 secrets.compare_digest 進行常數時間比較, + 防止時序攻擊(timing attack)。 + """ + import secrets + + expected_key = os.getenv("API_KEY") + + # 確保 API_KEY 已設置 + if not expected_key: + logger.error("API_KEY 環境變量未設置") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Server configuration error" + ) + + # 使用常數時間比較防止時序攻擊 + if not secrets.compare_digest(credentials.credentials, expected_key): + logger.warning("無效的 API Key 嘗試") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API Key" diff --git "a/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/RAG-ChatBot/main.py" "b/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/RAG-ChatBot/main.py" index 267dcc3..88a48b5 100644 --- "a/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/RAG-ChatBot/main.py" +++ "b/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/RAG-ChatBot/main.py" @@ -9,26 +9,50 @@ from fastapi.responses import HTMLResponse, StreamingResponse from pydantic import BaseModel from typing import List, Optional +from contextlib import asynccontextmanager import uvicorn import asyncio from rag_engine import RAGEngine +from middleware.rate_limiter import rate_limiter, rate_limit_middleware + + +# 生命週期管理 +@asynccontextmanager +async def lifespan(app: FastAPI): + """應用生命週期管理""" + # 啟動時 + await rate_limiter.start() + yield + # 關閉時 + await rate_limiter.stop() + # 創建 FastAPI 應用 app = FastAPI( title="RAG ChatBot API", description="檢索增強生成聊天機器人", - version="1.0.0" + version="1.0.0", + lifespan=lifespan ) -# CORS 配置 +# CORS 配置 - 從環境變量讀取允許的來源 +import os +ALLOWED_ORIGINS = os.getenv( + "ALLOWED_ORIGINS", + "http://localhost:3000,http://localhost:8080,http://127.0.0.1:3000" +).split(",") + app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=ALLOWED_ORIGINS, allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["Content-Type", "Authorization", "X-Requested-With"], ) +# 速率限制中間件 +app.middleware("http")(rate_limit_middleware) + # 初始化 RAG 引擎 rag_engine = RAGEngine() diff --git "a/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/RAG-ChatBot/middleware/__init__.py" "b/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/RAG-ChatBot/middleware/__init__.py" new file mode 100644 index 0000000..5a210a5 --- /dev/null +++ "b/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/RAG-ChatBot/middleware/__init__.py" @@ -0,0 +1,19 @@ +""" +中間件模組 + +提供 FastAPI 應用的各種中間件功能。 +""" + +from .rate_limiter import ( + RateLimiter, + rate_limiter, + rate_limit, + rate_limit_middleware +) + +__all__ = [ + 'RateLimiter', + 'rate_limiter', + 'rate_limit', + 'rate_limit_middleware' +] diff --git "a/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/RAG-ChatBot/middleware/rate_limiter.py" "b/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/RAG-ChatBot/middleware/rate_limiter.py" new file mode 100644 index 0000000..a0819de --- /dev/null +++ "b/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/RAG-ChatBot/middleware/rate_limiter.py" @@ -0,0 +1,282 @@ +""" +速率限制中間件 + +提供基於 IP 和 API Key 的速率限制功能,防止 API 濫用。 +支持滑動窗口算法和令牌桶算法。 +""" + +import time +from collections import defaultdict +from typing import Callable, Optional +from functools import wraps +import asyncio +from fastapi import Request, HTTPException, status +from fastapi.responses import JSONResponse +import logging + +logger = logging.getLogger(__name__) + + +class RateLimiter: + """ + 速率限制器 + + 使用滑動窗口算法實現請求限制。 + + Attributes: + requests_per_minute: 每分鐘允許的請求數 + requests_per_hour: 每小時允許的請求數 + """ + + def __init__( + self, + requests_per_minute: int = 60, + requests_per_hour: int = 1000, + burst_limit: int = 10 + ): + self.requests_per_minute = requests_per_minute + self.requests_per_hour = requests_per_hour + self.burst_limit = burst_limit + + # 存儲請求記錄 {client_id: [(timestamp, count), ...]} + self._minute_requests: dict[str, list[float]] = defaultdict(list) + self._hour_requests: dict[str, list[float]] = defaultdict(list) + self._burst_requests: dict[str, list[float]] = defaultdict(list) + + # 清理任務 + self._cleanup_task: Optional[asyncio.Task] = None + + async def start(self): + """啟動清理任務""" + self._cleanup_task = asyncio.create_task(self._cleanup_loop()) + + async def stop(self): + """停止清理任務""" + if self._cleanup_task: + self._cleanup_task.cancel() + try: + await self._cleanup_task + except asyncio.CancelledError: + pass + + async def _cleanup_loop(self): + """定期清理過期的請求記錄""" + while True: + await asyncio.sleep(60) # 每分鐘清理一次 + self._cleanup_old_requests() + + def _cleanup_old_requests(self): + """清理過期請求記錄""" + current_time = time.time() + + # 清理分鐘級記錄 + for client_id in list(self._minute_requests.keys()): + self._minute_requests[client_id] = [ + t for t in self._minute_requests[client_id] + if current_time - t < 60 + ] + if not self._minute_requests[client_id]: + del self._minute_requests[client_id] + + # 清理小時級記錄 + for client_id in list(self._hour_requests.keys()): + self._hour_requests[client_id] = [ + t for t in self._hour_requests[client_id] + if current_time - t < 3600 + ] + if not self._hour_requests[client_id]: + del self._hour_requests[client_id] + + def _get_client_id(self, request: Request) -> str: + """ + 獲取客戶端標識符 + + 優先使用 API Key,否則使用 IP 地址 + """ + # 嘗試從 Header 獲取 API Key + api_key = request.headers.get("X-API-Key") or request.headers.get("Authorization") + if api_key: + # 移除 "Bearer " 前綴(如果有) + if api_key.startswith("Bearer "): + api_key = api_key[7:] + return f"key:{api_key[:16]}" # 只使用前 16 字符 + + # 獲取客戶端 IP + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + ip = forwarded.split(",")[0].strip() + else: + ip = request.client.host if request.client else "unknown" + + return f"ip:{ip}" + + async def check_rate_limit(self, request: Request) -> bool: + """ + 檢查請求是否超過速率限制 + + Returns: + True 如果請求被允許,否則拋出 HTTPException + """ + client_id = self._get_client_id(request) + current_time = time.time() + + # 檢查突發限制(每秒) + burst_requests = [ + t for t in self._burst_requests[client_id] + if current_time - t < 1 + ] + if len(burst_requests) >= self.burst_limit: + logger.warning(f"Rate limit exceeded (burst) for client: {client_id}") + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail={ + "error": "Rate limit exceeded", + "message": "Too many requests per second", + "retry_after": 1 + }, + headers={"Retry-After": "1"} + ) + + # 檢查分鐘限制 + minute_requests = [ + t for t in self._minute_requests[client_id] + if current_time - t < 60 + ] + if len(minute_requests) >= self.requests_per_minute: + retry_after = int(60 - (current_time - minute_requests[0])) + logger.warning(f"Rate limit exceeded (minute) for client: {client_id}") + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail={ + "error": "Rate limit exceeded", + "message": f"Too many requests per minute. Limit: {self.requests_per_minute}", + "retry_after": retry_after + }, + headers={"Retry-After": str(retry_after)} + ) + + # 檢查小時限制 + hour_requests = [ + t for t in self._hour_requests[client_id] + if current_time - t < 3600 + ] + if len(hour_requests) >= self.requests_per_hour: + retry_after = int(3600 - (current_time - hour_requests[0])) + logger.warning(f"Rate limit exceeded (hour) for client: {client_id}") + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail={ + "error": "Rate limit exceeded", + "message": f"Too many requests per hour. Limit: {self.requests_per_hour}", + "retry_after": retry_after + }, + headers={"Retry-After": str(retry_after)} + ) + + # 記錄請求 + self._burst_requests[client_id].append(current_time) + self._minute_requests[client_id].append(current_time) + self._hour_requests[client_id].append(current_time) + + return True + + def get_remaining_requests(self, request: Request) -> dict: + """獲取剩餘請求配額""" + client_id = self._get_client_id(request) + current_time = time.time() + + minute_requests = len([ + t for t in self._minute_requests[client_id] + if current_time - t < 60 + ]) + hour_requests = len([ + t for t in self._hour_requests[client_id] + if current_time - t < 3600 + ]) + + return { + "remaining_per_minute": max(0, self.requests_per_minute - minute_requests), + "remaining_per_hour": max(0, self.requests_per_hour - hour_requests), + "limit_per_minute": self.requests_per_minute, + "limit_per_hour": self.requests_per_hour + } + + +# 全局速率限制器實例 +rate_limiter = RateLimiter( + requests_per_minute=60, + requests_per_hour=1000, + burst_limit=10 +) + + +def rate_limit( + requests_per_minute: Optional[int] = None, + requests_per_hour: Optional[int] = None +): + """ + 速率限制裝飾器 + + 用於對特定端點應用自定義速率限制 + + Usage: + @app.get("/api/chat") + @rate_limit(requests_per_minute=10) + async def chat(): + ... + """ + def decorator(func: Callable): + @wraps(func) + async def wrapper(*args, **kwargs): + # 獲取 request 對象 + request = kwargs.get('request') + if not request: + for arg in args: + if isinstance(arg, Request): + request = arg + break + + if request: + # 使用自定義限制或默認限制 + custom_limiter = RateLimiter( + requests_per_minute=requests_per_minute or rate_limiter.requests_per_minute, + requests_per_hour=requests_per_hour or rate_limiter.requests_per_hour + ) + await custom_limiter.check_rate_limit(request) + + return await func(*args, **kwargs) + return wrapper + return decorator + + +async def rate_limit_middleware(request: Request, call_next): + """ + FastAPI 速率限制中間件 + + Usage: + app.middleware("http")(rate_limit_middleware) + """ + # 跳過健康檢查和靜態文件 + skip_paths = ["/health", "/api/health", "/docs", "/redoc", "/openapi.json"] + if any(request.url.path.startswith(path) for path in skip_paths): + return await call_next(request) + + try: + await rate_limiter.check_rate_limit(request) + response = await call_next(request) + + # 添加速率限制相關的響應頭 + remaining = rate_limiter.get_remaining_requests(request) + response.headers["X-RateLimit-Limit-Minute"] = str(remaining["limit_per_minute"]) + response.headers["X-RateLimit-Remaining-Minute"] = str(remaining["remaining_per_minute"]) + response.headers["X-RateLimit-Limit-Hour"] = str(remaining["limit_per_hour"]) + response.headers["X-RateLimit-Remaining-Hour"] = str(remaining["remaining_per_hour"]) + + return response + + except HTTPException as e: + return JSONResponse( + status_code=e.status_code, + content=e.detail, + headers=dict(e.headers) if e.headers else None + ) diff --git "a/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/RAG-ChatBot/tests/test_rate_limiter.py" "b/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/RAG-ChatBot/tests/test_rate_limiter.py" new file mode 100644 index 0000000..940d148 --- /dev/null +++ "b/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/RAG-ChatBot/tests/test_rate_limiter.py" @@ -0,0 +1,281 @@ +""" +速率限制中間件測試 +測試 rate_limiter.py 的功能 +""" + +import pytest +import asyncio +import time +from unittest.mock import Mock, AsyncMock, patch +from fastapi import HTTPException +import sys +import os + +# 添加父目錄到路徑 +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from middleware.rate_limiter import RateLimiter, rate_limiter, rate_limit_middleware + + +class TestRateLimiter: + """RateLimiter 類測試""" + + @pytest.fixture + def limiter(self): + """創建測試用的限制器""" + return RateLimiter( + requests_per_minute=5, + requests_per_hour=20, + burst_limit=2 + ) + + @pytest.fixture + def mock_request(self): + """創建 Mock 請求對象""" + request = Mock() + request.headers = {} + request.client = Mock() + request.client.host = "192.168.1.1" + request.url = Mock() + request.url.path = "/api/chat" + return request + + def test_initialization(self, limiter): + """測試初始化""" + assert limiter.requests_per_minute == 5 + assert limiter.requests_per_hour == 20 + assert limiter.burst_limit == 2 + + def test_get_client_id_from_ip(self, limiter, mock_request): + """測試從 IP 獲取客戶端 ID""" + client_id = limiter._get_client_id(mock_request) + assert client_id == "ip:192.168.1.1" + + def test_get_client_id_from_api_key(self, limiter, mock_request): + """測試從 API Key 獲取客戶端 ID""" + mock_request.headers = {"X-API-Key": "test_api_key_12345678"} + client_id = limiter._get_client_id(mock_request) + assert client_id.startswith("key:") + assert "test_api_key_123" in client_id + + def test_get_client_id_from_bearer_token(self, limiter, mock_request): + """測試從 Bearer Token 獲取客戶端 ID""" + mock_request.headers = {"Authorization": "Bearer test_token_12345678"} + client_id = limiter._get_client_id(mock_request) + assert client_id.startswith("key:") + + def test_get_client_id_from_forwarded_header(self, limiter, mock_request): + """測試從 X-Forwarded-For 獲取 IP""" + mock_request.headers = {"X-Forwarded-For": "10.0.0.1, 192.168.1.1"} + client_id = limiter._get_client_id(mock_request) + assert client_id == "ip:10.0.0.1" + + @pytest.mark.asyncio + async def test_check_rate_limit_allows_request(self, limiter, mock_request): + """測試允許正常請求""" + result = await limiter.check_rate_limit(mock_request) + assert result is True + + @pytest.mark.asyncio + async def test_check_rate_limit_burst_exceeded(self, limiter, mock_request): + """測試超過突發限制""" + # 快速發送超過突發限制的請求 + await limiter.check_rate_limit(mock_request) + await limiter.check_rate_limit(mock_request) + + # 第三個請求應該被拒絕 + with pytest.raises(HTTPException) as exc_info: + await limiter.check_rate_limit(mock_request) + + assert exc_info.value.status_code == 429 + assert "Too many requests per second" in str(exc_info.value.detail) + + @pytest.mark.asyncio + async def test_check_rate_limit_minute_exceeded(self, limiter, mock_request): + """測試超過分鐘限制""" + # 模擬突發請求分散在時間上 + for i in range(5): + # 清除突發記錄以避免觸發突發限制 + limiter._burst_requests.clear() + await limiter.check_rate_limit(mock_request) + + # 清除突發記錄 + limiter._burst_requests.clear() + + # 第六個請求應該被拒絕 + with pytest.raises(HTTPException) as exc_info: + await limiter.check_rate_limit(mock_request) + + assert exc_info.value.status_code == 429 + assert "minute" in str(exc_info.value.detail).lower() + + @pytest.mark.asyncio + async def test_get_remaining_requests(self, limiter, mock_request): + """測試獲取剩餘請求配額""" + # 發送一個請求 + limiter._burst_requests.clear() + await limiter.check_rate_limit(mock_request) + + remaining = limiter.get_remaining_requests(mock_request) + + assert remaining["remaining_per_minute"] == 4 # 5 - 1 + assert remaining["remaining_per_hour"] == 19 # 20 - 1 + assert remaining["limit_per_minute"] == 5 + assert remaining["limit_per_hour"] == 20 + + def test_cleanup_old_requests(self, limiter): + """測試清理過期請求記錄""" + old_time = time.time() - 120 # 2 分鐘前 + current_time = time.time() + + limiter._minute_requests["test_client"] = [old_time, current_time] + limiter._hour_requests["test_client"] = [old_time, current_time] + + limiter._cleanup_old_requests() + + # 舊的分鐘記錄應該被清理 + assert len(limiter._minute_requests["test_client"]) == 1 + # 舊的小時記錄(2分鐘前)應該仍然保留 + assert len(limiter._hour_requests["test_client"]) == 2 + + @pytest.mark.asyncio + async def test_start_and_stop(self, limiter): + """測試啟動和停止清理任務""" + await limiter.start() + assert limiter._cleanup_task is not None + + await limiter.stop() + assert limiter._cleanup_task.cancelled() or limiter._cleanup_task.done() + + +class TestRateLimitMiddleware: + """rate_limit_middleware 測試""" + + @pytest.fixture + def mock_request(self): + """創建 Mock 請求""" + request = Mock() + request.headers = {} + request.client = Mock() + request.client.host = "192.168.1.1" + request.url = Mock() + request.url.path = "/api/chat" + return request + + @pytest.fixture + def mock_call_next(self): + """創建 Mock call_next""" + async def call_next(request): + response = Mock() + response.headers = {} + return response + return call_next + + @pytest.mark.asyncio + async def test_middleware_allows_request(self, mock_request, mock_call_next): + """測試中間件允許正常請求""" + # 重置全局限制器 + rate_limiter._minute_requests.clear() + rate_limiter._hour_requests.clear() + rate_limiter._burst_requests.clear() + + response = await rate_limit_middleware(mock_request, mock_call_next) + + assert response is not None + assert "X-RateLimit-Limit-Minute" in response.headers + + @pytest.mark.asyncio + async def test_middleware_skips_health_check(self, mock_request, mock_call_next): + """測試中間件跳過健康檢查路徑""" + mock_request.url.path = "/api/health" + + response = await rate_limit_middleware(mock_request, mock_call_next) + + # 健康檢查不應該有速率限制頭 + assert response is not None + + @pytest.mark.asyncio + async def test_middleware_skips_docs(self, mock_request, mock_call_next): + """測試中間件跳過文檔路徑""" + mock_request.url.path = "/docs" + + response = await rate_limit_middleware(mock_request, mock_call_next) + assert response is not None + + +class TestRateLimitDecorator: + """rate_limit 裝飾器測試""" + + @pytest.mark.asyncio + async def test_decorator_creates_custom_limiter(self): + """測試裝飾器創建自定義限制器""" + from middleware.rate_limiter import rate_limit + + @rate_limit(requests_per_minute=2) + async def test_endpoint(request=None): + return "success" + + mock_request = Mock() + mock_request.headers = {} + mock_request.client = Mock() + mock_request.client.host = "192.168.1.100" + + # 前兩個請求應該成功 + result = await test_endpoint(request=mock_request) + assert result == "success" + + +class TestDifferentClients: + """不同客戶端的測試""" + + @pytest.fixture + def limiter(self): + """創建限制器""" + return RateLimiter(requests_per_minute=2, requests_per_hour=10, burst_limit=2) + + @pytest.mark.asyncio + async def test_different_ips_have_separate_limits(self, limiter): + """測試不同 IP 有獨立的限制""" + request1 = Mock() + request1.headers = {} + request1.client = Mock() + request1.client.host = "192.168.1.1" + + request2 = Mock() + request2.headers = {} + request2.client = Mock() + request2.client.host = "192.168.1.2" + + # 客戶端1發送請求 + await limiter.check_rate_limit(request1) + await limiter.check_rate_limit(request1) + + # 客戶端2應該仍然可以發送請求 + result = await limiter.check_rate_limit(request2) + assert result is True + + @pytest.mark.asyncio + async def test_api_key_overrides_ip(self, limiter): + """測試 API Key 優先於 IP""" + # 相同 IP,不同 API Key + request1 = Mock() + request1.headers = {"X-API-Key": "key_user_1"} + request1.client = Mock() + request1.client.host = "192.168.1.1" + + request2 = Mock() + request2.headers = {"X-API-Key": "key_user_2"} + request2.client = Mock() + request2.client.host = "192.168.1.1" + + # 用戶1發送請求直到限制 + await limiter.check_rate_limit(request1) + await limiter.check_rate_limit(request1) + + # 用戶2(不同 API Key)應該仍然可以發送 + result = await limiter.check_rate_limit(request2) + assert result is True + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "--tb=short"]) diff --git "a/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/web-ui/app/layout.tsx" "b/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/web-ui/app/layout.tsx" index 5816d78..e63b1bd 100644 --- "a/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/web-ui/app/layout.tsx" +++ "b/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/web-ui/app/layout.tsx" @@ -1,6 +1,7 @@ import type { Metadata } from 'next' import { Inter } from 'next/font/google' import './globals.css' +import { ErrorBoundary } from '@/components/ErrorBoundary' const inter = Inter({ subsets: ['latin'] }) @@ -16,7 +17,11 @@ export default function RootLayout({ }) { return ( - {children} + + + {children} + + ) } diff --git "a/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/web-ui/components/ErrorBoundary.tsx" "b/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/web-ui/components/ErrorBoundary.tsx" new file mode 100644 index 0000000..e20cc20 --- /dev/null +++ "b/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/web-ui/components/ErrorBoundary.tsx" @@ -0,0 +1,153 @@ +'use client' + +import React, { Component, ErrorInfo, ReactNode } from 'react' + +interface Props { + children: ReactNode + fallback?: ReactNode +} + +interface State { + hasError: boolean + error: Error | null + errorInfo: ErrorInfo | null +} + +/** + * Error Boundary 組件 + * + * 捕獲子組件樹中的 JavaScript 錯誤,記錄錯誤並顯示備用 UI。 + * + * 使用方式: + * ```tsx + * + * + * + * ``` + */ +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props) + this.state = { + hasError: false, + error: null, + errorInfo: null + } + } + + static getDerivedStateFromError(error: Error): Partial { + // 更新 state 使下一次渲染顯示備用 UI + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + // 記錄錯誤信息 + console.error('ErrorBoundary caught an error:', error, errorInfo) + this.setState({ errorInfo }) + + // 可以在這裡將錯誤發送到錯誤追蹤服務 + // 例如: Sentry, LogRocket 等 + // reportError(error, errorInfo) + } + + handleRetry = (): void => { + this.setState({ + hasError: false, + error: null, + errorInfo: null + }) + } + + render(): ReactNode { + if (this.state.hasError) { + // 如果提供了自定義 fallback,使用它 + if (this.props.fallback) { + return this.props.fallback + } + + // 默認錯誤 UI + return ( +
+
+
+ + + +
+ +

+ 出錯了 +

+ +

+ 應用程序遇到了意外錯誤。請稍後重試。 +

+ + {process.env.NODE_ENV === 'development' && this.state.error && ( +
+

+ {this.state.error.message} +

+ {this.state.errorInfo && ( +
+ + 查看錯誤堆棧 + +
+                      {this.state.errorInfo.componentStack}
+                    
+
+ )} +
+ )} + +
+ + +
+
+
+ ) + } + + return this.props.children + } +} + +/** + * 使用 Hook 的 Error Boundary 包裝器 + */ +export function withErrorBoundary

( + WrappedComponent: React.ComponentType

, + fallback?: ReactNode +) { + return function WithErrorBoundaryWrapper(props: P) { + return ( + + + + ) + } +} + +export default ErrorBoundary diff --git "a/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/web-ui/components/index.ts" "b/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/web-ui/components/index.ts" new file mode 100644 index 0000000..2053b80 --- /dev/null +++ "b/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/web-ui/components/index.ts" @@ -0,0 +1,13 @@ +/** + * 組件索引文件 + * + * 統一導出所有可複用組件 + */ + +// Error Boundary +export { ErrorBoundary, withErrorBoundary } from './ErrorBoundary' + +// UI 組件 +export { Button } from './ui/Button' +export { Card, CardHeader, CardTitle, CardContent, CardFooter } from './ui/Card' +export { Section, SectionHeader } from './ui/Section' diff --git "a/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/web-ui/components/ui/Button.tsx" "b/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/web-ui/components/ui/Button.tsx" new file mode 100644 index 0000000..8d97d05 --- /dev/null +++ "b/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/web-ui/components/ui/Button.tsx" @@ -0,0 +1,78 @@ +import { ReactNode, ButtonHTMLAttributes } from 'react' +import Link from 'next/link' +import clsx from 'clsx' + +type ButtonVariant = 'primary' | 'secondary' | 'outline' +type ButtonSize = 'sm' | 'md' | 'lg' + +interface BaseButtonProps { + variant?: ButtonVariant + size?: ButtonSize + children: ReactNode + className?: string +} + +interface ButtonAsButtonProps extends BaseButtonProps, ButtonHTMLAttributes { + href?: never +} + +interface ButtonAsLinkProps extends BaseButtonProps { + href: string + onClick?: never +} + +type ButtonProps = ButtonAsButtonProps | ButtonAsLinkProps + +const variantStyles: Record = { + primary: 'text-white bg-blue-600 hover:bg-blue-700 border-transparent', + secondary: 'text-gray-700 bg-white hover:bg-gray-50 border-gray-300', + outline: 'text-blue-600 bg-transparent hover:bg-blue-50 border-blue-600' +} + +const sizeStyles: Record = { + sm: 'px-3 py-1.5 text-sm', + md: 'px-4 py-2 text-base', + lg: 'px-6 py-3 text-base' +} + +/** + * 可複用的 Button 組件 + * + * 支持多種變體和尺寸,可以作為按鈕或連結使用 + * + * @example + * ```tsx + * + * + * ``` + */ +export function Button({ + variant = 'primary', + size = 'md', + children, + className, + ...props +}: ButtonProps) { + const baseStyles = clsx( + 'inline-flex items-center justify-center font-medium rounded-md border transition-colors', + variantStyles[variant], + sizeStyles[size], + className + ) + + if ('href' in props && props.href) { + return ( + + {children} + + ) + } + + return ( + + ) +} + +export default Button diff --git "a/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/web-ui/components/ui/Card.tsx" "b/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/web-ui/components/ui/Card.tsx" new file mode 100644 index 0000000..35cf6b0 --- /dev/null +++ "b/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/web-ui/components/ui/Card.tsx" @@ -0,0 +1,111 @@ +import { ReactNode } from 'react' +import clsx from 'clsx' + +interface CardProps { + children: ReactNode + className?: string + /** 是否有邊框 */ + bordered?: boolean + /** 是否有懸停效果 */ + hoverable?: boolean + /** 內距大小 */ + padding?: 'sm' | 'md' | 'lg' +} + +const paddingStyles = { + sm: 'p-4', + md: 'p-6', + lg: 'p-8' +} + +/** + * 可複用的 Card 容器組件 + */ +export function Card({ + children, + className, + bordered = false, + hoverable = true, + padding = 'md' +}: CardProps) { + return ( +

+ {children} +
+ ) +} + +interface CardHeaderProps { + children: ReactNode + className?: string +} + +/** + * Card 標題區域 + */ +export function CardHeader({ children, className }: CardHeaderProps) { + return ( +
+ {children} +
+ ) +} + +interface CardTitleProps { + children: ReactNode + className?: string + as?: 'h2' | 'h3' | 'h4' +} + +/** + * Card 標題 + */ +export function CardTitle({ children, className, as: Tag = 'h4' }: CardTitleProps) { + return ( + + {children} + + ) +} + +interface CardContentProps { + children: ReactNode + className?: string +} + +/** + * Card 內容區域 + */ +export function CardContent({ children, className }: CardContentProps) { + return ( +
+ {children} +
+ ) +} + +interface CardFooterProps { + children: ReactNode + className?: string +} + +/** + * Card 底部區域 + */ +export function CardFooter({ children, className }: CardFooterProps) { + return ( +
+ {children} +
+ ) +} + +export default Card diff --git "a/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/web-ui/components/ui/Section.tsx" "b/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/web-ui/components/ui/Section.tsx" new file mode 100644 index 0000000..22bb10b --- /dev/null +++ "b/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/web-ui/components/ui/Section.tsx" @@ -0,0 +1,63 @@ +import { ReactNode } from 'react' +import clsx from 'clsx' + +interface SectionProps { + id?: string + children: ReactNode + className?: string + /** 是否使用白色背景 */ + white?: boolean + /** 是否添加垂直內距 */ + withPadding?: boolean +} + +/** + * 可複用的 Section 容器組件 + * + * 提供一致的最大寬度、水平內距和可選的背景色 + */ +export function Section({ + id, + children, + className, + white = false, + withPadding = true +}: SectionProps) { + return ( +
+ {children} +
+ ) +} + +interface SectionHeaderProps { + title: string + subtitle?: string + className?: string +} + +/** + * Section 標題組件 + * + * 提供一致的標題和副標題樣式 + */ +export function SectionHeader({ title, subtitle, className }: SectionHeaderProps) { + return ( +
+

{title}

+ {subtitle && ( +

{subtitle}

+ )} +
+ ) +} + +export default Section diff --git "a/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/web-ui/package.json" "b/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/web-ui/package.json" index c58fac3..9cbdd66 100644 --- "a/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/web-ui/package.json" +++ "b/5.AI\347\240\224\347\251\266\345\211\215\346\262\277_2024-2025/\345\257\246\346\210\260\351\240\205\347\233\256/web-ui/package.json" @@ -14,7 +14,7 @@ "next": "14.0.3", "react": "18.2.0", "react-dom": "18.2.0", - "axios": "1.6.2", + "axios": "^1.7.9", "tailwindcss": "3.3.5", "autoprefixer": "10.4.16", "postcss": "8.4.31", diff --git a/docs/API_DOCUMENTATION.md b/docs/API_DOCUMENTATION.md new file mode 100644 index 0000000..862329e --- /dev/null +++ b/docs/API_DOCUMENTATION.md @@ -0,0 +1,371 @@ +# API 文檔索引 + +> **最後更新**: 2025-01 +> **版本**: 1.0.0 + +本文檔提供 My-AI-Learning-Notes 專案中所有 API 服務的統一入口和說明。 + +--- + +## 📋 目錄 + +- [概述](#概述) +- [可用 API 服務](#可用-api-服務) +- [認證方式](#認證方式) +- [通用響應格式](#通用響應格式) +- [錯誤碼](#錯誤碼) +- [速率限制](#速率限制) +- [API 詳細文檔](#api-詳細文檔) + +--- + +## 概述 + +本專案包含多個 FastAPI 驅動的 REST API 服務,提供 LLM、RAG、文檔分析和代碼審查等功能。 + +### 技術棧 + +- **框架**: FastAPI 0.115+ +- **服務器**: Uvicorn (ASGI) +- **認證**: HTTPBearer + API Key +- **文檔**: OpenAPI 3.0 (Swagger UI + ReDoc) + +--- + +## 可用 API 服務 + +| 服務名稱 | 端口 | 說明 | Swagger 文檔 | +|---------|------|------|-------------| +| **RAG ChatBot** | 8000 | RAG 增強的聊天機器人 | `/docs` | +| **FastAPI LLM API** | 8000 | 多提供商 LLM 服務 | `/docs` | +| **AI Document Analyzer** | 8001 | 文檔分析服務 | `/docs` | +| **AI Code Review** | 8002 | 代碼審查服務 | `/docs` | + +--- + +## 認證方式 + +### API Key 認證 + +大多數端點需要 API Key 認證: + +```bash +# 使用 Authorization Header +curl -H "Authorization: Bearer YOUR_API_KEY" \ + http://localhost:8000/api/chat + +# 或使用 X-API-Key Header +curl -H "X-API-Key: YOUR_API_KEY" \ + http://localhost:8000/api/chat +``` + +### 無需認證的端點 + +以下端點不需要認證: +- `GET /` - 根路徑 +- `GET /health` - 健康檢查 +- `GET /docs` - Swagger 文檔 +- `GET /redoc` - ReDoc 文檔 + +--- + +## 通用響應格式 + +### 成功響應 + +```json +{ + "status": "success", + "data": { + // 響應數據 + }, + "metadata": { + "timestamp": "2025-01-15T10:30:00Z", + "duration": 0.123 + } +} +``` + +### 錯誤響應 + +```json +{ + "error": "Error message", + "status_code": 400, + "timestamp": "2025-01-15T10:30:00Z", + "details": { + // 可選的詳細錯誤信息 + } +} +``` + +--- + +## 錯誤碼 + +| 狀態碼 | 說明 | 常見原因 | +|-------|------|---------| +| `200` | 成功 | 請求成功處理 | +| `400` | 錯誤請求 | 參數錯誤、格式不正確 | +| `401` | 未授權 | API Key 無效或缺失 | +| `403` | 禁止訪問 | 無權限訪問該資源 | +| `404` | 未找到 | 資源不存在 | +| `429` | 請求過多 | 超過速率限制 | +| `500` | 服務器錯誤 | 內部錯誤 | +| `503` | 服務不可用 | LLM 提供商不可用 | + +--- + +## 速率限制 + +所有 API 端點都有速率限制: + +| 類型 | 限制 | 重置時間 | +|------|------|---------| +| 突發 | 10 請求/秒 | 1 秒 | +| 分鐘 | 60 請求/分鐘 | 60 秒 | +| 小時 | 1000 請求/小時 | 3600 秒 | + +### 響應頭 + +``` +X-RateLimit-Limit-Minute: 60 +X-RateLimit-Remaining-Minute: 55 +X-RateLimit-Limit-Hour: 1000 +X-RateLimit-Remaining-Hour: 990 +Retry-After: 30 (當被限制時) +``` + +--- + +## API 詳細文檔 + +### 1. RAG ChatBot API + +**位置**: `5.AI研究前沿_2024-2025/實戰項目/RAG-ChatBot/` + +#### 端點列表 + +| 方法 | 端點 | 說明 | +|------|------|------| +| `POST` | `/api/chat` | 聊天接口 | +| `POST` | `/api/chat/stream` | 流式聊天 | +| `POST` | `/api/documents` | 添加文檔 | +| `POST` | `/api/documents/upload` | 上傳文檔 | +| `GET` | `/api/documents` | 列出文檔 | +| `DELETE` | `/api/documents/{id}` | 刪除文檔 | +| `GET` | `/api/conversations/{id}` | 獲取對話 | +| `DELETE` | `/api/conversations/{id}` | 刪除對話 | +| `GET` | `/api/health` | 健康檢查 | +| `GET` | `/api/stats` | 統計信息 | + +#### 聊天請求示例 + +```bash +curl -X POST http://localhost:8000/api/chat \ + -H "Content-Type: application/json" \ + -d '{ + "message": "什麼是 RAG?", + "use_rag": true, + "top_k": 3 + }' +``` + +#### 響應示例 + +```json +{ + "response": "RAG (Retrieval-Augmented Generation) 是一種...", + "conversation_id": "conv_123", + "sources": [ + {"content": "...", "metadata": {"source": "doc1.pdf"}} + ], + "metadata": { + "tokens_used": 150, + "model": "gpt-4o-mini" + } +} +``` + +--- + +### 2. FastAPI LLM API + +**位置**: `3.LLM應用工程/1.LLM 部署/projects/fastapi-llm-api/` + +#### 端點列表 + +| 方法 | 端點 | 說明 | +|------|------|------| +| `POST` | `/chat/completions` | 聊天完成 (OpenAI 兼容) | +| `POST` | `/chat/stream` | 流式聊天 | +| `POST` | `/ai-assist/optimize-prompt` | 提示詞優化 | +| `POST` | `/ai-assist/suggest-model` | 模型推薦 | +| `GET` | `/stats/usage` | 使用統計 | +| `GET` | `/stats/costs` | 成本統計 | + +#### 聊天完成請求 + +```bash +curl -X POST http://localhost:8000/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-4o-mini", + "messages": [ + {"role": "user", "content": "Hello!"} + ], + "temperature": 0.7 + }' +``` + +--- + +### 3. 多提供商 LLM 服務 + +**位置**: `3.LLM應用工程/2.LLM as API/examples/frontend_integration/fastapi_backend/` + +#### 支持的提供商 + +| 提供商 | 模型 | 環境變量 | +|-------|------|---------| +| OpenAI | gpt-4o, gpt-4o-mini | `OPENAI_API_KEY` | +| Anthropic | claude-3-5-sonnet | `ANTHROPIC_API_KEY` | +| Google | gemini-1.5-pro | `GOOGLE_API_KEY` | + +#### 請求示例 + +```bash +curl -X POST http://localhost:8000/api/chat \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "messages": [{"role": "user", "content": "Hello"}], + "provider": "openai", + "model": "gpt-4o-mini" + }' +``` + +--- + +## 環境配置 + +### 必需的環境變量 + +```bash +# LLM API Keys +OPENAI_API_KEY=sk-... +ANTHROPIC_API_KEY=sk-ant-... +GOOGLE_API_KEY=... + +# 應用配置 +API_KEY=your-api-key-for-auth +ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080 + +# 可選配置 +LOG_LEVEL=INFO +API_PORT=8000 +``` + +--- + +## 客戶端 SDK 示例 + +### Python + +```python +import requests + +class RAGChatClient: + def __init__(self, base_url: str = "http://localhost:8000"): + self.base_url = base_url + self.session = requests.Session() + + def chat(self, message: str, use_rag: bool = True) -> dict: + response = self.session.post( + f"{self.base_url}/api/chat", + json={"message": message, "use_rag": use_rag} + ) + response.raise_for_status() + return response.json() + +# 使用 +client = RAGChatClient() +result = client.chat("什麼是 LangChain?") +print(result["response"]) +``` + +### JavaScript/TypeScript + +```typescript +import axios from 'axios'; + +class RAGChatClient { + private baseUrl: string; + + constructor(baseUrl: string = 'http://localhost:8000') { + this.baseUrl = baseUrl; + } + + async chat(message: string, useRag: boolean = true) { + const response = await axios.post(`${this.baseUrl}/api/chat`, { + message, + use_rag: useRag + }); + return response.data; + } +} + +// 使用 +const client = new RAGChatClient(); +const result = await client.chat('什麼是 LangChain?'); +console.log(result.response); +``` + +--- + +## 開發和測試 + +### 本地運行 + +```bash +# 安裝依賴 +pip install -r requirements.txt + +# 運行服務 +uvicorn main:app --reload --port 8000 + +# 訪問文檔 +open http://localhost:8000/docs +``` + +### 運行測試 + +```bash +# 運行所有測試 +pytest tests/ + +# 運行 API 測試 +pytest tests/test_api.py -v + +# 生成覆蓋率報告 +pytest --cov=. --cov-report=html +``` + +--- + +## 相關文檔 + +- [QUICKSTART.md](../QUICKSTART.md) - 快速入門指南 +- [DEPLOYMENT.md](../DEPLOYMENT.md) - 部署指南 +- [TROUBLESHOOTING.md](./TROUBLESHOOTING.md) - 故障排除指南 +- [CONTRIBUTING.md](../CONTRIBUTING.md) - 貢獻指南 + +--- + +## 更新日誌 + +### v1.0.0 (2025-01) +- 初始版本 +- 統一 API 文檔入口 +- 添加認證和速率限制說明 +- 添加客戶端 SDK 示例 diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 0000000..e0e62b7 --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -0,0 +1,512 @@ +# 故障排除指南 + +> **最後更新**: 2025-01 +> **適用版本**: 1.0.0+ + +本指南幫助您診斷和解決使用 My-AI-Learning-Notes 專案時可能遇到的常見問題。 + +--- + +## 📋 目錄 + +- [快速診斷](#快速診斷) +- [安裝問題](#安裝問題) +- [API 相關問題](#api-相關問題) +- [LLM 服務問題](#llm-服務問題) +- [RAG 系統問題](#rag-系統問題) +- [Docker 問題](#docker-問題) +- [性能問題](#性能問題) +- [常見錯誤碼](#常見錯誤碼) + +--- + +## 快速診斷 + +### 系統狀態檢查 + +```bash +# 檢查 Python 版本 +python --version # 需要 >= 3.9 + +# 檢查依賴安裝 +pip list | grep -E "(fastapi|langchain|openai)" + +# 檢查環境變量 +echo $OPENAI_API_KEY + +# 檢查服務健康狀態 +curl http://localhost:8000/api/health +``` + +### 日誌查看 + +```bash +# 查看應用日誌 +tail -f logs/api_*.log + +# 查看 Docker 日誌 +docker-compose logs -f + +# 查看特定服務日誌 +docker-compose logs -f rag-chatbot +``` + +--- + +## 安裝問題 + +### ❌ 問題:`pip install` 失敗 + +**症狀**: +``` +ERROR: Could not find a version that satisfies the requirement... +``` + +**解決方案**: + +1. **更新 pip**: + ```bash + pip install --upgrade pip + ``` + +2. **使用國內鏡像** (中國大陸): + ```bash + pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple + ``` + +3. **安裝系統依賴** (Linux): + ```bash + sudo apt-get update + sudo apt-get install python3-dev build-essential + ``` + +4. **使用 conda** (如果 pip 持續失敗): + ```bash + conda create -n ai-learning python=3.11 + conda activate ai-learning + pip install -r requirements.txt + ``` + +--- + +### ❌ 問題:`torch` 安裝失敗 + +**症狀**: +``` +ERROR: Could not build wheels for torch +``` + +**解決方案**: + +1. **使用官方安裝命令**: + ```bash + # CPU 版本 + pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu + + # CUDA 版本 (NVIDIA GPU) + pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121 + ``` + +2. **檢查系統兼容性**: + - Windows: 需要 Visual C++ Build Tools + - macOS: 需要 Xcode Command Line Tools + - Linux: 需要 gcc 和 g++ + +--- + +### ❌ 問題:ChromaDB 安裝失敗 + +**症狀**: +``` +ERROR: Failed building wheel for chroma-hnswlib +``` + +**解決方案**: + +1. **安裝編譯工具**: + ```bash + # macOS + xcode-select --install + + # Ubuntu/Debian + sudo apt-get install build-essential + + # Windows + # 安裝 Visual Studio Build Tools + ``` + +2. **使用預編譯版本**: + ```bash + pip install chromadb --prefer-binary + ``` + +--- + +## API 相關問題 + +### ❌ 問題:401 Unauthorized + +**症狀**: +```json +{"error": "Invalid API Key", "status_code": 401} +``` + +**解決方案**: + +1. **檢查 API Key 設置**: + ```bash + # 確保環境變量已設置 + export API_KEY="your-api-key" + + # 或在 .env 文件中 + echo "API_KEY=your-api-key" >> .env + ``` + +2. **檢查請求格式**: + ```bash + # 正確格式 + curl -H "Authorization: Bearer YOUR_API_KEY" ... + + # 或 + curl -H "X-API-Key: YOUR_API_KEY" ... + ``` + +--- + +### ❌ 問題:429 Too Many Requests + +**症狀**: +```json +{"error": "Rate limit exceeded", "retry_after": 30} +``` + +**解決方案**: + +1. **等待重試**: + ```python + import time + + response = requests.post(url, json=data) + if response.status_code == 429: + retry_after = int(response.headers.get('Retry-After', 30)) + time.sleep(retry_after) + response = requests.post(url, json=data) + ``` + +2. **實現指數退避**: + ```python + from tenacity import retry, wait_exponential, stop_after_attempt + + @retry(wait=wait_exponential(min=1, max=60), stop=stop_after_attempt(5)) + def make_request(): + response = requests.post(url, json=data) + response.raise_for_status() + return response + ``` + +3. **調整速率限制** (如果是自己的服務): + ```python + # 在 rate_limiter.py 中調整 + rate_limiter = RateLimiter( + requests_per_minute=120, # 增加限制 + requests_per_hour=2000 + ) + ``` + +--- + +### ❌ 問題:CORS 錯誤 + +**症狀**: +``` +Access to fetch at 'http://localhost:8000/api/chat' from origin 'http://localhost:3000' has been blocked by CORS policy +``` + +**解決方案**: + +1. **設置允許的來源**: + ```bash + # 在 .env 中設置 + ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080 + ``` + +2. **重啟服務**: + ```bash + # 重啟 FastAPI 服務 + uvicorn main:app --reload + ``` + +--- + +## LLM 服務問題 + +### ❌ 問題:OpenAI API 錯誤 + +**症狀**: +``` +openai.error.AuthenticationError: Incorrect API key provided +``` + +**解決方案**: + +1. **檢查 API Key**: + ```bash + # 確保 Key 格式正確 (sk-...) + echo $OPENAI_API_KEY + ``` + +2. **檢查配額**: + - 訪問 https://platform.openai.com/usage + - 確保有足夠的配額 + +3. **測試連接**: + ```python + from openai import OpenAI + client = OpenAI() + + try: + response = client.chat.completions.create( + model="gpt-4o-mini", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=10 + ) + print("連接成功!") + except Exception as e: + print(f"錯誤: {e}") + ``` + +--- + +### ❌ 問題:Ollama 連接失敗 + +**症狀**: +``` +Connection refused: http://localhost:11434 +``` + +**解決方案**: + +1. **檢查 Ollama 是否運行**: + ```bash + # 檢查狀態 + ollama list + + # 如果未運行,啟動服務 + ollama serve + ``` + +2. **拉取模型**: + ```bash + ollama pull llama3.2 + ``` + +3. **檢查端口**: + ```bash + # 確保端口未被佔用 + lsof -i :11434 + ``` + +--- + +## RAG 系統問題 + +### ❌ 問題:向量檢索無結果 + +**症狀**: +- 查詢返回空結果 +- 相關文檔未被檢索到 + +**解決方案**: + +1. **檢查文檔是否已索引**: + ```bash + curl http://localhost:8000/api/documents + ``` + +2. **檢查嵌入模型**: + ```python + # 確保嵌入模型正常工作 + from sentence_transformers import SentenceTransformer + + model = SentenceTransformer('all-MiniLM-L6-v2') + embedding = model.encode("測試文本") + print(f"嵌入維度: {len(embedding)}") + ``` + +3. **調整檢索參數**: + ```python + # 增加 top_k + result = await rag_engine.chat( + message="問題", + top_k=10 # 增加檢索數量 + ) + ``` + +4. **重建索引**: + ```bash + # 刪除 ChromaDB 數據 + rm -rf ./chroma_db + + # 重新索引文檔 + python scripts/index_documents.py + ``` + +--- + +### ❌ 問題:ChromaDB 持久化失敗 + +**症狀**: +``` +sqlite3.OperationalError: database is locked +``` + +**解決方案**: + +1. **確保只有一個進程訪問**: + ```bash + # 查找佔用進程 + lsof ./chroma_db/chroma.sqlite3 + + # 終止進程 + kill -9 + ``` + +2. **使用持久化配置**: + ```python + import chromadb + + client = chromadb.PersistentClient( + path="./chroma_db", + settings=chromadb.Settings( + anonymized_telemetry=False, + allow_reset=True + ) + ) + ``` + +--- + +## Docker 問題 + +### ❌ 問題:容器無法啟動 + +**症狀**: +``` +Error response from daemon: Conflict. The container name is already in use +``` + +**解決方案**: + +```bash +# 停止並刪除現有容器 +docker-compose down + +# 清理所有停止的容器 +docker container prune + +# 重新啟動 +docker-compose up -d +``` + +--- + +### ❌ 問題:內存不足 + +**症狀**: +``` +docker: Error response from daemon: OCI runtime create failed: container_linux.go:380: starting container process caused: process_linux.go:545: container init caused: Running hook #0:: error running hook: exit status 1, stdout: , stderr: Auto-detected mode: legacy +``` + +**解決方案**: + +1. **增加 Docker 內存限制**: + - Docker Desktop → Settings → Resources → Memory + - 建議至少 8GB + +2. **限制容器資源**: + ```yaml + # docker-compose.yml + services: + rag-chatbot: + deploy: + resources: + limits: + memory: 4G + ``` + +--- + +## 性能問題 + +### ❌ 問題:響應緩慢 + +**診斷**: +```bash +# 檢查響應時間 +time curl http://localhost:8000/api/chat -d '{"message":"test"}' + +# 檢查系統資源 +htop +nvidia-smi # 如果使用 GPU +``` + +**解決方案**: + +1. **啟用緩存**: + ```python + from functools import lru_cache + + @lru_cache(maxsize=100) + def get_embedding(text: str): + return model.encode(text) + ``` + +2. **使用更快的模型**: + ```python + # 使用更小的嵌入模型 + model = SentenceTransformer('paraphrase-MiniLM-L3-v2') + ``` + +3. **啟用 GPU 加速**: + ```python + # 確保使用 GPU + import torch + device = "cuda" if torch.cuda.is_available() else "cpu" + model = model.to(device) + ``` + +--- + +## 常見錯誤碼 + +| 錯誤碼 | 說明 | 解決方案 | +|-------|------|---------| +| `ECONNREFUSED` | 服務未啟動 | 啟動相應服務 | +| `TIMEOUT` | 請求超時 | 檢查網絡、增加超時時間 | +| `MEMORY_ERROR` | 內存不足 | 減少批量大小、增加內存 | +| `RATE_LIMIT` | 速率限制 | 等待或調整限制 | +| `AUTH_FAILED` | 認證失敗 | 檢查 API Key | +| `MODEL_NOT_FOUND` | 模型未找到 | 下載/拉取模型 | + +--- + +## 獲取幫助 + +如果以上方法無法解決您的問題: + +1. **搜索現有 Issues**: [GitHub Issues](https://github.com/markl-a/My-AI-Learning-Notes/issues) + +2. **創建新 Issue**: + - 提供詳細的錯誤信息 + - 包含復現步驟 + - 附上相關日誌 + +3. **參與討論**: [GitHub Discussions](https://github.com/markl-a/My-AI-Learning-Notes/discussions) + +--- + +## 相關文檔 + +- [QUICKSTART.md](../QUICKSTART.md) - 快速入門 +- [API_DOCUMENTATION.md](./API_DOCUMENTATION.md) - API 文檔 +- [DEPLOYMENT.md](../DEPLOYMENT.md) - 部署指南