Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .claude/agents/test-runner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
name: test-runner
description: Runs the project's pytest suite + ruff in a fresh context and reports PASS/FAIL with evidence. Bash + Read only; never edits code. Use after any change to confirm nothing regressed.
tools: Bash, Read
---

你是獨立的測試執行稽核員,運作在**全新、未被開發者汙染的脈絡**裡。
你的唯一工作:**實際把整套測試與 lint 跑起來**,如實回報 PASS / FAIL,不修改任何檔案。

步驟:
1. 跑測試 + 覆蓋率:
`cd /Users/ron/Desktop/agent-notes && .venv/bin/python -m pytest --cov=agentnotes --cov-report=term-missing`
2. 跑 lint:
`.venv/bin/ruff check src tests`
3. 這些都是離線測試(不需 NotebookLM / Notion / Telegram),也不該動到真實 `.state/`。
跑完用 `ls .state/processed .state/downloads 2>&1` 確認這兩個資料夾沒被測試建立。
4. 若 pytest 或 ruff 有任何失敗:
- 指出**哪個 test 失敗、哪一行 assert**(pytest 會印出 traceback 與 `assert` 左右值)。
- 用 Read 打開該測試與對應原始碼(`src/agentnotes/...`),判斷是測試過時還是真的 bug,給檔案:行定位與可能原因。
- **不要嘗試修復**,只回報。

輸出格式:**第一行** `PASS` 或 `FAIL`。其後附:
- pytest 結果(通過數 / 失敗數)與 ruff 結果(All checks passed 或錯誤列表)。
- 覆蓋率總結那一行(TOTAL ...)。
- 若 FAIL:失敗測試名稱、assert 訊息、可能原因(檔案:行)。

鐵則:**沒有親眼看到 pytest 印出全部通過、且 ruff 全綠之前不能回報 PASS**。寧可 FAIL 也不放過。
9 changes: 6 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ TELEGRAM_BOT_TOKEN=
TELEGRAM_ALLOWED_CHAT_IDS=

# --- Notion (via Notion MCP in the Agent SDK) ---
# A Notion page the Notion MCP integration can access; the bot creates the report DB under it on first run.
# A Notion page the Notion MCP integration can access. Each topic (see topics.json) gets
# its own "NotebookLM 報告 - <topic>" database, auto-created under THIS page the first time
# that topic is used. Required for any topic whose DB isn't pre-created/cached.
NOTION_PARENT_PAGE_ID=
# Leave empty on first run; the bot fills this after auto-creating the "NotebookLM 報告" database.
NOTION_DATABASE_ID=
# (Per-topic DB ids are NOT set here. They live in .state/topic_dbs.json, which the bot
# fills the first time it auto-creates a topic's database — seed it manually to reuse an
# existing DB, e.g. {"ai-agent": "<database_id>"}.)

# --- Auth ---
# Leave ANTHROPIC_API_KEY UNSET to use the Claude subscription pool (2026-06-15 billing split).
Expand Down
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: CI

on:
push:
pull_request:

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
- name: Install (package + dev tools)
run: pip install -e ".[dev]"
- name: Lint (ruff)
run: ruff check src tests
- name: Test (pytest + coverage)
run: pytest --cov=agentnotes --cov-report=term-missing
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,9 @@ logs/

# vendored notebooklm-skill clone
.vendor/

# test / lint artifacts
.coverage
.pytest_cache/
.ruff_cache/
htmlcov/
9 changes: 9 additions & 0 deletions PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,12 @@ cwc-style handoff log. The bot appends one block per job so a restart can see hi
## Job log
<!-- the bot appends: timestamp | chat | url | gate states | notion url / failure reason -->
- 2026-06-28 23:32 | 1782660251-5117498666-3 | https://www.youtube.com/watch?v=tTcxVv8HHNw | OK https://www.notion.so/38d5fc56c4398131827cc231a593c709
- 2026-06-29 15:35 | 1782717988-5117498666-6 | https://www.youtube.com/watch?v=mR-WAvEPRwE | OK https://www.notion.so/38e5fc56c439819e8542e4a612b7516a
- 2026-06-30 15:00 | 1782802268-5117498666-17 | https://www.youtube.com/watch?v=GrNbuWWJYiI | OK https://www.notion.so/38f5fc56c4398180abcad3add5b122c8
- 2026-06-30 16:10 | 1782806407-5117498666-20 | https://www.youtube.com/watch?v=Xu5gz2qsaz8 | OK https://www.notion.so/38f5fc56c4398123aa88edd9eed1b278
- 2026-06-30 22:46 | 1782830779-5117498666-25 | ai-agent | https://www.youtube.com/watch?v=mR-WAvEPRwE | EXCEPTION Exception('Claude Code returned an error result: success')
- 2026-06-30 22:48 | 1782830918-5117498666-28 | ai-agent | https://www.youtube.com/watch?v=mR-WAvEPRwE | EXCEPTION Exception('Claude Code returned an error result: success')
- 2026-07-01 00:01 | 1782835304-5117498666-33 | ai-agent | https://www.youtube.com/watch?v=mR-WAvEPRwE | EXCEPTION Exception('Claude Code returned an error result: success')
- 2026-07-01 00:32 | 1782836492-5117498666-38 | ai-agent | https://www.youtube.com/watch?v=mR-WAvEPRwE | OK https://www.notion.so/38f5fc56c4398149a61bf5f78cd1c2e5
- 2026-07-01 00:39 | 1782837592-5117498666-41 | ai-agent | https://www.youtube.com/watch?v=mR-WAvEPRwE | DUP https://www.notion.so/38f5fc56c4398149a61bf5f78cd1c2e5
- 2026-07-01 00:48 | 1782837603-5117498666-44 | ai-agent | https://www.youtube.com/watch?v=mR-WAvEPRwE | OK https://www.notion.so/38f5fc56c43981cbbccec8e802505b8d
65 changes: 55 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,25 @@ builderA(產報告) → 閘A(報告) → builderB(寫Notion) → 閘B(Notion)

