{"req_id_1": "REQ-2.4.3", "req_id_2": "REQ-4.1", "type": "安全性衝突", ...}
真正要讀結果的是寫規格書的人(PM、系統分析師)。第 4 週的主題是「讓系統可以交付」,今天先做最核心的一件事:把檢測結果整理成一份可以直接閱讀、可以交給團隊的審查報告。
第 3 週:智能優化與增強 → Day 18-21 失敗分析、prompt 改進、guardrail、A/B 測試,F1 0.767 → 0.897
第 4 週:發佈與開源
讀完這篇後,你會完成:
src/report_generator.py:ReportItem、ReviewReport
render_markdown()、to_dict()
tests/test_day22_report.py:5 個離線測試讀報告的人通常只想知道三件事:
| 問題 | 報告中對應的段落 |
|---|---|
| 問題嚴不嚴重? | 摘要:衝突數、依嚴重度統計 |
| 我該先看哪裡? | 熱點需求、依嚴重度排序的詳情 |
| 這些結果可信嗎? | 每筆的來源與 LLM 驗證狀態、待複核清單 |
第三點是這個系列一路走來學到的:Day 11-21 的每一次實驗都顯示 LLM 會犯錯。Day 18 找到驗證層放行的誤報,Day 19 看到補充層照抄 prompt 的文字,Day 21 的自我矛盾檢查也漏判了一句。報告不能假裝這些結果都一樣可靠,而是要讓讀者知道哪些需要再確認。
| 情況 | 原因 |
|---|---|
| 來自 LLM 補充層 | 規則沒有命中,完全由 LLM 判斷(Day 11) |
| 來自自我矛盾檢查 | 單條需求的判斷,完全由 LLM 判斷(Day 21) |
| 驗證層重試後仍缺答而保留 | 「寧可多報」的策略(Day 12、Day 20) |
| 沒有經過 LLM 驗證 | 只有規則,例如 Ollama 模式沒開時 |
規則命中、又經過 LLM 驗證保留的結果,則不需要複核。這不代表它一定正確(Day 18 的 2 個誤報就是這一類),但它是兩層都同意的結果,可信度最高。
constraints:[{id, text}, ...]
conflicts:OptimizedDetector 的 Conflict 或 API 結果的 dict
↓ build_report()
ReviewReport
├─ items:ReportItem(附上兩條需求原文、來源、是否待複核)
├─ by_severity / by_type / by_source
└─ hotspots:牽涉 ≥ 2 個衝突的需求
↓
render_markdown() → Markdown 報告
to_dict() → JSON(Day 24 的 CLI 使用)
ReviewReport 物件、Markdown、JSONsrc/report_generator.py(新增)ReviewReport 產生輸出專案結構變化:
srs-review-agent/
├── src/
│ ├── performance_optimizer.py ← Day 12-21(檢測器)
│ └── report_generator.py ← 【新增】報告資料結構、統計、Markdown / JSON
└── tests/
└── test_day22_report.py ← 【新增】5 個測試
今天只用 Python 標準庫,不需要新的套件。
建立 src/report_generator.py:
@dataclass
class ReportItem:
"""報告中的一個衝突"""
req_id_1: str
req_id_2: str
text_1: str
text_2: str
type: str
severity: str
description: str
confidence: float
verified: bool
source: str # simple / llm_supplement / self_check / ...
needs_review: bool
review_reason: str = ""
@property
def is_self_contradiction(self) -> bool:
return self.req_id_1 == self.req_id_2
和檢測器的 Conflict 相比,ReportItem 多了兩條需求的原文。檢測器只需要編號,讀者卻看不懂「REQ-2.4.3 ↔ REQ-4.1」。is_self_contradiction 沿用 Day 21 的約定:自我矛盾以 req_id_1 == req_id_2 表示。
檢測結果有兩種來源:直接呼叫 OptimizedDetector 得到的 Conflict dataclass,以及 API 存在資料庫裡的 dict(Day 13、16)。用一個小函數同時處理:
def _get(c, name, default=None):
"""同時支援 dataclass(Conflict)與 dict(API 結果)"""
if isinstance(c, dict):
return c.get(name, default)
return getattr(c, name, default)
def _enum_value(v):
return getattr(v, "value", v)
Conflict.severity 是 Severity.HIGH 這種列舉,dict 裡則是字串 "高",_enum_value() 把兩者統一成字串。
# 驗證層重試後仍缺答時寫入的理由(見 performance_optimizer._llm_verify_batch)
UNANSWERED_REASON = "LLM 未回答此題,保留"
def _review_reason(source: str, verified: bool, verification_result: Optional[str]) -> str:
if verification_result == UNANSWERED_REASON:
return "LLM 驗證未回答,依策略保留"
if source == "llm_supplement":
return "由 LLM 補充發現,規則未命中"
if source == "self_check":
return "由 LLM 判定單條需求自相矛盾"
if not verified:
return "未經 LLM 驗證"
return ""
「缺答而保留」的判斷依據,是 Day 12 寫在 _llm_verify_batch() 的那句理由。這兩處用同一個字串,所以把它定義成常數 UNANSWERED_REASON。如果之後有人改了檢測器的文字,測試會失敗,而不是讓報告默默漏標。
build_report() 先把每個衝突轉成 ReportItem,再依嚴重度與置信度排序:
rank = {s: i for i, s in enumerate(SEVERITY_ORDER)} # ["高", "中", "低"]
items.sort(key=lambda i: (rank.get(i.severity, len(rank)), -i.confidence, i.req_id_1, i.req_id_2))
排序鍵的最後兩項是需求編號,這樣置信度相同時順序也固定,同一份資料每次產生的報告都一樣。
熱點需求是「被捲入最多衝突的需求」:
involvement = Counter()
for i in items:
involvement[i.req_id_1] += 1
if not i.is_self_contradiction:
involvement[i.req_id_2] += 1
hotspots = [(req_id, n, texts.get(req_id, ""))
for req_id, n in sorted(involvement.items(), key=lambda kv: (-kv[1], kv[0]))
if n >= 2][:hotspot_limit]
自我矛盾只算一次,否則同一條需求會被重複計數。門檻設在 2:只牽涉 1 個衝突的需求,看衝突詳情就夠了;同時跟好幾條需求打架的需求,通常代表寫法本身有問題,值得優先處理。
依類型與來源的統計用 Counter(...).most_common(),來源會轉成中文標籤(simple → 「規則」、llm_supplement → 「LLM 補充」、self_check → 「自我矛盾檢查」)。
render_markdown() 依序輸出摘要、統計表、熱點需求、依嚴重度分組的詳情、待複核清單。兩個細節:
def _escape(text: str) -> str:
"""表格儲存格中的 | 與換行會破壞 Markdown 表格"""
return str(text).replace("|", "/").replace("\n", " ")
需求原文可能包含 |,直接放進表格會多出一欄。詳情段落裡,自我矛盾只列一次需求:
if i.is_self_contradiction:
lines.append(f"- {i.req_id_1}:{i.text_1}")
else:
lines.append(f"- {i.req_id_1}:{i.text_1}")
lines.append(f"- {i.req_id_2}:{i.text_2}")
報告最後固定加一句「LLM 的判斷可能有誤,請以人工複核為準」。to_dict() 輸出同樣內容的 JSON,完整代碼見 src/report_generator.py。
cd srs-review-agent
python3 -m pytest tests/test_day22_report.py -v
tests/test_day22_report.py::test_statistics PASSED [ 20%]
tests/test_day22_report.py::test_sorting_and_review PASSED [ 40%]
tests/test_day22_report.py::test_markdown PASSED [ 60%]
tests/test_day22_report.py::test_escape_in_tables PASSED [ 80%]
tests/test_day22_report.py::test_empty_and_json PASSED [100%]
============================== 5 passed in 0.06s ===============================
測試資料刻意混合了四種來源:規則 + 驗證通過、規則 + 驗證缺答、LLM 補充(dict 格式)、自我矛盾(Conflict 格式),確認每一種的待複核原因都正確。
寫測試時,我原本預期待複核清單的第 3 項是自我矛盾。實際上清單沿用了報告的排序:自我矛盾是「高」嚴重度,排在「中」的 LLM 補充前面,所以是第 2 項。這是測試寫錯了,報告的行為是對的:讀者在詳情和待複核清單看到的順序應該一致。
用 Day 7-10 共用資料集的社交媒體 SRS,只跑規則層:
from src.performance_optimizer import OptimizedDetector
from src.report_generator import build_report, render_markdown
conflicts = OptimizedDetector().detect_conflicts(constraints, enable_verification=False)
print(render_markdown(build_report(constraints, conflicts, title="社交媒體平台 SRS 審查報告",
meta={"檢測設定": "規則"})))
報告的摘要與熱點需求:
## 摘要
- 需求數:10
- 發現衝突:5 個,涉及 7 條需求
- 需要人工複核:5 個
### 熱點需求
| 需求 | 衝突數 | 內容 |
|------|--------|------|
| REQ-2.4.3 | 3 | 帖子內容採用明文存儲 |
| REQ-5.2 | 2 | 帳戶刪除後,數據永久保存 |
詳情依嚴重度分組:
### 嚴重度:高(3 個)
**1. 安全性衝突**(待複核)
- REQ-2.4.2:所有帖子內容必須加密
- REQ-2.4.3:帖子內容採用明文存儲
- 說明:檢測到 加密 vs 明文
- 置信度:95%|來源:規則|LLM 驗證:否
只用規則時,5 個結果全部標為「未經 LLM 驗證」而待複核。
這份報告剛好說明了熱點需求的意義與限制。REQ-2.4.3「帖子內容採用明文存儲」牽涉 3 個衝突,但 Day 18 的分析指出,其中「明文存儲 vs 支持端到端加密」與「明文存儲 vs 不支持任何加密機制」都是誤報,只有「必須加密 vs 明文存儲」是真的。所以熱點代表「值得優先檢查」,不代表「一定有問題」。報告裡的文字也是這樣寫的:「建議優先檢查」。
git add src/report_generator.py tests/test_day22_report.py
git commit -m "Day 22: 審查報告生成(統計、熱點需求、待複核清單、Markdown/JSON)"
Markdown 報告適合放進 Git 或 Wiki,但要寄給客戶或主管時,大家還是習慣 PDF,而且一眼看懂的圖表比表格更有說服力。明天 Day 23 我們用 reportlab 把今天的 ReviewReport 輸出成含長條圖的 PDF,並處理一個中文 PDF 必然會遇到的問題:字型。