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 週:發佈與開源
讀完這篇後,你會完成:
parse_requirements():從 Markdown 規格書抽出 REQ-x:內容,處理重複編號extract 子命令:列出規格書中的需求analyze 子命令:檢測並輸出 Markdown / JSON / PDF 報告,預設只用規則,--ollama 啟用 Day 20 的設定--fail-on 高,有達到門檻的衝突時結束碼為 2tests/test_day24_cli.py:6 個離線測試專案的測試規格書 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
src/cli.py(新增)專案結構變化:
srs-review-agent/
├── src/
│ ├── report_generator.py ← Day 22
│ ├── pdf_report.py ← Day 23
│ └── cli.py ← 【新增】extract / analyze
└── tests/
└── test_day24_cli.py ← 【新增】6 個測試
今天不需要新的套件。
建立 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 的版本相比:
\*{0,2}:**REQ-3.1**:… 這種粗體寫法也能抽到。測試第一次跑時這一條沒過,才發現編號和冒號之間夾了 **
只有「編號後面緊接冒號」的行才算需求。規格書裡提到需求的說明文字,例如「REQ-3.1.2.1(多用戶編輯)依賴 REQ-4.3.2.1(OAuth 認證)」,因為編號後面是括號,不會被誤抽成需求。
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 時也不必載入報告模組。
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 個衝突」這種訊息REQ-1 - 內容),默默產生一份「沒有衝突」的報告反而危險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) 並檢查回傳的結束碼,不必另外開一個子行程。
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) |
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 驗證過」的結果。
以 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
src/chunking.py 為長文件設計,但目前檢測器需要整份需求清單(兩兩比較、補充層看全文)。對超過上下文長度的規格書,補充層需要改成分段處理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,而且不必在主機上安裝任何字型。