## 行為細節

- **共用一個 NotebookLM 筆記本**:所有來源都加進名為 **「給 Agent Note」** 的同一個筆記本(不存在才建一次),
每次提問用 `ask -s <source_id>` 把範圍鎖在這次新增的來源,避免被筆記本內其他來源汙染。筆記本名稱在
`pipeline.py` 的 `NOTEBOOK_NAME`。
- **整理既有筆記本(sweep)**:傳 `#topic 整理現有`(或 `sweep`/`sync`),bot 會把該 topic 筆記本裡
**每個還沒匯出的來源**各自產一份報告、各自一頁 Notion;已匯出的自動跳過(可反覆執行、不重做)。
URL 來源用正規化網址比對,所以跟 `#topic <連結>` 走的是同一套防重複——已用連結匯過的不會再做一次。
- **連結或檔案都收**:傳 http/https 連結,或直接上傳附件檔(**PDF / DOCX / TXT / MD**,NotebookLM 能吃的)。
附件的 `#topic` 標籤放在 caption。bot 用 Telegram getFile 下載到 `.state/downloads/`,再 `source add --type file`
進對應筆記本。限制:Telegram bot API 只能下載 ≤20MB 的檔;超過會如實回失敗(不假裝成功)。
- **依 topic 分流(每個 topic 一本筆記本、一個 Notion DB)**:傳連結時用 `#標籤` 指定 topic,例如
`#ai 你的連結` 或 `你的連結 #phd`。bot 依 `topics.json` 把來源加進對應的 NotebookLM 筆記本
(如 `(AgentNote) AI agent`),整理後寫進對應的 Notion 資料庫(如 `NotebookLM 報告 - AI agent`)。
好處是順手在 NotebookLM 建立分主題的 RAG 語料庫。**沒標、標到不存在、或一次標多個 topic → bot 不處理,
直接回你目前可用的 topic 清單**(fail-closed,避免 typo 汙染語料庫)。
- **同一筆記本內仍鎖來源**:每次提問用 `ask -s <source_id>` 把範圍鎖在這次新增的來源,避免被同一本裡其他來源汙染。
- **防重複**:每個 topic 一份「已處理索引」(`.state/processed/<topic>.json`,gitignored)。同一個 (topic, 連結)
成功處理過後再傳一次 → bot **不重跑**,直接回你上次的 Notion 連結。比對用的是**正規化後的 URL**
(`youtu.be`↔`watch?v=`、`utm_*`/`si`/`fbclid` 等追蹤參數、結尾斜線、`www.`、`http/https`、`#fragment` 都會被忽略)。
索引**只在整條成功後才寫**,失敗的 job 仍可重試。要強制重做就在訊息加 `#force`。
- **topic 設定兩層**:定義在 `topics.json`(進 repo:別名 + 筆記本名 + Notion DB 標題);
自動建出來的 `topic -> notion_db_id` 快取在 `.state/topic_dbs.json`(gitignored)。新增 topic = 在
`topics.json` 加一筆,之後該 topic 的筆記本與 Notion DB 會在第一次用到時自動建立。
- **Notion 頁開頭自動加目錄**:每頁第一個區塊是 Notion 原生 Table of Contents block,依 `##` 標題自動產生,
方便點擊跳轉。

