Skip to content

Commit 23d4844

Browse files
authored
Merge pull request #17 from Integration-Automation/dev
Improve the automatic paper-research workflow (ranking, dedup, filters, concurrency)
2 parents f2fa0a3 + f44b40b commit 23d4844

13 files changed

Lines changed: 481 additions & 127 deletions

File tree

.claude/agents/rules/code-quality-reviewer.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ You do NOT modify files. The parent agent decides whether to fix.
5151
- Per-source rate limits live in `thesisagents/fetchers/rate_limit.py` as a token-bucket decorator. Each source plugin declares its own bucket (`arxiv: 1 req/3s`, `semantic_scholar: 1 req/s`, `scholar: 1 req/10s with jitter`, etc.). Do NOT bypass the bucket — even retries go through it.
5252
- Streamlit runs the UI on a separate thread per session. Mutate `st.session_state` only, never module globals. Long-running export jobs are dispatched to the FastAPI backend and polled.
5353
- All fixture-recording, CLI exports, and tests use `asyncio.run` at the outermost layer and never inside library code.
54+
- **Bounded fan-out over a paper collection.** A per-source token bucket caps traffic *within* one source, but a stage that fans one operation out over N papers (PDF download, OA resolve, LLM enrich) escapes those buckets — several papers can hit the *same* external API (Anthropic) or publisher CDN (`dl.acm.org`) at once through different source clients. Any `asyncio.gather(*(coro(item) for item in collection.papers))` MUST wrap each task in a module-level `asyncio.Semaphore` cap (the project uses 4–5: `pipeline._ENRICH_CONCURRENCY`, `pdf_download._DOWNLOAD_CONCURRENCY`, `oa_resolver._CONCURRENCY`). **Why:** an unbounded fan-out of a 25-paper search fires 25 concurrent Anthropic calls (an easy 429) or 25 hits on one CDN (an IP block) — it looks fine at 3 papers and fails at 30. **Anti-pattern:** `await asyncio.gather(*(_download_one(p, d) for p in collection.papers))` with no semaphore. **Pattern:** define `sem = asyncio.Semaphore(max(1, concurrency))` and a `_bounded` wrapper that does `async with sem: return await coro(item)`.
5455

5556
## Security (review-level)
5657

.claude/agents/rules/compliance-auditor.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ The line between `thesisagents/` and `sources/<name>/` is **not** "anything sour
4646

4747
**Loading source plugins:** at runtime `thesisagents/fetchers/base.py::load_fetcher(name)` does `importlib.import_module(f"thesisagents.sources.{name}")`. No more `sys.path` injection — sources are a regular sub-package of `thesisagents`. Plugins under `thesisagents/sources/<name>/` use **relative imports** internally (`from .fetcher import …`, `from .parser import …`, `from . import webrunner_backend`). Tests import them via the full path (`from thesisagents.sources.<name>.fetcher import …`) and reference module-string targets the same way (e.g. `monkeypatch.setattr("thesisagents.sources.acm.fetcher.get_client", …)`).
4848

49+
**Query semantics are enforced in core, never delegated to plugins.** Every filter field on `Query` (`min_citations`, `year_from` / `year_to`, `top_tier_only`) MUST be applied by the pipeline (`thesisagents/core/pipeline.py::run_search`, after dedup + rank) so it holds for **all** sources uniformly. A plugin MAY *additionally* push the filter to its upstream API as an optimisation (e.g. `semantic_scholar` sends `minCitationCount`), but core must never *rely* on plugins to do it. **Why (real incident):** `min_citations` was accepted by CLI/MCP/`Query`, documented as a threshold filter, and implemented by only the `semantic_scholar` plugin — so for the other 14 sources it silently did nothing; `min_citations=50` still returned 2-citation arXiv / OpenAlex / DBLP papers. **Anti-pattern:** wiring a `Query` filter into one source's request params and assuming the result set is filtered. **Pattern:** filter in `run_search`, and keep records whose value is *unknown* (`citation_count is None` / `year is None`) rather than treating unknown as failing the threshold (a source not reporting a count must not silently drop the paper).
50+
4951
**When in doubt:** "if a user installs ThesisAgents with the default `requirements.txt` and never enables a source plugin, should this source work?" Yes → core. No → source plugin.
5052

5153
---

