Compare commits

..

2 Commits

Author SHA1 Message Date
LittleYellow
cb16e8688f chore: .markdownlint.jsonc 明確關閉 MD013,避免被動啟用
前一個 commit 新增 .markdownlint.jsonc(關 MD060)時,該檔一存在就會取代
VS Code markdownlint 擴充的內建預設集,而預設集本來關著 MD013——等於間接把
行長檢查打開,讓既有檔案(CJK 表格、逐字保存的 bootstrap prompt)冒出大量
新警告。明確設 MD013:false 恢復專案原本行為,並註明日後想啟用該如何調整。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 06:13:04 +08:00
LittleYellow
3c1b268074 修復含空格/括號檔名的圖片連結失效,自檢改驗連結可 render
來源檔名含空格或不平衡括號時,convert 產出的 Markdown 圖片連結會失效——
圖檔明明存在於磁碟,整行卻退化成純文字(CommonMark 對未包住的空格與不平衡
括號視為分隔符)。用真 CommonMark parser 驗過:中文與成對括號正常,空格與
落單括號會壞。

- convert.py / from_docx.py / from_pdf.py / from_pptx.py:四處圖片連結目標
  一律以 <> 包住(![name](<rel>)),同時涵蓋空格與不平衡括號。
- selfcheck.py:新增 assert_links_ok()——以 markdown_it 真 render,斷言圖片
  連結數 == <img> 數且目標檔存在,取代原本只檢查 "![" 字串在不在的表面斷言。
  測資補內嵌圖片(docx/pdf/pptx 各一路徑)、檔名改含空格與不平衡括號。
- requirements.txt:明確宣告 markdown-it-py(原為 fastmcp 傳遞依賴,自檢直接
  使用,宣告以免上游調整依賴樹後自檢失效)。
- AGENTS.md §13.4:新增「斷言要驗結果可用,不是驗字串存在」規則。

驗證:四個自檢全通過;移除任一處 <> 修法,assert_links_ok 即失敗。
ingest / lint / search 在含括號檔名下全鏈路已另行驗證正常(source_ref 走
YAML 純量,不受影響)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 06:12:47 +08:00
8 changed files with 64 additions and 11 deletions

View File

@@ -3,5 +3,11 @@
// 這是人類在編輯器裡讀得順的排法。MD060 以「字元位置」比對管線符號,對 CJK // 這是人類在編輯器裡讀得順的排法。MD060 以「字元位置」比對管線符號,對 CJK
// 無感——兩者不可能同時滿足。選擇保留顯示寬度對齊,關閉此規則。 // 無感——兩者不可能同時滿足。選擇保留顯示寬度對齊,關閉此規則。
// 其餘規則維持預設(含 MD040程式碼區塊必須標語言 // 其餘規則維持預設(含 MD040程式碼區塊必須標語言
"MD060": false "MD060": false,
// MD013本設定檔一存在就會取代 VS Code 擴充內建的預設集(該預設集關閉
// MD013使行長檢查被動啟用——這會讓既有檔案尤其 CJK 表格與逐字保存的
// bootstrap prompt冒出大量新警告。維持專案原本的行為關閉。
// 若日後想強制 80 字元散文慣例,改為 { "tables": false } 並處理既有檔案。
"MD013": false
} }

View File

@@ -276,6 +276,9 @@ output**)。單筆修正只治標;規則修正才治本。
語料要有跨頁共用的核心詞。便利值會變成盲點的形狀——ASCII 檔名讓 `git status` 語料要有跨頁共用的核心詞。便利值會變成盲點的形狀——ASCII 檔名讓 `git status`
的路徑轉義 bug 溜過,互不重疊的語料讓 BM25 的 idf 退化 bug 溜過。 的路徑轉義 bug 溜過,互不重疊的語料讓 BM25 的 idf 退化 bug 溜過。
新增檢查時先問:**這組測資和真實輸入差在哪?差異處就是沒被測到的地方。** 新增檢查時先問:**這組測資和真實輸入差在哪?差異處就是沒被測到的地方。**
- **斷言要驗「結果可用」,不是驗「字串存在」**`assert "![img](" in md` 只證明字串被
組出來,證明不了連結解得開——真正該做的是用解析器 render 後確認產出 `<img>`
且目標檔存在。測資照現實建了、斷言卻停在表面bug 一樣會溜過去。
- 把任務轉成可驗證的目標,迭代到通過: - 把任務轉成可驗證的目標,迭代到通過:
- 「加驗證」→ 先寫無效輸入的測試,再讓它通過。 - 「加驗證」→ 先寫無效輸入的測試,再讓它通過。
- 「修 bug」→ 先寫重現 bug 的測試,再讓它通過。 - 「修 bug」→ 先寫重現 bug 的測試,再讓它通過。

