section-anything 0.1.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.
- section_anything/__init__.py +90 -0
- section_anything/__main__.py +5 -0
- section_anything/_worker.py +66 -0
- section_anything/api.py +267 -0
- section_anything/bridge.py +70 -0
- section_anything/cli.py +77 -0
- section_anything/config.py +131 -0
- section_anything/errors.py +42 -0
- section_anything/evaluate.py +72 -0
- section_anything/export.py +94 -0
- section_anything/extract.py +336 -0
- section_anything/isolate.py +153 -0
- section_anything/llm.py +298 -0
- section_anything/markers.py +117 -0
- section_anything/outline_check.py +255 -0
- section_anything/pipeline.py +284 -0
- section_anything/py.typed +0 -0
- section_anything/strategies/__init__.py +1 -0
- section_anything/strategies/base.py +51 -0
- section_anything/strategies/llm_text.py +255 -0
- section_anything/strategies/rules.py +232 -0
- section_anything/structure.py +402 -0
- section_anything-0.1.0.dist-info/METADATA +219 -0
- section_anything-0.1.0.dist-info/RECORD +29 -0
- section_anything-0.1.0.dist-info/WHEEL +5 -0
- section_anything-0.1.0.dist-info/entry_points.txt +2 -0
- section_anything-0.1.0.dist-info/licenses/LICENSE +202 -0
- section_anything-0.1.0.dist-info/licenses/NOTICE +9 -0
- section_anything-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Section Anything: find the sections of a PDF, with a cost/accuracy dial.
|
|
2
|
+
|
|
3
|
+
>>> import section_anything
|
|
4
|
+
>>> tree = section_anything.section("paper.pdf", mode="free")
|
|
5
|
+
>>> print(tree.to_markdown())
|
|
6
|
+
|
|
7
|
+
Pre-release: the API may change before 1.0. Names load lazily, so importing the package is cheap.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from importlib import import_module
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
_EXPORTS = {
|
|
18
|
+
"section": "section_anything.api",
|
|
19
|
+
"asection": "section_anything.api",
|
|
20
|
+
"SectionTree": "section_anything.api",
|
|
21
|
+
"resolve_mode": "section_anything.api",
|
|
22
|
+
"Settings": "section_anything.config",
|
|
23
|
+
"Price": "section_anything.config",
|
|
24
|
+
"DEFAULT_PRICES": "section_anything.config",
|
|
25
|
+
"extract_pdf": "section_anything.isolate",
|
|
26
|
+
"check_pdf": "section_anything.isolate",
|
|
27
|
+
"Extraction": "section_anything.extract",
|
|
28
|
+
"SectionAnythingError": "section_anything.errors",
|
|
29
|
+
"ConfigError": "section_anything.errors",
|
|
30
|
+
"UnpricedModelError": "section_anything.errors",
|
|
31
|
+
"CostCapExceeded": "section_anything.errors",
|
|
32
|
+
"LlmCallFailed": "section_anything.errors",
|
|
33
|
+
"PdfError": "section_anything.errors",
|
|
34
|
+
"PdfTooLarge": "section_anything.errors",
|
|
35
|
+
"ExtractionTimeout": "section_anything.errors",
|
|
36
|
+
"ExtractionFailed": "section_anything.errors",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"DEFAULT_PRICES",
|
|
41
|
+
"ConfigError",
|
|
42
|
+
"CostCapExceeded",
|
|
43
|
+
"Extraction",
|
|
44
|
+
"ExtractionFailed",
|
|
45
|
+
"ExtractionTimeout",
|
|
46
|
+
"LlmCallFailed",
|
|
47
|
+
"PdfError",
|
|
48
|
+
"PdfTooLarge",
|
|
49
|
+
"Price",
|
|
50
|
+
"SectionAnythingError",
|
|
51
|
+
"SectionTree",
|
|
52
|
+
"Settings",
|
|
53
|
+
"UnpricedModelError",
|
|
54
|
+
"__version__",
|
|
55
|
+
"asection",
|
|
56
|
+
"check_pdf",
|
|
57
|
+
"extract_pdf",
|
|
58
|
+
"resolve_mode",
|
|
59
|
+
"section",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
if TYPE_CHECKING:
|
|
63
|
+
from section_anything.api import SectionTree, asection, resolve_mode, section
|
|
64
|
+
from section_anything.config import DEFAULT_PRICES, Price, Settings
|
|
65
|
+
from section_anything.errors import (
|
|
66
|
+
ConfigError,
|
|
67
|
+
CostCapExceeded,
|
|
68
|
+
ExtractionFailed,
|
|
69
|
+
ExtractionTimeout,
|
|
70
|
+
LlmCallFailed,
|
|
71
|
+
PdfError,
|
|
72
|
+
PdfTooLarge,
|
|
73
|
+
SectionAnythingError,
|
|
74
|
+
UnpricedModelError,
|
|
75
|
+
)
|
|
76
|
+
from section_anything.extract import Extraction
|
|
77
|
+
from section_anything.isolate import check_pdf, extract_pdf
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def __getattr__(name: str) -> Any:
|
|
81
|
+
module = _EXPORTS.get(name)
|
|
82
|
+
if module is None:
|
|
83
|
+
raise AttributeError(f"module 'section_anything' has no attribute {name!r}")
|
|
84
|
+
value = getattr(import_module(module), name)
|
|
85
|
+
globals()[name] = value
|
|
86
|
+
return value
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def __dir__() -> list[str]:
|
|
90
|
+
return sorted(__all__)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Extraction worker: ``python -m section_anything._worker PDF OUT MAX_PAGES MAX_CHARS MEMORY_MB``.
|
|
2
|
+
|
|
3
|
+
Started by `section_anything.isolate`. Writes the extraction to OUT as JSON and reports progress
|
|
4
|
+
and the outcome as JSON lines on its original stdout. Error messages carry only text written by
|
|
5
|
+
this package or an exception's type name, never document text.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _limit_memory(mb: int) -> None:
|
|
17
|
+
"""Cap the address space on Linux. macOS does not enforce RLIMIT_AS, so it is skipped there."""
|
|
18
|
+
if mb <= 0 or not sys.platform.startswith("linux"):
|
|
19
|
+
return
|
|
20
|
+
try:
|
|
21
|
+
import resource
|
|
22
|
+
|
|
23
|
+
limit = mb * 1024 * 1024
|
|
24
|
+
resource.setrlimit(resource.RLIMIT_AS, (limit, limit))
|
|
25
|
+
except (ImportError, ValueError, OSError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main(argv: list[str] | None = None) -> int:
|
|
30
|
+
args = sys.argv[1:] if argv is None else argv
|
|
31
|
+
pdf, out, max_pages, max_chars, memory_mb = args[0], args[1], int(args[2]), int(args[3]), int(args[4])
|
|
32
|
+
# Keep the protocol on the original stdout; anything else that prints goes to stderr.
|
|
33
|
+
proto = os.fdopen(os.dup(1), "w", buffering=1, encoding="utf-8")
|
|
34
|
+
os.dup2(2, 1)
|
|
35
|
+
sys.stdout = sys.stderr
|
|
36
|
+
|
|
37
|
+
def emit(msg: dict) -> None:
|
|
38
|
+
proto.write(json.dumps(msg) + "\n")
|
|
39
|
+
proto.flush()
|
|
40
|
+
|
|
41
|
+
_limit_memory(memory_mb)
|
|
42
|
+
from section_anything.errors import PdfError, PdfTooLarge
|
|
43
|
+
from section_anything.extract import extract
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
ext = extract(Path(pdf), lambda n, total: emit({"page": n, "total": total}),
|
|
47
|
+
max_pages=max_pages, max_chars=max_chars)
|
|
48
|
+
ext.save(Path(out))
|
|
49
|
+
except PdfTooLarge as exc:
|
|
50
|
+
emit({"error": "too_large", "message": str(exc)})
|
|
51
|
+
return 2
|
|
52
|
+
except PdfError as exc:
|
|
53
|
+
emit({"error": "pdf", "message": str(exc)})
|
|
54
|
+
return 2
|
|
55
|
+
except MemoryError:
|
|
56
|
+
emit({"error": "memory", "message": f"out of memory (extract_memory_mb={memory_mb})"})
|
|
57
|
+
return 3
|
|
58
|
+
except Exception as exc: # noqa: BLE001 — reported by type only: messages may quote the PDF
|
|
59
|
+
emit({"error": "internal", "message": type(exc).__name__})
|
|
60
|
+
return 1
|
|
61
|
+
emit({"done": True})
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__":
|
|
66
|
+
sys.exit(main())
|
section_anything/api.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Public API: `section(pdf, mode)` -> `SectionTree`.
|
|
2
|
+
|
|
3
|
+
Modes, cheapest first:
|
|
4
|
+
|
|
5
|
+
- ``free``: no model. PDF bookmarks or the printed table of contents as the outline, then
|
|
6
|
+
typography and numbering rules. Nothing leaves the machine.
|
|
7
|
+
- ``quick``: the rules propose candidate heading lines; a small model keeps the real ones and
|
|
8
|
+
sets their levels. Sends only the candidate lines (plus a short peek at the next line).
|
|
9
|
+
- ``standard``: a model reads every line, with font hints, and marks where sections start and
|
|
10
|
+
end. Sends the document text.
|
|
11
|
+
- ``local:<model>``: quick, answered by a local Ollama model. Free; nothing leaves the machine.
|
|
12
|
+
|
|
13
|
+
Costs: a cloud model with no known price is refused before anything is read. Before a paid mode
|
|
14
|
+
runs, its estimated cost is checked against the per-document cap (`max_usd`); a run over the cap
|
|
15
|
+
is downgraded (standard -> quick -> free) or refused (``over_budget="fail"``). While it runs, the
|
|
16
|
+
cap is re-checked before every call and each call's output is limited to what the remaining budget
|
|
17
|
+
can pay for.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import concurrent.futures
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import time
|
|
27
|
+
from collections.abc import Callable, Mapping
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from section_anything.config import LOCAL_PREFIX, Price, Settings
|
|
33
|
+
from section_anything.errors import ConfigError, CostCapExceeded, PdfError, UnpricedModelError
|
|
34
|
+
from section_anything.export import heading_text, markdown_hashes, section_bodies
|
|
35
|
+
from section_anything.extract import Extraction
|
|
36
|
+
from section_anything.isolate import extract_pdf
|
|
37
|
+
from section_anything.llm import Usage
|
|
38
|
+
|
|
39
|
+
MODES = ("free", "quick", "standard", "local")
|
|
40
|
+
STRATEGY = {"free": "rules", "quick": "llm_review", "standard": "llm_text", "local": "llm_review"}
|
|
41
|
+
DOWNGRADE = {"standard": "quick", "quick": "free", "local": None, "free": None}
|
|
42
|
+
SECTION_KEYS = ("number", "title", "level", "page_start", "page_end", "start", "end", "flags")
|
|
43
|
+
|
|
44
|
+
Progress = Callable[[float, str], None]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve_mode(mode: str, settings: Settings, model: str | None = None) -> tuple[str, str | None]:
|
|
48
|
+
"""'free' | 'quick' | 'standard' | 'local:<ollama model>' -> (mode, model id or None).
|
|
49
|
+
|
|
50
|
+
`model` replaces the configured model of a quick or standard run, or names the local model."""
|
|
51
|
+
m = (mode or "free").strip()
|
|
52
|
+
if m == "local" or m.startswith("local:"):
|
|
53
|
+
tag = m.partition(":")[2].strip() or (model or "").strip() or settings.local_model.strip()
|
|
54
|
+
if not tag:
|
|
55
|
+
raise ConfigError("local mode needs a model: mode='local:<ollama model>' or SECTIONANY_LOCAL_MODEL")
|
|
56
|
+
return "local", LOCAL_PREFIX + tag
|
|
57
|
+
if m == "free":
|
|
58
|
+
return "free", None
|
|
59
|
+
if m == "quick":
|
|
60
|
+
return "quick", model or settings.quick_model
|
|
61
|
+
if m == "standard":
|
|
62
|
+
return "standard", model or settings.standard_model
|
|
63
|
+
raise ConfigError(f"unknown mode {m!r}: use free, quick, standard or local:<ollama model>")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _require_price(mode: str, model: str | None, settings: Settings) -> Price | None:
|
|
67
|
+
if model is None:
|
|
68
|
+
return None
|
|
69
|
+
price = settings.price_for(model)
|
|
70
|
+
if price is None:
|
|
71
|
+
raise UnpricedModelError(
|
|
72
|
+
f"no price for model {model!r}: pass prices={{{model!r}: {{'input': ..., 'output': ...}}}} "
|
|
73
|
+
"(USD per 1M tokens) or set SECTIONANY_PRICES, so the per-document cost cap can be enforced"
|
|
74
|
+
)
|
|
75
|
+
if mode in ("quick", "standard") and settings.api_key is None:
|
|
76
|
+
raise ConfigError(f"{mode} mode needs an API key: set SECTIONANY_API_KEY or pass api_key=")
|
|
77
|
+
return price
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class SectionTree:
|
|
82
|
+
"""The sections of one document, in reading order.
|
|
83
|
+
|
|
84
|
+
`sections` holds one dict per section with number, title, level (1 = top), page_start,
|
|
85
|
+
page_end, start and end (character offsets into `text`) and flags (for example
|
|
86
|
+
``doc_title``, ``front_matter``, ``start_marker_fuzzy``)."""
|
|
87
|
+
|
|
88
|
+
sections: list[dict]
|
|
89
|
+
mode: str
|
|
90
|
+
model: str | None
|
|
91
|
+
pages: int
|
|
92
|
+
usage: dict
|
|
93
|
+
seconds: float
|
|
94
|
+
downgraded: list[str] = field(default_factory=list)
|
|
95
|
+
extraction: Extraction | None = field(default=None, repr=False)
|
|
96
|
+
resolved: list[dict] = field(default_factory=list, repr=False) # full pipeline output
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def text(self) -> str:
|
|
100
|
+
"""The document text the character offsets refer to."""
|
|
101
|
+
return self.extraction.text if self.extraction is not None else ""
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def cost_usd(self) -> float:
|
|
105
|
+
return float(self.usage.get("cost_usd") or 0.0)
|
|
106
|
+
|
|
107
|
+
def summary(self) -> dict:
|
|
108
|
+
"""Mode, model, size, calls, tokens and cost of the run."""
|
|
109
|
+
return {
|
|
110
|
+
"mode": self.mode,
|
|
111
|
+
"model": self.model,
|
|
112
|
+
"pages": self.pages,
|
|
113
|
+
"sections": len(self.sections),
|
|
114
|
+
"flagged": sum(bool(s["flags"]) for s in self.sections),
|
|
115
|
+
"calls": self.usage.get("calls", 0),
|
|
116
|
+
"input_tokens": self.usage.get("input_tokens", 0),
|
|
117
|
+
"output_tokens": self.usage.get("output_tokens", 0),
|
|
118
|
+
"cost_usd": self.cost_usd,
|
|
119
|
+
"seconds": self.seconds,
|
|
120
|
+
"downgraded": list(self.downgraded),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
def to_markdown(self, body: bool = False) -> str:
|
|
124
|
+
"""Headings as Markdown: the document title (flag ``doc_title``) as '#', other headings
|
|
125
|
+
as '#' x (level + 1), capped at six. With `body`, each heading is followed by its section's
|
|
126
|
+
text reflowed into paragraphs; otherwise front matter is left out."""
|
|
127
|
+
out: list[str] = []
|
|
128
|
+
if body and self.extraction is not None and self.resolved:
|
|
129
|
+
from section_anything.structure import furniture_of
|
|
130
|
+
|
|
131
|
+
bodies = section_bodies(self.extraction, self.resolved, furniture_of(self.extraction))
|
|
132
|
+
for sec, paras in zip(self.resolved, bodies, strict=True):
|
|
133
|
+
if "front_matter" not in sec["flags"]:
|
|
134
|
+
out.append(f"{markdown_hashes(sec)} {heading_text(sec)}")
|
|
135
|
+
out.extend(paras)
|
|
136
|
+
return "\n\n".join(out) + "\n"
|
|
137
|
+
for sec in self.sections:
|
|
138
|
+
if "front_matter" in sec["flags"]:
|
|
139
|
+
continue
|
|
140
|
+
out.append(f"{markdown_hashes(sec)} {heading_text(sec)}")
|
|
141
|
+
return "\n".join(out) + ("\n" if out else "")
|
|
142
|
+
|
|
143
|
+
def to_dict(self) -> dict:
|
|
144
|
+
from section_anything import __version__
|
|
145
|
+
|
|
146
|
+
return {"section_anything": __version__, **self.summary(), "usage": self.usage,
|
|
147
|
+
"sections": self.sections}
|
|
148
|
+
|
|
149
|
+
def to_json(self, indent: int | None = 2) -> str:
|
|
150
|
+
return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _public(sec: dict) -> dict:
|
|
154
|
+
out = {k: (list(sec[k]) if k == "flags" else sec[k]) for k in SECTION_KEYS}
|
|
155
|
+
if sec.get("title_from_text") is False and "front_matter" not in out["flags"]:
|
|
156
|
+
out["flags"].append("generated_title")
|
|
157
|
+
return out
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _settings_for(settings: Settings | None, **overrides: Any) -> Settings:
|
|
161
|
+
base = settings if settings is not None else Settings()
|
|
162
|
+
prices = overrides.pop("prices", None)
|
|
163
|
+
if prices is not None:
|
|
164
|
+
overrides["prices"] = {m: Price.model_validate(p) for m, p in dict(prices).items()}
|
|
165
|
+
return base.with_overrides(**overrides)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def asection(
|
|
169
|
+
pdf_path: str | os.PathLike[str],
|
|
170
|
+
mode: str = "free",
|
|
171
|
+
*,
|
|
172
|
+
settings: Settings | None = None,
|
|
173
|
+
api_key: str | None = None,
|
|
174
|
+
base_url: str | None = None,
|
|
175
|
+
model: str | None = None,
|
|
176
|
+
max_usd: float | None = None,
|
|
177
|
+
prices: Mapping[str, Mapping[str, float] | Price] | None = None,
|
|
178
|
+
over_budget: str | None = None,
|
|
179
|
+
max_pages: int | None = None,
|
|
180
|
+
extract_timeout: float | None = None,
|
|
181
|
+
progress: Progress | None = None,
|
|
182
|
+
) -> SectionTree:
|
|
183
|
+
"""Async form of `section`."""
|
|
184
|
+
from section_anything import pipeline
|
|
185
|
+
|
|
186
|
+
s = _settings_for(settings, api_key=api_key, base_url=base_url, max_usd_per_doc=max_usd, prices=prices,
|
|
187
|
+
over_budget=over_budget, max_pages=max_pages, extract_timeout_seconds=extract_timeout)
|
|
188
|
+
mode, model = resolve_mode(mode, s, model)
|
|
189
|
+
_require_price(mode, model, s) # before the PDF is read: a refused run costs nothing
|
|
190
|
+
report = progress or (lambda _f, _m: None)
|
|
191
|
+
|
|
192
|
+
t0 = time.monotonic()
|
|
193
|
+
ext = await asyncio.to_thread(
|
|
194
|
+
extract_pdf, Path(pdf_path), settings=s,
|
|
195
|
+
on_page=lambda n, total: report(0.5 * n / total, f"Reading page {n} of {total}"),
|
|
196
|
+
)
|
|
197
|
+
if not ext.text.strip():
|
|
198
|
+
raise PdfError("no text layer: the PDF needs OCR first")
|
|
199
|
+
|
|
200
|
+
cap = s.max_usd_per_doc
|
|
201
|
+
usage = Usage() # one tally per document: a downgrade continues the same budget
|
|
202
|
+
downgraded: list[str] = []
|
|
203
|
+
while True:
|
|
204
|
+
strategy = pipeline.BY_ID[STRATEGY[mode]]
|
|
205
|
+
price = s.price_for(model) if model else None
|
|
206
|
+
if model and price is not None and strategy.uses_llm:
|
|
207
|
+
est = strategy.estimate(ext)
|
|
208
|
+
usd = price.cost(est.input_tokens, est.output_tokens)
|
|
209
|
+
if usage.cost_usd + usd > cap:
|
|
210
|
+
note = f"{mode}: estimated ${usd:.2f}, over the ${cap:.2f} per-document cap"
|
|
211
|
+
if s.over_budget == "fail" or DOWNGRADE[mode] is None:
|
|
212
|
+
raise CostCapExceeded(f"{note} (max_usd, SECTIONANY_MAX_USD_PER_DOC)")
|
|
213
|
+
downgraded.append(note)
|
|
214
|
+
mode, model = _next_mode(mode, s, downgraded)
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
def stage(f: float, msg: str) -> None:
|
|
218
|
+
report(0.5 + 0.5 * f, msg)
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
_raw, resolved, _metrics = await pipeline.run(
|
|
222
|
+
ext, strategy.id, model or "", cap, stage, usage=usage, settings=s)
|
|
223
|
+
except CostCapExceeded as exc:
|
|
224
|
+
if s.over_budget == "fail" or DOWNGRADE[mode] is None:
|
|
225
|
+
raise
|
|
226
|
+
downgraded.append(f"{mode}: {exc}")
|
|
227
|
+
mode, model = _next_mode(mode, s, downgraded)
|
|
228
|
+
continue
|
|
229
|
+
return SectionTree(
|
|
230
|
+
sections=[_public(x) for x in resolved], mode=mode, model=model, pages=len(ext.pages),
|
|
231
|
+
usage=usage.summary(), seconds=round(time.monotonic() - t0, 2), downgraded=downgraded,
|
|
232
|
+
extraction=ext, resolved=resolved,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _next_mode(mode: str, s: Settings, notes: list[str]) -> tuple[str, str | None]:
|
|
237
|
+
nxt = DOWNGRADE[mode]
|
|
238
|
+
while nxt is not None:
|
|
239
|
+
nxt_mode, nxt_model = resolve_mode(nxt, s)
|
|
240
|
+
if nxt_model is None or s.price_for(nxt_model) is not None:
|
|
241
|
+
return nxt_mode, nxt_model
|
|
242
|
+
notes.append(f"{nxt}: skipped, no price for model {nxt_model!r}")
|
|
243
|
+
nxt = DOWNGRADE[nxt]
|
|
244
|
+
raise ConfigError("no mode left to downgrade to") # unreachable: free is always available
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def section(
|
|
248
|
+
pdf_path: str | os.PathLike[str],
|
|
249
|
+
mode: str = "free",
|
|
250
|
+
**opts: Any,
|
|
251
|
+
) -> SectionTree:
|
|
252
|
+
"""Find the sections of a PDF.
|
|
253
|
+
|
|
254
|
+
`mode` is "free", "quick", "standard" or "local:<ollama model>". Options (each overrides the
|
|
255
|
+
matching ``SECTIONANY_*`` environment variable): `api_key`, `base_url`, `model`, `max_usd`
|
|
256
|
+
(per-document cap), `prices` ({model: {"input", "output"}} USD per 1M tokens, merged with the
|
|
257
|
+
built-in list), `over_budget` ("downgrade" or "fail"), `max_pages`, `extract_timeout`
|
|
258
|
+
(seconds), `progress` (callback(fraction, message)) and `settings` (a `Settings` object).
|
|
259
|
+
"""
|
|
260
|
+
coro = asection(pdf_path, mode, **opts)
|
|
261
|
+
try:
|
|
262
|
+
asyncio.get_running_loop()
|
|
263
|
+
except RuntimeError:
|
|
264
|
+
return asyncio.run(coro)
|
|
265
|
+
# Called from inside an event loop (a notebook, an async app): run on a separate thread.
|
|
266
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
267
|
+
return pool.submit(asyncio.run, coro).result()
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Browser bridge: answer a run's model calls with a model on someone else's machine.
|
|
2
|
+
|
|
3
|
+
A run whose model is "browser/<name>" never calls a provider. Each model call is queued here; a
|
|
4
|
+
client (for example a web page that can reach the user's own Ollama or LM Studio) long-polls
|
|
5
|
+
`next()`, sends the prompt to its model and posts the reply back with `resolve()`. The reply is
|
|
6
|
+
validated against the call's schema like any other model output.
|
|
7
|
+
|
|
8
|
+
The queue is in memory and per process: the host application must route a run's polling
|
|
9
|
+
requests to the process that runs it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import uuid
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from section_anything.config import BROWSER_PREFIX
|
|
19
|
+
from section_anything.errors import SectionAnythingError
|
|
20
|
+
|
|
21
|
+
__all__ = ["BROWSER_PREFIX", "BridgeError", "BridgeTimeout", "BrowserBridge"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BridgeError(SectionAnythingError, RuntimeError):
|
|
25
|
+
"""The remote model reported an error."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BridgeTimeout(BridgeError, TimeoutError):
|
|
29
|
+
"""No reply arrived in time."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BrowserBridge:
|
|
33
|
+
def __init__(self, wait_seconds: float = 300.0) -> None:
|
|
34
|
+
self.wait_seconds = wait_seconds
|
|
35
|
+
self._queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
36
|
+
self._pending: dict[str, asyncio.Future[str]] = {}
|
|
37
|
+
|
|
38
|
+
async def call(self, payload: dict[str, Any], wait_seconds: float | None = None) -> str:
|
|
39
|
+
"""Queue one model call and wait for the reply text."""
|
|
40
|
+
rid = uuid.uuid4().hex
|
|
41
|
+
fut: asyncio.Future[str] = asyncio.get_running_loop().create_future()
|
|
42
|
+
self._pending[rid] = fut
|
|
43
|
+
await self._queue.put({"id": rid, **payload})
|
|
44
|
+
try:
|
|
45
|
+
async with asyncio.timeout(self.wait_seconds if wait_seconds is None else wait_seconds):
|
|
46
|
+
return await fut
|
|
47
|
+
except TimeoutError as exc:
|
|
48
|
+
raise BridgeTimeout(
|
|
49
|
+
"no reply from your local model: keep the page open and check that the model is running"
|
|
50
|
+
) from exc
|
|
51
|
+
finally:
|
|
52
|
+
self._pending.pop(rid, None)
|
|
53
|
+
|
|
54
|
+
async def next(self, wait: float) -> dict[str, Any] | None:
|
|
55
|
+
"""The next queued call, or None after `wait` seconds."""
|
|
56
|
+
try:
|
|
57
|
+
async with asyncio.timeout(wait):
|
|
58
|
+
return await self._queue.get()
|
|
59
|
+
except TimeoutError:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
def resolve(self, rid: str, content: str | None, error: str | None) -> bool:
|
|
63
|
+
fut = self._pending.get(rid)
|
|
64
|
+
if fut is None or fut.done():
|
|
65
|
+
return False
|
|
66
|
+
if error is not None:
|
|
67
|
+
fut.set_exception(BridgeError(f"local model error: {error[:300]}"))
|
|
68
|
+
else:
|
|
69
|
+
fut.set_result(content or "")
|
|
70
|
+
return True
|
section_anything/cli.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Command line: ``section-anything FILE.pdf --mode quick --format md``.
|
|
2
|
+
|
|
3
|
+
The result goes to stdout (or ``--output``); a one-line cost summary goes to stderr.
|
|
4
|
+
Exit codes: 0 done, 2 bad arguments or configuration, 3 refused by a cost cap, 4 the PDF could not
|
|
5
|
+
be read, 1 anything else.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from section_anything.errors import ConfigError, CostCapExceeded, ExtractionFailed, ExtractionTimeout, PdfError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
18
|
+
from section_anything import __version__
|
|
19
|
+
|
|
20
|
+
p = argparse.ArgumentParser(
|
|
21
|
+
prog="section-anything",
|
|
22
|
+
description="Find the sections (headings, levels, page and character spans) of a PDF.",
|
|
23
|
+
)
|
|
24
|
+
p.add_argument("pdf", type=Path, help="PDF file")
|
|
25
|
+
p.add_argument("--mode", default="free",
|
|
26
|
+
help="free (default), quick, standard, or local:<ollama model>")
|
|
27
|
+
p.add_argument("--format", choices=("md", "json"), default="md", help="output format (default md)")
|
|
28
|
+
p.add_argument("--body", action="store_true", help="md: include each section's text, not only headings")
|
|
29
|
+
p.add_argument("--max-usd", type=float, default=None, help="per-document cost cap in USD (default 0.50)")
|
|
30
|
+
p.add_argument("--over-budget", choices=("downgrade", "fail"), default=None,
|
|
31
|
+
help="over the cap: downgrade standard -> quick -> free (default), or fail")
|
|
32
|
+
p.add_argument("--model", default=None, help="model for quick/standard, overriding the configured one")
|
|
33
|
+
p.add_argument("--base-url", default=None, help="OpenAI-compatible endpoint (default OpenAI)")
|
|
34
|
+
p.add_argument("--max-pages", type=int, default=None, help="refuse PDFs with more pages (default 500)")
|
|
35
|
+
p.add_argument("--timeout", type=float, default=None, help="extraction time limit in seconds (default 120)")
|
|
36
|
+
p.add_argument("-o", "--output", type=Path, default=None, help="write the result to this file")
|
|
37
|
+
p.add_argument("-q", "--quiet", action="store_true", help="no summary on stderr")
|
|
38
|
+
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
39
|
+
return p
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def main(argv: list[str] | None = None) -> int:
|
|
43
|
+
args = build_parser().parse_args(argv)
|
|
44
|
+
from section_anything.api import section
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
tree = section(
|
|
48
|
+
args.pdf, args.mode, model=args.model, base_url=args.base_url, max_usd=args.max_usd,
|
|
49
|
+
over_budget=args.over_budget, max_pages=args.max_pages, extract_timeout=args.timeout,
|
|
50
|
+
)
|
|
51
|
+
except (PdfError, ExtractionTimeout, ExtractionFailed) as exc:
|
|
52
|
+
print(f"section-anything: {exc}", file=sys.stderr)
|
|
53
|
+
return 4
|
|
54
|
+
except CostCapExceeded as exc:
|
|
55
|
+
print(f"section-anything: {exc}", file=sys.stderr)
|
|
56
|
+
return 3
|
|
57
|
+
except (ConfigError, ValueError) as exc: # includes invalid settings values
|
|
58
|
+
print(f"section-anything: {exc}", file=sys.stderr)
|
|
59
|
+
return 2
|
|
60
|
+
|
|
61
|
+
out = tree.to_json() + "\n" if args.format == "json" else tree.to_markdown(body=args.body)
|
|
62
|
+
if args.output:
|
|
63
|
+
args.output.write_text(out, encoding="utf-8")
|
|
64
|
+
else:
|
|
65
|
+
sys.stdout.write(out)
|
|
66
|
+
if not args.quiet:
|
|
67
|
+
s = tree.summary()
|
|
68
|
+
cost = "unpriced" if tree.usage.get("cost_usd") is None else f"${s['cost_usd']:.4f}"
|
|
69
|
+
note = f"; downgraded: {' | '.join(s['downgraded'])}" if s["downgraded"] else ""
|
|
70
|
+
print(f"{s['sections']} sections, {s['pages']} pages, mode {s['mode']}"
|
|
71
|
+
f"{' (' + s['model'] + ')' if s['model'] else ''}, {s['calls']} model calls, {cost}, "
|
|
72
|
+
f"{s['seconds']:.1f} s{note}", file=sys.stderr)
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
sys.exit(main())
|