docs/cli.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ exclusive modes:
1515
thesisagents (--query KEYWORDS | --paper IDENTIFIER)
1616
[--source SOURCES] [--exclude-source SOURCES]
1717
[--max N]
18-
[--year-from YEAR] [--year-to YEAR]
18+
[--year-from YEAR] [--year-to YEAR] [--min-citations N]
1919
[--export FORMATS]
2020
[--out DIR]
2121
[--filename-stem STEM]
@@ -39,6 +39,7 @@ thesisagents (--query KEYWORDS | --paper IDENTIFIER)
3939
| `--exclude-source` / `-x` || Comma-separated sources to remove from the mix, subtracted **after** `--source` resolves. The no-VPN gesture is to leave `--source` at its default and pass `--exclude-source ieee` — every other default source stays in. An unknown name (or excluding the whole mix) is an error. |
4040
| `--max` / `-n` | `25` | Range 1..200. |
4141
| `--year-from`, `--year-to` || Inclusive year filter. |
42+
| `--min-citations` || Drop papers below this citation count, enforced across **all** sources (not just Semantic Scholar). Papers whose source reports no count are kept. Omit for no minimum. |
4243
| `--export` / `-e` | mode-specific | Any of `pptx`, `xlsx`, `md`, `bib`, `json`, `ris`, `csv`, `csl`. **Default with `--query` is `pptx,xlsx,bib`; default with `--paper` is `pptx,bib`** (one-row Excel is busy work). Explicit `--export` always wins. `ris` (Zotero/Mendeley/EndNote), `csv` (flat table) and `csl` (CSL-JSON for Pandoc, written as `<stem>.csl.json`) are the interchange formats. |
4344
| `--list-sources`, `--list-exports` || Print the available search sources / export formats and exit (no query needed). |
4445
| `--out` / `-o` | `./exports` | Created if missing. |

tests/test_cli.py

Lines changed: 45 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -43,16 +43,28 @@ async def _fake_success(collection, out_dir):
4343

4444
@pytest.fixture()
4545
def patched_pipeline(monkeypatch, sample_papers):
46-
async def fake_run_search(query: Query, **_kwargs) -> PaperCollection:
46+
"""Stub cli's run_search + shutdown_clients and return a dict that captures
47+
the Query passed to run_search (``patched_pipeline["query"]``).
48+
49+
Tests that only need the pipeline stubbed ignore the return value; tests
50+
that assert on the constructed Query read ``patched_pipeline["query"]``
51+
instead of each re-rolling their own ``fake_run_search`` / ``fake_shutdown``
52+
boilerplate.
53+
"""
54+
captured: dict[str, Query] = {}
55+
56+
async def fake_run_search(query, **_kwargs): # NOSONAR async stub
57+
captured["query"] = query
4758
return PaperCollection(query=query, papers=tuple(sample_papers))
4859

49-
async def fake_shutdown() -> None:
60+
async def fake_shutdown(): # NOSONAR async stub
5061
return None
5162

5263
monkeypatch.setattr(cli_module, "run_search", fake_run_search)
5364
monkeypatch.setattr(cli_module, "shutdown_clients", fake_shutdown)
5465
# Leave the autouse ``_stub_download_pdfs`` in place so the new per-paper
5566
# PPT gate sees every paper as downloadable.
67+
return captured
5668

5769

5870
def test_cli_runs_end_to_end(tmp_path: Path, patched_pipeline, capsys):
@@ -248,28 +260,16 @@ def test_cli_rejects_doi_identifier_until_resolver_lands(tmp_path):
248260
assert code == 2
249261

250262

251-
def test_cli_source_default_is_multi_source(tmp_path, monkeypatch, sample_papers):
263+
def test_cli_source_default_is_multi_source(tmp_path, patched_pipeline):
252264
"""When --source is omitted, run_search must be invoked across the
253265
DEFAULT_SOURCES mix, not just arxiv."""
254266
from thesisagents.core.constants import DEFAULT_SOURCES
255267

256-
captured: dict[str, Query] = {}
257-
258-
async def fake_run_search(query: Query, **_kwargs) -> PaperCollection:
259-
captured["query"] = query
260-
return PaperCollection(query=query, papers=tuple(sample_papers))
261-
262-
async def fake_shutdown() -> None:
263-
return None
264-
265-
monkeypatch.setattr(cli_module, "run_search", fake_run_search)
266-
monkeypatch.setattr(cli_module, "shutdown_clients", fake_shutdown)
267-
268268
code = cli_module.main(
269269
["--query", "x", "--out", str(tmp_path), "--export", "bib"]
270270
)
271271
assert code == 0
272-
assert captured["query"].sources == DEFAULT_SOURCES
272+
assert patched_pipeline["query"].sources == DEFAULT_SOURCES
273273

274274

275275
def test_cli_list_sources(capsys):
@@ -293,31 +293,30 @@ def test_cli_list_exports(capsys):
293293
assert "pptx" in out
294294

295295

296-
def test_cli_exclude_source_prunes_default_mix(tmp_path, monkeypatch, sample_papers):
296+
def test_cli_exclude_source_prunes_default_mix(tmp_path, patched_pipeline):
297297
"""--exclude-source subtracts from the resolved mix; the no-VPN path drops
298298
only ieee and keeps every other default source."""
299299
from thesisagents.core.constants import DEFAULT_SOURCES
300300

