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.
Files changed (26) hide show
  1. paperflux-3.4.20260528/LICENSE +21 -0
  2. paperflux-3.4.20260528/PKG-INFO +93 -0
  3. paperflux-3.4.20260528/README.md +58 -0
  4. paperflux-3.4.20260528/pyproject.toml +65 -0
  5. paperflux-3.4.20260528/setup.cfg +4 -0
  6. paperflux-3.4.20260528/src/paperflux/__init__.py +1 -0
  7. paperflux-3.4.20260528/src/paperflux/assistants.py +363 -0
  8. paperflux-3.4.20260528/src/paperflux/cli.py +355 -0
  9. paperflux-3.4.20260528/src/paperflux/config.py +148 -0
  10. paperflux-3.4.20260528/src/paperflux/io_pdf.py +289 -0
  11. paperflux-3.4.20260528/src/paperflux/orchestrator.py +93 -0
  12. paperflux-3.4.20260528/src/paperflux/quote_locator.py +438 -0
  13. paperflux-3.4.20260528/src/paperflux/templates/config.yaml +33 -0
  14. paperflux-3.4.20260528/src/paperflux/templates/prompts/rag_category_prompt.j2 +22 -0
  15. paperflux-3.4.20260528/src/paperflux/templates/prompts/rag_category_system_prompt.txt +11 -0
  16. paperflux-3.4.20260528/src/paperflux/templates/prompts/rag_summary_prompt.j2 +14 -0
  17. paperflux-3.4.20260528/src/paperflux/utils.py +125 -0
  18. paperflux-3.4.20260528/src/paperflux.egg-info/PKG-INFO +93 -0
  19. paperflux-3.4.20260528/src/paperflux.egg-info/SOURCES.txt +24 -0
  20. paperflux-3.4.20260528/src/paperflux.egg-info/dependency_links.txt +1 -0
  21. paperflux-3.4.20260528/src/paperflux.egg-info/entry_points.txt +2 -0
  22. paperflux-3.4.20260528/src/paperflux.egg-info/requires.txt +11 -0
  23. paperflux-3.4.20260528/src/paperflux.egg-info/top_level.txt +1 -0
  24. paperflux-3.4.20260528/tests/test_cli_integration.py +424 -0
  25. paperflux-3.4.20260528/tests/test_config_parse.py +111 -0
  26. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)