View File

@@ -7,3 +7,5 @@ python-pptx # PowerPoint → Markdown
trafilatura # 網頁快照 → Markdown 正文萃取 trafilatura # 網頁快照 → Markdown 正文萃取
rank-bm25 # tools/search.py BM25第一版不用向量庫 rank-bm25 # tools/search.py BM25第一版不用向量庫
fastmcp # mcp/server.py 薄殼 fastmcp # mcp/server.py 薄殼
markdown-it-py # 自檢用:以 CommonMark 解析驗證產出的圖片連結真的能 render
# (原為 fastmcp 的傳遞依賴,明確宣告以免上游調整後自檢失效)

View File

@@ -125,7 +125,8 @@ def _vision_pdf(root, pdf_path, assets, conv_name):
blocks = [] blocks = []
for i, (img, text) in enumerate(zip(images, texts), 1): for i, (img, text) in enumerate(zip(images, texts), 1):
rel = img.relative_to(md_dir).as_posix() rel = img.relative_to(md_dir).as_posix()
blocks.append(f"<!-- page {i} (vision: {model}) -->\n\n![page {i}]({rel})\n\n{text}") # <> 包住:檔名含空格或不平衡括號時,未包住的連結會失效
blocks.append(f"<!-- page {i} (vision: {model}) -->\n\n![page {i}](<{rel}>)\n\n{text}")
return "\n\n".join(blocks), "ok", "from_vision" return "\n\n".join(blocks), "ok", "from_vision"

View File

@@ -50,7 +50,9 @@ def _par_md(par, doc, assets_dir, md_dir, counter):
rel = (assets_dir / name).relative_to(md_dir).as_posix() rel = (assets_dir / name).relative_to(md_dir).as_posix()
else: else:
rel = name # dry-run僅佔位 rel = name # dry-run僅佔位
parts.append(f"![{name}]({rel})") # 連結目標以 <> 包住:來源檔名可能含空格或不平衡括號,兩者都會讓
# Markdown 連結失效(圖片存在卻整行退化成純文字)
parts.append(f"![{name}](<{rel}>)")
parts.append(run.text) parts.append(run.text)
text = "".join(parts).strip() text = "".join(parts).strip()
lvl = _heading_level(par) lvl = _heading_level(par)

View File

@@ -42,7 +42,8 @@ def convert(src, assets_dir=None, md_dir=None):
assets_dir.mkdir(parents=True, exist_ok=True) assets_dir.mkdir(parents=True, exist_ok=True)
pix.save(str(assets_dir / name)) pix.save(str(assets_dir / name))
rel = (assets_dir / name).relative_to(md_dir).as_posix() rel = (assets_dir / name).relative_to(md_dir).as_posix()
blocks.append(f"![{name}]({rel})") # <> 包住:檔名含空格或不平衡括號時,未包住的連結會失效
blocks.append(f"![{name}](<{rel}>)")
doc.close() doc.close()
return "\n\n".join(blocks), "ok" return "\n\n".join(blocks), "ok"

View File

@@ -45,7 +45,8 @@ def convert(src, assets_dir=None, md_dir=None):
rel = (assets_dir / name).relative_to(md_dir).as_posix() rel = (assets_dir / name).relative_to(md_dir).as_posix()
else: else:
rel = name rel = name
blocks.append(f"![{name}]({rel})") # <> 包住:檔名含空格或不平衡括號時,未包住的連結會失效
blocks.append(f"![{name}](<{rel}>)")
if slide.has_notes_slide: if slide.has_notes_slide:
notes = slide.notes_slide.notes_text_frame.text.strip() notes = slide.notes_slide.notes_text_frame.text.strip()
if notes: if notes:

