Day 22-27 把系統做成了可以交付的形式:審查報告、PDF、命令列工具、Docker、Compose,還有 Day 27 剛加入的 OLLAMA_NUM_CTX 與 OLLAMA_TIMEOUT。功能齊了,但要用起來,使用者得翻遍 27 篇文章才知道:
今天寫一份完整的使用指南 docs/USAGE.md。但寫文件只是一半,另一半是更常被忽略的問題:文件怎麼跟著程式一起更新。這個系列一路走來最常修的,正是「文章寫的和程式做的不一樣」。所以今天也寫一組測試,讓文件和程式不一致時,測試直接失敗。
第 4 週:發佈與開源
讀完這篇後,你會完成:
docs/USAGE.md:安裝、CLI、Web、Docker、18 個環境變數、疑難排解、準確度參考tests/test_day28_docs.py:6 個測試,雙向比對文件與程式文件過時通常不是因為沒人寫,而是因為改程式的人沒有被提醒。回顧這個系列:
| 天 | 程式改了什麼 | 沒跟著改的文件 |
|---|---|---|
| Day 13 | API 端點 | 文章仍寫 /api/analysis |
| Day 15 | 登入用表單格式 | 文章仍寫 JSON |
| Day 17 | 新增 /refresh |
API 說明沒有列出 |
| Day 21 | 新增 SRS_SELF_CHECK |
只寫在當天的文章裡,沒有使用者文件 |
所以文件裡可以被機器檢查的部分,就應該讓機器檢查:
| 文件內容 | 程式中的真實來源 |
|---|---|
| 環境變數表 | 程式中所有 os.getenv("...") |
| CLI 參數表 | src/cli.py 的 argparse 定義 |
| 結束碼 | EXIT_OK、EXIT_INPUT_ERROR、EXIT_FAIL_ON |
| 提到的檔案路徑 | 檔案系統 |
| 範例指令 | 實際執行 |
說明文字、設計理由這些寫給人看的內容,機器檢查不了,仍要靠人。
docs/USAGE.md
1. 選擇使用方式 2. 安裝 3. CLI 4. Web 5. Docker Compose
6. 設定(環境變數表) 7. 疑難排解 8. 準確度參考
↑ 雙向比對
tests/test_day28_docs.py
├─ 程式的 os.getenv ⊆ 文件的環境變數表 (程式加了變數,文件要寫)
├─ 文件的環境變數表 ⊆ 程式 + compose + 前端 (程式刪了變數,文件要刪)
├─ 文件的 CLI 參數表 = argparse 定義
├─ 文件的結束碼表 = 程式常數
├─ 文件提到的檔案都存在
└─ 文件中的離線範例指令可以執行
docs/USAGE.md 與專案原始碼docs/USAGE.md、tests/test_day28_docs.py(新增)CONTRIBUTING.md 要求貢獻者遵守這些檢查專案結構變化:
srs-review-agent/
├── docs/
│ ├── Day-*.md ← 每一天的實作紀錄
│ └── USAGE.md ← 【新增】使用指南
└── tests/
└── test_day28_docs.py ← 【新增】6 個測試
今天不需要新的套件。
docs/USAGE.md 依「使用者會遇到的順序」安排,而不是依「系列的天數」:
## 1. 選擇使用方式
| 方式 | 適合 | 需要 |
| 命令列(CLI) | 分析一份規格書、接進 CI | Python 3.12+ |
| Web(API + 前端) | 團隊共用、多人登入、保存歷史 | Python、Node.js 18+ |
| Docker Compose | 一次部署整套系統 | Docker |
讀者先決定用哪一種,再跳到對應的段落。幾個特別花心思的地方:
COMPOSE_OLLAMA_URL 與只在前端打包時用的 VITE_PUBLIC_MODE
建立 tests/test_day28_docs.py。先寫一個取段落的工具:
USAGE = (ROOT / "docs" / "USAGE.md").read_text(encoding="utf-8")
def _section(title: str) -> str:
"""取出某個 ## / ### 標題底下、到下一個同級以上標題為止的內容"""
# 只把 ## / ### 當成段落標題;程式碼區塊裡的 bash 註解(# ...)與 Markdown 範例的 # 標題不算
match = re.search(rf"^(##+) {re.escape(title)}\n(.*?)(?=^#{{2,3}} |\Z)", USAGE, re.M | re.S)
assert match, f"找不到段落:{title}"
return match.group(2)
註解裡那一行是測試第一次執行失敗後才加上的:一開始把所有 # 開頭的行都當成標題,結果程式碼區塊裡的 bash 註解 # 只用規則,報告印到標準輸出 被當成新段落,範例指令全部被切掉,測試回報「沒有找到範例指令」。用 Markdown 寫的文件,程式碼區塊裡也會有 #。
def _env_vars_in_code() -> set:
names = set()
for path in (ROOT / "src").glob("*.py"):
names |= set(re.findall(r'os\.getenv\(\s*"([A-Z_]+)"', path.read_text(encoding="utf-8")))
return names
def _env_vars_in_docs() -> set:
return set(re.findall(r"^\| `([A-Z_]+)` \|", _section("6. 設定(環境變數)"), re.M))
def test_env_vars_documented():
"""測試 1:程式讀取的環境變數全部寫進文件"""
missing = _env_vars_in_code() - _env_vars_in_docs()
assert not missing, f"文件沒有列出:{sorted(missing)}"
正規表達式中的 \s* 是為了 src/database.py 那種跨行寫法:
DATABASE_URL = os.getenv(
"DATABASE_URL",
"sqlite:///./test.db" # 開發默認
)
反方向的檢查防止文件過時:
def test_documented_env_vars_exist():
"""測試 2:文件列出的環境變數都真的有被使用(避免文件過時)"""
other_sources = (ROOT / "docker-compose.yml").read_text(encoding="utf-8") + \
(ROOT / "frontend" / "index.jsx").read_text(encoding="utf-8")
stale = {name for name in _env_vars_in_docs() - _env_vars_in_code() if name not in other_sources}
assert not stale, f"文件列出但沒有任何地方使用:{sorted(stale)}"
COMPOSE_OLLAMA_URL 只出現在 docker-compose.yml,VITE_PUBLIC_MODE 只出現在前端,所以這兩個來源也要一起搜尋。
def _parser_options(command: str) -> set:
sub = next(a for a in build_parser()._actions if a.dest == "command").choices[command]
return {opt for action in sub._actions for opt in action.option_strings if opt not in ("-h", "--help")}
def test_cli_options_match_parser():
"""測試 3:CLI 參數表與 argparse 定義完全相同"""
# 反引號內可能帶選項值,例如 `--format md\|json\|pdf`
analyze_doc = set(re.findall(r"^\| `(-[-\w]+)[^`]*`(?:, `(--[-\w]+)`)?",
_section("3.3 參數").split("`extract`")[0], re.M))
analyze_doc = {o for pair in analyze_doc for o in pair if o}
...
assert analyze_doc == _parser_options("analyze"), \
f"analyze 差異:文件多 {analyze_doc - _parser_options('analyze')},程式多 {_parser_options('analyze') - analyze_doc}"
Day 24 的 build_parser() 在這裡派上用場:把建立 parser 獨立成函數,測試就能直接拿到參數定義,不必解析 --help 的輸出。
第一次執行時這個測試失敗了,訊息是「程式多 {'--format', '--fail-on'}」:文件裡寫的是 `--format md\|json\|pdf`,反引號裡除了參數名還有選項值,原本的正規表達式沒有涵蓋。文件本身沒錯,是測試要能讀懂文件的寫法。
def test_referenced_files_exist():
"""測試 5:文件中提到的專案檔案都存在"""
paths = set(re.findall(r"`((?:tests|docs|src|frontend)/[\w./-]+\.\w+)`", USAGE))
paths |= {"docs/" + p for p in re.findall(r"\]\(([\w-]+\.md)\)", USAGE)}
paths |= {"docker-compose.yml", ".env.example", "requirements.txt", "Dockerfile"}
missing = [p for p in sorted(paths) if not (ROOT / p).exists()]
assert not missing, f"文件提到但不存在:{missing}"
def test_offline_examples_run(capsys):
"""測試 6:3.1 / 3.2 中不需要 Ollama 的範例指令可以執行"""
commands = re.findall(r"^python3 -m src\.cli (.+)$",
_section("3.1 規格書格式") + _section("3.2 分析"), re.M)
offline = [c for c in commands if "--ollama" not in c and "--format pdf" not in c and " -o " not in c]
for command in offline:
args = command.split("#")[0].split()
assert main(args) == EXIT_OK, f"範例指令失敗:{command}"
範例指令直接呼叫 Day 24 的 main(argv),和使用者在終端機打的是同一條路徑。需要 Ollama、會寫檔案的範例則略過,避免測試依賴外部服務或留下檔案。
test_referenced_files_exist 也抓到一個問題:我一開始在 USAGE.md 寫了「端點說明見 docs/API.md」,但那份文件明天 Day 29 才會建立。今天先拿掉這個連結,明天建立 API 文件時再加回來,屆時這個測試會確認連結有效。
cd srs-review-agent
python3 -m pytest tests/test_day28_docs.py -v
tests/test_day28_docs.py::test_env_vars_documented PASSED [ 16%]
tests/test_day28_docs.py::test_documented_env_vars_exist PASSED [ 33%]
tests/test_day28_docs.py::test_cli_options_match_parser PASSED [ 50%]
tests/test_day28_docs.py::test_exit_codes PASSED [ 66%]
tests/test_day28_docs.py::test_referenced_files_exist PASSED [ 83%]
tests/test_day28_docs.py::test_offline_examples_run PASSED [100%]
============================== 6 passed in 0.09s ===============================
一組永遠通過的測試沒有意義。用兩種方式確認:
1. 故意讓文件過時:從 USAGE.md 刪掉 SRS_SELF_CHECK 那一列再跑:
E AssertionError: 文件沒有列出:['SRS_SELF_CHECK']
FAILED tests/test_day28_docs.py::test_env_vars_documented
還原後 6 個測試全部通過。
2. 真的發生過:Day 27 為了處理 prompt 截斷,在 OllamaLLM 加入了 OLLAMA_NUM_CTX 與 OLLAMA_TIMEOUT。撰寫 USAGE.md 時,我一開始是照著前幾天的筆記列環境變數,漏掉了這些最新的變數,測試立刻指出:
E AssertionError: 文件沒有列出:['OLLAMA_NUM_CTX']
OLLAMA_TIMEOUT 也是同樣的情況。補上之後,順便在疑難排解 7.4 加入 Day 27 的截斷警告,以及 OLLAMA_NUM_CTX、OLLAMA_TIMEOUT 的用法。這正是這組測試存在的理由:新增功能的人不必記得去改文件,測試會提醒他。
SESSION_INACTIVITY_MINUTES 的預設值改成 60,表格裡寫的 30 不會被發現git add docs/USAGE.md tests/test_day28_docs.py .env.example
git commit -m "Day 28: 使用指南與文件同步檢查"
USAGE.md 寫給使用系統的人;要串接 API 的開發者還需要另一份文件:每個端點的請求與回應、認證流程、錯誤格式,以及可以直接執行的範例程式。明天 Day 29 我們匯出 OpenAPI 規格、撰寫 API 文件與 Python / curl 範例,並用同樣的方式讓 API 文件與程式自動保持一致。