iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0

Day 22-23 讓檢測結果可以輸出成 Markdown、JSON 與 PDF 審查報告。但要產生一份報告,現在還得自己寫一段 Python:讀檔、抽出需求、建立 OptimizedDetector、呼叫 build_report()、再呼叫 render_pdf()。

今天把這些串成一個命令列工具:

python3 -m src.cli analyze spec.md --format pdf

給它一份 Markdown 規格書,它就交出一份報告。命令列工具還有一個網頁做不到的用途:接進 CI。規格書放在 Git 裡,每次修改都自動檢查,出現高嚴重度衝突時讓建置失敗。

這一天在系列中的位置

第 4 週:發佈與開源

  • Day 22 → 審查報告生成與統計分析(Markdown)
  • Day 23 → 圖表與 PDF 報告
  • Day 24 → 命令列工具 ← 今天
  • Day 25-26 → Docker 與 Docker Compose

今日目標

讀完這篇後,你會完成:

  1. 需求抽取 parse_requirements():從 Markdown 規格書抽出 REQ-x:內容,處理重複編號
  2. extract 子命令:列出規格書中的需求
  3. analyze 子命令:檢測並輸出 Markdown / JSON / PDF 報告,預設只用規則,--ollama 啟用 Day 20 的設定
  4. CI 整合:--fail-on 高,有達到門檻的衝突時結束碼為 2
  5. 測試 tests/test_day24_cli.py:6 個離線測試
  6. 對一份 58 條需求的規格書實際跑一次,看看系統在比測試資料更大的文件上表現如何

問題背景

規格書長什麼樣子

專案的測試規格書 tests/fixtures/srs_small.md 是這樣寫的:

# 線上筆記系統 - 需求規格書

## 2. 功能需求

### 2.1 筆記管理
- REQ-2.1.1:系統支持多用戶並行存取
- REQ-2.1.2:每個用戶可建立無限數量的筆記

## 4. 安全需求
- REQ-4.1.1:所有數據應加密存儲
- REQ-4.1.2:系統採用單用戶模式,每次只有一個用戶活動

