zotvault 0.9.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zotvault-0.9.1/LICENSE +21 -0
- zotvault-0.9.1/PKG-INFO +118 -0
- zotvault-0.9.1/README.md +98 -0
- zotvault-0.9.1/pyproject.toml +35 -0
- zotvault-0.9.1/setup.cfg +4 -0
- zotvault-0.9.1/tests/test_alerts.py +57 -0
- zotvault-0.9.1/tests/test_analysis_queue.py +51 -0
- zotvault-0.9.1/tests/test_analyze.py +137 -0
- zotvault-0.9.1/tests/test_annotations.py +134 -0
- zotvault-0.9.1/tests/test_assist.py +97 -0
- zotvault-0.9.1/tests/test_config.py +67 -0
- zotvault-0.9.1/tests/test_i18n_indexer.py +58 -0
- zotvault-0.9.1/tests/test_indexer.py +56 -0
- zotvault-0.9.1/tests/test_note_renderer.py +86 -0
- zotvault-0.9.1/tests/test_pipeline.py +182 -0
- zotvault-0.9.1/tests/test_proxy.py +78 -0
- zotvault-0.9.1/tests/test_related.py +34 -0
- zotvault-0.9.1/tests/test_search.py +107 -0
- zotvault-0.9.1/tests/test_state.py +61 -0
- zotvault-0.9.1/tests/test_synthesis.py +52 -0
- zotvault-0.9.1/tests/test_webapp.py +68 -0
- zotvault-0.9.1/tests/test_zotero_reader.py +48 -0
- zotvault-0.9.1/tests/test_zotero_writer.py +183 -0
- zotvault-0.9.1/zotvault/__init__.py +3 -0
- zotvault-0.9.1/zotvault/__main__.py +4 -0
- zotvault-0.9.1/zotvault/alerts.py +99 -0
- zotvault-0.9.1/zotvault/analysis_queue.py +65 -0
- zotvault-0.9.1/zotvault/analyze.py +376 -0
- zotvault-0.9.1/zotvault/annotations.py +212 -0
- zotvault-0.9.1/zotvault/assist.py +112 -0
- zotvault-0.9.1/zotvault/cli.py +678 -0
- zotvault-0.9.1/zotvault/config.py +473 -0
- zotvault-0.9.1/zotvault/daemon.py +187 -0
- zotvault-0.9.1/zotvault/enrich.py +161 -0
- zotvault-0.9.1/zotvault/i18n.py +46 -0
- zotvault-0.9.1/zotvault/indexer.py +81 -0
- zotvault-0.9.1/zotvault/note_renderer.py +147 -0
- zotvault-0.9.1/zotvault/pdf_resolver.py +135 -0
- zotvault-0.9.1/zotvault/pipeline.py +346 -0
- zotvault-0.9.1/zotvault/proxy.py +183 -0
- zotvault-0.9.1/zotvault/related.py +183 -0
- zotvault-0.9.1/zotvault/search.py +228 -0
- zotvault-0.9.1/zotvault/state.py +344 -0
- zotvault-0.9.1/zotvault/static/index.html +253 -0
- zotvault-0.9.1/zotvault/synthesis.py +142 -0
- zotvault-0.9.1/zotvault/tray.py +127 -0
- zotvault-0.9.1/zotvault/webapp.py +338 -0
- zotvault-0.9.1/zotvault/zotero_reader.py +264 -0
- zotvault-0.9.1/zotvault/zotero_writer.py +397 -0
- zotvault-0.9.1/zotvault.egg-info/PKG-INFO +118 -0
- zotvault-0.9.1/zotvault.egg-info/SOURCES.txt +53 -0
- zotvault-0.9.1/zotvault.egg-info/dependency_links.txt +1 -0
- zotvault-0.9.1/zotvault.egg-info/entry_points.txt +2 -0
- zotvault-0.9.1/zotvault.egg-info/requires.txt +4 -0
- zotvault-0.9.1/zotvault.egg-info/top_level.txt +1 -0
zotvault-0.9.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jongmin Baek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
zotvault-0.9.1/PKG-INFO
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zotvault
|
|
3
|
+
Version: 0.9.1
|
|
4
|
+
Summary: Local-first paper pipeline orchestrator between Zotero and Obsidian: detect, note, fetch PDF, queue AI analysis, index.
|
|
5
|
+
Author: Jongmin Baek
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jbaek-dev/ZotVault
|
|
8
|
+
Project-URL: Changelog, https://github.com/jbaek-dev/ZotVault/blob/main/CHANGELOG.md
|
|
9
|
+
Keywords: zotero,obsidian,papers,research,pkm,local-first
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: MacOS
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Provides-Extra: tray
|
|
17
|
+
Requires-Dist: pystray>=0.19; extra == "tray"
|
|
18
|
+
Requires-Dist: Pillow>=9; extra == "tray"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# ZotVault
|
|
22
|
+
|
|
23
|
+
**Local-first paper pipeline orchestrator between [Zotero](https://www.zotero.org/) and [Obsidian](https://obsidian.md/).**
|
|
24
|
+
|
|
25
|
+
ZotVault watches your Zotero library and, for every new paper, automatically: creates an Obsidian note, secures a PDF (open-access first, politely rate-limited), puts the paper on an AI-analysis queue, and keeps your vault's index current. Around that core loop it adds one-shot DOI/arXiv adding, paper search, a local dashboard, arXiv keyword alerts, an in-library citation graph, embedding-based related-paper suggestions, and synthesis-cluster proposals.
|
|
26
|
+
|
|
27
|
+
Everything runs on your machine. No cloud accounts required, no API keys required for the core loop, **zero runtime dependencies** (Python ≥ 3.9 standard library only).
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
[search] [collect] [archive] [wiki]
|
|
31
|
+
arXiv / S2 / Crossref ──▶ dashboard / CLI / agent ──▶ Zotero (BBT) ──▶ Obsidian vault
|
|
32
|
+
▲ │ you approve │ sqlite poll │
|
|
33
|
+
└── arXiv alert inbox ───┘ ▼ ▼
|
|
34
|
+
daemon: note → PDF (OA→proxy) → queue → index
|
|
35
|
+
+ citation graph · related · synthesis
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
|
|
40
|
+
- **One-shot add** — `zotvault add 10.1103/PhysRevB.1.1 arXiv:2405.01234`: metadata via Crossref/DataCite/arXiv → straight into Zotero through the same local channel the browser connector uses — including the OA PDF when one exists (Zotero downloads it itself, like the browser connector). Duplicates are detected before saving; a pipeline cycle runs immediately after the add. Optional [translation-server](https://github.com/zotero/translation-server) support for arbitrary URL imports.
|
|
41
|
+
- **Automatic wiki-fication** — new Zotero items become Obsidian notes (template-compatible, atomic writes). Existing notes are **never rewritten**; your manual sections are structurally safe.
|
|
42
|
+
- **Edit-safe highlight sync** — Zotero PDF highlights land in ONE marker-delimited block per note (grouped by color, deep links back to the exact annotation), kept in sync incl. deletions. Figure/area annotations are embedded as images (copied from Zotero's cache). Color groups can be renamed to *your* semantics (`label_red = "Core Claims"`). Everything outside the block is untouchable; unmarked legacy notes are opt-in (`[annotations] adopt_existing`). See [docs/MIGRATION.md](docs/MIGRATION.md).
|
|
43
|
+
- **PDF resolution, politely** — Zotero attachment → cache → arXiv → Unpaywall → (opt-in) institutional proxy with browser-session cookies. Sequential, delayed, daily-capped: designed to *not* get your campus blocked. See [docs/PROXY.md](docs/PROXY.md).
|
|
44
|
+
- **AI-analysis queue** — ZotVault doesn't run an LLM; it feeds yours. `zotvault queue --json` lists unanalyzed papers with readable PDF paths; when your agent (e.g. Claude batch) writes `*_analysis.md`, completion is auto-detected.
|
|
45
|
+
- **Dashboard** — `zotvault web` → http://127.0.0.1:8377 : search → tick → add, queue, alerts inbox, suggestions, audit trail. Localhost only.
|
|
46
|
+
- **arXiv alerts** — daily keyword digest into a review inbox. Nothing enters Zotero without your click (propose, don't execute).
|
|
47
|
+
- **Alert triage (assist)** — optionally, a *small* local Ollama model scores inbox candidates 0–10 for relevance under a strict JSON contract (constrained decoding + validation + one retry); the dashboard sorts by score. Advisory only, off by default.
|
|
48
|
+
- **Citation graph** — Semantic Scholar citation counts + who-cites-whom *within your library*, regenerated into `Citation_Graph.md`.
|
|
49
|
+
- **Related papers** — local [Ollama](https://ollama.com) embeddings over your analysis notes → `Related_Suggestions.md` link candidates. Free, offline.
|
|
50
|
+
- **Synthesis suggestions** — clusters analyzed-but-unsynthesized papers into proposed review topics.
|
|
51
|
+
- **Auditable** — every automatic action lands in a SQLite trace (`zotvault trace`).
|
|
52
|
+
|
|
53
|
+
## Requirements
|
|
54
|
+
|
|
55
|
+
- Python ≥ 3.9 (CLI is cross-platform; the double-click app + `install-daemon` autostart are macOS today)
|
|
56
|
+
- Zotero desktop with **[Better BibTeX](https://retorque.re/zotero-better-bibtex/)** — **required**: it is ZotVault's citekey source. Without it, items can't be named and nothing syncs (ZotVault will mark them `blocked` and tell you).
|
|
57
|
+
- An Obsidian vault (any folder). ZotVault writes per-paper notes under `<papers_subdir>/<citekey>/` — configure the paths; it does not impose a vault structure.
|
|
58
|
+
- Optional: `pdftotext` (poppler) for full-text AI analysis, Ollama (related papers / local analysis), an institutional web proxy for licensed PDFs.
|
|
59
|
+
|
|
60
|
+
## Quick start
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
git clone https://github.com/jbaek-dev/ZotVault && cd ZotVault
|
|
64
|
+
pip install . # or: pipx install . / uv tool install .
|
|
65
|
+
zotvault init # writes ~/.zotvault/config.toml
|
|
66
|
+
# edit: [vault] dir, [pdf] unpaywall_email (+ [alerts] keywords if you want digests)
|
|
67
|
+
zotvault doctor # verifies Zotero, Better BibTeX, vault paths
|
|
68
|
+
zotvault run-once --dry-run
|
|
69
|
+
zotvault run-once
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**Run it continuously.** Two options on every platform:
|
|
73
|
+
|
|
74
|
+
- **System tray** (recommended): `pip install ".[tray]"` then `zotvault tray` — daemon + tray icon with Open Dashboard / Run now / Pause / Quit. The only third-party packages ZotVault can use, and only if you opt in.
|
|
75
|
+
- **OS service:** `zotvault install-daemon` — macOS writes a launchd plist, Linux writes a systemd user unit, Windows prints the Task Scheduler command. Never auto-loads; it tells you the enable command.
|
|
76
|
+
|
|
77
|
+
macOS extra: double-click ZotVault.app (`bash scripts/build_app.sh`).
|
|
78
|
+
|
|
79
|
+
Every command also works without installing, via `python3 -m zotvault.cli <cmd>`.
|
|
80
|
+
|
|
81
|
+
## Commands
|
|
82
|
+
|
|
83
|
+
| command | what it does |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `init` / `doctor` | config file / environment health check |
|
|
86
|
+
| `run-once [--dry-run]` / `daemon` | one cycle / poll loop (+dashboard thread) |
|
|
87
|
+
| `install-daemon` | autostart: launchd plist (macOS) / systemd unit (Linux) / schtasks cmd (Windows) |
|
|
88
|
+
| `tray` | daemon + system-tray icon (needs `pip install ".[tray]"`) |
|
|
89
|
+
| `add <ids…> [--dry-run]` | resolve DOI/arXiv/URL → save to Zotero |
|
|
90
|
+
| `search <query> [--source arxiv\|s2\|crossref]` | search with in-library marks |
|
|
91
|
+
| `web` | dashboard server in the foreground |
|
|
92
|
+
| `queue [--json]` | papers awaiting AI analysis |
|
|
93
|
+
| `alerts [--fetch\|--approve N\|--dismiss N]` | arXiv digest inbox |
|
|
94
|
+
| `enrich [--limit N]` | citation graph + embeddings + suggestion notes |
|
|
95
|
+
| `assist [--limit N]` | score pending alerts with the small local model |
|
|
96
|
+
| `related <citekey>` / `synthesis [--write]` | similarity / cluster proposals |
|
|
97
|
+
| `status` / `trace [--limit N]` | state summary / audit trail |
|
|
98
|
+
|
|
99
|
+
## Design guarantees
|
|
100
|
+
|
|
101
|
+
- **Read-only toward Zotero's data.** Library reads use a temp snapshot of `zotero.sqlite`; adds go through Zotero's own connector endpoint (Zotero writes its DB itself). ZotVault never touches `storage/`; downloaded PDFs live in `~/.zotvault/pdfs/`.
|
|
102
|
+
- **Vault safety.** Existing notes are never rewritten; auto-notes (`Citation_Graph.md`, `Related_Suggestions.md`, `_Synthesis_Suggestions.md`) are clearly marked and ZotVault-owned; index.md is only patched via a strict counter regex; log.md is append-only; `--dry-run` previews; no delete code path exists.
|
|
103
|
+
- **Polite networking.** OA-first, sequential fetches, delays, hard daily caps (separate, stricter cap for the proxy), honest User-Agent, backs off on HTTP 429.
|
|
104
|
+
- **Nothing personal in code.** All user-specific values live in `~/.zotvault/config.toml`.
|
|
105
|
+
|
|
106
|
+
## Agent integration
|
|
107
|
+
|
|
108
|
+
Any agent can drive ZotVault through the localhost API (`/api/search`, `/api/add`, `/api/queue`, `/api/status`). A ready-made tool module for the Polaris local agent (not yet public) lives in that repo (`polaris/tools/zotvault_tools.py`): search results show up with in-library marks, and adding requires the user to have explicitly picked papers.
|
|
109
|
+
|
|
110
|
+
## Tests
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
python3 -m unittest discover -s tests -v # 101 tests, no network needed
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT — see [LICENSE](LICENSE). Changelog in [CHANGELOG.md](CHANGELOG.md).
|
zotvault-0.9.1/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# ZotVault
|
|
2
|
+
|
|
3
|
+
**Local-first paper pipeline orchestrator between [Zotero](https://www.zotero.org/) and [Obsidian](https://obsidian.md/).**
|
|
4
|
+
|
|
5
|
+
ZotVault watches your Zotero library and, for every new paper, automatically: creates an Obsidian note, secures a PDF (open-access first, politely rate-limited), puts the paper on an AI-analysis queue, and keeps your vault's index current. Around that core loop it adds one-shot DOI/arXiv adding, paper search, a local dashboard, arXiv keyword alerts, an in-library citation graph, embedding-based related-paper suggestions, and synthesis-cluster proposals.
|
|
6
|
+
|
|
7
|
+
Everything runs on your machine. No cloud accounts required, no API keys required for the core loop, **zero runtime dependencies** (Python ≥ 3.9 standard library only).
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
[search] [collect] [archive] [wiki]
|
|
11
|
+
arXiv / S2 / Crossref ──▶ dashboard / CLI / agent ──▶ Zotero (BBT) ──▶ Obsidian vault
|
|
12
|
+
▲ │ you approve │ sqlite poll │
|
|
13
|
+
└── arXiv alert inbox ───┘ ▼ ▼
|
|
14
|
+
daemon: note → PDF (OA→proxy) → queue → index
|
|
15
|
+
+ citation graph · related · synthesis
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Features
|
|
19
|
+
|
|
20
|
+
- **One-shot add** — `zotvault add 10.1103/PhysRevB.1.1 arXiv:2405.01234`: metadata via Crossref/DataCite/arXiv → straight into Zotero through the same local channel the browser connector uses — including the OA PDF when one exists (Zotero downloads it itself, like the browser connector). Duplicates are detected before saving; a pipeline cycle runs immediately after the add. Optional [translation-server](https://github.com/zotero/translation-server) support for arbitrary URL imports.
|
|
21
|
+
- **Automatic wiki-fication** — new Zotero items become Obsidian notes (template-compatible, atomic writes). Existing notes are **never rewritten**; your manual sections are structurally safe.
|
|
22
|
+
- **Edit-safe highlight sync** — Zotero PDF highlights land in ONE marker-delimited block per note (grouped by color, deep links back to the exact annotation), kept in sync incl. deletions. Figure/area annotations are embedded as images (copied from Zotero's cache). Color groups can be renamed to *your* semantics (`label_red = "Core Claims"`). Everything outside the block is untouchable; unmarked legacy notes are opt-in (`[annotations] adopt_existing`). See [docs/MIGRATION.md](docs/MIGRATION.md).
|
|
23
|
+
- **PDF resolution, politely** — Zotero attachment → cache → arXiv → Unpaywall → (opt-in) institutional proxy with browser-session cookies. Sequential, delayed, daily-capped: designed to *not* get your campus blocked. See [docs/PROXY.md](docs/PROXY.md).
|
|
24
|
+
- **AI-analysis queue** — ZotVault doesn't run an LLM; it feeds yours. `zotvault queue --json` lists unanalyzed papers with readable PDF paths; when your agent (e.g. Claude batch) writes `*_analysis.md`, completion is auto-detected.
|
|
25
|
+
- **Dashboard** — `zotvault web` → http://127.0.0.1:8377 : search → tick → add, queue, alerts inbox, suggestions, audit trail. Localhost only.
|
|
26
|
+
- **arXiv alerts** — daily keyword digest into a review inbox. Nothing enters Zotero without your click (propose, don't execute).
|
|
27
|
+
- **Alert triage (assist)** — optionally, a *small* local Ollama model scores inbox candidates 0–10 for relevance under a strict JSON contract (constrained decoding + validation + one retry); the dashboard sorts by score. Advisory only, off by default.
|
|
28
|
+
- **Citation graph** — Semantic Scholar citation counts + who-cites-whom *within your library*, regenerated into `Citation_Graph.md`.
|
|
29
|
+
- **Related papers** — local [Ollama](https://ollama.com) embeddings over your analysis notes → `Related_Suggestions.md` link candidates. Free, offline.
|
|
30
|
+
- **Synthesis suggestions** — clusters analyzed-but-unsynthesized papers into proposed review topics.
|
|
31
|
+
- **Auditable** — every automatic action lands in a SQLite trace (`zotvault trace`).
|
|
32
|
+
|
|
33
|
+
## Requirements
|
|
34
|
+
|
|
35
|
+
- Python ≥ 3.9 (CLI is cross-platform; the double-click app + `install-daemon` autostart are macOS today)
|
|
36
|
+
- Zotero desktop with **[Better BibTeX](https://retorque.re/zotero-better-bibtex/)** — **required**: it is ZotVault's citekey source. Without it, items can't be named and nothing syncs (ZotVault will mark them `blocked` and tell you).
|
|
37
|
+
- An Obsidian vault (any folder). ZotVault writes per-paper notes under `<papers_subdir>/<citekey>/` — configure the paths; it does not impose a vault structure.
|
|
38
|
+
- Optional: `pdftotext` (poppler) for full-text AI analysis, Ollama (related papers / local analysis), an institutional web proxy for licensed PDFs.
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
git clone https://github.com/jbaek-dev/ZotVault && cd ZotVault
|
|
44
|
+
pip install . # or: pipx install . / uv tool install .
|
|
45
|
+
zotvault init # writes ~/.zotvault/config.toml
|
|
46
|
+
# edit: [vault] dir, [pdf] unpaywall_email (+ [alerts] keywords if you want digests)
|
|
47
|
+
zotvault doctor # verifies Zotero, Better BibTeX, vault paths
|
|
48
|
+
zotvault run-once --dry-run
|
|
49
|
+
zotvault run-once
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**Run it continuously.** Two options on every platform:
|
|
53
|
+
|
|
54
|
+
- **System tray** (recommended): `pip install ".[tray]"` then `zotvault tray` — daemon + tray icon with Open Dashboard / Run now / Pause / Quit. The only third-party packages ZotVault can use, and only if you opt in.
|
|
55
|
+
- **OS service:** `zotvault install-daemon` — macOS writes a launchd plist, Linux writes a systemd user unit, Windows prints the Task Scheduler command. Never auto-loads; it tells you the enable command.
|
|
56
|
+
|
|
57
|
+
macOS extra: double-click ZotVault.app (`bash scripts/build_app.sh`).
|
|
58
|
+
|
|
59
|
+
Every command also works without installing, via `python3 -m zotvault.cli <cmd>`.
|
|
60
|
+
|
|
61
|
+
## Commands
|
|
62
|
+
|
|
63
|
+
| command | what it does |
|
|
64
|
+
|---|---|
|
|
65
|
+
| `init` / `doctor` | config file / environment health check |
|
|
66
|
+
| `run-once [--dry-run]` / `daemon` | one cycle / poll loop (+dashboard thread) |
|
|
67
|
+
| `install-daemon` | autostart: launchd plist (macOS) / systemd unit (Linux) / schtasks cmd (Windows) |
|
|
68
|
+
| `tray` | daemon + system-tray icon (needs `pip install ".[tray]"`) |
|
|
69
|
+
| `add <ids…> [--dry-run]` | resolve DOI/arXiv/URL → save to Zotero |
|
|
70
|
+
| `search <query> [--source arxiv\|s2\|crossref]` | search with in-library marks |
|
|
71
|
+
| `web` | dashboard server in the foreground |
|
|
72
|
+
| `queue [--json]` | papers awaiting AI analysis |
|
|
73
|
+
| `alerts [--fetch\|--approve N\|--dismiss N]` | arXiv digest inbox |
|
|
74
|
+
| `enrich [--limit N]` | citation graph + embeddings + suggestion notes |
|
|
75
|
+
| `assist [--limit N]` | score pending alerts with the small local model |
|
|
76
|
+
| `related <citekey>` / `synthesis [--write]` | similarity / cluster proposals |
|
|
77
|
+
| `status` / `trace [--limit N]` | state summary / audit trail |
|
|
78
|
+
|
|
79
|
+
## Design guarantees
|
|
80
|
+
|
|
81
|
+
- **Read-only toward Zotero's data.** Library reads use a temp snapshot of `zotero.sqlite`; adds go through Zotero's own connector endpoint (Zotero writes its DB itself). ZotVault never touches `storage/`; downloaded PDFs live in `~/.zotvault/pdfs/`.
|
|
82
|
+
- **Vault safety.** Existing notes are never rewritten; auto-notes (`Citation_Graph.md`, `Related_Suggestions.md`, `_Synthesis_Suggestions.md`) are clearly marked and ZotVault-owned; index.md is only patched via a strict counter regex; log.md is append-only; `--dry-run` previews; no delete code path exists.
|
|
83
|
+
- **Polite networking.** OA-first, sequential fetches, delays, hard daily caps (separate, stricter cap for the proxy), honest User-Agent, backs off on HTTP 429.
|
|
84
|
+
- **Nothing personal in code.** All user-specific values live in `~/.zotvault/config.toml`.
|
|
85
|
+
|
|
86
|
+
## Agent integration
|
|
87
|
+
|
|
88
|
+
Any agent can drive ZotVault through the localhost API (`/api/search`, `/api/add`, `/api/queue`, `/api/status`). A ready-made tool module for the Polaris local agent (not yet public) lives in that repo (`polaris/tools/zotvault_tools.py`): search results show up with in-library marks, and adding requires the user to have explicitly picked papers.
|
|
89
|
+
|
|
90
|
+
## Tests
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
python3 -m unittest discover -s tests -v # 101 tests, no network needed
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
MIT — see [LICENSE](LICENSE). Changelog in [CHANGELOG.md](CHANGELOG.md).
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "zotvault"
|
|
7
|
+
version = "0.9.1"
|
|
8
|
+
description = "Local-first paper pipeline orchestrator between Zotero and Obsidian: detect, note, fetch PDF, queue AI analysis, index."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Jongmin Baek" }]
|
|
14
|
+
keywords = ["zotero", "obsidian", "papers", "research", "pkm", "local-first"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Operating System :: MacOS",
|
|
18
|
+
"Topic :: Scientific/Engineering",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/jbaek-dev/ZotVault"
|
|
23
|
+
Changelog = "https://github.com/jbaek-dev/ZotVault/blob/main/CHANGELOG.md"
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
tray = ["pystray>=0.19", "Pillow>=9"]
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
zotvault = "zotvault.cli:main"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools]
|
|
32
|
+
packages = ["zotvault"]
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.package-data]
|
|
35
|
+
zotvault = ["static/*.html"]
|
zotvault-0.9.1/setup.cfg
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import tempfile
|
|
2
|
+
import unittest
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from zotvault.alerts import store_entries
|
|
6
|
+
from zotvault.state import State
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def entry(aid, published="2026-07-04", title="T"):
|
|
10
|
+
return {"arxiv_id": aid, "title": title, "summary": "s", "published": published,
|
|
11
|
+
"authors": ["A B"], "doi": "", "pdf_url": "", "categories": []}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestAlerts(unittest.TestCase):
|
|
15
|
+
def setUp(self):
|
|
16
|
+
self.td = tempfile.TemporaryDirectory()
|
|
17
|
+
self.state = State(Path(self.td.name) / "s.db")
|
|
18
|
+
|
|
19
|
+
def tearDown(self):
|
|
20
|
+
self.state.close()
|
|
21
|
+
self.td.cleanup()
|
|
22
|
+
|
|
23
|
+
def test_store_dedupe_cutoff_library(self):
|
|
24
|
+
self.state.upsert_item(1, item_key="K", citekey="InLib2026", arxiv_id="2406.00001")
|
|
25
|
+
seen = self.state.alert_seen_ids()
|
|
26
|
+
lib = self.state.arxiv_map()
|
|
27
|
+
entries = [
|
|
28
|
+
entry("2407.00001v1"), # new -> stored
|
|
29
|
+
entry("2407.00001v2"), # same base id -> skipped
|
|
30
|
+
entry("2406.00001"), # already in library -> skipped
|
|
31
|
+
entry("2401.00009", published="2026-01-01"), # too old -> skipped
|
|
32
|
+
]
|
|
33
|
+
added = store_entries(entries, "valleytronics", "cond-mat.mes-hall",
|
|
34
|
+
cutoff="2026-07-01", seen=seen, in_library=lib,
|
|
35
|
+
state=self.state)
|
|
36
|
+
self.assertEqual(added, 1)
|
|
37
|
+
rows = self.state.alerts_list("pending")
|
|
38
|
+
self.assertEqual(len(rows), 1)
|
|
39
|
+
self.assertEqual(rows[0]["arxiv_id"], "2407.00001")
|
|
40
|
+
self.assertIn("valleytronics", rows[0]["matched"])
|
|
41
|
+
# second fetch: no duplicates
|
|
42
|
+
added2 = store_entries([entry("2407.00001v3")], "kw", "",
|
|
43
|
+
cutoff="2026-07-01", seen=self.state.alert_seen_ids(),
|
|
44
|
+
in_library=lib, state=self.state)
|
|
45
|
+
self.assertEqual(added2, 0)
|
|
46
|
+
|
|
47
|
+
def test_status_flow(self):
|
|
48
|
+
store_entries([entry("2407.11111")], "kw", "", "2026-07-01",
|
|
49
|
+
set(), {}, self.state)
|
|
50
|
+
row = self.state.alerts_list("pending")[0]
|
|
51
|
+
self.state.alert_set_status(row["id"], "dismissed")
|
|
52
|
+
self.assertEqual(self.state.alerts_list("pending"), [])
|
|
53
|
+
self.assertEqual(len(self.state.alerts_list("dismissed")), 1)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
unittest.main()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import tempfile
|
|
2
|
+
import unittest
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from zotvault import analysis_queue
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def make_vault(td: Path):
|
|
9
|
+
papers = td / "30_Resources" / "Papers" / "zotero"
|
|
10
|
+
a = papers / "Kim2026"
|
|
11
|
+
a.mkdir(parents=True)
|
|
12
|
+
(a / "Kim2026.md").write_text("note", encoding="utf-8")
|
|
13
|
+
(a / "Kim2026_claude_analysis.md").write_text("analysis", encoding="utf-8")
|
|
14
|
+
b = papers / "Lee2025"
|
|
15
|
+
b.mkdir(parents=True)
|
|
16
|
+
(b / "Lee2025.md").write_text("note", encoding="utf-8")
|
|
17
|
+
c = papers / "Park2024"
|
|
18
|
+
c.mkdir(parents=True) # folder without note (edge case)
|
|
19
|
+
return papers
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestQueue(unittest.TestCase):
|
|
23
|
+
def test_scan_pending_progress(self):
|
|
24
|
+
with tempfile.TemporaryDirectory() as td:
|
|
25
|
+
papers = make_vault(Path(td))
|
|
26
|
+
entries = analysis_queue.scan(papers)
|
|
27
|
+
self.assertEqual(len(entries), 3)
|
|
28
|
+
by_key = {e.citekey: e for e in entries}
|
|
29
|
+
self.assertTrue(by_key["Kim2026"].analyzed)
|
|
30
|
+
self.assertFalse(by_key["Lee2025"].analyzed)
|
|
31
|
+
self.assertTrue(by_key["Lee2025"].has_note)
|
|
32
|
+
self.assertFalse(by_key["Park2024"].has_note)
|
|
33
|
+
pend = analysis_queue.pending(papers)
|
|
34
|
+
self.assertEqual({e.citekey for e in pend}, {"Lee2025", "Park2024"})
|
|
35
|
+
self.assertEqual(analysis_queue.progress(papers), (1, 3))
|
|
36
|
+
|
|
37
|
+
def test_analysis_file_for(self):
|
|
38
|
+
with tempfile.TemporaryDirectory() as td:
|
|
39
|
+
papers = make_vault(Path(td))
|
|
40
|
+
hit = analysis_queue.analysis_file_for(papers, "Kim2026")
|
|
41
|
+
self.assertIsNotNone(hit)
|
|
42
|
+
self.assertTrue(hit.name.endswith("_claude_analysis.md"))
|
|
43
|
+
self.assertIsNone(analysis_queue.analysis_file_for(papers, "Lee2025"))
|
|
44
|
+
self.assertIsNone(analysis_queue.analysis_file_for(papers, "Nope2000"))
|
|
45
|
+
|
|
46
|
+
def test_missing_dir(self):
|
|
47
|
+
self.assertEqual(analysis_queue.scan(Path("/nonexistent/papers")), [])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
unittest.main()
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import tempfile
|
|
2
|
+
import unittest
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from unittest import mock
|
|
5
|
+
|
|
6
|
+
from zotvault import analyze
|
|
7
|
+
from zotvault.config import Config
|
|
8
|
+
from zotvault.state import State
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def make_cfg(td: Path, engine: str = "ollama", model: str = "testmodel") -> Config:
|
|
12
|
+
cfg = Config()
|
|
13
|
+
cfg.vault_dir = td
|
|
14
|
+
cfg.state_db = td / "state.db"
|
|
15
|
+
cfg.pdf_dir = td / "pdfs"
|
|
16
|
+
cfg.analysis_engine = engine
|
|
17
|
+
cfg.analysis_model = model
|
|
18
|
+
cfg.analysis_daily_limit = 2
|
|
19
|
+
return cfg
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def make_paper(papers: Path, citekey: str, with_analysis: bool = False) -> Path:
|
|
23
|
+
folder = papers / citekey
|
|
24
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
(folder / (citekey + ".md")).write_text(
|
|
26
|
+
'---\ntitle: "T"\nauthors: "A"\nyear: "2024"\n---\n\n'
|
|
27
|
+
"## 📄 Abstract\nThis paper studies things in detail and reports results.\n\n---\n",
|
|
28
|
+
encoding="utf-8")
|
|
29
|
+
if with_analysis:
|
|
30
|
+
(folder / (citekey + "_claude_analysis.md")).write_text("x", encoding="utf-8")
|
|
31
|
+
return folder
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
LONG_BODY = "## 🧠 Core Summary (neutral)\n" + ("Solid neutral sentences. " * 20)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TestHelpers(unittest.TestCase):
|
|
38
|
+
def test_engine_suffix_defaults(self):
|
|
39
|
+
cfg = Config()
|
|
40
|
+
for engine, want in (("claude-cli", "claude"), ("anthropic", "claude"),
|
|
41
|
+
("ollama", "ollama"), ("openai-compatible", "ai")):
|
|
42
|
+
cfg.analysis_engine = engine
|
|
43
|
+
cfg.analysis_suffix = ""
|
|
44
|
+
self.assertEqual(analyze.engine_suffix(cfg), want)
|
|
45
|
+
cfg.analysis_suffix = "custom"
|
|
46
|
+
self.assertEqual(analyze.engine_suffix(cfg), "custom")
|
|
47
|
+
|
|
48
|
+
def test_prompt_build_and_wrap(self):
|
|
49
|
+
cfg = Config()
|
|
50
|
+
cfg.analysis_engine = "ollama"
|
|
51
|
+
cfg.analysis_model = "m1"
|
|
52
|
+
prompt = analyze.build_prompt(
|
|
53
|
+
{"title": "T", "authors": "A", "year": "2024", "journal": "J",
|
|
54
|
+
"doi": "10.1/x", "citekey": "K2024"},
|
|
55
|
+
"FULLTEXT HERE", "full-text", cfg)
|
|
56
|
+
self.assertIn("Title: T", prompt)
|
|
57
|
+
self.assertIn("FULLTEXT HERE", prompt)
|
|
58
|
+
note = analyze.wrap_note("## body", "K2024", "full-text", cfg)
|
|
59
|
+
self.assertIn("type: ai_analysis", note)
|
|
60
|
+
self.assertIn("source: ollama:m1", note)
|
|
61
|
+
self.assertIn('citekey: "K2024"', note)
|
|
62
|
+
self.assertIn('input_basis: "full-text"', note)
|
|
63
|
+
self.assertIn("immutable: true", note)
|
|
64
|
+
|
|
65
|
+
def test_generate_dispatch_none(self):
|
|
66
|
+
cfg = Config()
|
|
67
|
+
with self.assertRaises(ValueError):
|
|
68
|
+
analyze.generate("hi", cfg)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TestAnalyzeOne(unittest.TestCase):
|
|
72
|
+
def test_skip_existing(self):
|
|
73
|
+
with tempfile.TemporaryDirectory() as td:
|
|
74
|
+
td = Path(td)
|
|
75
|
+
cfg = make_cfg(td)
|
|
76
|
+
papers = cfg.papers_dir
|
|
77
|
+
folder = make_paper(papers, "Kim2026", with_analysis=True)
|
|
78
|
+
state = State(cfg.state_db)
|
|
79
|
+
status, _ = analyze.analyze_one("Kim2026", folder, None, cfg, state)
|
|
80
|
+
state.close()
|
|
81
|
+
self.assertEqual(status, "exists")
|
|
82
|
+
|
|
83
|
+
def test_write_via_abstract_fallback(self):
|
|
84
|
+
with tempfile.TemporaryDirectory() as td:
|
|
85
|
+
td = Path(td)
|
|
86
|
+
cfg = make_cfg(td)
|
|
87
|
+
folder = make_paper(cfg.papers_dir, "Lee2025")
|
|
88
|
+
state = State(cfg.state_db)
|
|
89
|
+
with mock.patch.object(analyze, "_gen_ollama", return_value=LONG_BODY):
|
|
90
|
+
status, detail = analyze.analyze_one("Lee2025", folder, None, cfg, state)
|
|
91
|
+
self.assertEqual(status, "written", detail)
|
|
92
|
+
target = folder / "Lee2025_ollama_analysis.md"
|
|
93
|
+
self.assertTrue(target.exists())
|
|
94
|
+
text = target.read_text(encoding="utf-8")
|
|
95
|
+
self.assertIn('input_basis: "abstract+metadata only"', text)
|
|
96
|
+
self.assertEqual(state.analyses_today(), 1)
|
|
97
|
+
state.close()
|
|
98
|
+
|
|
99
|
+
def test_short_output_rejected(self):
|
|
100
|
+
with tempfile.TemporaryDirectory() as td:
|
|
101
|
+
td = Path(td)
|
|
102
|
+
cfg = make_cfg(td)
|
|
103
|
+
folder = make_paper(cfg.papers_dir, "Park2024")
|
|
104
|
+
state = State(cfg.state_db)
|
|
105
|
+
with mock.patch.object(analyze, "_gen_ollama", return_value="ok"):
|
|
106
|
+
status, detail = analyze.analyze_one("Park2024", folder, None, cfg, state)
|
|
107
|
+
state.close()
|
|
108
|
+
self.assertEqual(status, "error")
|
|
109
|
+
self.assertFalse((folder / "Park2024_ollama_analysis.md").exists())
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TestBatch(unittest.TestCase):
|
|
113
|
+
def test_engine_none_refuses(self):
|
|
114
|
+
with tempfile.TemporaryDirectory() as td:
|
|
115
|
+
cfg = make_cfg(Path(td), engine="none")
|
|
116
|
+
state = State(cfg.state_db)
|
|
117
|
+
out = analyze.run_batch(cfg, state)
|
|
118
|
+
state.close()
|
|
119
|
+
self.assertEqual(out[0]["status"], "error")
|
|
120
|
+
|
|
121
|
+
def test_budget_enforced(self):
|
|
122
|
+
with tempfile.TemporaryDirectory() as td:
|
|
123
|
+
td = Path(td)
|
|
124
|
+
cfg = make_cfg(td) # daily_limit = 2
|
|
125
|
+
for ck in ("A2020", "B2021", "C2022"):
|
|
126
|
+
make_paper(cfg.papers_dir, ck)
|
|
127
|
+
state = State(cfg.state_db)
|
|
128
|
+
with mock.patch.object(analyze, "_gen_ollama", return_value=LONG_BODY):
|
|
129
|
+
results = analyze.run_batch(cfg, state)
|
|
130
|
+
state.close()
|
|
131
|
+
statuses = sorted(r["status"] for r in results)
|
|
132
|
+
self.assertEqual(statuses.count("written"), 2)
|
|
133
|
+
self.assertEqual(statuses.count("deferred"), 1)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
unittest.main()
|