View File

@@ -26,13 +26,39 @@ import to_image
# 測資檔名一律用「中文 + 空格」——真實來源檔就長這樣,而 git status --porcelain # 測資檔名一律用「中文 + 空格」——真實來源檔就長這樣,而 git status --porcelain
# 對這兩者都會加引號轉義。ASCII 檔名的測資曾讓 ingest 的 preflight bug 溜過§12 # 對這兩者都會加引號轉義。ASCII 檔名的測資曾讓 ingest 的 preflight bug 溜過§12
DOCX, XLSX, PDF, SCAN = ("壓測 報告.docx", "測試案例 清單.xlsx", DOCX, XLSX, PDF, SCAN = ("壓測 報告(1).docx", "測試案例 清單.xlsx",
"規格 說明.pdf", "掃描件 無文字層.pdf") "規格 說明.pdf", "掃描件(2 期.pdf")
PPTX, HTML, TXT, DOC = ("結案 簡報.pptx", "指引 快照.html", PPTX, HTML, TXT, DOC = ("結案 簡報.pptx", "指引 快照.html",
"不支援 筆記.txt", "舊版 報告.doc") "不支援 筆記.txt", "舊版 報告.doc")
def assert_links_ok(md, base):
"""每個圖片連結都要真的 render 成 <img>、且目標檔存在。
只斷言 "![" 在不在會讓損毀連結矇混過關——含空格/不平衡括號的檔名會產出
語法有效但解析失敗的連結圖檔明明在磁碟上卻整行退化成純文字§13.4)。
"""
import re
import urllib.parse
from markdown_it import MarkdownIt
want = md.count("![")
html = MarkdownIt("commonmark").render(md)
got = html.count("<img")
assert got == want, f"{want} 個圖片連結只有 {got} 個 render 成功:\n{md[:300]}"
for m in re.finditer(r'<img src="([^"]+)"', html):
p = base / urllib.parse.unquote(m.group(1))
assert p.is_file(), f"連結目標不存在:{p}"
return want
def make_fixtures(d): def make_fixtures(d):
import fitz
png = d / "img.png" # 內嵌圖片用docx/pdf/pptx 三條抽圖路徑都要走到
pix = fitz.Pixmap(fitz.csRGB, fitz.IRect(0, 0, 8, 8))
pix.clear_with(128)
pix.save(str(png))
import docx as docxlib import docx as docxlib
doc = docxlib.Document() doc = docxlib.Document()
doc.add_heading("支付閘道測試報告", level=1) doc.add_heading("支付閘道測試報告", level=1)
@@ -42,6 +68,7 @@ def make_fixtures(d):
t.rows[0].cells[1].text = "結果" t.rows[0].cells[1].text = "結果"
t.rows[1].cells[0].text = "TC-001" t.rows[1].cells[0].text = "TC-001"
t.rows[1].cells[1].text = "FAIL" t.rows[1].cells[1].text = "FAIL"
doc.add_picture(str(png))
doc.save(d / DOCX) doc.save(d / DOCX)
import openpyxl import openpyxl
@@ -52,10 +79,10 @@ def make_fixtures(d):
ws.append(["TC-001", "逾時 | 重試"]) ws.append(["TC-001", "逾時 | 重試"])
wb.save(d / XLSX) wb.save(d / XLSX)
import fitz
pdf = fitz.open() pdf = fitz.open()
page = pdf.new_page() page = pdf.new_page()
page.insert_text((72, 72), "Payment gateway timeout defect reproduced under load test.") page.insert_text((72, 72), "Payment gateway timeout defect reproduced under load test.")
page.insert_image(fitz.Rect(72, 100, 122, 150), filename=str(png))
pdf.save(d / PDF) pdf.save(d / PDF)
pdf.close() pdf.close()
scanned = fitz.open() scanned = fitz.open()
@@ -64,9 +91,11 @@ def make_fixtures(d):
scanned.close() scanned.close()
from pptx import Presentation from pptx import Presentation
from pptx.util import Inches
prs = Presentation() prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[1]) slide = prs.slides.add_slide(prs.slide_layouts[1])
slide.shapes.title.text = "結案報告" slide.shapes.title.text = "結案報告"
slide.shapes.add_picture(str(png), Inches(1), Inches(3))
slide.notes_slide.notes_text_frame.text = "備註:法遵項目全數通過" slide.notes_slide.notes_text_frame.text = "備註:法遵項目全數通過"
prs.save(d / PPTX) prs.save(d / PPTX)
@@ -103,12 +132,12 @@ def main():
(root / sub).mkdir(parents=True) (root / sub).mkdir(parents=True)
(root / "raw/manifest.json").write_text('{"version": 1, "files": {}}', encoding="utf-8") (root / "raw/manifest.json").write_text('{"version": 1, "files": {}}', encoding="utf-8")
convert.main([str(fx / DOCX), str(fx / SCAN), convert.main([str(fx / DOCX), str(fx / XLSX), str(fx / PDF), str(fx / SCAN),
str(fx / HTML), "--root", str(root)]) str(fx / PPTX), str(fx / HTML), "--root", str(root)])
m = json.loads((root / "raw/manifest.json").read_text(encoding="utf-8")) m = json.loads((root / "raw/manifest.json").read_text(encoding="utf-8"))
origs = {k: v for k, v in m["files"].items() if v["kind"] == "original"} origs = {k: v for k, v in m["files"].items() if v["kind"] == "original"}
convs = {k: v for k, v in m["files"].items() if v["kind"] == "converted"} convs = {k: v for k, v in m["files"].items() if v["kind"] == "converted"}
assert len(origs) == 3 and len(convs) == 2, m assert len(origs) == 6 and len(convs) == 5, m # SCAN 為 needs_ocr無 converted
assert origs[f"raw/originals/{SCAN}"]["status"] == "needs_ocr" assert origs[f"raw/originals/{SCAN}"]["status"] == "needs_ocr"
for k, v in convs.items(): for k, v in convs.items():
assert (root / k).is_file(), k assert (root / k).is_file(), k
@@ -116,6 +145,13 @@ def main():
assert (root / f"raw/originals/{DOCX}").is_file() assert (root / f"raw/originals/{DOCX}").is_file()
print("PASS: convert.py 端到端 + manifest 對應") print("PASS: convert.py 端到端 + manifest 對應")
# 圖片連結必須真的能 render——DOCX/PDF/PPTX 三條抽圖路徑都要驗到,
# 且測資檔名刻意含空格與不平衡括號(最容易讓連結失效的形狀)
linked = sum(assert_links_ok((root / k).read_text(encoding="utf-8"),
root / "raw/converted") for k in convs)
assert linked >= 3, f"應至少驗到 docx/pdf/pptx 各一個圖片連結,實際 {linked}"
print(f"PASS: 圖片連結可 render 且目標存在({linked} 個,檔名含空格/括號)")
# 冪等:同檔再跑一次 → skipmanifest 不變 # 冪等:同檔再跑一次 → skipmanifest 不變
before = (root / "raw/manifest.json").read_text(encoding="utf-8") before = (root / "raw/manifest.json").read_text(encoding="utf-8")
convert.main([str(fx / DOCX), "--root", str(root)]) convert.main([str(fx / DOCX), "--root", str(root)])
@@ -228,6 +264,7 @@ def main():
assert len(conv_rel) == 1 and mv["files"][conv_rel[0]]["converter"] == "from_vision", mv assert len(conv_rel) == 1 and mv["files"][conv_rel[0]]["converter"] == "from_vision", mv
md_v = (root_v / conv_rel[0]).read_text(encoding="utf-8") md_v = (root_v / conv_rel[0]).read_text(encoding="utf-8")
assert "可疑交易監控" in md_v and "(vision" in md_v and "![page 1]" in md_v, md_v assert "可疑交易監控" in md_v and "(vision" in md_v and "![page 1]" in md_v, md_v
assert_links_ok(md_v, root_v / "raw/converted") # vision 頁的連結同樣要能 render
print("PASS: convert.py --vision 端到端needs_ocr → 本地 vision 轉錄)") print("PASS: convert.py --vision 端到端needs_ocr → 本地 vision 轉錄)")
print("ALL PASS") print("ALL PASS")