local-notebook
README.md
# local-notebook
本機版 NotebookLM 替代工具(第一版)。把 PDF / TXT / MD(含逐字稿)匯入 SQLite,
透過 MCP server 讓 Claude Code 查詢,回答時附上可追查的原始出處
(檔名 + 頁碼 / 行號 / 時間戳)。
```
PDF / TXT / MD / 逐字稿
↓ (src/ingest.py,唯讀開啟原始檔,只複製不修改)
Python 匯入
↓
SQLite + FTS5 (data/notebook.db)
↓
MCP Server (src/server.py)
↓
Claude Code 查詢
↓
回答 + 原始來源 + 頁碼/時間戳
```
第一版範圍(依原始規格):不做向量資料庫、不做 OCR、不做 Docker,全部跑在
`.venv` 內,不需要系統管理員權限。
---
## 1. 需求與已驗證環境
【確認】以下是本次實際建置與測試所用的環境(在本機沙箱內執行並以 `pytest`
全數通過,共 22 項測試,其中 1 項因沒有 CJK 字型而 skip):
- Python 3.11.15
- SQLite 3.45.1(內建於該 Python,支援 `fts5` 的 `trigram` tokenizer)
- `mcp` 2.2.0(`pip install mcp` 目前抓到的版本;此版把舊版 `FastMCP` 類別
改名為 `MCPServer`,import 路徑是 `from mcp.server.mcpserver import MCPServer`。
如果你的環境裝到 1.x 版 `mcp`,需要改回
`from mcp.server.fastmcp import FastMCP`,API 用法相同(`.tool()` 裝飾器、
`.run()`),但本專案的 `src/server.py` 是針對 2.x 寫的,尚未針對 1.x 測試。)
- `pypdf` 6.19.0
- `pytest` 9.1.1(測試用)
【確認】目標環境 Windows 11 + Python 3.12.10(`pypdf` 6.19.0)也已實際跑過
`pytest`:151 項通過、1 項 skip(同樣是缺 CJK 字型的那項;含網頁 UI 的測試)。
---
## 2. 安裝(Windows,比照你原本的規劃)
```powershell
cd D:\local-notebook
py -3.12 -m venv .venv
.venv\Scripts\pip install --upgrade pip
.venv\Scripts\pip install -e .
.venv\Scripts\pip install -e ".[dev]" # 想跑測試才需要
```
專案檔案、虛擬環境和套件都裝在 `D:\local-notebook\` 底下。Python/pip 執行時
仍可能寫入使用者層級的暫存位置(例如 `%TEMP%`、pip 快取
`%LOCALAPPDATA%\pip\Cache`),但不碰:
- `C:\Program Files`
- Windows Service
- 系統 PATH / Registry
- Docker / PostgreSQL
- 全域 `pip install`(沒加 `-e .` 就用系統 pip 的話才會裝到全域,這裡用的是
專案自己的 `.venv`)
若公司電腦的權限有疑慮,先用你原本規劃的三步驟測試:
```powershell
py -3.12 -m venv D:\python_test
D:\python_test\Scripts\python.exe -m pip --version
D:\python_test\Scripts\python.exe -m pip install pytest
```
三步都成功,代表一般 Python 開發環境可用。
---
## 3. 使用方式
### 3.1 匯入檔案
```powershell
.venv\Scripts\python -m src.ingest --notebook "弱電SOP" --tags "SOP,弱電" `
C:\path\to\CableTray-SOP.pdf C:\path\to\meeting_transcript.txt
```
整個資料夾一次匯入時,**不要**直接寫 `C:\path\to\*.pdf`。PowerShell 不會替外部
程式展開萬用字元,Python 收到的是字面上的 `*.pdf`,只會印出一行
`[跳過] 找不到檔案`,什麼都沒匯入(已實測)。請先用 `Get-ChildItem` 展開:
```powershell
$files = Get-ChildItem "C:\path\to\*.pdf" | ForEach-Object FullName
.venv\Scripts\python -m src.ingest --notebook "弱電SOP" --tags "SOP,弱電" $files
```
- `--notebook`:notebook 名稱,不存在會自動建立。
- `--tags`:逗號分隔的標籤,存在 notebook 上(notebook 已存在時,有給 `--tags` 會覆蓋舊標籤,沒給則保留)(v1 只存一個字串欄位,不做多對多標籤)。
- `--force`:即使檔案內容雜湊(sha256)跟已匯入的重複,也重新匯入並取代舊紀錄(刪舊與寫新在同一個交易內,中途失敗會 rollback,舊資料不會遺失)。
- 支援的副檔名:`.pdf` `.txt` `.md`。檔案不存在、型別不支援、或 pypdf 判定 PDF
損毀(`PyPdfError`)時會印出 `[跳過]` 並繼續下一個檔案。其他錯誤(例如資料庫
錯誤,或 pypdf 對某些畸形 PDF 丟出的一般例外)仍會中止整批匯入;中止前已
rollback,且這次匯入剛複製進 `originals/` 的檔案會被刪除,不留孤兒檔。
- notebook 名稱含 Windows 不允許的字元(`< > : " / \ | ? *`)時,`originals/`
底下的資料夾名會把它們換成 `_`;名稱是 Windows 裝置名(`CON`、`NUL`、
`COM1` 等)時資料夾名前面會加 `_`。資料庫裡的 notebook 名稱不受影響。
- 【確認】原始檔只會被「唯讀開啟」計算雜湊與擷取文字,然後用
`shutil.copy2` 複製一份到 `data/originals/<notebook>/` 底下,不會修改、
不會搬移原始檔(`tests/test_ingest_and_search.py::test_original_file_never_modified`
有驗證匯入前後原始檔的位元組完全一致)。
- 同一個 notebook 內,同雜湊的檔案預設會被跳過(`status: skipped_duplicate`),
避免重複灌資料。
### 3.2 啟動 MCP Server
```powershell
.venv\Scripts\python -m src.server
```
這個指令本身不會印出東西就繼續掛著等待——它是用 stdio 跟 MCP client(例如
Claude Desktop / Claude Code)溝通,不是要你手動互動。正常關閉方式是讓呼叫它
的 client 結束連線。
Claude 啟動 server 時,Python 必須找得到 `src` 套件,也就是專案根目錄
(`src\` 的上一層)要在搜尋路徑上。在其他資料夾直接執行 `python -m src.server`
會出現 `ModuleNotFoundError: No module named 'src'`(已實測;editable 安裝加進
搜尋路徑的是 `src\` 本身,不是專案根目錄)。
所以設定裡用 `PYTHONPATH` 指定專案根目錄,不依賴 client 啟動 server 時的工作
目錄。以下以專案在 `F:\github\local-notebook\local-notebook` 為例,請換成你實際
的路徑。
**Claude Code**(一行指令註冊):
```powershell
claude mcp add local-notebook -e PYTHONPATH=F:\github\local-notebook\local-notebook -- F:\github\local-notebook\local-notebook\.venv\Scripts\python.exe -m src.server
```
**Claude Desktop**(`claude_desktop_config.json`;Claude Code 的 `.mcp.json` 也是
同樣的寫法):
```json
{
"mcpServers": {
"local-notebook": {
"command": "F:\\github\\local-notebook\\local-notebook\\.venv\\Scripts\\python.exe",
"args": ["-m", "src.server"],
"env": { "PYTHONPATH": "F:\\github\\local-notebook\\local-notebook" }
}
}
}
```
【確認】在 `C:\` 底下設好上面的 `PYTHONPATH` 再執行,`src.server` 可以正常匯入,
資料庫也指向專案裡的 `data\notebook.db`(資料庫路徑由程式檔位置決定,和工作
目錄無關)。
【未驗證】還沒有在 Claude Code/Claude Desktop 裡實際註冊並呼叫過。舊版範例用的
`cwd` 欄位已拿掉,因為不確定 client 會不會讀它,改用 `PYTHONPATH` 就不需要它。
註冊後在 Claude 裡問一句「列出我的 notebook」,就能確認有沒有接通。
### 3.2.1 網頁 UI(匯入、搜尋、匯出)
雙擊專案根目錄的 `start-ui.bat`,瀏覽器會自動開啟 `http://127.0.0.1:8765`。
也可以在 PowerShell 執行:
```powershell
.venv\Scripts\python.exe -m src.ui
```
畫面上可以:
1. 建立 notebook,或更新已存在 notebook 的 tags(tags 留空會保留原值)。
2. 拖放或選擇 `.pdf` `.txt` `.md` 匯入。每個檔案各自顯示結果:已匯入、重複已跳過、或失敗原因;一個檔案失敗不影響其他檔案。
3. 搜尋,並把結果匯出成 CSV(Excel 可直接開啟中文)或 Markdown(附檔名、頁碼、行號、時間戳)。
注意:
- 只接受本機連線(`127.0.0.1`),區網其他電腦連不進來。
- 8765 埠已被使用時會顯示錯誤;通常代表 UI 已經開著,直接開啟上面的網址即可。
- 關閉瀏覽器不會停止伺服器,要關閉 `start-ui.bat` 開出的命令視窗。
- UI 匯入不支援 `--force`;需要強制重新匯入時用命令列。
- CSV 中以 `=`、`+`、`-`、`@` 等開頭的內容前面會加 `'`,避免 Excel 當成公式執行。
- 匯入失敗時畫面只顯示簡短原因,不顯示本機路徑;完整錯誤記在命令視窗裡。
- UI、命令列、MCP 共用同一個 `data\notebook.db`,任一邊匯入的資料其他兩邊都查得到。
### 3.3 MCP 工具一覽
| 工具 | 參數 | 回傳 |
|---|---|---|
| `list_notebooks` | 無 | 所有 notebook:id、name、tags、created_at、source_count、chunk_count |
| `list_sources` | `notebook` | 該 notebook 內所有來源檔:id、filename、file_type、file_hash、imported_at、chunk_count |
| `search` | `notebook`, `query`, `limit`(預設 10) | 符合的段落:chunk_id、seq、page、line_start、line_end、timestamp、text、snippet、source_file、source_id、notebook、score |
| `get_chunk` | `chunk_id` | 單一段落完整原文 + 來源;查無資料回傳 `null` |
| `get_source` | `source_id` | 來源檔中繼資料 + 其所有段落(含 80 字預覽) |
每筆 `search` / `get_chunk` 結果都帶有 `page`(PDF)或 `line_start`/`line_end`
+ `timestamp`(TXT/MD,逐字稿才會有 timestamp),讓 Claude 回答時可以寫成
「根據 CableTray-SOP.pdf 第 12 頁……」或「根據 meeting.txt 第 3 行
(00:01:10)……」。
> **注意 PDF 頁碼**:`page` 是 PDF 檔案從第一頁開始數的頁序(`src/ingest.py`
> 的 `chunk_pdf` 逐頁編號),不一定等於頁面上印出的頁碼。文件有封面、目錄、
> 羅馬數字前置頁時,兩者會不同。正式引用工程規範前,請開啟原始 PDF 核對
> 印刷頁碼與條文編號。
---
## 4. 執行測試
```powershell
.venv\Scripts\pip install -e ".[dev]"
.venv\Scripts\python -m pytest tests\ -v
```
【確認】實際執行結果(Windows 11 + Python 3.12.10):**151 個測試通過、1 個 skip**(見下方明細),涵蓋:
- `tests/test_ingest_and_search.py`(7 項):TXT/MD 分段、原始檔未被修改、
重複匯入跳過與 `--force` 覆蓋、`get_chunk`/`get_source` 往返、
notebook 之間互不干擾、不支援的副檔名會丟出錯誤、`list_notebooks` 計數正確。
- `tests/test_pdf_ingest.py`(2 項):用 `fpdf2` 產生真的 PDF(一份純英文、
一份用系統 Noto CJK 字型產生的繁體中文 PDF)餵給 `pypdf` 驗證逐頁分段、
頁碼、以及底下第 5 節提到的 NUL byte 問題確實被修好。
- `tests/test_mcp_server.py`(1 項):直接呼叫真正的 `MCPServer.call_tool()`
把 5 個工具都跑過一次。
- `tests/test_mcp_stdio_subprocess.py`(1 項):**真的**把
`python -m src.server` 開成子行程,用 `mcp.ClientSession` 走 stdio 協定
跟它對話(`list_tools` + 呼叫 `list_notebooks`、`search`),這是唯一會
抓到「`python -m src.server` 這個進入點本身跑不起來」或「有東西污染了
stdout 導致 JSON-RPC 協定壞掉」這類問題的測試。
- `tests/test_optimizations.py`(11 項):審查時找到的 bug 的回歸測試,包括
`list_notebooks` 段落數不被放大、`limit<=0`、notebook 名稱含非法字元或
Windows 裝置名、損毀 PDF 不中斷整批、`--tags` 更新、`--force` 失敗時保留
舊資料,以及匯入失敗不留孤兒檔。
- `tests/test_ui_app.py`(127 項,含參數化展開):網頁 UI 的 API,用 Starlette
`TestClient` 走真正的 HTTP 路徑。涵蓋 Host/Origin/token 檢查、notebook 與
tags、上傳檔名清理與路徑不外洩、逐檔錯誤隔離、搜尋參數驗證、CSV(BOM、
公式防護)與 Markdown 匯出,以及前端不使用 `innerHTML`。
- `tests/test_ui_launcher.py`(3 項):埠號被佔用時不開瀏覽器並回報錯誤;
開瀏覽器的那一刻 server 一定已經接受連線。
`fpdf2` 與 `fonttools` 只有跑測試才需要(用來產生測試用的假 PDF),
`ingest.py` / `server.py` 本身完全不依賴它們。若你的機器上沒有系統 CJK 字型,
`test_pdf_traditional_chinese_extraction_and_search` 會自動 `skip`(不會判定失敗),
其餘測試不受影響。
---
## 5. 已知限制(實測發現,不是猜的)
1. **FTS5 `trigram` tokenizer 對短查詢無效**
【確認】`trigram` tokenizer 是把內容切成重疊的 3 字元窗格來建索引,直接在
這個沙箱用 sqlite3 驗證:對內容 `"電纜橋架施工規範 Cable Tray"` 查詢
`"橋架"`(2 個中文字)回傳 0 筆,查詢 `"橋架施"`(3 個字)回傳 1 筆;
英文查詢 `"Ca"`(2 字元)也是 0 筆,`"Cable"` 才找得到。
這代表很多常見的兩字中文詞(例如「規範」「載重」)用 FTS5 搜不到,即使
內文明明有出現。
**處理方式**:`src/search.py` 的 `search()` 已經加了判斷——查詢字串去除
前後空白後若少於 3 個字元,改用 SQL `LIKE '%...%'` 子字串比對(結果的
`snippet` 與 `score` 會是 `null`,因為 LIKE 沒有 bm25 排序);3 字元以上
才走原本的 FTS5 trigram 路徑。這個行為由
`tests/test_ingest_and_search.py::test_txt_ingest_chunking_and_search`
實際驗證(對「橋架」查詢會確實找到兩個含此詞的段落)。
2. **pypdf 從某些內嵌 CJK 字型的 PDF 擷取文字時,字元間會夾雜 NUL byte(`\x00`)**
【確認】用 `fpdf2` + 系統內的 Noto Sans CJK 字型產生一份含繁體中文的
PDF,`pypdf.extract_text()` 擷取出來的結果是
`"\x00第\x00一\x00頁..."` 這種每個字中間插一個 NUL byte 的形式,不是乾淨的
中文字串。
**處理方式**:`src/ingest.py` 的 `_clean_pdf_text()` 在每一頁擷取後都會
先 `.replace("\x00", "")` 再存進資料庫,`tests/test_pdf_ingest.py::test_pdf_traditional_chinese_extraction_and_search`
有驗證清理後存入的文字與搜尋結果都不含 NUL byte。
【無法驗證】這個現象是不是所有中文 PDF 的通病,還是只跟這次測試用的
字型/產生方式有關——我沒有另一份「正常」的中文 PDF 可以對照測試。已加上
的清理是保險措施,不代表所有中文 PDF 擷取出來的文字品質都沒問題,正式
匯入你手上的 PDF 之後,建議用 `get_source` / `get_chunk` 抽查幾段內容,
確認擷取出來的文字跟原文一致。
3. **(已修正)原始檔複製目的地曾經跟資料庫路徑脫鉤**:早期版本裡,
`ingest.py` 決定「原始檔要複製到哪個 `originals/` 資料夾」是看環境變數
`LOCAL_NOTEBOOK_DB`,跟實際傳進來的 SQLite 連線指向哪個 `.db` 檔案無關。
寫測試時就是因為這樣,才發現用自訂路徑開連線(測試常見寫法)時,原始檔
卻被複製進了專案本身的 `data/originals/`,而不是測試用的暫存資料夾。
已改成 `db.originals_dir_for(conn)`:用 `PRAGMA database_list` 直接讀這個
連線實際打開的資料庫檔案路徑,再取其同層的 `originals/`,保證
`data/notebook.db` 跟 `data/originals/` 永遠對應同一份資料庫,不會因為呼叫方式不同而分裂。修好之後重新整批跑測試,專案的
`data/` 目錄在測試前後都是空的(測試只會寫進各自的暫存目錄)。
4. **沒有 OCR**:掃描圖檔式的 PDF(頁面上沒有文字層)會被整頁跳過,
`ingest_file` 回傳的 `chunk_count` 可能是 0 或比實際頁數少很多,不會報錯,
但你不會拿到那些頁的任何內容——這是規格明講「第一版不做 OCR」的預期行為,
不是 bug。
5. **PDF 一頁一個 chunk,不做細切**:符合規格「第一版不做向量資料庫」的簡化
設計;一頁內容很長時,`search()` 回傳的 `text` 會是整頁,`snippet`
欄位可以用來快速定位關鍵字在頁面中的位置。
6. **沒有語意搜尋**:`search` 是關鍵字(trigram 子字串)比對,不是向量相似度,
同義詞或换句話說的查詢不會自動找到相關段落——這也是規格明講的第一版範圍。
---
## 專案結構
```
local-notebook/
├─ .venv/
├─ data/
│ ├─ originals/ # 匯入時複製進來的原始檔(依 notebook 分資料夾)
│ └─ notebook.db # SQLite(metadata + 擷取文字 + FTS5 索引)
├─ src/
│ ├─ db.py # schema、連線、notebook 建立
│ ├─ ingest.py # CLI:匯入 PDF/TXT/MD
│ ├─ search.py # list_notebooks / list_sources / search / get_chunk / get_source 的實作
│ ├─ server.py # MCP server,把上面幾個函式包成 5 個 tool
│ └─ ui/
│ ├─ app.py # 網頁 UI 的 API(只包裝 db / ingest / search)
│ ├─ launcher.py # 綁定埠號、啟動 server、就緒後開瀏覽器
│ ├─ __main__.py # python -m src.ui
│ └─ static/ # index.html / app.js / style.css
├─ tests/
│ ├─ test_ingest_and_search.py
│ ├─ test_pdf_ingest.py
│ ├─ test_mcp_server.py
│ ├─ test_mcp_stdio_subprocess.py
│ ├─ test_optimizations.py
│ ├─ test_ui_app.py
│ └─ test_ui_launcher.py
├─ samples/
│ └─ sample_transcript.txt # 範例逐字稿,可用來快速試跑 ingest
├─ start-ui.bat # 雙擊啟動網頁 UI
├─ pyproject.toml
└─ README.md
```
`data/notebook.db` 與 `data/originals/` 不會隨程式碼一起打包(都是執行時才
產生的資料),第一次執行 `src/ingest.py` 時會自動建立。
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues