pagecite 2.0.0__py3-none-any.whl

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.
pagecite/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """pagecite: summarise research PDFs where every claim cites its page and is checked against the text."""
2
+
3
+ __version__ = "2.0.0"
pagecite/__main__.py ADDED
@@ -0,0 +1,203 @@
1
+ """pagecite: summarise research PDFs where every claim cites its page and is checked against the text."""
2
+
3
+ import argparse
4
+ import importlib.util
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from . import __version__
9
+ from .config import DATA_DIR, Settings, load_env
10
+
11
+
12
+ def fail(message: str) -> int:
13
+ print(f"Error: {message}", file=sys.stderr)
14
+ return 1
15
+
16
+
17
+ def open_store(settings: Settings):
18
+ from .store import Store
19
+
20
+ return Store(settings.db_path)
21
+
22
+
23
+ def expand(sources: list[str]) -> list[str]:
24
+ """Folders become the PDFs inside them; files and URLs pass through."""
25
+ from .pdf import find_pdfs, is_url
26
+
27
+ out = []
28
+ for s in sources:
29
+ path = Path(s).expanduser()
30
+ if not is_url(s) and path.is_dir():
31
+ found = find_pdfs(path)
32
+ if not found:
33
+ print(f"No PDFs in {s}", file=sys.stderr)
34
+ out += [str(p) for p in found]
35
+ else:
36
+ out.append(s)
37
+ return out
38
+
39
+
40
+ def cmd_summarize(args, settings: Settings) -> int:
41
+ from . import library, render
42
+ from .pdf import PDFError
43
+ from .summarize import ClaudeError, Summarizer
44
+
45
+ store, summarizer = open_store(settings), Summarizer(model=settings.model)
46
+ notes = Path(args.output).expanduser() if args.output else settings.notes_dir
47
+ sources, failed = expand(args.sources), 0
48
+ for i, source in enumerate(sources):
49
+ if len(sources) > 1:
50
+ print(f"[{i + 1}/{len(sources)}] {source}", file=sys.stderr)
51
+ try:
52
+ result = library.summarize(source, store, summarizer, args.refresh)
53
+ except (PDFError, ClaudeError) as e:
54
+ failed += 1
55
+ fail(str(e))
56
+ continue
57
+ doc, pages = store.doc(result.doc_id), store.pages(result.doc_id)
58
+ if i:
59
+ print("\n" + "─" * 60 + "\n")
60
+ print(render.terminal(result.summary, doc, pages, color=sys.stdout.isatty()))
61
+ if result.cached:
62
+ print(f"\n(from your library, #{result.doc_id}; --refresh to summarise again)", file=sys.stderr)
63
+ if notes:
64
+ print(f"Note: {render.write_note(result.summary, doc, pages, notes)}", file=sys.stderr)
65
+ return 1 if failed else 0
66
+
67
+
68
+ def cmd_ask(args, settings: Settings) -> int:
69
+ from .ask import Asker
70
+
71
+ answer = Asker(open_store(settings), model=settings.model).ask(" ".join(args.question), args.doc)
72
+ sources = answer.sources()
73
+ index = {(c.doc_id, c.page): n for n, c in enumerate(sources, 1)}
74
+ text = ""
75
+ for part in answer.parts:
76
+ marks = sorted({index[(c.doc_id, c.page)] for c in part.cites})
77
+ text += part.text + "".join(f"[{m}]" for m in marks)
78
+ print(text.strip())
79
+ if sources:
80
+ print("\nSources")
81
+ for n, c in enumerate(sources, 1):
82
+ excerpt = " ".join(c.text.split())
83
+ print(f" [{n}] {c.title}, p. {c.page} (#{c.doc_id}): “{excerpt[:160]}{'…' if len(excerpt) > 160 else ''}”")
84
+ return 0
85
+
86
+
87
+ def cmd_list(args, settings: Settings) -> int:
88
+ docs = open_store(settings).docs()
89
+ if not docs:
90
+ print("Your library is empty. Start with: pagecite summarize paper.pdf")
91
+ for d in docs:
92
+ mark = "" if d["summary"] else " (not summarised)"
93
+ print(f"#{d['id']:<4} {d['title']} · {d['page_count']} pages · {d['added_at'][:10]}{mark}")
94
+ return 0
95
+
96
+
97
+ def cmd_show(args, settings: Settings) -> int:
98
+ from . import render
99
+
100
+ store = open_store(settings)
101
+ doc, summary = store.doc(args.id), store.summary(args.id)
102
+ if doc is None:
103
+ return fail(f"No document #{args.id}. See `pagecite list`.")
104
+ if summary is None:
105
+ return fail(f"#{args.id} has no summary yet: pagecite summarize {doc['source']}")
106
+ pages = store.pages(args.id)
107
+ print(render.markdown(summary, doc, pages) if args.markdown else
108
+ render.terminal(summary, doc, pages, color=sys.stdout.isatty()))
109
+ return 0
110
+
111
+
112
+ def cmd_search(args, settings: Settings) -> int:
113
+ hits = open_store(settings).search(" ".join(args.query), args.limit, args.doc)
114
+ if not hits:
115
+ print("No matching pages.")
116
+ for h in hits:
117
+ print(f"#{h['doc_id']} {h['title']}, p. {h['n']}\n {' '.join(h['snippet'].split())}")
118
+ return 0
119
+
120
+
121
+ def cmd_mcp(args, settings: Settings) -> int:
122
+ if importlib.util.find_spec("mcp") is None:
123
+ return fail("The MCP server needs the mcp extra: pipx install 'pagecite[mcp]'")
124
+ from .ask import Asker
125
+ from .mcp_server import build_server
126
+ from .summarize import Summarizer
127
+
128
+ store = open_store(settings)
129
+ build_server(store, Summarizer(model=settings.model), Asker(store, model=settings.model)).run("stdio")
130
+ return 0
131
+
132
+
133
+ def cmd_demo(args, settings: Settings) -> int:
134
+ from . import render
135
+ from .demo import sample
136
+
137
+ doc, summary = sample()
138
+ meta = {"title": doc.title, "source": doc.source, "page_count": doc.page_count}
139
+ print("Demo: a fictional 4-page report and a sample summary bundled with pagecite (no API call).\n"
140
+ "The checks are real: two sample claims are deliberately wrong.\n", file=sys.stderr)
141
+ print(render.terminal(summary, meta, doc.pages, color=sys.stdout.isatty()))
142
+ return 0
143
+
144
+
145
+ def build_parser() -> argparse.ArgumentParser:
146
+ p = argparse.ArgumentParser(prog="pagecite", description=__doc__)
147
+ p.add_argument("--version", action="version", version=f"pagecite {__version__}")
148
+ p.add_argument("--db", help=f"library file (default {DATA_DIR / 'library.db'})")
149
+ sub = p.add_subparsers(dest="command")
150
+
151
+ s = sub.add_parser("summarize", aliases=["sum"], help="summarise PDFs: files, folders or http(s) URLs")
152
+ s.add_argument("sources", nargs="+")
153
+ s.add_argument("-o", "--output", help="also write each summary as a Markdown note into this folder")
154
+ s.add_argument("--refresh", action="store_true", help="summarise again even if the file is in your library")
155
+
156
+ a = sub.add_parser("ask", help="ask your library (or one document) a question; the answer cites pages")
157
+ a.add_argument("question", nargs="+")
158
+ a.add_argument("--doc", type=int, help="only this document (id from `pagecite list`)")
159
+
160
+ sub.add_parser("list", aliases=["ls"], help="the PDFs in your library")
161
+
162
+ sh = sub.add_parser("show", help="print a stored summary")
163
+ sh.add_argument("id", type=int)
164
+ sh.add_argument("--markdown", action="store_true", help="as an Obsidian note")
165
+
166
+ se = sub.add_parser("search", help="full-text search over every page you've read")
167
+ se.add_argument("query", nargs="+")
168
+ se.add_argument("--doc", type=int, help="only this document")
169
+ se.add_argument("-n", "--limit", type=int, default=10)
170
+
171
+ sub.add_parser("mcp", help="run the MCP server over stdio (for Claude Desktop, Claude Code, …)")
172
+ sub.add_parser("demo", help="see a checked summary on a fictional report (no API key needed)")
173
+ return p
174
+
175
+
176
+ COMMANDS = {"summarize": cmd_summarize, "sum": cmd_summarize, "ask": cmd_ask, "list": cmd_list, "ls": cmd_list,
177
+ "show": cmd_show, "search": cmd_search, "mcp": cmd_mcp, "demo": cmd_demo}
178
+
179
+
180
+ def main(argv: list[str] | None = None) -> int:
181
+ load_env(Path.cwd() / ".env")
182
+ load_env(DATA_DIR / ".env")
183
+ parser = build_parser()
184
+ args = parser.parse_args(argv)
185
+ if args.command is None:
186
+ parser.print_help()
187
+ return 0
188
+ settings = Settings.from_env()
189
+ if args.db:
190
+ settings.db_path = Path(args.db).expanduser()
191
+
192
+ from .summarize import ClaudeError
193
+
194
+ try:
195
+ return COMMANDS[args.command](args, settings)
196
+ except ClaudeError as e:
197
+ return fail(str(e))
198
+ except KeyboardInterrupt:
199
+ return 130
200
+
201
+
202
+ if __name__ == "__main__":
203
+ sys.exit(main())
pagecite/ask.py ADDED
@@ -0,0 +1,125 @@
1
+ """Ask a question of one document or your whole library. Pages go to Claude as search-result
2
+ blocks with citations on, so every sentence of the answer points to a document and a page, and
3
+ the cited text is returned by the API itself rather than written by the model.
4
+
5
+ For one document every page is sent, with a cache breakpoint after the last one, so follow-up
6
+ questions about the same PDF read the pages from the prompt cache. Across the library, only the
7
+ best-matching pages (full-text search) are sent."""
8
+
9
+ from dataclasses import dataclass, field
10
+
11
+ import anthropic
12
+
13
+ from .store import Store
14
+ from .summarize import MAX_CHARS, ClaudeError, call_errors
15
+
16
+ SYSTEM_PROMPT = """You answer questions about the user's documents using only the pages provided. Each
17
+ search result is one page of one document. Be brief and concrete. If the pages don't answer the
18
+ question, say so plainly instead of guessing. Keep the document's hedges: an estimate stays an
19
+ estimate."""
20
+
21
+
22
+ @dataclass
23
+ class Cite:
24
+ doc_id: int
25
+ title: str
26
+ page: int
27
+ text: str
28
+
29
+
30
+ @dataclass
31
+ class Part:
32
+ text: str
33
+ cites: list[Cite] = field(default_factory=list)
34
+
35
+
36
+ @dataclass
37
+ class Answer:
38
+ parts: list[Part]
39
+ pages_sent: int = 0
40
+
41
+ @property
42
+ def text(self) -> str:
43
+ return "".join(p.text for p in self.parts)
44
+
45
+ def sources(self) -> list[Cite]:
46
+ """Distinct citations in the order they first appear."""
47
+ seen, out = set(), []
48
+ for p in self.parts:
49
+ for c in p.cites:
50
+ if (c.doc_id, c.page) not in seen:
51
+ seen.add((c.doc_id, c.page))
52
+ out.append(c)
53
+ return out
54
+
55
+
56
+ def search_blocks(pages: list[dict], cache: bool = False) -> list[dict]:
57
+ blocks = [
58
+ {
59
+ "type": "search_result",
60
+ "source": f"doc:{p['doc_id']}#page={p['n']}",
61
+ "title": f"{p['title']}, p. {p['n']}",
62
+ "content": [{"type": "text", "text": p["text"].strip() or "(no text on this page)"}],
63
+ "citations": {"enabled": True},
64
+ }
65
+ for p in pages
66
+ ]
67
+ if cache and blocks:
68
+ blocks[-1]["cache_control"] = {"type": "ephemeral"}
69
+ return blocks
70
+
71
+
72
+ def to_parts(content, pages: list[dict]) -> list[Part]:
73
+ parts = []
74
+ for block in content:
75
+ if block.type != "text":
76
+ continue
77
+ cites = []
78
+ for c in getattr(block, "citations", None) or []:
79
+ if getattr(c, "type", "") != "search_result_location" or not 0 <= c.search_result_index < len(pages):
80
+ continue
81
+ p = pages[c.search_result_index]
82
+ cites.append(Cite(p["doc_id"], p["title"], p["n"], c.cited_text))
83
+ parts.append(Part(block.text, cites))
84
+ return parts
85
+
86
+
87
+ class Asker:
88
+ def __init__(self, store: Store, client: anthropic.Anthropic | None = None, model: str = "claude-opus-5-5"):
89
+ self.store = store
90
+ self._client = client
91
+ self.model = model
92
+
93
+ @property
94
+ def client(self) -> anthropic.Anthropic:
95
+ if self._client is None:
96
+ self._client = anthropic.Anthropic()
97
+ return self._client
98
+
99
+ def pages_for(self, question: str, doc_id: int | None, limit: int) -> tuple[list[dict], bool]:
100
+ """The pages to send, and whether they are a whole document (worth caching)."""
101
+ if doc_id is not None:
102
+ doc = self.store.doc(doc_id)
103
+ if doc is None:
104
+ raise ClaudeError(f"No document #{doc_id} in the library.")
105
+ texts = self.store.pages(doc_id)
106
+ if sum(len(t) for t in texts) <= MAX_CHARS:
107
+ return [{"doc_id": doc_id, "n": n, "text": t, "title": doc["title"]} for n, t in enumerate(texts, 1)], True
108
+ return self.store.search(question, limit, doc_id), False
109
+
110
+ def ask(self, question: str, doc_id: int | None = None, limit: int = 12) -> Answer:
111
+ pages, whole = self.pages_for(question, doc_id, limit)
112
+ if not pages:
113
+ return Answer([Part("Nothing in your library matches that question.")])
114
+ response = call_errors(lambda: self.client.beta.messages.create(
115
+ model=self.model,
116
+ max_tokens=4000,
117
+ system=SYSTEM_PROMPT,
118
+ messages=[{"role": "user", "content": [*search_blocks(pages, cache=whole), {"type": "text", "text": question}]}],
119
+ output_config={"effort": "low"},
120
+ betas=["server-side-fallback-2026-07-01"],
121
+ fallbacks="default",
122
+ ))
123
+ if response.stop_reason == "refusal":
124
+ raise ClaudeError("Claude declined to answer this one.")
125
+ return Answer(to_parts(response.content, pages), len(pages))
pagecite/config.py ADDED
@@ -0,0 +1,35 @@
1
+ """Settings come from environment variables, optionally loaded from a .env file."""
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ DATA_DIR = Path(os.environ.get("PAGECITE_HOME", Path.home() / ".pagecite"))
8
+
9
+
10
+ def load_env(path: Path) -> None:
11
+ """Load KEY=VALUE lines into os.environ without overriding variables already set."""
12
+ if not path.exists():
13
+ return
14
+ for line in path.read_text().splitlines():
15
+ line = line.strip()
16
+ if line and not line.startswith("#") and "=" in line:
17
+ key, _, value = line.partition("=")
18
+ os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
19
+
20
+
21
+ @dataclass
22
+ class Settings:
23
+ db_path: Path = DATA_DIR / "library.db"
24
+ model: str = "claude-opus-5-5"
25
+ notes_dir: Path | None = None # where `summarize` writes Markdown notes when no -o is given
26
+
27
+ @classmethod
28
+ def from_env(cls) -> "Settings":
29
+ e = os.environ.get
30
+ notes = e("PAGECITE_NOTES_DIR", "").strip()
31
+ return cls(
32
+ db_path=Path(e("PAGECITE_DB", str(DATA_DIR / "library.db"))).expanduser(),
33
+ model=e("PAGECITE_MODEL", "claude-opus-5-5"),
34
+ notes_dir=Path(notes).expanduser() if notes else None,
35
+ )
pagecite/demo.py ADDED
@@ -0,0 +1,82 @@
1
+ """`pagecite demo`: a fictional four-page report, built as a real PDF, and a sample summary written
2
+ for the demo (no API call). The verification that runs on it is the real one: two of the sample
3
+ claims are deliberately wrong, so you can see what a flagged claim looks like."""
4
+
5
+ import pymupdf
6
+
7
+ from .pdf import Document, from_bytes
8
+ from .summarize import Summary
9
+
10
+ PAGES = [
11
+ """The State of Urban Cargo Bikes 2026
12
+ Northside Mobility Lab - Annual Report (fictional demo document)
13
+
14
+ Executive summary
15
+ Cargo bikes moved from niche to mainstream in European city logistics this year. Across the 14 cities
16
+ we tracked, operators made 2.3 million deliveries by cargo bike, up from 1.6 million in 2025.
17
+ Our central finding is that cargo bikes now beat vans on cost per parcel in dense city centres,
18
+ but only where cities provide loading space and protected lanes.""",
19
+ """Market size
20
+ We estimate the European cargo bike logistics market at EUR 1.9bn in 2026, growing at roughly 18% a year.
21
+ This estimate is based on operator surveys and should be read as a range rather than a point figure.
22
+
23
+ Cost per parcel
24
+ In the densest districts the average cost per parcel was EUR 1.42 by cargo bike against EUR 2.10 by van.
25
+ Outside the city centre the advantage disappears: vans were cheaper beyond a 6 km delivery radius.""",
26
+ """What holds adoption back
27
+ Operators named the lack of loading bays as the single biggest obstacle, ahead of theft and weather.
28
+ Insurance premiums rose 22% year on year, which several smaller operators said threatens their margins.
29
+ Where cities built protected lanes, delivery times fell by 11% on average.""",
30
+ """Outlook
31
+ We expect growth to continue but slow, as the easiest routes have already been converted.
32
+ The open question is whether cities will reallocate kerb space from parking to loading at the pace
33
+ operators need. Without that, the cost advantage we measured may not survive outside a few showcase districts.""",
34
+ ]
35
+
36
+ SAMPLE = {
37
+ "title": "The State of Urban Cargo Bikes 2026",
38
+ "model": "sample summary (written for the demo, not by Claude)",
39
+ "tldr": "A fictional annual report on cargo-bike delivery in 14 European cities: volumes grew sharply and cargo "
40
+ "bikes now undercut vans on cost per parcel in dense centres, but only with loading space and protected lanes.",
41
+ "tldr_pages": [1, 2],
42
+ "arguments": [
43
+ {"text": "Cargo bikes are cheaper than vans per parcel in dense city centres, conditional on city infrastructure.",
44
+ "pages": [1], "quote": "cargo bikes now beat vans on cost per parcel in dense city centres"},
45
+ {"text": "Beyond a short delivery radius the cost advantage reverses in favour of vans.",
46
+ "pages": [2], "quote": "vans were cheaper beyond a 6 km delivery radius"},
47
+ {"text": "Missing loading bays are the main barrier to adoption.",
48
+ "pages": [1], "quote": "the lack of loading bays as the single biggest obstacle"},
49
+ ],
50
+ "data_points": [
51
+ {"text": "Cargo-bike deliveries across the 14 cities in 2026 (1.6 million in 2025).", "value": "2.3 million",
52
+ "pages": [1], "quote": "operators made 2.3 million deliveries by cargo bike"},
53
+ {"text": "Estimated European market size, an estimate from operator surveys.", "value": "EUR 1.9bn",
54
+ "pages": [2], "quote": "European cargo bike logistics market at EUR 1.9bn in 2026"},
55
+ {"text": "Rise in insurance premiums year on year.", "value": "27%",
56
+ "pages": [3], "quote": "Insurance premiums rose 22% year on year"},
57
+ ],
58
+ "quotes": [
59
+ {"quote": "Without that, the cost advantage we measured may not survive outside a few showcase districts.",
60
+ "pages": [4], "why": "The report's own caveat on its headline finding."},
61
+ ],
62
+ "open_questions": [
63
+ {"text": "Will cities reallocate kerb space from parking to loading fast enough?", "pages": [4]},
64
+ {"text": "How wide is the range around the EUR 1.9bn market estimate?", "pages": [2]},
65
+ ],
66
+ "tags": ["cargo-bikes", "urban-logistics", "last-mile", "transport-policy"],
67
+ }
68
+
69
+
70
+ def sample_pdf() -> bytes:
71
+ doc = pymupdf.open()
72
+ for text in PAGES:
73
+ page = doc.new_page()
74
+ page.insert_textbox(pymupdf.Rect(60, 60, 540, 780), text, fontsize=11)
75
+ doc.set_metadata({"title": "The State of Urban Cargo Bikes 2026"})
76
+ data = doc.tobytes()
77
+ doc.close()
78
+ return data
79
+
80
+
81
+ def sample() -> tuple[Document, Summary]:
82
+ return from_bytes(sample_pdf(), "cargo-bikes-2026.pdf (demo)"), Summary.from_json(SAMPLE)
pagecite/library.py ADDED
@@ -0,0 +1,27 @@
1
+ """Read a PDF into the library and summarise it, reusing the stored summary when the same file
2
+ (same SHA-256) was summarised before."""
3
+
4
+ from dataclasses import dataclass
5
+
6
+ from . import pdf
7
+ from .store import Store
8
+ from .summarize import Summarizer, Summary
9
+
10
+
11
+ @dataclass
12
+ class Result:
13
+ doc_id: int
14
+ summary: Summary
15
+ cached: bool
16
+
17
+
18
+ def summarize(source: str, store: Store, summarizer: Summarizer, refresh: bool = False) -> Result:
19
+ doc = pdf.load(source)
20
+ doc_id = store.add(doc)
21
+ if not refresh:
22
+ cached = store.summary(doc_id)
23
+ if cached is not None:
24
+ return Result(doc_id, cached, True)
25
+ summary = summarizer.summarize(doc)
26
+ store.save_summary(doc_id, summary)
27
+ return Result(doc_id, summary, False)
pagecite/mcp_server.py ADDED
@@ -0,0 +1,105 @@
1
+ """An MCP server over your PDF library, so Claude Desktop, Claude Code or any MCP client can
2
+ summarise PDFs with checked citations, search every page you've read, and ask cited questions.
3
+ Runs locally over stdio: `pagecite mcp`.
4
+
5
+ search, documents and read_pages need no Anthropic key: they return page text and the client's own
6
+ model does the reasoning. summarize (for a new file) and ask call Claude.
7
+ """
8
+
9
+ from . import library
10
+ from .ask import Asker
11
+ from .pdf import PDFError
12
+ from .store import Store
13
+ from .summarize import Summarizer
14
+
15
+
16
+ class ToolError(Exception):
17
+ pass
18
+
19
+
20
+ def _claim(claim, check) -> dict:
21
+ out = {k: v for k, v in vars(claim).items() if v not in ("", [])}
22
+ out["check"] = check.status
23
+ if check.reason:
24
+ out["reason"] = check.reason
25
+ return out
26
+
27
+
28
+ def _checked(store: Store, doc_id: int) -> dict:
29
+ doc, summary, pages = store.doc(doc_id), store.summary(doc_id), store.pages(doc_id)
30
+ out = {"id": doc_id, "title": summary.title or doc["title"], "source": doc["source"], "pages": doc["page_count"],
31
+ "tldr": summary.tldr, "tldr_pages": summary.tldr_pages, "tags": summary.tags, "counts": summary.counts(pages)}
32
+ for section, rows in summary.checked(pages).items():
33
+ out[section] = [_claim(c, check) for c, check in rows]
34
+ return out
35
+
36
+
37
+ def list_documents(store: Store) -> list[dict]:
38
+ return [{"id": d["id"], "title": d["title"], "source": d["source"], "pages": d["page_count"],
39
+ "summarized": bool(d["summary"])} for d in store.docs()]
40
+
41
+
42
+ def search_pages(store: Store, query: str, limit: int = 10, doc_id: int | None = None) -> list[dict]:
43
+ hits = store.search(query, max(1, min(limit, 50)), doc_id)
44
+ return [{"doc_id": h["doc_id"], "title": h["title"], "page": h["n"], "snippet": h["snippet"]} for h in hits]
45
+
46
+
47
+ def read_pages(store: Store, doc_id: int, pages: list[int]) -> list[dict]:
48
+ doc = store.doc(doc_id)
49
+ if doc is None:
50
+ raise ToolError(f"No document #{doc_id}.")
51
+ texts = store.pages(doc_id)
52
+ wanted = [p for p in dict.fromkeys(pages) if 1 <= p <= len(texts)][:20]
53
+ if not wanted:
54
+ raise ToolError(f"Document #{doc_id} has pages 1-{len(texts)}.")
55
+ return [{"page": p, "text": texts[p - 1]} for p in wanted]
56
+
57
+
58
+ def summarize(store: Store, summarizer: Summarizer, source: str, refresh: bool = False) -> dict:
59
+ try:
60
+ result = library.summarize(source, store, summarizer, refresh)
61
+ except PDFError as e:
62
+ raise ToolError(str(e)) from e
63
+ return _checked(store, result.doc_id) | {"from_cache": result.cached}
64
+
65
+
66
+ def ask(asker: Asker, question: str, doc_id: int | None = None) -> dict:
67
+ answer = asker.ask(question, doc_id)
68
+ return {"answer": answer.text,
69
+ "sources": [{"doc_id": c.doc_id, "title": c.title, "page": c.page, "cited_text": c.text} for c in answer.sources()]}
70
+
71
+
72
+ def build_server(store: Store, summarizer: Summarizer, asker: Asker):
73
+ from mcp.server.mcpserver import MCPServer
74
+
75
+ server = MCPServer(
76
+ "pagecite",
77
+ instructions=(
78
+ "The user's library of PDFs (papers, reports, decks) with page-level text and summaries. Every "
79
+ "summary claim cites pages; check='verified' means its quote and numbers were found on that page, "
80
+ "'flagged' means they were not (reason given). Cite documents by title and page as p. N."
81
+ ),
82
+ )
83
+
84
+ @server.tool(description="List the PDFs in the library, newest first.")
85
+ def documents() -> list[dict]:
86
+ return list_documents(store)
87
+
88
+ @server.tool(description="Full-text search over every page of every PDF (or one document). Returns doc id, title, page and a snippet.")
89
+ def search(query: str, limit: int = 10, doc_id: int | None = None) -> list[dict]:
90
+ return search_pages(store, query, limit, doc_id)
91
+
92
+ @server.tool(description="The full text of up to 20 pages of one document.")
93
+ def read(doc_id: int, pages: list[int]) -> list[dict]:
94
+ return read_pages(store, doc_id, pages)
95
+
96
+ @server.tool(description="Summarise a PDF (local path or http(s) URL): TL;DR, arguments, data points, quotes, open "
97
+ "questions, each with pages and a verification result. Reuses the stored summary unless refresh.")
98
+ def summarize_pdf(source: str, refresh: bool = False) -> dict:
99
+ return summarize(store, summarizer, source, refresh)
100
+
101
+ @server.tool(description="Ask a question of one document (doc_id) or the whole library. The answer cites document and page.")
102
+ def ask_library(question: str, doc_id: int | None = None) -> dict:
103
+ return ask(asker, question, doc_id)
104
+
105
+ return server
pagecite/pdf.py ADDED
@@ -0,0 +1,80 @@
1
+ """Load a PDF from a file, a URL or raw bytes and split it into page texts (pages are 1-indexed)."""
2
+
3
+ import hashlib
4
+ import ssl
5
+ import urllib.error
6
+ import urllib.request
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ import pymupdf
11
+ import truststore
12
+
13
+ MAX_BYTES = 100 * 1024 * 1024 # refuse anything bigger than this from a URL
14
+
15
+
16
+ class PDFError(Exception):
17
+ pass
18
+
19
+
20
+ @dataclass
21
+ class Document:
22
+ sha: str
23
+ title: str
24
+ source: str
25
+ pages: list[str]
26
+
27
+ @property
28
+ def page_count(self) -> int:
29
+ return len(self.pages)
30
+
31
+ def has_text(self) -> bool:
32
+ return any(p.strip() for p in self.pages)
33
+
34
+
35
+ def is_url(source: str) -> bool:
36
+ return source.startswith(("http://", "https://"))
37
+
38
+
39
+ def fetch(url: str, timeout: float = 30) -> bytes:
40
+ request = urllib.request.Request(url, headers={"User-Agent": "pagecite (+https://github.com/owen-alderson/pagecite)"})
41
+ try:
42
+ # The OS trust store, so downloads work on python.org builds that ship without CA certificates.
43
+ context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
44
+ with urllib.request.urlopen(request, timeout=timeout, context=context) as response:
45
+ data = response.read(MAX_BYTES + 1)
46
+ except (urllib.error.URLError, TimeoutError, ValueError) as e:
47
+ raise PDFError(f"Could not download {url}: {getattr(e, 'reason', e)}") from e
48
+ if len(data) > MAX_BYTES:
49
+ raise PDFError(f"{url} is larger than {MAX_BYTES // (1024 * 1024)} MB.")
50
+ return data
51
+
52
+
53
+ def from_bytes(data: bytes, source: str, name: str = "") -> Document:
54
+ if not data.lstrip()[:5].startswith(b"%PDF"):
55
+ raise PDFError(f"{source} is not a PDF.")
56
+ try:
57
+ doc = pymupdf.open(stream=data, filetype="pdf")
58
+ except Exception as e: # pymupdf raises its own error types for damaged files
59
+ raise PDFError(f"Could not read {source}: {e}") from e
60
+ if doc.needs_pass:
61
+ raise PDFError(f"{source} is password-protected.")
62
+ with doc:
63
+ pages = [page.get_text() for page in doc]
64
+ title = ((doc.metadata or {}).get("title") or "").strip()
65
+ return Document(hashlib.sha256(data).hexdigest(), title or name or source, source, pages)
66
+
67
+
68
+ def load(source: str) -> Document:
69
+ """A path or an http(s) URL."""
70
+ if is_url(source):
71
+ name = source.rstrip("/").rsplit("/", 1)[-1].split("?")[0]
72
+ return from_bytes(fetch(source), source, name[:-4] if name.lower().endswith(".pdf") else name)
73
+ path = Path(source).expanduser()
74
+ if not path.is_file():
75
+ raise PDFError(f"No such file: {source}")
76
+ return from_bytes(path.read_bytes(), str(path), path.stem)
77
+
78
+
79
+ def find_pdfs(folder: Path) -> list[Path]:
80
+ return sorted(p for p in folder.iterdir() if p.is_file() and p.suffix.lower() == ".pdf")