Day 5 的 src/workflow.py 已經用正規表達式 (REQ-[\d.]+):([^(\n]+) 抽需求,但它只接受全形冒號,而且會在第一個半形括號 ( 截斷內容,「支持 REST API (v2)」會變成「支持 REST API 」。CLI 需要一個更寬鬆的版本。

結束碼的約定

CI 只看結束碼。三種情況要分開:

結束碼 意義 CI 該做什麼
0 成功,沒有達到門檻的衝突 通過
1 輸入錯誤:檔案不存在、需求不足、Ollama 連不上、沒有字型 修正設定
2 成功執行,但有達到 --fail-on 門檻的衝突 修正規格書

把「工具壞了」與「規格書有問題」分成 1 和 2,CI 的錯誤訊息才不會誤導人。


實現方法

spec.md
  ↓ parse_requirements()  → [{id, text}, ...]、重複編號警告
  ↓ build_detector(args)
      預設:OptimizedDetector(),只跑規則(不需要 Ollama)
      --ollama:OllamaLLM + 驗證 v1 + 補充 v2 + guardrail(Day 20)
               --no-supplement 關閉補充層、--self-check 開啟第 4 層(Day 21)
  ↓ detector.detect_conflicts()
  ↓ build_report()(Day 22)
  ↓ render_markdown() / to_dict() / render_pdf()(Day 22-23)
  ↓ 結束碼:0 / 1 / 2
  • 輸入:Markdown 規格書與命令列參數
  • 輸出:報告(標準輸出或檔案)、摘要(標準錯誤)、結束碼
  • 檔案:src/cli.py(新增)
  • 下游:Day 25 的 Docker 映像檔可以直接執行這個 CLI

專案結構變化:

srs-review-agent/
├── src/
│   ├── report_generator.py   ← Day 22
│   ├── pdf_report.py         ← Day 23
│   └── cli.py                ← 【新增】extract / analyze
└── tests/
    └── test_day24_cli.py     ← 【新增】6 個測試

今天不需要新的套件。


代碼示例

1. 抽出需求

建立 src/cli.py:

# 「- REQ-2.1.1:系統支持多用戶」「REQ-3: 響應時間 < 500ms」「**REQ-4**:…」
# (全形、半形冒號都接受;編號後可接 Markdown 粗體的 **)
REQUIREMENT_PATTERN = re.compile(r"(REQ-[A-Za-z0-9][\w.\-]*)\*{0,2}\s*[::]\s*(.+)")


def parse_requirements(text: str) -> Tuple[List[Dict], List[str]]:
    requirements: List[Dict] = []
    seen = {}
    warnings = []
    for line_no, line in enumerate(text.splitlines(), start=1):
        match = REQUIREMENT_PATTERN.search(line)
        if not match:
            continue
        req_id, body = match.group(1), match.group(2).strip()
        if not body:
            continue
        if req_id in seen:
            warnings.append(f"第 {line_no} 行:{req_id} 重複(第 {seen[req_id]} 行已定義),略過")
            continue
        seen[req_id] = line_no
        requirements.append({"id": req_id, "text": body})
    return requirements, warnings

和 Day 5 的版本相比:

  • 全形、半形冒號都接受:規格書常由不同的人編寫,兩種都會出現
  • 內容取到行尾:括號內的補充說明(「至少 100 人並行」「OT 或 CRDT 算法」)往往是需求的重點,不論全形或半形括號都保留
  • \*{0,2}:**REQ-3.1**:… 這種粗體寫法也能抽到。測試第一次跑時這一條沒過,才發現編號和冒號之間夾了 **
  • 重複編號保留第一個並警告:重複通常是複製貼上時忘了改,直接覆蓋會讓前一條需求消失

只有「編號後面緊接冒號」的行才算需求。規格書裡提到需求的說明文字,例如「REQ-3.1.2.1(多用戶編輯)依賴 REQ-4.3.2.1(OAuth 認證)」,因為編號後面是括號,不會被誤抽成需求。

2. 建立檢測器

def build_detector(args):
    from src.performance_optimizer import OptimizedDetector

    if not args.ollama:
        # 只用規則:不需要 Ollama,毫秒級完成
        return OptimizedDetector(), dict(enable_verification=False), "規則"

    from src.llm_verifier import OllamaLLM
    detector = OptimizedDetector(
        llm=OllamaLLM(model_name=args.model),
        cache_path=args.cache,
        verify_prompt_version="v1",
        supplement_prompt_version="v2",
        use_guardrail=True,
    )
    kwargs = dict(enable_verification=True, verify_strategy="all", batch_size=10,
                  enable_supplement=not args.no_supplement, enable_self_check=args.self_check)
    ...
    return detector, kwargs, " + ".join(layers) + f"({args.model})"

預設只用規則:不需要 Ollama,在任何 CI 環境都能跑,毫秒級完成。加上 --ollama 才使用 Day 20 驗證過的設定。第 4 層預設關閉,依據是 Day 21 的 A/B 結果:對整份規格書,它沒有找到新的衝突,卻多了 1 個誤報,LLM 呼叫增加 5 倍。

import 寫在函數裡面:只跑規則時不必載入 Ollama 客戶端;只要 extract 時也不必載入報告模組。

3. analyze 子命令

def cmd_analyze(args) -> int:
    path = Path(args.file)
    if not path.exists():
        print(f"錯誤:找不到檔案 {path}", file=sys.stderr)
        return EXIT_INPUT_ERROR
    text = path.read_text(encoding="utf-8")
    requirements, warnings = parse_requirements(text)
    for w in warnings:
        print(f"警告:{w}", file=sys.stderr)
    if len(requirements) < 2:
        print(f"錯誤:{path} 只找到 {len(requirements)} 條需求(格式:REQ-x.y:內容),至少需要 2 條",
              file=sys.stderr)
        return EXIT_INPUT_ERROR

    detector, kwargs, setting = build_detector(args)
    try:
        conflicts = detector.detect_conflicts(requirements, **kwargs)
    except ConnectionError as e:
        print(f"錯誤:{e}", file=sys.stderr)
        return EXIT_INPUT_ERROR

    title = args.title or f"{_title_from_markdown(text, path.stem)} — 審查報告"
    meta = {"來源檔案": path.name, "檢測設定": setting,
            "LLM 呼叫": detector.batch_verifier.llm_call_count}
    report = build_report(requirements, conflicts, title=title, meta=meta)
    ...

幾個設計細節:

  • 報告寫到標準輸出,摘要與警告寫到標準錯誤:python3 -m src.cli analyze spec.md > report.md 得到的檔案裡只有報告,不會混入「58 條需求,17 個衝突」這種訊息
  • 「找到 0 條需求」要報錯:通常代表格式不對(例如寫成 REQ-1 - 內容),默默產生一份「沒有衝突」的報告反而危險
  • Ollama 連不上:Day 11 的 OllamaLLM 會丟出帶修復提示的 ConnectionError,Day 20 的 guardrail 會先指數退避重試 2 次,最後才由這裡轉成結束碼 1
  • 報告標題取自規格書的第一個 # 標題,meta 記下檢測設定與 LLM 呼叫次數,這兩項會出現在 Day 22 報告的開頭

最後判斷門檻:

SEVERITY_RANK = {"高": 3, "中": 2, "低": 1}

if args.fail_on:
    threshold = SEVERITY_RANK[args.fail_on]
    if any(SEVERITY_RANK.get(i.severity, 0) >= threshold for i in report.items):
        return EXIT_FAIL_ON
return EXIT_OK

PDF 沒有指定 -o 時,預設寫到規格書旁邊的 <檔名>.report.pdf;找不到中文字型時,Day 23 的 FontNotFoundError 會轉成結束碼 1 與安裝建議。build_parser() 定義所有參數,完整代碼見 src/cli.py。


驗證結果

測試(離線)

cd srs-review-agent
python3 -m pytest tests/test_day24_cli.py -v
tests/test_day24_cli.py::test_parse_requirements PASSED                  [ 16%]
tests/test_day24_cli.py::test_analyze_markdown PASSED                    [ 33%]
tests/test_day24_cli.py::test_analyze_json_and_output_file PASSED        [ 50%]
tests/test_day24_cli.py::test_fail_on_threshold PASSED                   [ 66%]
tests/test_day24_cli.py::test_input_errors PASSED                        [ 83%]
tests/test_day24_cli.py::test_extract_json PASSED                        [100%]

============================== 6 passed in 0.08s ===============================

測試直接呼叫 main(argv) 並檢查回傳的結束碼,不必另外開一個子行程。

srs_small.md:只用規則

python3 -m src.cli extract tests/fixtures/srs_small.md
python3 -m src.cli analyze tests/fixtures/srs_small.md

extract 列出 9 條需求。analyze 在標準錯誤印出摘要:

9 條需求,1 個衝突(高 1、中 0、低 0),待複核 1 個

標準輸出是 Day 22 格式的報告,找到「REQ-2.1.1 系統支持多用戶並行存取 vs REQ-4.1.2 系統採用單用戶模式」。結束碼:

指令 結束碼
analyze tests/fixtures/srs_small.md 0
analyze tests/fixtures/srs_small.md --fail-on 高 2
analyze nope.md 1(找不到檔案 nope.md)

srs_medium.md:58 條需求,使用 Mistral 7B

tests/fixtures/srs_medium.md 是一份「線上協作文檔系統」的規格書,比 Day 7-10 的測試資料大得多:

python3 -m src.cli analyze tests/fixtures/srs_medium.md --ollama --cache .cache/verify_cache.json -o medium.md
python3 -m src.cli analyze tests/fixtures/srs_medium.md --ollama --cache .cache/verify_cache.json --format pdf -o medium.pdf
58 條需求,17 個衝突(高 0、中 17、低 0),待複核 17 個

第一次執行花了 81 秒;第二次產生 PDF 時,Day 12 的磁碟緩存命中,只花 0.4 秒。

但打開報告,問題很明顯:17 個衝突全部來自 LLM 補充層,規則層一個都沒找到。逐一檢查:

# 需求 A 需求 B 補充層的理由
1 支持富文本編輯(粗體、斜體、標題等) 支持 Markdown 語法 使用不同的編輯方式,無法同時成立
5 自動保存版本(每 30 秒) 支持回到任意歷史版本 對應於不同的版本控制方式,無法同時成立
13 傳輸層 TLS 1.3 加密 端到端加密選項(用戶可選) 對應於不同的加密方式,無法同時成立
16 支持 Docker 容器化部署 使用 Kubernetes 編排 對應於不同的部署方式,無法同時成立

17 個全部是誤報。模型幾乎都把同一節裡相鄰的兩條需求配成一對,理由套用同一個句型「對應於不同的 X,無法同時成立」。其中 3 筆(#9、#10、#14)的理由甚至描述的是另一對需求,例如 #14 的需求是「OAuth 單點登錄 vs 多因素認證」,理由卻寫「使用微服務架構和使用 Kubernetes 編排」。

反過來,這份規格書第 7 節自己列了 3 個「已知的矛盾」,例如「1000 並行用戶 vs < 100ms 反應時間」,系統一個都沒找到。這三個都屬於 Day 18 判斷為「性能取捨」的類型。

為什麼在大文件上會這樣

把這次的結果和前幾天的實驗放在一起看:

資料 需求數 補充層的表現
Day 7-10 共用資料集 每份 8-11 條,刻意放入衝突 10 個結果全部正確(Day 20)
srs_medium.md 58 條,幾乎沒有直接矛盾 17 個結果全部錯誤

補充 prompt 要求模型「找出潛在的衝突」,Day 7 資料集裡確實有衝突可找;但一份寫得還算一致的規格書裡沒有那麼多衝突,模型仍然要交出「一些東西」,於是開始配對相鄰的需求。Day 18-21 的所有結論,都是在「每份 SRS 都有好幾個衝突」的小資料集上得到的,這正是 Day 18 提醒過的「資料集太小」,在這裡具體地出現了。

另一個隱憂是長度:這次的補充 prompt 有 3,868 個字元,Ollama 回報實際是 2,623 個 token;而本文的 Mistral 上下文長度是 4096。需求再多一倍,整份需求一次丟給補充層的做法就會超過上限。

這兩個問題都需要在更大、而且「衝突很少」的資料上量測,Day 27 的大規模基準測試會回到這裡。在那之前,對大型規格書比較穩妥的用法是關閉補充層:

python3 -m src.cli analyze spec.md --ollama --no-supplement

只保留「規則找到、LLM 驗證過」的結果。


接進 CI

以 GitHub Actions 為例,在規格書有變動時只跑規則層(不需要 Ollama):

# .github/workflows/srs-review.yml(範例)
on:
  push:
    paths: ["docs/spec.md"]
jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pip install -r requirements.txt
      - run: python3 -m src.cli analyze docs/spec.md -o review.md --fail-on 高

有高嚴重度衝突時結束碼為 2,這個步驟就會失敗。這份設定檔是範例,本文沒有實際在 GitHub 上執行。


權衡與限制

  • 需求格式是固定的:只認得「REQ-編號:內容」。用表格、編號清單(1.2.3)或其他前綴(FR-01)寫的規格書,需要先轉換或擴充 REQUIREMENT_PATTERN
  • 沒有使用 Day 3 的分塊:Day 3 的 src/chunking.py 為長文件設計,但目前檢測器需要整份需求清單(兩兩比較、補充層看全文)。對超過上下文長度的規格書,補充層需要改成分段處理
  • 補充層在大文件上不可靠:見上方 srs_medium.md 的結果。這是目前最大的品質問題
  • CI 只能用規則層:GitHub 的一般執行環境沒有 GPU,跑 7B 模型會非常慢。要在 CI 用 LLM 層,需要一台自架的 Ollama 主機

提交變更

git add src/cli.py tests/test_day24_cli.py
git commit -m "Day 24: 命令列工具(extract / analyze、Markdown/JSON/PDF、--fail-on)"

明天預告

CLI 在本機跑得很順,但換一台電腦就要重新安裝 Python 套件、中文字型、設定 Ollama 位址。明天 Day 25 我們把後端與 CLI 打包成 Docker 映像檔:一行指令啟動 API,也能直接用容器分析規格書、產生 PDF,而且不必在主機上安裝任何字型。


上一篇
Day 23:圖表與 PDF 報告
下一篇
Day 25:Docker 容器化
系列文
解決需求規格書矛盾:用 Claude Code × MCP 實作自律型文檔審查 Agent 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言