Expand All @@ -37,13 +53,34 @@ src/agentnotes/
contract.py Default-FAIL 證據契約(核心防線)
evaluators.py 確定性檢查 + 稽核員 prompt(核心防線)
agent.py Agent SDK 封裝:builder(可寫/可 Notion)vs evaluator(read-only、新 context)
pipeline.py 固定單趟 pipeline,把 evaluator 當閘
pipeline.py 單一來源的證據閘 pipeline(_run_source);run_pipeline=新來源、run_sweep=整理既有
notebooklm_cli.py Python 直呼 notebooklm CLI(找筆記本 id、列來源)—— sweep 用來逐來源防重複
topics.py topic registry:#標籤 → (NotebookLM 筆記本, Notion DB) 路由 + db_id 快取
dedup.py 防重複:URL 正規化 + 每 topic 的「已處理索引」(#force 可覆寫)
ingest/base.py InputAdapter 介面(Line 之後接同介面)
ingest/telegram.py getUpdates 長輪詢 + sendMessage
ingest/telegram.py getUpdates 長輪詢 + sendMessage + 解析 #topic + 下載附件檔
main.py 派工迴圈
.claude/agents/evaluator.md 稽核員子代理人定義
topics.json topic 定義(別名 / 筆記本名 / Notion DB 標題)—— 進 repo
.state/topic_dbs.json 自動建立後的 topic → notion_db_id 快取(gitignored)
.claude/agents/evaluator.md 稽核員子代理人定義(產出 PASS/FAIL)
.claude/agents/test-runner.md 測試執行子代理人:全新脈絡跑 pytest+ruff 回報
deploy/com.ron.agentnotes.plist launchd 常駐
tests/test_evidence.py 證據防線的離線測試(不需任何外部服務)
.github/workflows/ci.yml CI:push/PR 自動跑 ruff + pytest(含覆蓋率)

tests/ pytest 套件(全離線,不需外部服務)
conftest.py 共用 fixtures:cfg / registry / index / good_report /
fake_agents(可調 gate 結果的假 builder+evaluator)/ poll_once(驅動 poll)
test_evidence.py 證據防線:確定性檢查 + Default-FAIL 契約
test_evaluators.py 確定性檢查的邊界(marker、門檻、失敗標題、計數)
test_contract.py 契約不變量(無證據不過、未知 gate 報錯、磁碟往返)
test_config.py .env 解析 + chat-id allowlist
test_topics.py #標籤 解析、別名正規化、db_id 快取
test_dedup.py URL 正規化、檔案雜湊、索引、跳過/入帳/#force
test_gate.py verdict 解析 + Gate B 對真實頁面正文做檢查
test_attachments.py 附件型別 gate、報告 prompt 檔案分支、poll 文件路由
test_sweep.py 整理既有:指令偵測、逐來源防重複/失敗/#force
test_telegram.py poll() URL 路由(topic 缺/多、多連結、白名單、offset)
test_main.py _title_hint / _job_id / handle_job 派工
```

認證:靠本機已登入的 **Claude 訂閱**(2026-06-15 分池計費),**不要**設 `ANTHROPIC_API_KEY`,
Expand Down Expand Up @@ -73,9 +110,17 @@ cp .env.example .env
## 驗證(由內而外,全部證據導向)

```bash
# A) 證據防線(不需任何外部服務)—— 先確認「防謊報」本身是對的
python tests/test_evidence.py
# 驗:正常報告 PASS;空白/登入頁/過短 → FAIL;契約沒證據不給過
# 0) 一次性:裝開發/測試工具(pytest、pytest-cov、ruff)
.venv/bin/python -m pip install -e ".[dev]"

# A) 整個離線測試套件(不需任何外部服務)—— pytest 自動 discovery
.venv/bin/python -m pytest # 跑全部
.venv/bin/python -m pytest --cov=agentnotes --cov-report=term-missing # 帶覆蓋率
.venv/bin/ruff check src tests # lint
# 涵蓋:證據防線(防謊報)、contract、config、topic 路由、防重複、附件、sweep、
# telegram poll 路由、main 派工。共用 fixtures 在 tests/conftest.py。
# 也可用「test-runner」子代理人在全新脈絡自動跑 pytest+ruff 並回報 PASS/FAIL
# (.claude/agents/test-runner.md)。CI 在 push/PR 會自動跑同一套(.github/workflows/ci.yml)。

# B) NotebookLM 段:對一支已知影片實跑,人工讀 jobs/<id>/report.md 確認繁中、逐段、## header
# C) 整鏈:把一個連結傳給 bot → 收到 ✅ + 可開的 Notion 連結
Expand Down
23 changes: 23 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ dependencies = [
"httpx",
]

[project.optional-dependencies]
dev = ["pytest>=8", "pytest-cov", "ruff"]

[project.scripts]
agent-notes = "agentnotes.main:run"

Expand All @@ -17,3 +20,23 @@ where = ["src"]
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "-q --strict-markers"

[tool.ruff]
target-version = "py311"
# 120 not 88/100: the bot's prompts/replies are full of CJK, and ruff counts code
# points — 100 CJK chars already render ~200 columns wide, so a tighter cap would
# force ugly mid-sentence breaks in Chinese string literals.
line-length = 120
src = ["src", "tests"]

[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B"]

[tool.coverage.run]
source = ["agentnotes"]
branch = true
25 changes: 24 additions & 1 deletion src/agentnotes/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,23 @@
class StepTimeout(Exception):
"""An agent step exceeded its wall-clock budget."""


class AgentError(Exception):
"""A builder/evaluator step failed for an actionable reason (turned into a clear
user-facing message). Most common: the spawned ``claude`` CLI is not logged in,
which the SDK surfaces as the cryptic "returned an error result: success"."""


# Substrings in an SDK error that mean "the spawned claude couldn't even start a turn"
# — overwhelmingly an auth/login problem (e.g. after a CLI upgrade wiped the session).
_AUTH_HINT_MARKERS = ("returned an error result", "not logged in", "/login", "please run")

_AUTH_HELP = (
"Claude CLI 可能未登入(常見於 CLI 升級後 session 失效)。"
"請在終端機跑 `claude auth status` 確認,必要時 `claude auth login`"
"(背景常駐可改用 `claude setup-token` 取得長效權杖)。"
)

# Tools the builder may use. "Skill" lets it invoke the notebooklm skill, which
# shells out to python via "Bash".
BUILDER_TOOLS = ["Skill", "Bash", "Read", "Write", "Edit", "Glob", "Grep"]
Expand Down Expand Up @@ -142,10 +159,16 @@ async def _consume() -> AgentRun:
timeout = float(os.environ.get("JOB_STEP_TIMEOUT_S", DEFAULT_STEP_TIMEOUT_S))
try:
return await asyncio.wait_for(_consume(), timeout=timeout)
except asyncio.TimeoutError as e:
except TimeoutError as e:
raise StepTimeout(
f"agent 步驟超過 {int(timeout)} 秒未完成(可能 NotebookLM/Playwright/MCP 卡住)"
) from e
except Exception as e:
# Map the SDK's cryptic auth failure into an actionable message; re-raise the rest.
text = str(e).lower()
if any(m in text for m in _AUTH_HINT_MARKERS):
raise AgentError(f"{_AUTH_HELP}\n原始錯誤:{e}") from e
raise


async def run_builder(prompt: str, *, cwd: Path = ROOT, with_notion: bool = False,
Expand Down
6 changes: 2 additions & 4 deletions src/agentnotes/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,12 @@ def _load_dotenv(path: Path) -> None:
class Config:
telegram_bot_token: str
telegram_allowed_chat_ids: frozenset[int]
notion_parent_page_id: str
notion_database_id: str
notion_parent_page_id: str # where a topic's Notion DB is auto-created on first use
report_min_cjk_chars: int
report_min_headers: int

@classmethod
def load(cls) -> "Config":
def load(cls) -> Config:
_load_dotenv(ROOT / ".env")
raw_ids = os.environ.get("TELEGRAM_ALLOWED_CHAT_IDS", "").strip()
ids = frozenset(
Expand All @@ -46,7 +45,6 @@ def load(cls) -> "Config":
telegram_bot_token=os.environ.get("TELEGRAM_BOT_TOKEN", "").strip(),
telegram_allowed_chat_ids=ids,
notion_parent_page_id=os.environ.get("NOTION_PARENT_PAGE_ID", "").strip(),
notion_database_id=os.environ.get("NOTION_DATABASE_ID", "").strip(),
report_min_cjk_chars=int(os.environ.get("REPORT_MIN_CJK_CHARS", "400")),
report_min_headers=int(os.environ.get("REPORT_MIN_HEADERS", "3")),
)
4 changes: 2 additions & 2 deletions src/agentnotes/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class Contract:
state: dict = field(default_factory=dict)

@classmethod
def create(cls, job_dir: Path) -> "Contract":
def create(cls, job_dir: Path) -> Contract:
"""Create a fresh Default-FAIL contract for a job."""
job_dir.mkdir(parents=True, exist_ok=True)
state = {g: {"passes": False, "evidence": None, "note": None} for g in GATES}
Expand All @@ -30,7 +30,7 @@ def create(cls, job_dir: Path) -> "Contract":
return c

@classmethod
def load(cls, job_dir: Path) -> "Contract":
def load(cls, job_dir: Path) -> Contract:
path = job_dir / "contract.json"
return cls(path=path, state=json.loads(path.read_text(encoding="utf-8")))

Expand Down
Loading
Loading