iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0

第 3 週結束時,檢測器在 Day 7-10 共用資料集上的 F1 是 0.897,Day 21 又加上了第 4 層的單條需求自我矛盾檢查。檢測本身已經不錯了,但結果的呈現方式還停在工程師的角度:

  • API 回傳的是 JSON:{"req_id_1": "REQ-2.4.3", "req_id_2": "REQ-4.1", "type": "安全性衝突", ...}
  • 前端的衝突表格一次只能看一個任務,沒有整體統計
  • 沒有任何地方告訴使用者「哪些結果是 LLM 猜的、應該再看一次」

真正要讀結果的是寫規格書的人(PM、系統分析師)。第 4 週的主題是「讓系統可以交付」,今天先做最核心的一件事:把檢測結果整理成一份可以直接閱讀、可以交給團隊的審查報告。

這一天在系列中的位置

第 3 週:智能優化與增強 → Day 18-21 失敗分析、prompt 改進、guardrail、A/B 測試,F1 0.767 → 0.897
第 4 週:發佈與開源

  • Day 22 → 審查報告生成與統計分析(Markdown) ← 今天
  • Day 23 → 圖表與 PDF 報告
  • Day 24 → 命令列工具
  • Day 25-26 → Docker 與 Docker Compose
  • Day 27-29 → 性能基準、使用文檔、API 文檔
  • Day 30 → 開源發佈

今日目標

讀完這篇後,你會完成:

  1. 報告資料結構 src/report_generator.py:ReportItem、ReviewReport
  2. 統計分析:依嚴重度、類型、檢測來源統計;找出熱點需求(牽涉 2 個以上衝突的需求)
  3. 待複核清單:自動標出需要人工再看一次的結果,並說明原因
  4. Markdown 與 JSON 輸出:render_markdown()、to_dict()
  5. 測試 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、JSON
  • 檔案:src/report_generator.py(新增)
  • 下游:Day 23 的 PDF 與 Day 24 的 CLI 都從 ReviewReport 產生輸出

專案結構變化:

srs-review-agent/
├── src/
│   ├── performance_optimizer.py   ← Day 12-21(檢測器)
│   └── report_generator.py        ← 【新增】報告資料結構、統計、Markdown / JSON
└── tests/
    └── test_day22_report.py       ← 【新增】5 個測試

今天只用 Python 標準庫,不需要新的套件。


代碼示例

1. 報告資料結構

建立 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 表示。

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() 把兩者統一成字串。

3. 待複核的判斷

# 驗證層重試後仍缺答時寫入的理由(見 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。如果之後有人改了檢測器的文字,測試會失敗,而不是讓報告默默漏標。

4. 統計與熱點需求

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 → 「自我矛盾檢查」)。

5. Markdown 輸出

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 明文存儲」是真的。所以熱點代表「值得優先檢查」,不代表「一定有問題」。報告裡的文字也是這樣寫的:「建議優先檢查」。


權衡與限制

  • 嚴重度沿用檢測器的設定:規則層的嚴重度是寫死在規則上的(Day 7),LLM 補充一律是「中」,自我矛盾一律是「高」。報告只是如實呈現,沒有重新評估
  • 熱點只看數量:被 3 個誤報捲入的需求,排名和被 3 個真衝突捲入的需求一樣。如果之後累積了使用者的回饋,可以改成依「確認過的衝突」計數
  • 說明文字的語言取決於檢測設定:補充層 v2 要求繁體中文(Day 19),但用 v1 時說明可能是英文(Day 18 統計 7 個中有 6-7 個)。報告照原文呈現,不做翻譯
  • 沒有修復建議:報告只指出衝突,不建議怎麼改。規劃中「修復建議生成」需要再一次 LLM 呼叫,成本與品質都要另外評估,今天先不做

提交變更

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 必然會遇到的問題:字型。


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

尚未有邦友留言

立即登入留言