iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0

Day 12 完成了批量驗證與緩存:在 Day 7-10 共用資料集上,Mistral 7B 版本 F1=0.767,首次執行 56.1 秒,重跑同一批 SRS 時幾乎不花時間。

到目前為止,所有功能都只能用 python3 tests/... 呼叫。今天要把 OptimizedDetector 包裝成 HTTP API,讓明天的 React 前端與其他工具可以透過網路使用衝突檢測。

這一天在系列中的位置

第 2 週:檢測優化與生產系統

  • Day 8-12 → 檢測器從規則演進到 LLM 批量驗證
  • Day 13 → 用 FastAPI 提供 Web API ← 今天
  • Day 14 → React 前端
  • Day 15-17 → 用戶認證、資料庫、會話管理

今日目標

今天要完成:

  1. REST 端點:提交檢測任務、查詢結果、列出任務、健康檢查、性能指標
  2. 非同步任務:提交後立即回傳 job_id,檢測在背景執行
  3. 輸入驗證與錯誤格式:錯誤一律以 {"detail": ...} 回傳
  4. 可切換的檢測器:預設只用規則(毫秒級),設定 SRS_USE_OLLAMA=1 改用 Day 12 的 Mistral 版本
  5. API 測試:10 個測試,涵蓋正常流程與錯誤情況

問題背景:為什麼需要「任務」而不是直接回傳結果

最直覺的 API 設計是「送出約束 → 等待 → 拿到衝突」。但從 Day 12 的數字來看,使用 LLM 時一份 SRS 要數十秒:

  • HTTP 請求等太久,瀏覽器或反向代理可能先逾時
  • 使用者看不到進度,只能乾等

所以採用任務模式:

POST /api/v1/conflicts/detect   → 立即回傳 {"job_id": ..., "status": "queued"}
          (背景執行檢測)
GET  /api/v1/conflicts/{job_id} → 輪詢,直到 status = completed / failed

實現方法

端點清單

所有端點都定義在 src/api_main.py,相對於 http://localhost:8000:

方法 路徑 說明
GET / API 基本資訊
GET /api/v1/health 健康檢查(含目前使用的 LLM)
GET /api/v1/info 功能與端點清單
POST /api/v1/conflicts/detect 提交檢測任務
GET /api/v1/conflicts/{job_id} 查詢任務狀態與結果
GET /api/v1/conflicts 列出所有任務
GET /api/v1/metrics 檢測器統計(LLM 呼叫次數、緩存命中率)
GET /docs、/redoc FastAPI 自動產生的互動式文件
  • 輸入:[{id, text}, ...] 約束清單(與 Day 10-12 的 detector 相同)
  • 輸出:JSON 格式的衝突清單
  • 檔案:src/api_main.py(新增)
  • 下游:Day 14 的前端呼叫這些端點;Day 15-16 會在同一個檔案加入 /api/v1/auth/* 與需要登入的 /api/v1/detect

注意:專案中的 src/api_main.py 是 Day 13-16 逐步擴充後的版本,開頭已經 import 了 Day 15 的 src/database.py 與 src/api_auth.py。今天只看不需要認證的 /api/v1/conflicts/* 這組端點,任務狀態存在記憶體的 jobs 字典裡。

專案結構變化:

srs-review-agent/
├── src/
│   ├── llm_verifier.py          ← Day 11(OllamaLLM)
│   ├── performance_optimizer.py ← Day 12(OptimizedDetector)
│   ├── api_main.py              ← 【新增】FastAPI 應用
│   └── ...
├── tests/
│   ├── test_day13_api.py        ← 【新增】API 測試
│   └── ...
└── requirements.txt             ← 已包含 fastapi、uvicorn、httpx

環境準備

pip install fastapi uvicorn httpx pytest

httpx 是 FastAPI 的 TestClient 需要的套件。也可以直接 pip install -r requirements.txt 安裝整個系列的依賴。


代碼示例

1. 建立應用與 CORS

建立 src/api_main.py:

from fastapi import FastAPI, HTTPException, BackgroundTasks
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
import os
import uuid

from src.performance_optimizer import OptimizedDetector
from src.llm_verifier import OllamaLLM

app = FastAPI(title="SRS Review Agent API", version="2.0.0")

# 允許前端(Day 14 的 Vite 開發伺服器)跨域呼叫
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True,
                   allow_methods=["*"], allow_headers=["*"])

allow_origins=["*"] 只適合開發環境。正式部署時應改成前端的實際網域。

2. 可切換的檢測器

jobs = {}  # 任務狀態(記憶體,重啟即消失;Day 15 改存資料庫)

USE_OLLAMA = os.getenv("SRS_USE_OLLAMA") == "1"
if USE_OLLAMA:
    detector = OptimizedDetector(
        llm=OllamaLLM(),
        cache_path=os.getenv("SRS_CACHE_PATH", ".cache/verify_cache.json"),
    )
else:
    detector = OptimizedDetector()   # 規則 + 模擬驗證,不需要 Ollama

預設模式讓 API 在沒有 Ollama 的環境(CI、前端開發)也能啟動,且回應在毫秒級。detection_kwargs() 會依模式決定檢測參數:使用 LLM 時改成 verify_strategy="all" 並開啟補充層,理由與 Day 12 相同(規則置信度都 ≥ 0.85,selective 不會驗證任何候選)。

detector 是全域單例,所以 Day 12 的記憶體緩存會在不同請求之間共用。

3. 請求格式與正規化

class DetectionOptions(BaseModel):
    verify: bool = True
    batch_size: int = Field(10, ge=1, le=100)
    verify_strategy: str = "selective"


class DetectionRequest(BaseModel):
    constraints: List = Field(default_factory=list)
    options: Optional[DetectionOptions] = None

constraints 刻意宣告成未指定型別的 List,因為 Day 16 的前端會送純文字清單 ["...", "..."],而今天的端點收的是 [{id, text}]。兩種格式都交給 normalize_constraints() 統一處理:

def normalize_constraints(raw: list) -> List[dict]:
    normalized = []
    for i, item in enumerate(raw):
        if isinstance(item, str):
            normalized.append({"id": f"REQ-{i+1}", "text": item})
        elif isinstance(item, dict) and item.get("id") and item.get("text"):
            normalized.append({"id": str(item["id"]), "text": str(item["text"])})
        else:
            raise ValueError(f"第 {i+1} 個約束格式錯誤,需為字串或含 id、text 的物件")
    return normalized

這裡有一個我踩到的坑:一開始背景任務寫成 [{"id": c.id, "text": c.text} for c in request.constraints]。因為 List 沒有指定元素型別,Pydantic 不會把 dict 轉成物件,c.id 直接丟出 'dict' object has no attribute 'id',每一個任務都是 failed。偏偏原本的測試只檢查「有沒有 status 欄位」,所以測試全過。今天把測試改成必須 completed 且找到 2 個衝突,才抓到這個 bug。

4. 提交任務

@app.post("/api/v1/conflicts/detect", tags=["衝突檢測 (舊版)"])
async def detect_conflicts_legacy(request: DetectionRequest,
                                  background_tasks: BackgroundTasks):
    if len(request.constraints) < 2:
        raise HTTPException(status_code=400, detail="至少需要 2 個約束")
    if len(request.constraints) > 500:
        raise HTTPException(status_code=400, detail="最多 500 個約束")
    try:
        normalize_constraints(request.constraints)   # 格式錯誤在這裡就回 400
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

    job_id = f"job_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
    jobs[job_id] = {"status": "processing", "start_time": datetime.now().isoformat(),
                    "request": request}
    background_tasks.add_task(run_detection, job_id, request)
    return {"job_id": job_id, "status": "queued", "message": "檢測任務已提交 (舊版本)"}

格式檢查在提交時就做,不等背景任務失敗,使用者能立刻拿到 400 與錯誤原因,不用輪詢之後才發現 failed。(「舊版」是 Day 16 加入認證版 /api/v1/detect 之後補上的標籤。)

5. 背景任務:必須是同步函數

def run_detection(job_id: str, request: DetectionRequest):
    start_time = datetime.now()
    try:
        constraints = normalize_constraints(request.constraints)
        options = request.options or DetectionOptions()
        conflicts = detector.detect_conflicts(
            constraints,
            **detection_kwargs(options.verify, options.batch_size, options.verify_strategy),
        )
        jobs[job_id]["results"] = {
            "conflicts_found": len(conflicts),
            "conflicts": [{"req_id_1": c.req_id_1, "req_id_2": c.req_id_2,
                           "type": c.conflict_type.value, "severity": c.severity.value,
                           "description": c.description, "confidence": c.confidence,
                           "verified": c.verified} for c in conflicts],
        }
        jobs[job_id]["status"] = "completed"
        ...  # 記錄 end_time、duration_ms
    except Exception as e:
        jobs[job_id]["status"] = "failed"
        jobs[job_id]["error"] = str(e)

注意這裡是 def 而不是 async def。detect_conflicts() 是同步呼叫,使用 Ollama 時一次要十幾秒:

  • 寫成 async def,它會在 event loop 上直接執行,這十幾秒內整個伺服器無法處理其他請求,連 /health 都卡住
  • 寫成 def,FastAPI 的 BackgroundTasks 會把它丟到 threadpool 執行,event loop 保持暢通

實測:在 SRS_USE_OLLAMA=1 下提交任務後立刻呼叫 /api/v1/health,0.01 秒就回應,任務稍後正常完成。

6. 查詢結果與統一的錯誤格式

@app.get("/api/v1/conflicts/{job_id}", tags=["衝突檢測"])
async def get_results(job_id: str):
    if job_id not in jobs:
        raise HTTPException(status_code=404, detail=f"任務 {job_id} 未找到")
    job = jobs[job_id]
    return {"job_id": job_id, "status": job["status"], "results": job.get("results"),
            "error": job.get("error"),
            "timing": {"start_time": job["start_time"], "end_time": job.get("end_time"),
                       "duration_ms": job.get("duration_ms")}}


@app.exception_handler(HTTPException)
async def http_exception_handler(request, exc):
    return JSONResponse(status_code=exc.status_code, content={
        "error": "HTTPException", "code": exc.status_code,
        "detail": exc.detail,    # FastAPI 標準欄位,前端讀取 data.detail
        "message": exc.detail,   # 保留舊欄位以相容
        "timestamp": datetime.now().isoformat(),
    })

自訂錯誤處理器時很容易漏掉 detail。FastAPI 預設的錯誤格式是 {"detail": ...},前端(frontend/App.jsx)也是讀 data.detail;如果只回傳 message,前端永遠只能顯示「檢測失敗」這種籠統訊息。

/api/v1/health、/api/v1/info、/api/v1/metrics、/api/v1/conflicts 都是直接回傳字典的簡單端點,完整代碼見 src/api_main.py。


驗證結果

API 測試

tests/test_day13_api.py 使用 FastAPI 的 TestClient,不需要真的啟動伺服器。TestClient 會在回應前把 BackgroundTasks 執行完,所以提交後可以馬上查到 completed:

cd srs-review-agent
python3 -m pytest tests/test_day13_api.py -v
tests/test_day13_api.py::test_root PASSED                                [ 10%]
tests/test_day13_api.py::test_health_check PASSED                        [ 20%]
tests/test_day13_api.py::test_info PASSED                                [ 30%]
tests/test_day13_api.py::test_detect_conflicts PASSED                    [ 40%]
tests/test_day13_api.py::test_get_results PASSED                         [ 50%]
tests/test_day13_api.py::test_list_jobs PASSED                           [ 60%]
tests/test_day13_api.py::test_metrics PASSED                             [ 70%]
tests/test_day13_api.py::test_invalid_request PASSED                     [ 80%]
tests/test_day13_api.py::test_not_found PASSED                           [ 90%]
tests/test_day13_api.py::test_api_documentation PASSED                   [100%]

============================== 10 passed in 0.20s ==============================

其中 test_get_results 會斷言任務狀態為 completed、conflicts_found == 2;test_invalid_request 斷言回應包含 detail。

啟動伺服器並手動測試

# 預設模式(規則 + 模擬驗證)
uvicorn src.api_main:app --reload --port 8000

# 或使用 Mistral 7B(需先啟動 Ollama)
SRS_USE_OLLAMA=1 uvicorn src.api_main:app --port 8000

開啟 http://localhost:8000/docs 可以在瀏覽器直接試打每個端點。

1. 健康檢查

curl http://localhost:8000/api/v1/health
{"status": "healthy", "version": "1.0.0", "timestamp": "2026-09-25T19:45:11.822709",
 "components": {"detector": "ready", "llm": "mock", "cache": "ready", "jobs": 0}}

2. 提交任務

curl -X POST http://localhost:8000/api/v1/conflicts/detect \
  -H "Content-Type: application/json" \
  -d '{"constraints": [
        {"id": "REQ-1", "text": "系統支持多用戶並行存取"},
        {"id": "REQ-2", "text": "系統採用單用戶模式"},
        {"id": "REQ-3", "text": "所有數據必須加密存儲"},
        {"id": "REQ-4", "text": "使用明文存儲以提高性能"}]}'
{"job_id": "job_20260925_194511_6da524", "status": "queued", "message": "檢測任務已提交 (舊版本)"}

3. 查詢結果

curl http://localhost:8000/api/v1/conflicts/job_20260925_194511_6da524
{
  "job_id": "job_20260925_194511_6da524",
  "status": "completed",
  "results": {
    "conflicts_found": 2,
    "conflicts": [
      {"req_id_1": "REQ-1", "req_id_2": "REQ-2", "type": "邏輯矛盾", "severity": "高",
       "description": "檢測到 多用戶 vs 單用戶", "confidence": 0.95, "verified": false},
      {"req_id_1": "REQ-3", "req_id_2": "REQ-4", "type": "安全性衝突", "severity": "高",
       "description": "檢測到 加密 vs 明文", "confidence": 0.95, "verified": false}
    ]
  },
  "error": null,
  "timing": {"start_time": "2026-09-25T19:45:11.824175",
             "end_time": "2026-09-25T19:45:11.832748", "duration_ms": 0}
}

預設模式下 verified 為 false:兩個候選的置信度都是 0.95,selective 策略直接放行,沒有經過驗證。

4. 錯誤情況

curl -X POST http://localhost:8000/api/v1/conflicts/detect \
  -H "Content-Type: application/json" \
  -d '{"constraints": [{"id": "REQ-1", "text": "x"}, {"foo": 1}]}'
{"error": "HTTPException", "code": 400,
 "detail": "第 2 個約束格式錯誤,需為字串或含 id、text 的物件",
 "message": "第 2 個約束格式錯誤,需為字串或含 id、text 的物件",
 "timestamp": "2026-09-25T19:45:12.336295"}

使用 Mistral 7B 時

在 SRS_USE_OLLAMA=1 下提交一份 5 條需求的電商 SRS,連續送兩次相同的內容:

耗時 LLM 呼叫 結果
第 1 次 10.9 秒 2 次(批量驗證 1 + 補充 1) 2 個衝突,verified: true
第 2 次 < 0.1 秒 0 次(緩存) 與第 1 次相同

規則層的誤報「支持明文顯示訂單細節 vs 不需要加密用戶的個人信息」被批量驗證擋下。但補充層這次回報了「所有支付數據必須加密傳輸 vs 支持本地離線購物車」,這一對其實很難說是衝突,同時也漏掉了「實時同步到伺服器 vs 本地離線購物車」。這又回到 Day 12 的結論:LLM 結果需要人工複核,API 回傳的 verified 只代表「經過 LLM 驗證」,不代表「一定正確」。


權衡與限制

  • 任務存在記憶體:伺服器重啟後 jobs 就清空了,也無法跑多個 worker 共用任務。Day 15 會改存到資料庫。
  • 沒有認證:任何人都能提交和查詢任何任務。Day 15-16 會加入 JWT 認證與用戶隔離。
  • BackgroundTasks 不是任務佇列:它和 API 在同一個行程裡,沒有重試、排程或持久化。流量大時可以換成 Celery、RQ 這類獨立的任務佇列,這裡先列為可選延伸。
  • 全域 detector 在多執行緒下共用緩存:同時處理多個任務時,Day 12 的 AdvancedCache 沒有加鎖。目前的單人使用情境問題不大,多人同時使用時應該加上 threading.Lock。

提交變更

git add src/api_main.py tests/test_day13_api.py
git commit -m "Day 13: FastAPI 衝突檢測 API(任務模式、輸入正規化、Ollama 開關)"

明天預告

API 已經能用 curl 呼叫了,但一般使用者不會打 curl。明天 Day 14 會用 React 建立前端介面:輸入需求、提交檢測,並以表格查看任務與衝突結果。


上一篇
Day 12:性能優化與批量處理
下一篇
Day 14:React 前端 UI 與用戶體驗
系列文
解決需求規格書矛盾:用 Claude Code × MCP 實作自律型文檔審查 Agent 共 16 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言