301-
captured: dict[str, Query] = {}
302-
303-
async def fake_run_search(query: Query, **_kwargs) -> PaperCollection: # NOSONAR async stub
304-
captured["query"] = query
305-
return PaperCollection(query=query, papers=tuple(sample_papers))
306-
307-
async def fake_shutdown() -> None: # NOSONAR async stub
308-
return None
309-
310-
monkeypatch.setattr(cli_module, "run_search", fake_run_search)
311-
monkeypatch.setattr(cli_module, "shutdown_clients", fake_shutdown)
312-
313301
code = cli_module.main(
314302
["--query", "x", "--out", str(tmp_path), "--export", "bib",
315303
"--exclude-source", "ieee"]
316304
)
317305
assert code == 0
318-
assert "ieee" not in captured["query"].sources
306+
assert "ieee" not in patched_pipeline["query"].sources
319307
expected = tuple(s for s in DEFAULT_SOURCES if s != "ieee")
320-
assert captured["query"].sources == expected
308+
assert patched_pipeline["query"].sources == expected
309+
310+
311+
def test_cli_min_citations_flows_into_query(tmp_path, patched_pipeline):
312+
"""--min-citations is parsed and passed through to the Query (it was
313+
previously unreachable from the CLI)."""
314+
code = cli_module.main(
315+
["--query", "x", "--out", str(tmp_path), "--export", "bib",
316+
"--min-citations", "50"]
317+
)
318+
assert code == 0
319+
assert patched_pipeline["query"].min_citations == 50
321320

322321

323322
def test_cli_exclude_unknown_source_errors(tmp_path):
@@ -337,90 +336,50 @@ def test_cli_exclude_all_sources_errors(tmp_path):
337336
)
338337

339338

340-
def test_cli_top_tier_filter_off_by_default(tmp_path, monkeypatch, sample_papers):
339+
def test_cli_top_tier_filter_off_by_default(tmp_path, patched_pipeline):
341340
"""top_tier_only is OFF by default (broader coverage including IEEE / ACM
342341
workshops); --top-tier-only flips it on."""
343-
captured: dict[str, Query] = {}
344-
345-
async def fake_run_search(query: Query, **_kwargs) -> PaperCollection:
346-
captured["query"] = query
347-
return PaperCollection(query=query, papers=tuple(sample_papers))
348-
349-
async def fake_shutdown() -> None:
350-
return None
351-
352-
monkeypatch.setattr(cli_module, "run_search", fake_run_search)
353-
monkeypatch.setattr(cli_module, "shutdown_clients", fake_shutdown)
354-
355342
code = cli_module.main(
356343
["--query", "x", "--out", str(tmp_path), "--export", "bib"]
357344
)
358345
assert code == 0
359-
assert captured["query"].top_tier_only is False
346+
assert patched_pipeline["query"].top_tier_only is False
360347

361-
captured.clear()
348+
patched_pipeline.clear()
362349
code = cli_module.main(
363350
["--query", "x", "--top-tier-only", "--out", str(tmp_path), "--export", "bib"]
364351
)
365352
assert code == 0
366-
assert captured["query"].top_tier_only is True
353+
assert patched_pipeline["query"].top_tier_only is True
367354

368355

369-
def test_cli_default_triggers_pdf_download(tmp_path, monkeypatch, sample_papers):
356+
def test_cli_default_triggers_pdf_download(tmp_path, monkeypatch, patched_pipeline):
370357
"""Default flag set should invoke download_pdfs; --no-pdf disables it."""
371358
calls: list[str] = []
372359

373-
async def fake_run_search(query: Query, **_kwargs) -> PaperCollection:
374-
return PaperCollection(query=query, papers=tuple(sample_papers))
375-
376-
async def fake_shutdown() -> None:
377-
return None
378-
379-
async def fake_download(_collection, _out_dir):
360+
async def fake_download(_collection, _out_dir): # NOSONAR async stub
380361
calls.append("called")
381362
return []
382363

383-
monkeypatch.setattr(cli_module, "run_search", fake_run_search)
384-
monkeypatch.setattr(cli_module, "shutdown_clients", fake_shutdown)
385364
monkeypatch.setattr(cli_module, "download_pdfs", fake_download)
386-
387365
code = cli_module.main(
388-
[
389-
"--query", "x",
390-
"--source", "arxiv",
391-
"--out", str(tmp_path),
392-
"--export", "bib",
393-
]
366+
["--query", "x", "--source", "arxiv", "--out", str(tmp_path), "--export", "bib"]
394367
)
395368
assert code == 0
396369
assert calls == ["called"]
397370

398371

