Compare commits

..

7 Commits

Author SHA1 Message Date
LittleYellow
50f03d8939 ci: checkout 改為完整歷史(fetch-depth: 0)
預設淺層 clone 只有一個 commit,Claude 需要完整歷史才能比對分支與產生 diff。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 17:49:16 +08:00
LittleYellow
61d35dd851 ci: workflow 指定 claude 執行檔路徑
加 path_to_claude_code_executable 指向 /usr/local/bin/claude,
避免 runner 環境 PATH 找不到執行檔;順帶移除 GITEA_SERVER_URL 上方註解。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 17:37:42 +08:00
45fd1d5874 ci: 新增 Gitea Actions workflow 讓 Claude 回應 issue/PR 事件 (#8)
## 摘要

新增 `.gitea/workflows/claude.yml`,以 `markwylde/claude-code-gitea-action@v1.0.20` 掛在 issue/PR 事件上,讓 Claude 能回應留言與審查。

單一檔案、26 行、無既有檔案異動。

## 內容

- 觸發事件:`issue_comment`(created)、`pull_request_review_comment`(created)、`issues`(opened/assigned/labeled)、`pull_request_review`(submitted)
- runner label:`yellow-zeabur-runner`
- `GITEA_SERVER_URL` 覆寫為 `https://yellow-git.zeabur.app`,避免容器內部連結 `http://gitea:3000` 在外部無法存取

## 前提

合併後要生效,需 repo 已設定 secrets `ANTHROPIC_API_KEY` 與 `USER_TOKEN`,且 runner label `yellow-zeabur-runner` 已註冊。

## 審核注意

- 不含任何 `raw/`、`wiki/` 內容或 manifest 帳本變更(AGENTS.md §1.4/§14 不受影響)。
- 本分支原本還帶著 4 個 commit,但那些內容已由 PR #1#5 squash 合併進 main;分支已重設到 main 之上,只保留這一支 commit。

Co-authored-by: LittleYellow <crazytea@gmail.com>
Reviewed-on: #8
2026-07-25 06:19:16 +00:00
b90727fc11 feat: diag_refs 對帳診斷 + ingest 進度可見性/Ctrl-C 安全 (Closes #2) (#5)
## 摘要

本 PR 含兩個獨立關注點,各一 commit。

### 1. `diag_refs` — source_ref↔manifest 對帳診斷 (`04f4fd5`) — 回應 #4

lint 的「source_ref 不在 manifest」只報對不上、不辨成因。新增 `tools/diag_refs.py`,把每個對不上的 source_ref 分成五類(hash 不符/路徑字串岔開/檔名 hash 後綴岔開/檔在磁碟未登記/完全無對應),各附修法,讓「補帳本(§14.3)」與「修規則(§12)」的相反處置不再混為一談。純唯讀、結束碼 `0/1`,可當搬機或改 manifest 後的閘門。附 selfcheck 驗五型別分類、結束碼與零寫檔。README §3.3/§6/§7/§8 同步。

### 2. ingest 進度可見性 + Ctrl-C 安全回復 (`4add7fa`) — Closes #2

`--all-pending` 長時間本地 Ollama 攝入全程靜默,使用者不確定有沒有在跑、又不敢中斷:

- **進度可見**:每份來源 `[i/N]`、長文件逐段 `分段 k/n`、每個 item `↳ 新增/更新`、完成 `✓`,全 `flush=True` 即時顯示。
- **Ctrl-C 安全**:獨立攔 `KeyboardInterrupt`(它不是 `Exception` 子類,原本會略過善後)。因 commit 在迴圈之後,**main 必然未受影響**;中斷時印可照做的回復指令並以結束碼 130 收場。

## 測試

- `python tools/selfcheck_diag_refs.py` — ALL PASS
- `python tools/selfcheck_ingest.py` — ALL PASS(含進度標記、中斷後 main 未動、回復指令實測可回乾淨 main)
- README markdownlint 0 issues

Closes #2

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: LittleYellow <crazytea@gmail.com>
Reviewed-on: #5
2026-07-24 05:42:35 +00:00
356ae9367c feat: convert --verify 對帳 + 修復 CRLF 使 hash 失準 + raw/** .gitattributes (#1)
## 目的

`convert` 後檔案被手動刪除/就地改動時,提供正規的稽核與修復途徑;並修掉一個讓「還原後 re-hash 比對」在 Windows 失效的既有 bug。

## 變更

- **`convert.py --verify`(純唯讀對帳)**:re-hash 全部登記檔、掃未登記檔,回報 missing / mismatch / unregistered,不一致以退出碼 1 表示(可當 CI/排程閘門)。`converted/assets/*` 與 `.gitkeep` 正確略過。
- **修 CRLF 使 hash 失準**:`write_text` 在 Windows 把 `\n`→`\r\n`,但登記的 SHA-256 算在 `\n` bytes 上 → 磁碟 bytes 與帳本永遠對不上(converted md + web 快照原始檔)。改為 `write_bytes` 寫入所登記的那份 bytes。
- **`.gitattributes`(`raw/** -text`)**:`core.autocrlf=true` 下 checkout 會在 git 層重新引入 CRLF,抵銷上一項修復;關閉 raw 的換行正規化,把「登記 hash == 磁碟 bytes」不變式延伸到 git checkin/checkout。
- **selfcheck**:補 clean / missing / mismatch / unregistered 四情境(全程唯讀斷言;clean 案例含未入帳本的 assets,順帶證明不誤報)。
- **AGENTS.md**:新增 §14「raw 完整性與修復(對帳)」,並於 §1.4 補「登記 hash == 磁碟 bytes」不變式與 `.gitattributes` 機制。

## 驗證

`.venv/Scripts/python.exe tools/convert/selfcheck.py` → `ALL PASS`。

## 備註

`--verify` 是唯讀稽核工具,不改任何檔(含 manifest),符合 §7「lint 絕不擅自刪改」精神。修復決策(可衍生 vs 信任根、先 `git restore` 不改帳本、動 manifest 走 PR)詳見 AGENTS.md §14。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: LittleYellow <crazytea@gmail.com>
Reviewed-on: #1
2026-07-23 02:27:47 +00:00
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
15 changed files with 583 additions and 25 deletions

8
.gitattributes vendored Normal file
View File

@@ -0,0 +1,8 @@
# raw/ 是 hash 釘選的 provenance 帳本AGENTS.md §1.4 / §9 / §14
# git 一律不得正規化其換行,否則 checkout 後磁碟 bytes 會與 manifest 的
# SHA-256 不符,讓 --verify 對帳失準(本專案 core.autocrlf=true
# 轉換器已在寫入時以 write_bytes 固定為登記的那份 bytes此處把同樣的
# 保證延伸到 git 的 checkin/checkout。
raw/originals/** -text
raw/converted/** -text
raw/manifest.json -text

View File

@@ -0,0 +1,27 @@
name: Claude Assistant
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned, labeled]
pull_request_review:
types: [submitted]
jobs:
claude-response:
runs-on: yellow-zeabur-runner
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: markwylde/claude-code-gitea-action@v1.0.20
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
gitea_token: ${{ secrets.USER_TOKEN }}
claude_git_name: Claude
claude_git_email: claude@anthropic.com
path_to_claude_code_executable: /usr/local/bin/claude
env:
GITEA_SERVER_URL: https://yellow-git.zeabur.app

View File

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

View File

@@ -26,7 +26,10 @@
EmbeddingGemmaGoogle或 snowflake-arctic-embedSnowflake
4. **raw 層不可變**`raw/` 內的文件只讀不改。原始檔與轉換後 Markdown 皆登記
SHA-256 至 `raw/manifest.json`(結構見 §9轉換檔條目必須回指原始檔 hash。
wiki 頁面引用來源必須帶 `source_ref`(格式見 §3.3)。
wiki 頁面引用來源必須帶 `source_ref`(格式見 §3.3)。登記的 hash 必須等於檔案在
磁碟上的實際 bytesUTF-8換行不轉換——轉換器一律以 write_bytes 寫入所登記的
那份 bytes不受平台 CRLF 影響git 亦以 `.gitattributes``raw/**` 關閉換行
正規化,避免 checkout 重新引入 CRLF完整性以 §14 `--verify` 稽核。
5. **HITL 閘門**:所有 wiki 層的變更以 git branch + PR 形式提交,經人工審核後才
合併至 main。攝入腳本永遠不直接 commit 到 main。lint 絕不擅自刪檔,一律標記
待人工核准。
@@ -276,6 +279,9 @@ output**)。單筆修正只治標;規則修正才治本。
語料要有跨頁共用的核心詞。便利值會變成盲點的形狀——ASCII 檔名讓 `git status`
的路徑轉義 bug 溜過,互不重疊的語料讓 BM25 的 idf 退化 bug 溜過。
新增檢查時先問:**這組測資和真實輸入差在哪?差異處就是沒被測到的地方。**
- **斷言要驗「結果可用」,不是驗「字串存在」**`assert "![img](" in md` 只證明字串被
組出來,證明不了連結解得開——真正該做的是用解析器 render 後確認產出 `<img>`
且目標檔存在。測資照現實建了、斷言卻停在表面bug 一樣會溜過去。
- 把任務轉成可驗證的目標,迭代到通過:
- 「加驗證」→ 先寫無效輸入的測試,再讓它通過。
- 「修 bug」→ 先寫重現 bug 的測試,再讓它通過。
@@ -297,3 +303,45 @@ output**)。單筆修正只治標;規則修正才治本。
**有效的跡象**diff 裡不必要的變更變少、因過度複雜而重寫的次數變少、
釐清問題發生在動手**之前**而非犯錯之後、刻意的捷徑是可見的(`ponytail:`
而非沉默的。
## 14. raw 完整性與修復(對帳)
raw 層以 manifest 的 SHA-256 為信任根§1.4、§9。檔案被**手動刪除**或**就地改動**時,
下列為正規稽核與修復流程。核心原則manifest 是 provenance **真相帳本**、不是 cache——
hash 的用途是讓刪除**可復原、可驗證**,故先**復原檔案**,而非改帳本去遷就殘缺的磁碟。
### 14.1 稽核
```text
python tools/convert/convert.py --verify
```
純唯讀(不改任何檔,含 manifestre-hash 全部登記檔並掃描 `raw/`,回報三類意外、
有不一致以退出碼 1 表示(可當 CI排程閘門
- **missing** — 帳本有登記,磁碟上不見了(手動刪除)。
- **mismatch** — 檔案還在但 sha256 與登記值不符raw 被就地改動,違反 §1.4)。
- **unregistered** — `raw/originals``raw/converted` 下有檔卻不在帳本。
`converted/assets/*`(圖片)由 markdown 連結追蹤而非帳本,故略過;`.gitkeep` 亦略過。
### 14.2 修復決策(可衍生 vs 信任根)
<!-- ASSUMPTION: 本決策樹為推論的正規流程,原始需求只定義 raw 不可變與 hash 登記;
依「可衍生converted優先還原、信任根original不可再生」的性質分流。 -->
1. **先還原,不改帳本**(多數「誤刪」到此為止):`raw/` 進版控,`git restore <path>`
取回精確 bytes或從備份再跑 §14.1 確認 hash == 登記值。帳本零改動。
2. **converted 救不回** → 從 original 重轉。注意兩個陷阱:(a) `convert.py` 以 **original
的 hash** 去重original 條目還在會直接 skip、**不會**因 converted 不見而重生,需先
自帳本移除該 converted 條目;(b) 函式庫版本變動可能使重轉 bytes 不同 → converted
hash 變 → 連累所有指向它的 wiki `source_ref`。故**能還原就別重轉**;重轉屬實質變更,
連同 source_ref 一起走 PR。
3. **original 救不回** → 不可逆的 provenance 損失,**不得靜默刪條目**(會連鎖 orphan 掉
對應 converted、再斷所有 source_ref據實記錄該檔遺失並上報人工HITL§5
### 14.3 動 manifest 的紀律
- 任何帳本變更走 **branch + PR**§5——manifest 是 provenance 帳本,改它需人工審核。
- `log.md` 追加一筆 `edit` 條目§10說明對了什麼、為什麼。
- 刪任何 `converted` 條目前,先確認沒有 wiki `source_ref` 引用它(否則 §7 的 schema
檢查會抓到斷鏈);有引用就改為還原檔案,或同 PR 一併更新引用。

View File

@@ -126,6 +126,16 @@ ollama list # 對照 tasks.ingest.model / tasks.lint.model / tasks.fallba
- **結束碼**`0` = 無發現;`1` = 有待人工核准項目(供 CI 判斷 / 標紅通知)。
- 排程:[.gitea/workflows/lint.yaml](.gitea/workflows/lint.yaml)(每週一台北時間 05:00草稿部署 Gitea 時啟用)。
> **`source_ref 不在 manifest` 的成因分類**lint 只報「對不上」,不分辨成因。跑
> [tools/diag_refs.py](tools/diag_refs.py) 把每個對不上的 source_ref 分成五類hash 不符 /
> 路徑字串岔開 / 檔名 hash 後綴岔開 / 檔在磁碟未登記 / 完全無對應),各附修法——
> 「補帳本」§14.3與「修規則」§12的處置相反先分類再動手。純唯讀不改任何檔
> 結束碼同 lint`0` 全部解得開 / `1` 有對不上),可當搬機或改 manifest 後的閘門。
>
> ```powershell
> .venv\Scripts\python tools\diag_refs.py
> ```
### 3.4 查詢 `search` — BM25 檢索
```powershell
@@ -180,7 +190,7 @@ wiki/entities/ # 實體頁(系統/模組/API/法規/專案)
wiki/concepts/ # 概念頁(跨來源綜合)
index.md # 目錄式導航(由 ingest 自動重建)
log.md # append-only 操作日誌
tools/ # convert/ ingest.py lint.py search.py kb.py共用模組
tools/ # convert/ ingest.py lint.py diag_refs.py search.py kb.py共用模組
mcp/server.py # MCP 薄殼
```
@@ -197,8 +207,10 @@ mcp/server.py # MCP 薄殼
| `須在 main 分支執行` | 攝入前先切回 `main`。 |
| `工作區有 raw/ 以外的未提交變更` | 先 commit / stash 非 `raw/` 的變更再攝入。 |
| `... 不在 manifest 的 converted 條目中` | 該來源尚未轉換;先跑 `convert.py`。 |
| lint 報 `source_ref 不在 manifest` | 跑 `tools/diag_refs.py` 分類成因:帳本掉條目→補登走 PR§14.3路徑字串岔開→修規則§12別逐頁改。 |
| `LLM 輸出驗證失敗(含 fallback` | 本地模型連主模型 + fallback 都無法產出合規 JSON檢查 Ollama 是否在線、模型是否拉好。 |
| `push 失敗(無 remote 或離線)` | 正常;分支已保留本地,手動 push 後開 PR。 |
| ingest 跑很久、看不到進度/想中斷 | 已逐來源 `[i/N]` 逐分段回報進度。Ctrl-C 安全main 不受影響,中斷時會印回復指令(`git checkout main && git stash -u && git branch -D <branch>`),續跑 `--all-pending` 自動接續。 |
---
@@ -210,5 +222,6 @@ mcp/server.py # MCP 薄殼
.venv\Scripts\python tools\convert\selfcheck.py
.venv\Scripts\python tools\selfcheck_ingest.py
.venv\Scripts\python tools\selfcheck_lint.py
.venv\Scripts\python tools\selfcheck_diag_refs.py
.venv\Scripts\python tools\selfcheck_search.py
```

View File

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

View File

@@ -7,6 +7,7 @@
用法python tools/convert/convert.py <檔案路徑或URL>... [--vision] [--dry-run] [--root DIR]
支援 .docx/.xlsx/.pdf/.pptx/.html 與 URL舊版 .doc/.xls/.ppt 經 LibreOffice 升版;
--vision 讓掃描/圖片型 PDF 走本地 vision 模型轉錄(見 from_vision.py
--verify 對帳模式re-hash 全部登記檔、掃出未登記檔,純唯讀不改檔,供刪除/竄改後稽核。
"""
import argparse
import datetime
@@ -87,7 +88,10 @@ def _register_pair(m, root, orig_rel, orig_entry, md_rel, md_text, converter, dr
print(f"warning: {orig_rel} 轉換結果為空白,請人工檢查來源", file=sys.stderr)
if not dry:
md_path = root / md_rel
md_path.write_text(md_text, encoding="utf-8")
# write_bytes非 write_text登記的 sha256 算在 md_text.encode() 上,
# 而 write_text 在 Windows 會把 \n 轉 \r\n使磁碟 bytes 與登記 hash 不符,
# 令日後「還原後 re-hash 比對」與 --verify 失準。寫入即登記的那份 bytes。
md_path.write_bytes(md_text.encode("utf-8"))
m["files"][orig_rel] = orig_entry
m["files"][md_rel] = {
"kind": "converted",
@@ -125,10 +129,59 @@ def _vision_pdf(root, pdf_path, assets, conv_name):
blocks = []
for i, (img, text) in enumerate(zip(images, texts), 1):
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"
def verify(root, m):
"""對帳模式re-hash 每個 manifest 條目對應的檔案,並掃出未登記的 raw 檔。
純唯讀——不改任何檔案(含 manifest只回報。有不一致以 SystemExit(1) 表示,
可供 CI排程當閘門用。
偵測三類意外:
missing —— 帳本有登記,磁碟上檔案不見了(手動刪除)。
mismatch —— 檔案還在但 sha256 與登記值不符raw 層被就地改動,違反 §1.4)。
unregistered —— raw/originals 或 raw/converted 下有檔卻不在帳本;
converted/assets/* 由 markdown 連結而非帳本追蹤,故略過,.gitkeep 亦略過。
"""
missing, mismatch = [], []
for rel, e in m["files"].items():
p = root / rel
if not p.is_file():
missing.append(rel)
elif hashlib.sha256(p.read_bytes()).hexdigest() != e["sha256"]:
mismatch.append(rel)
unregistered = []
for sub in ("originals", "converted"):
base = root / "raw" / sub
if not base.is_dir():
continue
for p in base.rglob("*"):
if not p.is_file() or p.name == ".gitkeep":
continue
if "assets" in p.relative_to(base).parts: # 由 markdown 連結,不入帳本
continue
rel = p.relative_to(root).as_posix()
if rel not in m["files"]:
unregistered.append(rel)
for rel in missing:
print(f"missing: {rel}(帳本有登記,磁碟上不見了)", file=sys.stderr)
for rel in mismatch:
print(f"mismatch: {rel}sha256 與登記值不符raw 層被就地改動)", file=sys.stderr)
for rel in sorted(unregistered):
print(f"unregistered: {rel}(磁碟上有檔,帳本未登記)", file=sys.stderr)
if missing or mismatch or unregistered:
print(f"\nverify: {len(m['files'])} 筆登記 → "
f"{len(missing)} missing / {len(mismatch)} mismatch / "
f"{len(unregistered)} unregistered純唯讀未改任何檔", file=sys.stderr)
raise SystemExit(1)
print(f"verify: {len(m['files'])} 筆登記全部對得上raw/ 無未登記檔案")
def process_local(root, m, path, dry, vision=False):
src = pathlib.Path(path).resolve()
if not src.is_file():
@@ -207,7 +260,9 @@ def process_url(root, m, url, dry):
"added_at": _now(), "status": "converted",
"source_url": url, "fetched_at": _now()}
if not dry:
(originals / name).write_text(html, encoding="utf-8") # 完整快照(來源可能消失)
# write_bytes同 §_register_pair登記 hash 算在 html.encode() 上,
# 避免 Windows 換行轉換讓快照 bytes 與登記 hash 不符(完整快照,來源可能消失)
(originals / name).write_bytes(html.encode("utf-8"))
md_text, status = from_web.extract(html)
if status != "ok":
orig_entry["status"] = "pending"
@@ -222,7 +277,10 @@ def process_url(root, m, url, dry):
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("inputs", nargs="+", help="檔案路徑或 http(s) URL")
ap.add_argument("inputs", nargs="*", help="檔案路徑或 http(s) URL")
ap.add_argument("--verify", action="store_true",
help="對帳模式re-hash 全部登記檔、掃出未登記檔,純唯讀不改檔;"
"有不一致以退出碼 1 表示(不吃 inputs")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--vision", action="store_true",
help="掃描/圖片型 PDFneeds_ocr改走本地 vision 模型轉錄,"
@@ -233,6 +291,11 @@ def main(argv=None):
a = ap.parse_args(argv)
root = pathlib.Path(a.root).resolve()
m = load_manifest(root)
if a.verify:
verify(root, m)
return
if not a.inputs:
ap.error("需指定至少一個檔案/URL或改用 --verify 對帳")
failed = []
for item in a.inputs:
try:

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()
else:
rel = name # dry-run僅佔位
parts.append(f"![{name}]({rel})")
# 連結目標以 <> 包住:來源檔名可能含空格或不平衡括號,兩者都會讓
# Markdown 連結失效(圖片存在卻整行退化成純文字)
parts.append(f"![{name}](<{rel}>)")
parts.append(run.text)
text = "".join(parts).strip()
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)
pix.save(str(assets_dir / name))
rel = (assets_dir / name).relative_to(md_dir).as_posix()
blocks.append(f"![{name}]({rel})")
# <> 包住:檔名含空格或不平衡括號時,未包住的連結會失效
blocks.append(f"![{name}](<{rel}>)")
doc.close()
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()
else:
rel = name
blocks.append(f"![{name}]({rel})")
# <> 包住:檔名含空格或不平衡括號時,未包住的連結會失效
blocks.append(f"![{name}](<{rel}>)")
if slide.has_notes_slide:
notes = slide.notes_slide.notes_text_frame.text.strip()
if notes:

View File

@@ -26,13 +26,39 @@ import to_image
# 測資檔名一律用「中文 + 空格」——真實來源檔就長這樣,而 git status --porcelain
# 對這兩者都會加引號轉義。ASCII 檔名的測資曾讓 ingest 的 preflight bug 溜過§12
DOCX, XLSX, PDF, SCAN = ("壓測 報告.docx", "測試案例 清單.xlsx",
"規格 說明.pdf", "掃描件 無文字層.pdf")
DOCX, XLSX, PDF, SCAN = ("壓測 報告(1).docx", "測試案例 清單.xlsx",
"規格 說明.pdf", "掃描件(2 期.pdf")
PPTX, HTML, TXT, DOC = ("結案 簡報.pptx", "指引 快照.html",
"不支援 筆記.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):
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
doc = docxlib.Document()
doc.add_heading("支付閘道測試報告", level=1)
@@ -42,6 +68,7 @@ def make_fixtures(d):
t.rows[0].cells[1].text = "結果"
t.rows[1].cells[0].text = "TC-001"
t.rows[1].cells[1].text = "FAIL"
doc.add_picture(str(png))
doc.save(d / DOCX)
import openpyxl
@@ -52,10 +79,10 @@ def make_fixtures(d):
ws.append(["TC-001", "逾時 | 重試"])
wb.save(d / XLSX)
import fitz
pdf = fitz.open()
page = pdf.new_page()
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.close()
scanned = fitz.open()
@@ -64,9 +91,11 @@ def make_fixtures(d):
scanned.close()
from pptx import Presentation
from pptx.util import Inches
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[1])
slide.shapes.title.text = "結案報告"
slide.shapes.add_picture(str(png), Inches(1), Inches(3))
slide.notes_slide.notes_text_frame.text = "備註:法遵項目全數通過"
prs.save(d / PPTX)
@@ -103,12 +132,12 @@ def main():
(root / sub).mkdir(parents=True)
(root / "raw/manifest.json").write_text('{"version": 1, "files": {}}', encoding="utf-8")
convert.main([str(fx / DOCX), str(fx / SCAN),
str(fx / HTML), "--root", str(root)])
convert.main([str(fx / DOCX), str(fx / XLSX), str(fx / PDF), str(fx / SCAN),
str(fx / PPTX), str(fx / HTML), "--root", str(root)])
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"}
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"
for k, v in convs.items():
assert (root / k).is_file(), k
@@ -116,12 +145,58 @@ def main():
assert (root / f"raw/originals/{DOCX}").is_file()
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 不變
before = (root / "raw/manifest.json").read_text(encoding="utf-8")
convert.main([str(fx / DOCX), "--root", str(root)])
assert (root / "raw/manifest.json").read_text(encoding="utf-8") == before
print("PASS: 冪等(同 hash 跳過)")
# --verify 對帳純唯讀clean 通過、三類意外都抓到、且完全不改 manifest。
# root 已有 docx/pdf/pptx 抽出的 assets不入帳本——clean 案例即證明 assets 不被誤報。
def run_verify(rt):
mm = json.loads((rt / "raw/manifest.json").read_text(encoding="utf-8"))
err, code = io.StringIO(), 0
with contextlib.redirect_stderr(err), contextlib.redirect_stdout(io.StringIO()):
try:
convert.verify(rt, mm)
except SystemExit as e:
code = e.code
return code, err.getvalue()
before = (root / "raw/manifest.json").read_text(encoding="utf-8")
code, _ = run_verify(root)
assert code == 0, "clean 帳本應通過對帳(含未入帳本的 assets"
rroot = tmp / "root_del" # missing刪掉一個 converted .md
shutil.copytree(root, rroot)
gone = next(p for p in (rroot / "raw/converted").glob("*.md"))
gone.unlink()
code, log = run_verify(rroot)
assert code == 1 and "missing" in log and gone.name in log, log
rroot = tmp / "root_mut" # mismatch就地改動一個 original 的 bytes
shutil.copytree(root, rroot)
tampered = next(p for p in (rroot / "raw/originals").iterdir() if p.is_file())
tampered.write_bytes(tampered.read_bytes() + b"tampered")
code, log = run_verify(rroot)
assert code == 1 and "mismatch" in log, log
rroot = tmp / "root_unreg" # unregisteredraw/originals 塞入未登記檔
shutil.copytree(root, rroot)
(rroot / "raw/originals/未登記 檔.docx").write_bytes(b"stray")
code, log = run_verify(rroot)
assert code == 1 and "unregistered" in log and "未登記 檔.docx" in log, log
assert (root / "raw/manifest.json").read_text(encoding="utf-8") == before # 全程唯讀
print("PASS: --verify 對帳clean/missing/mismatch/unregistered純唯讀")
# dry-run全新沙盒不落地
root2 = tmp / "root2"
for sub in ("raw/originals", "raw/converted"):
@@ -228,6 +303,7 @@ def main():
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")
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("ALL PASS")

134
tools/diag_refs.py Normal file
View File

@@ -0,0 +1,134 @@
"""source_ref ↔ manifest 對帳診斷AGENTS.md §7 schema / §14 對帳的輔助工具)。
lint.py 對「source_ref 不在 manifest」只報一句無法區分成因本工具把每個
對不上的 source_ref 分類成可據以修復的型別(見 CAT_TITLE / FIX讓「帳本掉
條目」「路徑字串岔開」「檔名 hash 後綴岔開」「檔真的遺失」彼此不再混為一談。
純唯讀——不改任何檔(含 manifest有對不上以結束碼 1 表示(可當搬機/改
manifest 後的閘門)。分類邏輯與 lint 一致:以最後一個 '#' 切出 path 與 sha8。
用法python tools/diag_refs.py [--root DIR]
結束碼0 = 全部解得開1 = 有對不上。
"""
import argparse
import json
import pathlib
import re
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent))
import kb
CAT_TITLE = {
"hash-mismatch": "Hash 不符(帳本有此路徑,但 sha256 與 ref 不同)",
"normalize": "路徑字串岔開(正規化後與帳本 key 相同:斜線/前綴/大小寫)",
"stem": "檔名 hash 後綴岔開帳本有同檔名幹、hash 後綴不同的 key",
"unregistered": "檔在磁碟、帳本無此條目",
"absent": "完全無對應(帳本、磁碟、檔名幹皆無)",
}
FIX = {
"hash-mismatch": "raw 被就地改動或重轉(違反 §1.4);還原檔案,或連同 source_ref 走 PR§14.2)。",
"normalize": "修產生岔開字串的那一個環節(路徑正規化),別逐頁改 source_ref§12 change the ruler",
"stem": "source_ref 指到不同 hash 後綴的檔名核對正確檔名別改帳本去遷就§14.2)。",
"unregistered": "帳本掉了條目、檔還在;走 branch+PR 補登 manifest§14.3),別動 wiki 頁。",
"absent": "source_ref 可能攝入時寫歪,或原始檔真的遺失;上報 HITL 據實記錄§14.2)。",
}
# 分類輸出順序(愈可機械修復的排愈前)
ORDER = ["hash-mismatch", "normalize", "stem", "unregistered", "absent"]
def _norm(path):
"""正規化路徑字串以偵測「同一檔、不同寫法」:反斜線→斜線、去開頭 ./、轉小寫。"""
s = path.replace("\\", "/")
if s.startswith("./"):
s = s[2:]
return s.lower()
def _stem(path):
"""取檔名幹:去目錄、去 .md、去結尾的 -<8 碼 hex>convert 的檔名 hash 後綴)。"""
name = path.replace("\\", "/").rsplit("/", 1)[-1]
name = re.sub(r"\.md$", "", name, flags=re.I)
return re.sub(r"-[0-9a-f]{8}$", "", name, flags=re.I).lower()
def classify_ref(source_ref, files, exists):
"""把單一 source_ref 分類。filesmanifest['files']path→entry
exists(path)→bool 判磁碟是否有該檔。回傳 (category, detail)。
category 為 "resolved" 或 CAT_TITLE 的任一鍵。"""
path, _, sha8 = str(source_ref).rpartition("#")
entry = files.get(path)
if entry is not None:
if entry["sha256"].startswith(sha8):
return "resolved", path
return "hash-mismatch", f"帳本 sha256={entry['sha256'][:8]} ≠ ref #{sha8}"
norm = _norm(path)
for k in files:
if _norm(k) == norm:
return "normalize", f"帳本 key = '{k}'"
stem = _stem(path)
for k, e in files.items():
if e.get("kind") == "converted" and _stem(k) == stem:
return "stem", f"帳本 key = '{k}'"
if exists(path):
return "unregistered", f"磁碟有 '{path}',帳本無此 key"
return "absent", f"'{path}' 帳本無、磁碟無、無同名幹 key"
def collect(root, files, exists):
"""掃全庫 wiki 頁,回傳 (findings, n_refs)。findingscategory→[(page, ref, detail)]。"""
findings = {c: [] for c in ORDER}
n_refs = 0
for p in kb.iter_pages(root):
rel = p.relative_to(root).as_posix()
try:
meta, _ = kb.parse_page(p.read_text(encoding="utf-8"))
except Exception as e:
findings["absent"].append((rel, "(無法解析頁面)", str(e)))
continue
for s in meta.get("sources") or []:
n_refs += 1
cat, detail = classify_ref(s, files, exists)
if cat != "resolved":
findings[cat].append((rel, str(s), detail))
return findings, n_refs
def format_report(findings, n_refs):
total = sum(len(v) for v in findings.values())
lines = [f"檢查 source_ref{n_refs},對不上:{total}", ""]
if total == 0:
lines.append("全部解得開source_ref 與 manifest 一致)。")
return "\n".join(lines)
for cat in ORDER:
items = findings[cat]
if not items:
continue
lines.append(f"## {CAT_TITLE[cat]}{len(items)}")
lines.append(f"→ 修法:{FIX[cat]}")
for page, ref, detail in items:
lines.append(f" - [{page}] {ref}")
lines.append(f" {detail}")
lines.append("")
return "\n".join(lines)
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--root", default=str(kb.ROOT))
a = ap.parse_args(argv)
root = pathlib.Path(a.root).resolve()
mpath = root / "raw" / "manifest.json"
try:
files = json.loads(mpath.read_text(encoding="utf-8"))["files"]
except (OSError, KeyError, json.JSONDecodeError) as e:
raise SystemExit(f"讀不到 / 解析不了 manifest{mpath}{e}")
findings, n_refs = collect(root, files, lambda p: (root / p).is_file())
print(format_report(findings, n_refs))
if sum(len(v) for v in findings.values()):
raise SystemExit(1)
if __name__ == "__main__":
main()

View File

@@ -123,6 +123,8 @@ def summarize(cfg, filename, text):
parts = split_chunks(text, limit)
partials = []
for i, part in enumerate(parts, 1):
# 長文件的分段迴圈是最容易「長時間靜默」的地方issue #2逐段回報進度
print(f" 分段 {i}/{len(parts)}", flush=True)
obj, _ = kb.llm_json(cfg, "ingest",
CHUNK_PROMPT.format(i=i, n=len(parts), content=part),
validate_chunk)
@@ -170,14 +172,16 @@ def upsert_item(root, cfg, item, source_ref, today, changed):
if source_ref not in meta["sources"]:
meta["sources"].append(source_ref)
existing.write_text(kb.dump_page(meta, obj["updated_markdown"]), encoding="utf-8")
changed.append(f"更新 {existing.relative_to(root).as_posix()}")
msg = f"更新 {existing.relative_to(root).as_posix()}"
else:
p = root / kb.PAGE_DIRS[item["type"]] / f"{slug}.md"
meta = {"type": item["type"], "title": item["title"],
"description": item["description"], "tags": item["tags"],
"timestamp": today, "sources": [source_ref], "status": "draft"}
p.write_text(kb.dump_page(meta, item["facts_markdown"]), encoding="utf-8")
changed.append(f"新增 {p.relative_to(root).as_posix()}")
msg = f"新增 {p.relative_to(root).as_posix()}"
changed.append(msg)
print(f"{msg}", flush=True)
# ---------- git ----------
@@ -240,7 +244,7 @@ def main(argv=None):
skipped = [t for t in targets if conv[t]["sha256"][:8] in done]
targets = [t for t in targets if conv[t]["sha256"][:8] not in done]
for t in skipped:
print(f"skip: {t} 已有 summary 引用(--force 可重跑)")
print(f"skip: {t} 已有 summary 引用(--force 可重跑)", flush=True)
if not targets:
print("沒有待攝入的來源。")
return
@@ -261,21 +265,24 @@ def main(argv=None):
run_git(root, "checkout", "-q", "-b", branch)
ok, failed, changed = [], [], []
try:
for t in targets:
for i, t in enumerate(targets, 1):
try:
print(f"[{i}/{len(targets)}] 攝入 {t}", flush=True)
text = (root / t).read_text(encoding="utf-8")
sha8 = conv[t]["sha256"][:8]
source_ref = f"{t}#{sha8}"
obj, model = summarize(cfg, pathlib.Path(conv[t]["original_path"]).name, text)
p = write_summary_page(root, obj, source_ref, sha8, today)
changed.append(f"新增 {p.relative_to(root).as_posix()}")
msg = f"新增 {p.relative_to(root).as_posix()}"
changed.append(msg)
print(f"{msg}", flush=True)
for item in obj["knowledge_items"]:
upsert_item(root, cfg, item, source_ref, today, changed)
ok.append((t, model))
print(f"ingested: {t}model={model}")
print(f" {t} 完成model={model}", flush=True)
except Exception as e:
failed.append((t, e))
print(f"error: {t}: {e}", file=sys.stderr)
print(f" {t}: {e}", file=sys.stderr, flush=True)
if not ok:
run_git(root, "checkout", "-q", "main")
run_git(root, "branch", "-q", "-D", branch)
@@ -297,6 +304,16 @@ def main(argv=None):
print(f"完成:{len(ok)} 成功、{len(failed)} 失敗。變更在分支 {branch},經 PR 審核後合併。")
except SystemExit:
raise
except KeyboardInterrupt:
# KeyboardInterrupt 不是 Exception 子類,會略過下方善後——故獨立攔截。
# 中斷時尚未 commitcommit 在迴圈之後main 必然未受影響;給一條可照做的回復路徑。
print(f"\n已中斷Ctrl-C。main 未受影響——本次半成品都在分支 {branch}、尚未 commit。\n"
f"回到乾淨的 main\n"
f" git checkout main && git stash -u && git branch -D {branch}\n"
f"git stash -u 收起分支上未提交的半成品含未追蹤頁面;確定不要再 git stash drop\n"
f"續跑 python tools/ingest.py --all-pending 會自動從尚未攝入的來源接續。",
file=sys.stderr, flush=True)
raise SystemExit(130)
except Exception:
print(f"攝入中斷。目前在分支 {branch},工作區可能有未提交變更,"
"請人工檢查(不自動清除以免遺失資料)。", file=sys.stderr)

View File

@@ -0,0 +1,114 @@
"""Phase 4 自檢:沙盒植入五種 source_ref 岔開型別hash 不符、路徑正規化、
檔名 hash 後綴岔開、檔在磁碟未登記、完全無對應)+一個正常解得開的 ref
驗證 diag_refs 逐一分類正確、報告涵蓋各型別、結束碼 1、且不修改任何檔案。
另驗全部解得開時結束碼 0。
執行:.venv/Scripts/python tools/selfcheck_diag_refs.py
"""
import io
import json
import pathlib
import shutil
import sys
import tempfile
from contextlib import redirect_stdout
sys.path.insert(0, str(pathlib.Path(__file__).parent))
import kb
import diag_refs
SHA_A = "abcd1234" + "0" * 56
SHA_B = "beef5678" + "0" * 56
# 帳本 key 一律 posix 正斜線+中文/連字號檔名(真實 convert 產出的形狀)
K_OK = "raw/converted/www-pluspay-faq-06b02fd9.md"
K_STEM = "raw/converted/www-pluspay-terms-11112222.md" # 同幹、不同 hash 後綴
FILES = {
K_OK: {"kind": "converted", "sha256": SHA_A, "original_path": "raw/originals/faq.html",
"original_sha256": SHA_A, "converter": "from_web", "converted_at": "2026-07-01T00:00:00"},
K_STEM: {"kind": "converted", "sha256": SHA_B, "original_path": "raw/originals/terms.html",
"original_sha256": SHA_B, "converter": "from_web", "converted_at": "2026-07-01T00:00:00"},
}
# ref → 預期分類。unregistered 的檔會實際落地absent 的不落地。
CASES = {
"resolved": f"{K_OK}#abcd1234",
"hash-mismatch": f"{K_OK}#deadbeef",
"normalize": r"raw\converted\www-pluspay-faq-06b02fd9.md#abcd1234", # 反斜線
"stem": "raw/converted/www-pluspay-terms-99998888.md#cccccccc", # 同幹、異後綴、非 key
"unregistered": "raw/converted/www-pluspay-instructions-77776666.md#77777777",
"absent": "raw/converted/www-pluspay-ghost-00001111.md#cafebabe",
}
def unit():
"""classify_ref 純函式逐型別驗證。"""
on_disk = {"raw/converted/www-pluspay-instructions-77776666.md"}
exists = lambda p: p in on_disk
for expect, ref in CASES.items():
cat, detail = diag_refs.classify_ref(ref, FILES, exists)
assert cat == expect, f"{ref!r} 應分類為 {expect},實得 {cat}{detail}"
print("PASS: classify_ref 六型別(含 resolved分類正確")
def build(root):
for sub in ("raw/converted", "wiki/summaries", "wiki/entities", "wiki/concepts"):
(root / sub).mkdir(parents=True)
(root / "raw/manifest.json").write_text(
json.dumps({"version": 1, "files": FILES}), encoding="utf-8")
# unregistered 的檔要真的存在於磁碟,才會被判為「檔在、帳本無」而非 absent
(root / "raw/converted/www-pluspay-instructions-77776666.md").write_text(
"# 未登記\n", encoding="utf-8")
today = "2026-07-01"
for i, (name, ref) in enumerate(CASES.items()):
meta = {"type": "entity", "title": f"{name}", "description": "測試頁",
"tags": ["t"], "timestamp": today, "sources": [ref], "status": "draft"}
(root / f"wiki/entities/p{i}.md").write_text(kb.dump_page(meta, "內文。"), encoding="utf-8")
def run(argv):
buf = io.StringIO()
code = 0
try:
with redirect_stdout(buf):
diag_refs.main(argv)
except SystemExit as e:
code = e.code
return code, buf.getvalue()
def main():
unit()
tmp = pathlib.Path(tempfile.mkdtemp(prefix="ppqa-diag-"))
try:
root = tmp / "repo"
build(root)
before = {p: p.read_bytes() for p in root.rglob("*")
if p.is_file()}
code, out = run(["--root", str(root)])
assert code == 1, f"有岔開應以結束碼 1實得 {code}\n{out}"
for cat in ("hash-mismatch", "normalize", "stem", "unregistered", "absent"):
assert diag_refs.CAT_TITLE[cat].split("")[0] in out, f"報告缺型別 {cat}\n{out}"
assert "對不上5" in out, out
after = {p: p.read_bytes() for p in root.rglob("*") if p.is_file()}
assert before == after, "diag_refs 修改了檔案!"
print("PASS: 整合——五型別全報、結束碼 1、零寫檔")
# 全部解得開 → 結束碼 0
good = {"type": "entity", "title": "好頁", "description": "d", "tags": ["t"],
"timestamp": "2026-07-01", "sources": [f"{K_OK}#abcd1234"], "status": "draft"}
for p in (root / "wiki/entities").glob("*.md"):
p.unlink()
(root / "wiki/entities/ok.md").write_text(kb.dump_page(good, "內文。"), encoding="utf-8")
code, out = run(["--root", str(root)])
assert code == 0, f"全部解得開應以結束碼 0實得 {code}\n{out}"
assert "全部解得開" in out, out
print("PASS: 全部解得開時結束碼 0")
print("ALL PASS")
finally:
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
main()

View File

@@ -5,11 +5,13 @@
執行:.venv/Scripts/python tools/selfcheck_ingest.py
"""
import hashlib
import io
import pathlib
import shutil
import subprocess
import sys
import tempfile
from contextlib import redirect_stderr, redirect_stdout
sys.path.insert(0, str(pathlib.Path(__file__).parent))
import kb
@@ -82,9 +84,14 @@ def main():
git(root, "add", "-A")
git(root, "commit", "-q", "-m", "init")
# 1) 攝入建分支、產頁、commit、回到 main
# 1) 攝入建分支、產頁、commit、回到 main並驗進度輸出可見issue #2
kb.llm_json = fake_llm
ingest.main(["raw/converted/doc1.md", "--root", str(root)])
buf = io.StringIO()
with redirect_stdout(buf):
ingest.main(["raw/converted/doc1.md", "--root", str(root)])
out = buf.getvalue()
for marker in ("[1/1] 攝入", "分段 1/", "↳ 新增", "", "完成"):
assert marker in out, f"進度輸出缺少 {marker!r}\n{out}"
assert git(root, "branch", "--show-current") == "main"
branch = git(root, "branch", "--list", "ingest/*").strip("* ").strip()
assert branch, "未建立 ingest 分支"
@@ -147,6 +154,45 @@ def main():
except RuntimeError as e:
assert "fallback" in str(e)
print("PASS: 重試 + fallback 切換 + 全失敗報錯")
# 5) Ctrl-C 安全issue #2中斷時 main 不受影響、以 130 收場,
# 且文件宣稱的回復指令真的能回到乾淨 main。第 2 份來源開始摘要時中斷,
# 此時第 1 份的頁面已寫入(未追蹤、未 commit——正是使用者最怕的半成品狀態。
add_doc(root, "docA.md", "來源A待攝入。")
add_doc(root, "docB.md", "來源B待攝入。")
git(root, "add", "-A")
git(root, "commit", "-q", "-m", "docA/docB")
head_before = git(root, "rev-parse", "main") # 攝入不得動到的 main 狀態
state = {"n": 0}
def boom(cfg, task, prompt, validate):
state["n"] += 1
if state["n"] >= 2: # 第 2 份來源的 summarize 呼叫
raise KeyboardInterrupt
return ({"title": "來源A", "description": "d", "slug": "fresh-a",
"tags": ["x"], "summary_markdown": "- a", "knowledge_items": []},
"fake:test")
kb.llm_json = boom
err = io.StringIO()
try:
with redirect_stdout(io.StringIO()), redirect_stderr(err):
ingest.main(["raw/converted/docA.md", "raw/converted/docB.md",
"--root", str(root)])
raise AssertionError("KeyboardInterrupt 應轉為 SystemExit(130)")
except SystemExit as e:
assert e.code == 130, e.code
msg = err.getvalue()
assert "git checkout main" in msg and "main 未受影響" in msg, msg
assert git(root, "rev-parse", "main") == head_before, "main 被動到了!"
cur = git(root, "branch", "--show-current")
assert cur.startswith("ingest/"), f"中斷後應停在 ingest 分支,實得 {cur!r}"
assert git(root, "status", "--porcelain"), "應有未提交的半成品"
# 照文件指令回復,斷言真的回到乾淨 main驗「結果可用」非「字串存在」§13.4
git(root, "checkout", "main")
git(root, "stash", "-u")
git(root, "branch", "-D", cur)
assert git(root, "branch", "--show-current") == "main"
assert not git(root, "status", "--porcelain"), "回復後工作區仍不乾淨"
print("PASS: Ctrl-C → main 未受影響、130 收場、回復指令實測可回乾淨 main")
print("ALL PASS")
finally:
kb.llm_json, kb.ollama_chat = real_llm, real_chat