paperflux 3.4.20260528__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.
- paperflux-3.4.20260528/LICENSE +21 -0
- paperflux-3.4.20260528/PKG-INFO +93 -0
- paperflux-3.4.20260528/README.md +58 -0
- paperflux-3.4.20260528/pyproject.toml +65 -0
- paperflux-3.4.20260528/setup.cfg +4 -0
- paperflux-3.4.20260528/src/paperflux/__init__.py +1 -0
- paperflux-3.4.20260528/src/paperflux/assistants.py +363 -0
- paperflux-3.4.20260528/src/paperflux/cli.py +355 -0
- paperflux-3.4.20260528/src/paperflux/config.py +148 -0
- paperflux-3.4.20260528/src/paperflux/io_pdf.py +289 -0
- paperflux-3.4.20260528/src/paperflux/orchestrator.py +93 -0
- paperflux-3.4.20260528/src/paperflux/quote_locator.py +438 -0
- paperflux-3.4.20260528/src/paperflux/templates/config.yaml +33 -0
- paperflux-3.4.20260528/src/paperflux/templates/prompts/rag_category_prompt.j2 +22 -0
- paperflux-3.4.20260528/src/paperflux/templates/prompts/rag_category_system_prompt.txt +11 -0
- paperflux-3.4.20260528/src/paperflux/templates/prompts/rag_summary_prompt.j2 +14 -0
- paperflux-3.4.20260528/src/paperflux/utils.py +125 -0
- paperflux-3.4.20260528/src/paperflux.egg-info/PKG-INFO +93 -0
- paperflux-3.4.20260528/src/paperflux.egg-info/SOURCES.txt +24 -0
- paperflux-3.4.20260528/src/paperflux.egg-info/dependency_links.txt +1 -0
- paperflux-3.4.20260528/src/paperflux.egg-info/entry_points.txt +2 -0
- paperflux-3.4.20260528/src/paperflux.egg-info/requires.txt +11 -0
- paperflux-3.4.20260528/src/paperflux.egg-info/top_level.txt +1 -0
- paperflux-3.4.20260528/tests/test_cli_integration.py +424 -0
- paperflux-3.4.20260528/tests/test_config_parse.py +111 -0
- paperflux-3.4.20260528/tests/test_quote_locator.py +144 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Emanuël Habets
|
|
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.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: paperflux
|
|
3
|
+
Version: 3.4.20260528
|
|
4
|
+
Summary: AI-powered PDF annotation for research papers
|
|
5
|
+
Author: PaperFlux contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Documentation, https://ehabets.github.io/PaperFlux/
|
|
8
|
+
Project-URL: Homepage, https://github.com/ehabets/PaperFlux
|
|
9
|
+
Project-URL: Issues, https://github.com/ehabets/PaperFlux/issues
|
|
10
|
+
Project-URL: Source, https://github.com/ehabets/PaperFlux
|
|
11
|
+
Keywords: annotation,openai,papers,pdf,research
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: openai>=2.38.0
|
|
25
|
+
Requires-Dist: typer>=0.15.4
|
|
26
|
+
Requires-Dist: PyMuPDF>=1.24
|
|
27
|
+
Requires-Dist: pyyaml>=6.0
|
|
28
|
+
Requires-Dist: pydantic>=2.7
|
|
29
|
+
Requires-Dist: rich>=13.7
|
|
30
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
31
|
+
Requires-Dist: jinja2>=3.1
|
|
32
|
+
Provides-Extra: dev
|
|
33
|
+
Requires-Dist: pytest>=8.2; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# PaperFlux
|
|
37
|
+
|
|
38
|
+
AI-powered PDF annotation for research papers. PaperFlux extracts exact quotations, organizes them by category (contributions, limitations, claims, evidence), and annotates your PDFs with precise highlights.
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
### 1. Installation
|
|
43
|
+
|
|
44
|
+
Install the latest release from PyPI:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
python -m pip install paperflux
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
For local development from a cloned repository:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
python -m venv .venv
|
|
54
|
+
source .venv/bin/activate
|
|
55
|
+
pip install -e ".[dev]"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### 2. Set API Key
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
export PAPERFLUX_OPENAI_API_KEY="sk-your-key"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### 3. Run
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
paperflux init
|
|
68
|
+
paperflux --config config.yaml path/to/paper.pdf
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Features
|
|
72
|
+
|
|
73
|
+
- Batch processing: `*.pdf`
|
|
74
|
+
- Three detail levels (low/medium/high)
|
|
75
|
+
- RAG-based extraction with exact quotes
|
|
76
|
+
- Color-coded highlights by category
|
|
77
|
+
- Markdown summary with sticky note
|
|
78
|
+
- Quote-match report with matched/skipped counts and scores
|
|
79
|
+
- Layout-aware quote matching across column, table, figure, and caption interruptions
|
|
80
|
+
- Stage-level CLI progress during extraction and annotation
|
|
81
|
+
- Configurable prompts and colors
|
|
82
|
+
|
|
83
|
+
## Documentation
|
|
84
|
+
|
|
85
|
+
For detailed setup, configuration options, and advanced usage, see the [full documentation](https://ehabets.github.io/PaperFlux/).
|
|
86
|
+
|
|
87
|
+
## Contributing
|
|
88
|
+
|
|
89
|
+
Contributions welcome! Fork the repo, create a feature branch, and open a PR.
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
See [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# PaperFlux
|
|
2
|
+
|
|
3
|
+
AI-powered PDF annotation for research papers. PaperFlux extracts exact quotations, organizes them by category (contributions, limitations, claims, evidence), and annotates your PDFs with precise highlights.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
### 1. Installation
|
|
8
|
+
|
|
9
|
+
Install the latest release from PyPI:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
python -m pip install paperflux
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
For local development from a cloned repository:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
python -m venv .venv
|
|
19
|
+
source .venv/bin/activate
|
|
20
|
+
pip install -e ".[dev]"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### 2. Set API Key
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
export PAPERFLUX_OPENAI_API_KEY="sk-your-key"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### 3. Run
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
paperflux init
|
|
33
|
+
paperflux --config config.yaml path/to/paper.pdf
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Features
|
|
37
|
+
|
|
38
|
+
- Batch processing: `*.pdf`
|
|
39
|
+
- Three detail levels (low/medium/high)
|
|
40
|
+
- RAG-based extraction with exact quotes
|
|
41
|
+
- Color-coded highlights by category
|
|
42
|
+
- Markdown summary with sticky note
|
|
43
|
+
- Quote-match report with matched/skipped counts and scores
|
|
44
|
+
- Layout-aware quote matching across column, table, figure, and caption interruptions
|
|
45
|
+
- Stage-level CLI progress during extraction and annotation
|
|
46
|
+
- Configurable prompts and colors
|
|
47
|
+
|
|
48
|
+
## Documentation
|
|
49
|
+
|
|
50
|
+
For detailed setup, configuration options, and advanced usage, see the [full documentation](https://ehabets.github.io/PaperFlux/).
|
|
51
|
+
|
|
52
|
+
## Contributing
|
|
53
|
+
|
|
54
|
+
Contributions welcome! Fork the repo, create a feature branch, and open a PR.
|
|
55
|
+
|
|
56
|
+
## License
|
|
57
|
+
|
|
58
|
+
See [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0.3", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "paperflux"
|
|
7
|
+
version = "3.4.20260528"
|
|
8
|
+
description = "AI-powered PDF annotation for research papers"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "PaperFlux contributors" },
|
|
15
|
+
]
|
|
16
|
+
keywords = ["annotation", "openai", "papers", "pdf", "research"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Environment :: Console",
|
|
20
|
+
"Intended Audience :: Science/Research",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Scientific/Engineering :: Information Analysis",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"openai>=2.38.0",
|
|
30
|
+
"typer>=0.15.4",
|
|
31
|
+
"PyMuPDF>=1.24",
|
|
32
|
+
"pyyaml>=6.0",
|
|
33
|
+
"pydantic>=2.7",
|
|
34
|
+
"rich>=13.7",
|
|
35
|
+
"python-dotenv>=1.0.0",
|
|
36
|
+
"jinja2>=3.1",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[project.optional-dependencies]
|
|
40
|
+
dev = [
|
|
41
|
+
"pytest>=8.2",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[project.scripts]
|
|
45
|
+
paperflux = "paperflux.cli:run"
|
|
46
|
+
|
|
47
|
+
[project.urls]
|
|
48
|
+
Documentation = "https://ehabets.github.io/PaperFlux/"
|
|
49
|
+
Homepage = "https://github.com/ehabets/PaperFlux"
|
|
50
|
+
Issues = "https://github.com/ehabets/PaperFlux/issues"
|
|
51
|
+
Source = "https://github.com/ehabets/PaperFlux"
|
|
52
|
+
|
|
53
|
+
[tool.setuptools.packages.find]
|
|
54
|
+
where = ["src"]
|
|
55
|
+
|
|
56
|
+
[tool.setuptools.package-data]
|
|
57
|
+
paperflux = [
|
|
58
|
+
"templates/config.yaml",
|
|
59
|
+
"templates/prompts/*.j2",
|
|
60
|
+
"templates/prompts/*.txt",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
[tool.pytest.ini_options]
|
|
64
|
+
pythonpath = ["src"]
|
|
65
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""PaperFlux package."""
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Callable, Dict, Optional, Union
|
|
6
|
+
|
|
7
|
+
from openai import OpenAI, AsyncOpenAI
|
|
8
|
+
from .config import Config
|
|
9
|
+
from jinja2 import Environment, FileSystemLoader
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
ProgressCallback = Callable[[str], None]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _resolve_config_path(path: Union[str, Path], cfg: Config) -> Path:
|
|
17
|
+
candidate = Path(path)
|
|
18
|
+
if candidate.is_absolute():
|
|
19
|
+
return candidate
|
|
20
|
+
config_dir = getattr(cfg, "_config_dir", None)
|
|
21
|
+
if config_dir is not None:
|
|
22
|
+
return config_dir / candidate
|
|
23
|
+
return candidate
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load_template(template_path: Union[str, Path]):
|
|
27
|
+
"""
|
|
28
|
+
Load Jinja2 template given its filesystem path.
|
|
29
|
+
"""
|
|
30
|
+
template_path = Path(template_path)
|
|
31
|
+
env = Environment(loader=FileSystemLoader(str(template_path.parent or Path("."))))
|
|
32
|
+
return env.get_template(template_path.name)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load_text_file(path: Union[str, Path]) -> str:
|
|
36
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
37
|
+
return handle.read().strip()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _build_text_payload(format_payload: dict, verbosity: str) -> dict:
|
|
41
|
+
return {"format": format_payload, "verbosity": verbosity}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _multi_category_schema(max_quotes_per_category: int) -> dict:
|
|
45
|
+
schema = {
|
|
46
|
+
"type": "object",
|
|
47
|
+
"additionalProperties": False,
|
|
48
|
+
"properties": {
|
|
49
|
+
"categories": {
|
|
50
|
+
"type": "array",
|
|
51
|
+
"items": {
|
|
52
|
+
"type": "object",
|
|
53
|
+
"additionalProperties": False,
|
|
54
|
+
"properties": {
|
|
55
|
+
"name": {"type": "string"},
|
|
56
|
+
"quotes": {
|
|
57
|
+
"type": "array",
|
|
58
|
+
"maxItems": max_quotes_per_category,
|
|
59
|
+
"items": {
|
|
60
|
+
"type": "object",
|
|
61
|
+
"additionalProperties": False,
|
|
62
|
+
"properties": {
|
|
63
|
+
"text": {"type": "string"},
|
|
64
|
+
"pages": {
|
|
65
|
+
"type": "array",
|
|
66
|
+
"items": {"type": "integer", "minimum": 1},
|
|
67
|
+
},
|
|
68
|
+
"prefix": {"type": "string"},
|
|
69
|
+
"suffix": {"type": "string"},
|
|
70
|
+
},
|
|
71
|
+
"required": ["text", "pages", "prefix", "suffix"],
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
"category_summary": {"type": "string"},
|
|
75
|
+
},
|
|
76
|
+
"required": ["name", "quotes", "category_summary"],
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
"required": ["categories"],
|
|
81
|
+
}
|
|
82
|
+
return schema
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _reasoning_payload(effort: str) -> Dict[str, str]:
|
|
86
|
+
return {"effort": effort}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _dump_failed_response(label: str, content: str) -> Optional[Path]:
|
|
90
|
+
"""
|
|
91
|
+
Persist the raw response body when JSON parsing fails so users can inspect it.
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
dump_dir = Path(".paperflux")
|
|
95
|
+
dump_dir.mkdir(exist_ok=True)
|
|
96
|
+
timestamp = int(time.time())
|
|
97
|
+
safe_label = "".join(c if c.isalnum() or c in ("-", "_") else "_" for c in label)[:40]
|
|
98
|
+
path = dump_dir / f"failed_response_{safe_label}_{timestamp}.txt"
|
|
99
|
+
path.write_text(content, encoding="utf-8")
|
|
100
|
+
return path
|
|
101
|
+
except Exception:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _ensure_response_completed(resp: Any, context: str, max_output_tokens: int) -> None:
|
|
106
|
+
"""
|
|
107
|
+
Raise a descriptive error if the Responses API indicates the run was truncated.
|
|
108
|
+
"""
|
|
109
|
+
status = getattr(resp, "status", None)
|
|
110
|
+
if status and status != "completed":
|
|
111
|
+
incomplete = getattr(resp, "incomplete_details", None)
|
|
112
|
+
reason = getattr(incomplete, "reason", None)
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"{context} response ended with status='{status}' (reason='{reason}'). "
|
|
115
|
+
f"Consider increasing ui.max_output_tokens (currently {max_output_tokens}) "
|
|
116
|
+
"or lowering ui.reasoning_effort, detail_level, category count, or "
|
|
117
|
+
"rag.max_quotes_per_category."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _extract_response_text(resp) -> str:
|
|
122
|
+
"""
|
|
123
|
+
Best-effort extraction of plain text from Responses API result.
|
|
124
|
+
Prefers resp.output_text when available, with fallbacks for structured outputs.
|
|
125
|
+
"""
|
|
126
|
+
# Newer SDKs expose output_text
|
|
127
|
+
text = getattr(resp, "output_text", None)
|
|
128
|
+
if isinstance(text, str) and text.strip():
|
|
129
|
+
return text
|
|
130
|
+
|
|
131
|
+
# Fallback: iterate output structure
|
|
132
|
+
try:
|
|
133
|
+
output = getattr(resp, "output", None)
|
|
134
|
+
if output and isinstance(output, list):
|
|
135
|
+
chunks = []
|
|
136
|
+
for item in output:
|
|
137
|
+
# message/content/output_text structure
|
|
138
|
+
if getattr(item, "type", None) == "message":
|
|
139
|
+
for c in getattr(item, "content", []) or []:
|
|
140
|
+
if getattr(c, "type", None) == "output_text":
|
|
141
|
+
t = getattr(c, "text", "")
|
|
142
|
+
if t:
|
|
143
|
+
chunks.append(t)
|
|
144
|
+
if chunks:
|
|
145
|
+
return "".join(chunks)
|
|
146
|
+
except (AttributeError, TypeError, ValueError): # fallback for unknown SDK shapes
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
# Last resort: stringify
|
|
150
|
+
return str(resp)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _extract_parsed_json(resp: Any) -> Optional[dict]:
|
|
154
|
+
"""
|
|
155
|
+
Try to extract structured output when response_format=json_schema is used.
|
|
156
|
+
"""
|
|
157
|
+
parsed = getattr(resp, "output_parsed", None)
|
|
158
|
+
if parsed:
|
|
159
|
+
if isinstance(parsed, list) and parsed and isinstance(parsed[0], dict):
|
|
160
|
+
return parsed[0]
|
|
161
|
+
if isinstance(parsed, dict):
|
|
162
|
+
return parsed
|
|
163
|
+
output = getattr(resp, "output", None)
|
|
164
|
+
if isinstance(output, list):
|
|
165
|
+
for item in output:
|
|
166
|
+
for c in getattr(item, "content", []) or []:
|
|
167
|
+
maybe = getattr(c, "parsed", None)
|
|
168
|
+
if isinstance(maybe, dict):
|
|
169
|
+
return maybe
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def analyze_pdf(
|
|
174
|
+
path: Path,
|
|
175
|
+
cfg: Config,
|
|
176
|
+
progress_callback: Optional[ProgressCallback] = None,
|
|
177
|
+
) -> dict:
|
|
178
|
+
"""
|
|
179
|
+
Use OpenAI Responses API with built-in file_search tool to retrieve per-category evidence
|
|
180
|
+
and synthesize a global summary. Retrieval happens server-side with tool_choice='auto'.
|
|
181
|
+
- Creates a vector store
|
|
182
|
+
- Uploads the PDF
|
|
183
|
+
- Runs one bundled category extraction response with file_search enabled.
|
|
184
|
+
- Generates a final markdown summary from category summaries.
|
|
185
|
+
"""
|
|
186
|
+
# Sync client used for vector store creation/upload (simpler surface)
|
|
187
|
+
client_sync = OpenAI(api_key=cfg.openai.api_key)
|
|
188
|
+
vector_store_id: Optional[str] = None
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
# 1) Create vector store and upload PDF
|
|
192
|
+
if progress_callback:
|
|
193
|
+
progress_callback("Creating temporary vector store")
|
|
194
|
+
vector_store = client_sync.vector_stores.create(
|
|
195
|
+
name="PaperFlux Vector Store",
|
|
196
|
+
expires_after={
|
|
197
|
+
"anchor": "last_active_at",
|
|
198
|
+
"days": cfg.rag.vector_store_expires_after_days,
|
|
199
|
+
},
|
|
200
|
+
)
|
|
201
|
+
vector_store_id = vector_store.id
|
|
202
|
+
if progress_callback:
|
|
203
|
+
progress_callback(f"Uploading and indexing {path.name}")
|
|
204
|
+
with open(path, "rb") as f:
|
|
205
|
+
client_sync.vector_stores.files.upload_and_poll(
|
|
206
|
+
vector_store_id=vector_store_id,
|
|
207
|
+
file=f
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
categories = cfg.extraction_categories.categories # Dict[name, description]
|
|
211
|
+
|
|
212
|
+
# 2) Bundle categories into a single Responses job with file_search tool
|
|
213
|
+
client_async = AsyncOpenAI(api_key=cfg.openai.api_key)
|
|
214
|
+
category_template = _load_template(_resolve_config_path(cfg.rag.category_prompt_file, cfg))
|
|
215
|
+
category_system_prompt = _load_text_file(
|
|
216
|
+
_resolve_config_path(cfg.rag.category_system_prompt_file, cfg)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
category_entries = [
|
|
220
|
+
{"name": cat, "description": desc}
|
|
221
|
+
for cat, desc in categories.items()
|
|
222
|
+
]
|
|
223
|
+
user_msg = category_template.render(
|
|
224
|
+
categories=category_entries,
|
|
225
|
+
max_quotes_per_category=cfg.rag.max_quotes_per_category,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
file_search_tool = {
|
|
229
|
+
"type": "file_search",
|
|
230
|
+
"vector_store_ids": [vector_store_id],
|
|
231
|
+
}
|
|
232
|
+
if cfg.rag.max_num_results is not None:
|
|
233
|
+
file_search_tool["max_num_results"] = cfg.rag.max_num_results
|
|
234
|
+
tools = [file_search_tool]
|
|
235
|
+
|
|
236
|
+
schema = _multi_category_schema(cfg.rag.max_quotes_per_category)
|
|
237
|
+
text_payload = _build_text_payload(
|
|
238
|
+
{
|
|
239
|
+
"type": "json_schema",
|
|
240
|
+
"name": "multi_category_schema",
|
|
241
|
+
"schema": schema,
|
|
242
|
+
"strict": True,
|
|
243
|
+
},
|
|
244
|
+
cfg.ui.verbosity,
|
|
245
|
+
)
|
|
246
|
+
reasoning_payload = _reasoning_payload(cfg.ui.reasoning_effort)
|
|
247
|
+
request_input = [
|
|
248
|
+
{"role": "system", "content": category_system_prompt},
|
|
249
|
+
{"role": "user", "content": user_msg},
|
|
250
|
+
]
|
|
251
|
+
kwargs = {
|
|
252
|
+
"model": cfg.openai.model,
|
|
253
|
+
"input": request_input,
|
|
254
|
+
"max_output_tokens": cfg.ui.max_output_tokens,
|
|
255
|
+
"tools": tools,
|
|
256
|
+
"text": text_payload,
|
|
257
|
+
"tool_choice": {"type": "file_search"},
|
|
258
|
+
"store": False,
|
|
259
|
+
}
|
|
260
|
+
if cfg.rag.include_search_results:
|
|
261
|
+
kwargs["include"] = ["file_search_call.results"]
|
|
262
|
+
kwargs["reasoning"] = reasoning_payload
|
|
263
|
+
if progress_callback:
|
|
264
|
+
progress_callback("Extracting quotes with OpenAI")
|
|
265
|
+
multi_resp = await client_async.responses.create(**kwargs)
|
|
266
|
+
_ensure_response_completed(multi_resp, "Category bundle", cfg.ui.max_output_tokens)
|
|
267
|
+
|
|
268
|
+
result = _extract_parsed_json(multi_resp)
|
|
269
|
+
if result is None:
|
|
270
|
+
text_val = _extract_response_text(multi_resp)
|
|
271
|
+
if not text_val or not text_val.strip():
|
|
272
|
+
dump_path = _dump_failed_response(
|
|
273
|
+
"category_bundle_no_parsed_json",
|
|
274
|
+
getattr(multi_resp, "output_text", "") or str(multi_resp),
|
|
275
|
+
)
|
|
276
|
+
hint = f" (raw saved to {dump_path})" if dump_path else ""
|
|
277
|
+
raise ValueError(f"Category bundle response missing parsed JSON{hint}")
|
|
278
|
+
result = json.loads(text_val)
|
|
279
|
+
if not isinstance(result, dict):
|
|
280
|
+
raise ValueError(f"Category bundle returned non-dict JSON: {type(result)}")
|
|
281
|
+
|
|
282
|
+
bundle_list = result.get("categories")
|
|
283
|
+
if not isinstance(bundle_list, list):
|
|
284
|
+
raise ValueError("Category bundle JSON missing 'categories' array")
|
|
285
|
+
|
|
286
|
+
quotes: Dict[str, list] = {}
|
|
287
|
+
category_summaries: Dict[str, str] = {}
|
|
288
|
+
for entry in bundle_list:
|
|
289
|
+
if not isinstance(entry, dict):
|
|
290
|
+
continue
|
|
291
|
+
category = entry.get("name") or entry.get("category")
|
|
292
|
+
if not isinstance(category, str):
|
|
293
|
+
continue
|
|
294
|
+
cat_data = entry
|
|
295
|
+
raw_quotes = (
|
|
296
|
+
cat_data.get("quotes")
|
|
297
|
+
or cat_data.get("evidence")
|
|
298
|
+
or cat_data.get("quote_list")
|
|
299
|
+
or []
|
|
300
|
+
)
|
|
301
|
+
normalised_quotes = []
|
|
302
|
+
for item in raw_quotes:
|
|
303
|
+
if isinstance(item, dict):
|
|
304
|
+
text_val = item.get("text") or item.get("quote") or item.get("content")
|
|
305
|
+
if not isinstance(text_val, str) or not text_val.strip():
|
|
306
|
+
continue
|
|
307
|
+
pages_val = item.get("pages") or item.get("page")
|
|
308
|
+
if isinstance(pages_val, int):
|
|
309
|
+
pages = [pages_val]
|
|
310
|
+
elif isinstance(pages_val, list):
|
|
311
|
+
pages = [p for p in pages_val if isinstance(p, int) and p > 0]
|
|
312
|
+
else:
|
|
313
|
+
pages = []
|
|
314
|
+
prefix_val = item.get("prefix") or item.get("context_before") or ""
|
|
315
|
+
suffix_val = item.get("suffix") or item.get("context_after") or ""
|
|
316
|
+
normalised_quotes.append({
|
|
317
|
+
"text": text_val.strip(),
|
|
318
|
+
"pages": pages,
|
|
319
|
+
"prefix": prefix_val.strip() if isinstance(prefix_val, str) else "",
|
|
320
|
+
"suffix": suffix_val.strip() if isinstance(suffix_val, str) else "",
|
|
321
|
+
})
|
|
322
|
+
elif isinstance(item, str) and item.strip():
|
|
323
|
+
normalised_quotes.append({
|
|
324
|
+
"text": item.strip(),
|
|
325
|
+
"pages": [],
|
|
326
|
+
"prefix": "",
|
|
327
|
+
"suffix": "",
|
|
328
|
+
})
|
|
329
|
+
quotes[category] = normalised_quotes
|
|
330
|
+
summary_val = cat_data.get("category_summary") or cat_data.get("summary") or ""
|
|
331
|
+
category_summaries[category] = summary_val
|
|
332
|
+
|
|
333
|
+
# 3) Synthesize global summary (no tools required)
|
|
334
|
+
summary_template = _load_template(_resolve_config_path(cfg.rag.summary_prompt_file, cfg))
|
|
335
|
+
summary_msg = summary_template.render(
|
|
336
|
+
detail_level=cfg.ui.detail_level,
|
|
337
|
+
category_summaries=category_summaries
|
|
338
|
+
)
|
|
339
|
+
summary_text_payload = _build_text_payload({"type": "text"}, cfg.ui.verbosity)
|
|
340
|
+
summary_reasoning = _reasoning_payload(cfg.ui.reasoning_effort)
|
|
341
|
+
summary_kwargs = {
|
|
342
|
+
"model": cfg.openai.model,
|
|
343
|
+
"input": summary_msg,
|
|
344
|
+
"max_output_tokens": cfg.ui.max_output_tokens,
|
|
345
|
+
"text": summary_text_payload,
|
|
346
|
+
"store": False,
|
|
347
|
+
}
|
|
348
|
+
summary_kwargs["reasoning"] = summary_reasoning
|
|
349
|
+
if progress_callback:
|
|
350
|
+
progress_callback("Generating summary")
|
|
351
|
+
summary_resp = await client_async.responses.create(**summary_kwargs)
|
|
352
|
+
_ensure_response_completed(summary_resp, "Summary", cfg.ui.max_output_tokens)
|
|
353
|
+
key_takeaways = _extract_response_text(summary_resp)
|
|
354
|
+
|
|
355
|
+
return {"key_takeaways": key_takeaways, "quotes": quotes}
|
|
356
|
+
finally:
|
|
357
|
+
if vector_store_id:
|
|
358
|
+
try:
|
|
359
|
+
if progress_callback:
|
|
360
|
+
progress_callback("Cleaning up temporary vector store")
|
|
361
|
+
client_sync.vector_stores.delete(vector_store_id=vector_store_id)
|
|
362
|
+
except Exception as exc:
|
|
363
|
+
logger.warning("Failed to delete vector store %s: %s", vector_store_id, exc)
|