399-
def test_cli_no_pdf_flag_skips_download(tmp_path, monkeypatch, sample_papers):
372+
def test_cli_no_pdf_flag_skips_download(tmp_path, monkeypatch, patched_pipeline):
400373
calls: list[str] = []
401374

402-
async def fake_run_search(query: Query, **_kwargs) -> PaperCollection:
403-
return PaperCollection(query=query, papers=tuple(sample_papers))
404-
405-
async def fake_shutdown() -> None:
406-
return None
407-
408-
async def fake_download(_collection, _out_dir):
375+
async def fake_download(_collection, _out_dir): # NOSONAR async stub
409376
calls.append("called")
410377
return []
411378

412-
monkeypatch.setattr(cli_module, "run_search", fake_run_search)
413-
monkeypatch.setattr(cli_module, "shutdown_clients", fake_shutdown)
414379
monkeypatch.setattr(cli_module, "download_pdfs", fake_download)
415-
416380
code = cli_module.main(
417-
[
418-
"--query", "x",
419-
"--source", "arxiv",
420-
"--no-pdf",
421-
"--out", str(tmp_path),
422-
"--export", "bib",
423-
]
381+
["--query", "x", "--source", "arxiv", "--no-pdf",
382+
"--out", str(tmp_path), "--export", "bib"]
424383
)
425384
assert code == 0
426385
assert calls == []
@@ -747,21 +706,9 @@ async def fake_enrich(collection, language=None, model=None): # noqa: ARG001
747706
return calls
748707

749708

750-
def _fake_search_with_papers(monkeypatch, sample_papers):
751-
async def fake_run_search(query, **_kwargs):
752-
return PaperCollection(query=query, papers=tuple(sample_papers))
753-
754-
async def fake_shutdown():
755-
return None
756-
757-
monkeypatch.setattr(cli_module, "run_search", fake_run_search)
758-
monkeypatch.setattr(cli_module, "shutdown_clients", fake_shutdown)
759-
760-
761-
def test_cli_auto_enriches_when_api_key_set(tmp_path, monkeypatch, sample_papers):
709+
def test_cli_auto_enriches_when_api_key_set(tmp_path, monkeypatch, patched_pipeline):
762710
"""ANTHROPIC_API_KEY in env + no --lightweight = auto-enrich fires."""
763711
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
764-
_fake_search_with_papers(monkeypatch, sample_papers)
765712
calls = _stub_enrich_collection(monkeypatch)
766713
code = cli_module.main(
767714
["--query", "x", "--source", "arxiv", "--out", str(tmp_path), "--export", "bib"]
@@ -770,10 +717,9 @@ def test_cli_auto_enriches_when_api_key_set(tmp_path, monkeypatch, sample_papers
770717
assert calls == ["called"]
771718

772719

773-
def test_cli_lightweight_skips_auto_enrich(tmp_path, monkeypatch, sample_papers):
720+
def test_cli_lightweight_skips_auto_enrich(tmp_path, monkeypatch, patched_pipeline):
774721
"""--lightweight wins over the auto-enrich default."""
775722
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
776-
_fake_search_with_papers(monkeypatch, sample_papers)
777723
calls = _stub_enrich_collection(monkeypatch)
778724
code = cli_module.main(
779725
[
@@ -786,10 +732,9 @@ def test_cli_lightweight_skips_auto_enrich(tmp_path, monkeypatch, sample_papers)
786732
assert calls == []
787733

788734

789-
def test_cli_no_key_does_not_auto_enrich(tmp_path, monkeypatch, sample_papers):
735+
def test_cli_no_key_does_not_auto_enrich(tmp_path, monkeypatch, patched_pipeline):
790736
"""No ANTHROPIC_API_KEY → no Anthropic call, lightweight deck."""
791737
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
792-
_fake_search_with_papers(monkeypatch, sample_papers)
793738
calls = _stub_enrich_collection(monkeypatch)
794739
code = cli_module.main(
795740
["--query", "x", "--source", "arxiv", "--out", str(tmp_path), "--export", "bib"]
@@ -798,11 +743,10 @@ def test_cli_no_key_does_not_auto_enrich(tmp_path, monkeypatch, sample_papers):
798743
assert calls == []
799744

800745

801-
def test_cli_explicit_enrich_still_works(tmp_path, monkeypatch, sample_papers):
746+
def test_cli_explicit_enrich_still_works(tmp_path, monkeypatch, patched_pipeline):
802747
"""--enrich runs even without a key in env (the explicit path used to
803748
error inside the API client; here we only check the CLI dispatch)."""
804749
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
805-
_fake_search_with_papers(monkeypatch, sample_papers)
806750
calls = _stub_enrich_collection(monkeypatch)
807751
code = cli_module.main(
808752
[

0 commit comments

Comments
 (0)