sci-etl-cli 0.2.0__tar.gz → 0.4.0__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 (68) hide show
  1. sci_etl_cli-0.4.0/PKG-INFO +139 -0
  2. sci_etl_cli-0.4.0/README.md +93 -0
  3. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/pyproject.toml +10 -5
  4. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/app.py +2 -0
  5. sci_etl_cli-0.4.0/src/sci_etl_cli/assembly.py +311 -0
  6. sci_etl_cli-0.4.0/src/sci_etl_cli/commands/forget.py +62 -0
  7. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/commands/init.py +39 -10
  8. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/commands/run.py +44 -26
  9. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/commands/search.py +16 -10
  10. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/commands/status.py +6 -1
  11. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/commands/validate.py +48 -32
  12. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/errors.py +3 -0
  13. sci_etl_cli-0.4.0/src/sci_etl_cli/options.py +62 -0
  14. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/output.py +17 -11
  15. sci_etl_cli-0.4.0/src/sci_etl_cli/plugins.py +84 -0
  16. sci_etl_cli-0.4.0/src/sci_etl_cli/reporting.py +57 -0
  17. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/settings.py +112 -17
  18. sci_etl_cli-0.4.0/src/sci_etl_cli/templates/config-openalex.yaml +48 -0
  19. sci_etl_cli-0.4.0/src/sci_etl_cli/templates/config-pubmed.yaml +48 -0
  20. sci_etl_cli-0.4.0/src/sci_etl_cli/templates/config-semantic_scholar.yaml +48 -0
  21. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/templates/config.yaml +5 -4
  22. sci_etl_cli-0.4.0/src/sci_etl_cli/templates/env.example +3 -0
  23. sci_etl_cli-0.4.0/src/sci_etl_cli/templates/extraction-trials.txt +12 -0
  24. sci_etl_cli-0.4.0/src/sci_etl_cli/templates/relevance-trials.txt +5 -0
  25. sci_etl_cli-0.4.0/src/sci_etl_cli.egg-info/PKG-INFO +139 -0
  26. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli.egg-info/SOURCES.txt +8 -3
  27. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli.egg-info/requires.txt +6 -1
  28. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/tests/test_app.py +1 -1
  29. sci_etl_cli-0.4.0/tests/test_assembly.py +184 -0
  30. sci_etl_cli-0.4.0/tests/test_forget.py +151 -0
  31. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/tests/test_output.py +19 -1
  32. sci_etl_cli-0.4.0/tests/test_plugins.py +81 -0
  33. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/tests/test_run.py +81 -17
  34. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/tests/test_settings.py +60 -7
  35. sci_etl_cli-0.4.0/tests/test_sources.py +316 -0
  36. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/tests/test_validate.py +3 -3
  37. sci_etl_cli-0.2.0/PKG-INFO +0 -296
  38. sci_etl_cli-0.2.0/README.md +0 -254
  39. sci_etl_cli-0.2.0/src/sci_etl_cli/assembly.py +0 -205
  40. sci_etl_cli-0.2.0/src/sci_etl_cli/entity_filter.py +0 -36
  41. sci_etl_cli-0.2.0/src/sci_etl_cli/options.py +0 -32
  42. sci_etl_cli-0.2.0/src/sci_etl_cli/plugins.py +0 -53
  43. sci_etl_cli-0.2.0/src/sci_etl_cli/reporting.py +0 -30
  44. sci_etl_cli-0.2.0/src/sci_etl_cli/shutdown.py +0 -36
  45. sci_etl_cli-0.2.0/src/sci_etl_cli/templates/env.example +0 -1
  46. sci_etl_cli-0.2.0/src/sci_etl_cli.egg-info/PKG-INFO +0 -296
  47. sci_etl_cli-0.2.0/tests/test_assembly.py +0 -126
  48. sci_etl_cli-0.2.0/tests/test_plugins.py +0 -38
  49. sci_etl_cli-0.2.0/tests/test_shutdown.py +0 -67
  50. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/LICENSE +0 -0
  51. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/setup.cfg +0 -0
  52. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/__init__.py +0 -0
  53. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/__main__.py +0 -0
  54. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/commands/__init__.py +0 -0
  55. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/commands/parse.py +0 -0
  56. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/py.typed +0 -0
  57. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/templates/extraction.txt +0 -0
  58. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/templates/relevance.txt +0 -0
  59. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/usage.py +0 -0
  60. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli.egg-info/dependency_links.txt +0 -0
  61. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli.egg-info/entry_points.txt +0 -0
  62. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli.egg-info/top_level.txt +0 -0
  63. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/tests/test_errors.py +0 -0
  64. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/tests/test_init.py +0 -0
  65. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/tests/test_parse.py +0 -0
  66. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/tests/test_search.py +0 -0
  67. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/tests/test_status.py +0 -0
  68. {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/tests/test_usage.py +0 -0
@@ -0,0 +1,139 @@
1
+ Metadata-Version: 2.4
2
+ Name: sci-etl-cli
3
+ Version: 0.4.0
4
+ Summary: Command-line tool for running sci-etl-core extraction pipelines from a YAML file.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/xueromll/sci-etl-cli
7
+ Project-URL: Documentation, https://xueromll.github.io/sci-etl-core/latest/cli/
8
+ Project-URL: Repository, https://github.com/xueromll/sci-etl-cli
9
+ Project-URL: Issues, https://github.com/xueromll/sci-etl-cli/issues
10
+ Keywords: etl,cli,arxiv,llm,text-mining,scientific-literature
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Scientific/Engineering
22
+ Classifier: Topic :: Text Processing
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.11
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Requires-Dist: sci-etl-core[arxiv,async,config,html,llm,pdf,xml]<0.8,>=0.7.0
28
+ Requires-Dist: click>=8.2
29
+ Requires-Dist: rich>=13.7
30
+ Requires-Dist: pydantic>=2
31
+ Requires-Dist: python-dotenv
32
+ Provides-Extra: dev
33
+ Requires-Dist: pytest; extra == "dev"
34
+ Requires-Dist: pytest-asyncio; extra == "dev"
35
+ Requires-Dist: pytest-mock; extra == "dev"
36
+ Requires-Dist: pytest-cov; extra == "dev"
37
+ Provides-Extra: lint
38
+ Requires-Dist: ruff; extra == "lint"
39
+ Requires-Dist: mypy; extra == "lint"
40
+ Requires-Dist: types-PyYAML; extra == "lint"
41
+ Provides-Extra: docs
42
+ Requires-Dist: mkdocs<2,>=1.6; extra == "docs"
43
+ Requires-Dist: mkdocs-material<10,>=9.7; extra == "docs"
44
+ Requires-Dist: mkdocs-click<1,>=0.9; extra == "docs"
45
+ Dynamic: license-file
46
+
47
+ # sci-etl-cli
48
+
49
+ `sci-etl` runs [sci-etl-core](https://github.com/xueromll/sci-etl-core)
50
+ extraction pipelines from a single YAML file, with no wiring code. It searches
51
+ arXiv, PubMed, Semantic Scholar, or OpenAlex, asks an LLM which papers are
52
+ relevant, extracts structured entities from their full text, and writes them
53
+ to a CSV, one row per entity tagged with the paper it came from. Runs resume where the last one stopped, and `forget` hands
54
+ chosen papers back for another pass.
55
+
56
+ **Documentation: https://xueromll.github.io/sci-etl-core/latest/cli/**
57
+
58
+ ```text
59
+ $ sci-etl init hot-jupiters
60
+ $ sci-etl validate hot-jupiters/config.yaml
61
+ $ sci-etl search hot-jupiters/config.yaml --limit 5
62
+ $ sci-etl run hot-jupiters/config.yaml --limit 20
63
+ $ sci-etl status hot-jupiters/config.yaml
64
+ ```
65
+
66
+ ## Installation
67
+
68
+ Python 3.11 or newer is required.
69
+
70
+ ```bash
71
+ pip install sci-etl-cli
72
+ ```
73
+
74
+ This installs the `sci-etl` command along with
75
+ `sci-etl-core[config,async,arxiv,html,llm,pdf,xml]`, `click`, and `rich`, so
76
+ every source works out of the box. `pipx install sci-etl-cli` keeps it in an environment of
77
+ its own.
78
+
79
+ ## Quick start
80
+
81
+ 1. `sci-etl init hot-jupiters` writes a working example project.
82
+ 2. Copy `.env.example` to `.env` beside the config and set `LLM_API_KEY`.
83
+ 3. `sci-etl validate hot-jupiters/config.yaml` checks the config, prompts, key,
84
+ and packages.
85
+ 4. `sci-etl search hot-jupiters/config.yaml` previews matching papers without
86
+ calling the LLM.
87
+ 5. `sci-etl run hot-jupiters/config.yaml` exports entities to `out/planets.csv`.
88
+ Run it again to continue where it stopped.
89
+
90
+ ## Documentation
91
+
92
+ | Topic | Where |
93
+ |-------|-------|
94
+ | A first project, step by step | [Quick start](https://xueromll.github.io/sci-etl-core/latest/cli/quick-start/) |
95
+ | Every command and option, stopping, resuming, token usage | [Commands](https://xueromll.github.io/sci-etl-core/latest/cli/commands/) |
96
+ | Every config key and its default | [Config file](https://xueromll.github.io/sci-etl-core/latest/cli/config-file/) |
97
+ | Domain rules written in Python | [Plug-ins](https://xueromll.github.io/sci-etl-core/latest/cli/plugins/) |
98
+ | What each exit code means | [Exit codes](https://xueromll.github.io/sci-etl-core/latest/cli/exit-codes/) |
99
+
100
+ ## Development
101
+
102
+ ```bash
103
+ git clone https://github.com/xueromll/sci-etl-cli.git
104
+ cd sci-etl-cli
105
+ python -m venv .venv
106
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
107
+ pip install -e ".[dev,lint]"
108
+ pytest --cov=sci_etl_cli --cov-report=term-missing
109
+ ruff check .
110
+ mypy
111
+ ```
112
+
113
+ To work against a local sci-etl-core checkout, install it first with
114
+ `pip install -e "../sci-etl-core[config,async,arxiv,html,llm,pdf,xml]"`.
115
+
116
+ The suite runs offline: every source is served by an `httpx.MockTransport` and
117
+ the LLM by a scripted client, so `run` is exercised end to end against real CSV
118
+ and state files. Coverage must stay at 100%.
119
+
120
+ ### Documentation
121
+
122
+ The pages under `docs/` are published as the CLI section of the
123
+ [sci-etl-core site](https://xueromll.github.io/sci-etl-core/), which reads this
124
+ repository's `docs/` folder and the `nav` in `mkdocs.yml`. Preview them on
125
+ their own with:
126
+
127
+ ```bash
128
+ pip install -e ".[docs]"
129
+ mkdocs serve
130
+ ```
131
+
132
+ Link to sci-etl-core pages with absolute URLs, since the pages are also built
133
+ on their own. `mkdocs build --strict` runs in CI. Publishing a release asks
134
+ sci-etl-core to rebuild the site when the `DOCS_DISPATCH_TOKEN` secret is set;
135
+ otherwise run sci-etl-core's Docs workflow by hand.
136
+
137
+ ## License
138
+
139
+ Released under the MIT License. See [LICENSE](LICENSE).
@@ -0,0 +1,93 @@
1
+ # sci-etl-cli
2
+
3
+ `sci-etl` runs [sci-etl-core](https://github.com/xueromll/sci-etl-core)
4
+ extraction pipelines from a single YAML file, with no wiring code. It searches
5
+ arXiv, PubMed, Semantic Scholar, or OpenAlex, asks an LLM which papers are
6
+ relevant, extracts structured entities from their full text, and writes them
7
+ to a CSV, one row per entity tagged with the paper it came from. Runs resume where the last one stopped, and `forget` hands
8
+ chosen papers back for another pass.
9
+
10
+ **Documentation: https://xueromll.github.io/sci-etl-core/latest/cli/**
11
+
12
+ ```text
13
+ $ sci-etl init hot-jupiters
14
+ $ sci-etl validate hot-jupiters/config.yaml
15
+ $ sci-etl search hot-jupiters/config.yaml --limit 5
16
+ $ sci-etl run hot-jupiters/config.yaml --limit 20
17
+ $ sci-etl status hot-jupiters/config.yaml
18
+ ```
19
+
20
+ ## Installation
21
+
22
+ Python 3.11 or newer is required.
23
+
24
+ ```bash
25
+ pip install sci-etl-cli
26
+ ```
27
+
28
+ This installs the `sci-etl` command along with
29
+ `sci-etl-core[config,async,arxiv,html,llm,pdf,xml]`, `click`, and `rich`, so
30
+ every source works out of the box. `pipx install sci-etl-cli` keeps it in an environment of
31
+ its own.
32
+
33
+ ## Quick start
34
+
35
+ 1. `sci-etl init hot-jupiters` writes a working example project.
36
+ 2. Copy `.env.example` to `.env` beside the config and set `LLM_API_KEY`.
37
+ 3. `sci-etl validate hot-jupiters/config.yaml` checks the config, prompts, key,
38
+ and packages.
39
+ 4. `sci-etl search hot-jupiters/config.yaml` previews matching papers without
40
+ calling the LLM.
41
+ 5. `sci-etl run hot-jupiters/config.yaml` exports entities to `out/planets.csv`.
42
+ Run it again to continue where it stopped.
43
+
44
+ ## Documentation
45
+
46
+ | Topic | Where |
47
+ |-------|-------|
48
+ | A first project, step by step | [Quick start](https://xueromll.github.io/sci-etl-core/latest/cli/quick-start/) |
49
+ | Every command and option, stopping, resuming, token usage | [Commands](https://xueromll.github.io/sci-etl-core/latest/cli/commands/) |
50
+ | Every config key and its default | [Config file](https://xueromll.github.io/sci-etl-core/latest/cli/config-file/) |
51
+ | Domain rules written in Python | [Plug-ins](https://xueromll.github.io/sci-etl-core/latest/cli/plugins/) |
52
+ | What each exit code means | [Exit codes](https://xueromll.github.io/sci-etl-core/latest/cli/exit-codes/) |
53
+
54
+ ## Development
55
+
56
+ ```bash
57
+ git clone https://github.com/xueromll/sci-etl-cli.git
58
+ cd sci-etl-cli
59
+ python -m venv .venv
60
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
61
+ pip install -e ".[dev,lint]"
62
+ pytest --cov=sci_etl_cli --cov-report=term-missing
63
+ ruff check .
64
+ mypy
65
+ ```
66
+
67
+ To work against a local sci-etl-core checkout, install it first with
68
+ `pip install -e "../sci-etl-core[config,async,arxiv,html,llm,pdf,xml]"`.
69
+
70
+ The suite runs offline: every source is served by an `httpx.MockTransport` and
71
+ the LLM by a scripted client, so `run` is exercised end to end against real CSV
72
+ and state files. Coverage must stay at 100%.
73
+
74
+ ### Documentation
75
+
76
+ The pages under `docs/` are published as the CLI section of the
77
+ [sci-etl-core site](https://xueromll.github.io/sci-etl-core/), which reads this
78
+ repository's `docs/` folder and the `nav` in `mkdocs.yml`. Preview them on
79
+ their own with:
80
+
81
+ ```bash
82
+ pip install -e ".[docs]"
83
+ mkdocs serve
84
+ ```
85
+
86
+ Link to sci-etl-core pages with absolute URLs, since the pages are also built
87
+ on their own. `mkdocs build --strict` runs in CI. Publishing a release asks
88
+ sci-etl-core to rebuild the site when the `DOCS_DISPATCH_TOKEN` secret is set;
89
+ otherwise run sci-etl-core's Docs workflow by hand.
90
+
91
+ ## License
92
+
93
+ Released under the MIT License. See [LICENSE](LICENSE).
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sci-etl-cli"
7
- version = "0.2.0"
7
+ version = "0.4.0"
8
8
  description = "Command-line tool for running sci-etl-core extraction pipelines from a YAML file."
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  license-files = ["LICENSE"]
12
- requires-python = ">=3.10"
12
+ requires-python = ">=3.11"
13
13
  keywords = ["etl", "cli", "arxiv", "llm", "text-mining", "scientific-literature"]
14
14
  classifiers = [
15
15
  "Development Status :: 3 - Alpha",
@@ -18,7 +18,6 @@ classifiers = [
18
18
  "Operating System :: OS Independent",
19
19
  "Programming Language :: Python :: 3",
20
20
  "Programming Language :: Python :: 3 :: Only",
21
- "Programming Language :: Python :: 3.10",
22
21
  "Programming Language :: Python :: 3.11",
23
22
  "Programming Language :: Python :: 3.12",
24
23
  "Programming Language :: Python :: 3.13",
@@ -29,7 +28,7 @@ classifiers = [
29
28
  ]
30
29
 
31
30
  dependencies = [
32
- "sci-etl-core[async,llm,pdf]>=0.2.0,<0.3",
31
+ "sci-etl-core[config,async,arxiv,html,llm,pdf,xml]>=0.7.0,<0.8",
33
32
  "click>=8.2",
34
33
  "rich>=13.7",
35
34
  "pydantic>=2",
@@ -39,12 +38,18 @@ dependencies = [
39
38
  [project.optional-dependencies]
40
39
  dev = ["pytest", "pytest-asyncio", "pytest-mock", "pytest-cov"]
41
40
  lint = ["ruff", "mypy", "types-PyYAML"]
41
+ docs = [
42
+ "mkdocs>=1.6,<2",
43
+ "mkdocs-material>=9.7,<10",
44
+ "mkdocs-click>=0.9,<1",
45
+ ]
42
46
 
43
47
  [project.scripts]
44
48
  sci-etl = "sci_etl_cli.app:main"
45
49
 
46
50
  [project.urls]
47
51
  Homepage = "https://github.com/xueromll/sci-etl-cli"
52
+ Documentation = "https://xueromll.github.io/sci-etl-core/latest/cli/"
48
53
  Repository = "https://github.com/xueromll/sci-etl-cli"
49
54
  Issues = "https://github.com/xueromll/sci-etl-cli/issues"
50
55
 
@@ -68,7 +73,7 @@ exclude_also = ["if TYPE_CHECKING:"]
68
73
 
69
74
  [tool.ruff]
70
75
  line-length = 120
71
- target-version = "py310"
76
+ target-version = "py311"
72
77
  src = ["src", "tests"]
73
78
 
74
79
  [tool.ruff.lint]
@@ -6,6 +6,7 @@ from typing import Any
6
6
  import click
7
7
  from sci_etl_core.exceptions import SciEtlError
8
8
 
9
+ from sci_etl_cli.commands.forget import forget_command
9
10
  from sci_etl_cli.commands.init import init_command
10
11
  from sci_etl_cli.commands.parse import parse_command
11
12
  from sci_etl_cli.commands.run import run_command
@@ -63,6 +64,7 @@ cli.add_command(validate_command)
63
64
  cli.add_command(search_command)
64
65
  cli.add_command(run_command)
65
66
  cli.add_command(status_command)
67
+ cli.add_command(forget_command)
66
68
  cli.add_command(parse_command)
67
69
 
68
70
 
@@ -0,0 +1,311 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Any, Callable
7
+
8
+ from sci_etl_core.exceptions import ConfigurationError
9
+
10
+ from sci_etl_cli.errors import CliFailure
11
+
12
+ if TYPE_CHECKING:
13
+ import httpx
14
+ from sci_etl_core import (
15
+ AsyncCsvExporter,
16
+ AsyncETLPipeline,
17
+ AsyncOpenAICompatibleClient,
18
+ AsyncSqliteLLMResponseCache,
19
+ AsyncSqliteStateManager,
20
+ AsyncStateManager,
21
+ PipelineMetadata,
22
+ )
23
+ from sci_etl_core.extractors.async_base import AsyncExtractor
24
+ from sci_etl_core.processors import RecordValidator
25
+ from sci_etl_core.signals import ShutdownSignal
26
+
27
+ from sci_etl_cli.settings import CliConfig
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class ProjectParts:
32
+ """What a run loads from the project folder before touching the network."""
33
+
34
+ relevance_prompt: str
35
+ extraction_prompt: str
36
+ validators: tuple[RecordValidator, ...]
37
+
38
+
39
+ def load_project_parts(config: CliConfig) -> ProjectParts:
40
+ """Read both prompts and build the plug-ins.
41
+
42
+ Raises:
43
+ ConfigurationError: A prompt file or a plug-in cannot be loaded.
44
+ """
45
+ return ProjectParts(
46
+ relevance_prompt=read_prompt(config.prompts.relevance),
47
+ extraction_prompt=read_prompt(config.prompts.extraction),
48
+ validators=tuple(build_validators(config)),
49
+ )
50
+
51
+
52
+ def read_prompt(path: Path) -> str:
53
+ """Return a prompt file's text.
54
+
55
+ Raises:
56
+ ConfigurationError: The file is missing, unreadable, not UTF-8, or blank.
57
+ """
58
+ try:
59
+ text = path.read_text(encoding="utf-8")
60
+ except FileNotFoundError as exc:
61
+ raise ConfigurationError(f"Prompt file not found: {path}") from exc
62
+ except (OSError, UnicodeDecodeError) as exc:
63
+ raise ConfigurationError(f"Prompt file could not be read: {path}: {exc}") from exc
64
+ if not text.strip():
65
+ raise ConfigurationError(f"Prompt file is empty: {path}")
66
+ return text
67
+
68
+
69
+ def build_validators(config: CliConfig) -> list[RecordValidator]:
70
+ """Return the ``export.validators`` plug-ins in the order they are listed."""
71
+ from sci_etl_core.processors import RecordValidator
72
+
73
+ from sci_etl_cli.plugins import load_plugin, wrong_type
74
+
75
+ validators: list[RecordValidator] = []
76
+ for reference in config.export.validators:
77
+ plugin = load_plugin(reference, config.project_root)
78
+ if not isinstance(plugin, RecordValidator):
79
+ raise wrong_type(reference, plugin, "RecordValidator")
80
+ validators.append(plugin)
81
+ return validators
82
+
83
+
84
+ def build_http_client(config: CliConfig) -> httpx.AsyncClient:
85
+ return config.http.build_client()
86
+
87
+
88
+ def build_llm_client(config: CliConfig) -> AsyncOpenAICompatibleClient:
89
+ from sci_etl_core import AsyncOpenAICompatibleClient
90
+
91
+ return AsyncOpenAICompatibleClient.from_config(config.llm)
92
+
93
+
94
+ def build_llm_cache(config: CliConfig) -> AsyncSqliteLLMResponseCache | None:
95
+ """Return the SQLite response cache at ``llm.cache``, or ``None`` when caching is off."""
96
+ if config.llm.cache is None:
97
+ return None
98
+ from sci_etl_core import AsyncSqliteLLMResponseCache
99
+
100
+ return AsyncSqliteLLMResponseCache(config.llm.cache)
101
+
102
+
103
+ def build_extractor(config: CliConfig, http_client: httpx.AsyncClient) -> AsyncExtractor:
104
+ """Build the extractor ``source.kind`` names, with the parsers its full text needs.
105
+
106
+ Every source takes ``max_retries`` and ``backoff_factor`` from ``http`` and
107
+ its rate limiter from ``full_text``. arXiv also waits ``pipeline.search_delay``
108
+ before each listing request and parses LaTeX e-prints and PDFs; PubMed
109
+ parses PubMed Central's JATS XML; Semantic Scholar and OpenAlex parse the
110
+ open-access PDF, and every source falls back to the abstract.
111
+ """
112
+ source = config.source
113
+ if source.kind == "arxiv":
114
+ from sci_etl_core import AsyncArxivExtractor
115
+ from sci_etl_core.parsers import LatexTarballParser, PdfPlumberParser
116
+
117
+ return AsyncArxivExtractor.from_config(
118
+ config.http,
119
+ config.pipeline,
120
+ client=http_client,
121
+ pdf_parser=PdfPlumberParser(),
122
+ latex_parser=LatexTarballParser(),
123
+ full_text=config.full_text,
124
+ )
125
+ settings: dict[str, Any] = {
126
+ "max_retries": config.http.max_retries,
127
+ "backoff_factor": config.http.backoff_factor,
128
+ "rate_limiter": config.full_text.build_limiter(),
129
+ "api_key": source.api_key.get_secret_value() or None,
130
+ }
131
+ if source.kind == "pubmed":
132
+ from sci_etl_core import AsyncPubMedExtractor
133
+ from sci_etl_core.parsers import JatsXmlParser
134
+
135
+ return AsyncPubMedExtractor(
136
+ http_client,
137
+ tool=source.tool,
138
+ email=source.email,
139
+ sort=source.sort,
140
+ full_text_parser=JatsXmlParser(),
141
+ **settings,
142
+ )
143
+ from sci_etl_core.parsers import PdfPlumberParser
144
+
145
+ if source.kind == "semantic_scholar":
146
+ from sci_etl_core import AsyncSemanticScholarExtractor
147
+
148
+ return AsyncSemanticScholarExtractor(
149
+ http_client, PdfPlumberParser(), year=source.year, fields_of_study=source.fields_of_study, **settings
150
+ )
151
+ from sci_etl_core import AsyncOpenAlexExtractor
152
+
153
+ return AsyncOpenAlexExtractor(
154
+ http_client, PdfPlumberParser(), filter=source.filter, sort=source.sort, mailto=source.mailto, **settings
155
+ )
156
+
157
+
158
+ def build_state_manager(config: CliConfig) -> AsyncStateManager:
159
+ if config.state.backend == "sqlite":
160
+ from sci_etl_core import AsyncSqliteStateManager
161
+
162
+ return AsyncSqliteStateManager(config.state.database)
163
+ from sci_etl_core import AsyncFileStateManager
164
+
165
+ return AsyncFileStateManager(config.state.processed_ids, config.state.metadata)
166
+
167
+
168
+ def build_exporter(config: CliConfig) -> AsyncCsvExporter:
169
+ """Write one CSV row per entity: ``record_id``, the key and value columns, and ``extra``."""
170
+ from sci_etl_core import AsyncCsvExporter
171
+
172
+ export = config.export
173
+ return AsyncCsvExporter(
174
+ export.destination,
175
+ [export.key_column, *export.value_columns],
176
+ escape_formulas=export.escape_formulas,
177
+ )
178
+
179
+
180
+ def build_pipeline(
181
+ config: CliConfig,
182
+ log: Callable[[str], None],
183
+ http_client: httpx.AsyncClient,
184
+ llm_client: AsyncOpenAICompatibleClient,
185
+ llm_cache: AsyncSqliteLLMResponseCache | None,
186
+ state_manager: AsyncStateManager,
187
+ parts: ProjectParts,
188
+ shutdown: ShutdownSignal,
189
+ ) -> AsyncETLPipeline:
190
+ """Assemble the pipeline, answering LLM requests from ``llm_cache`` when there is one.
191
+
192
+ ``log`` receives one line per listing page. The validators run inside the
193
+ entity extractor, which logs each rejected entity with its reasons.
194
+ ``shutdown`` stops a run cleanly: records in flight finish and are marked
195
+ processed, and ``run()`` raises ``PipelineInterrupted``.
196
+ """
197
+ from sci_etl_core import (
198
+ AsyncETLPipeline,
199
+ AsyncLLMEntityExtractor,
200
+ AsyncLLMRelevanceFilter,
201
+ AsyncSqliteStateManager,
202
+ CachingLLMClient,
203
+ )
204
+
205
+ from sci_etl_cli.reporting import listing_reporter
206
+
207
+ answering = llm_client if llm_cache is None else CachingLLMClient(llm_client, llm_cache, model=config.llm.model)
208
+ entity_extractor: Any = AsyncLLMEntityExtractor(
209
+ answering,
210
+ parts.extraction_prompt,
211
+ result_key=config.prompts.result_key,
212
+ timeout=config.llm.timeout,
213
+ validator=build_validator(parts.validators),
214
+ label_field=config.export.key_column,
215
+ )
216
+ return AsyncETLPipeline.from_config(
217
+ config.pipeline,
218
+ extractor=listing_reporter(build_extractor(config, http_client), log),
219
+ relevance_filter=AsyncLLMRelevanceFilter(llm_client=answering, system_prompt=parts.relevance_prompt),
220
+ entity_extractor=entity_extractor,
221
+ exporter=build_exporter(config),
222
+ state_manager=state_manager,
223
+ closeables=[
224
+ http_client,
225
+ llm_client,
226
+ *([] if llm_cache is None else [llm_cache]),
227
+ *_closeable_state(state_manager, AsyncSqliteStateManager),
228
+ ],
229
+ shutdown=shutdown,
230
+ )
231
+
232
+
233
+ def build_validator(validators: tuple[RecordValidator, ...]) -> RecordValidator | None:
234
+ """Combine the ``export.validators`` plug-ins into one validator, or ``None`` when there are none."""
235
+ if not validators:
236
+ return None
237
+ from sci_etl_core.processors import CompositeValidator
238
+
239
+ return CompositeValidator(list(validators))
240
+
241
+
242
+ def _closeable_state(
243
+ state_manager: AsyncStateManager, closeable: type[AsyncSqliteStateManager]
244
+ ) -> list[AsyncSqliteStateManager]:
245
+ return [state_manager] if isinstance(state_manager, closeable) else []
246
+
247
+
248
+ async def read_state(config: CliConfig) -> tuple[set[str], PipelineMetadata]:
249
+ """Load processed ids and metadata without creating state that does not exist yet."""
250
+ from sci_etl_core.models import PipelineMetadata
251
+
252
+ if _state_is_absent(config):
253
+ return set(), PipelineMetadata()
254
+ manager = build_state_manager(config)
255
+ try:
256
+ return await manager.load_processed_ids(), await manager.load_metadata()
257
+ finally:
258
+ await _release_state(manager)
259
+
260
+
261
+ @dataclass(frozen=True, slots=True)
262
+ class ForgetReport:
263
+ """Which requested ids the state tracked and so forgot, and which it did not, each in request order."""
264
+
265
+ forgotten: tuple[str, ...]
266
+ untracked: tuple[str, ...]
267
+
268
+ @property
269
+ def requested(self) -> int:
270
+ return len(self.forgotten) + len(self.untracked)
271
+
272
+
273
+ async def forget_records(config: CliConfig, record_ids: Sequence[str]) -> ForgetReport:
274
+ """Clear the processed mark and failed attempts of every tracked id in ``record_ids``.
275
+
276
+ Calls the state manager's ``forget``, which the ``Forgetting`` protocol
277
+ describes, so the next run that lists the records again processes them
278
+ again. An id the state neither processed nor failed is reported as
279
+ untracked, a repeated id counts once, and state that does not exist yet
280
+ is not created.
281
+
282
+ Raises:
283
+ CliFailure: The configured state manager cannot forget records.
284
+ """
285
+ from sci_etl_core import Forgetting
286
+
287
+ requested = tuple(dict.fromkeys(record_ids))
288
+ if _state_is_absent(config):
289
+ return ForgetReport(forgotten=(), untracked=requested)
290
+ manager = build_state_manager(config)
291
+ try:
292
+ if not isinstance(manager, Forgetting):
293
+ raise CliFailure(f"The {config.state.backend} state backend cannot forget records")
294
+ tracked = await manager.load_processed_ids() | (await manager.failure_counts()).keys()
295
+ forgotten = tuple(record_id for record_id in requested if record_id in tracked)
296
+ await manager.forget(forgotten)
297
+ finally:
298
+ await _release_state(manager)
299
+ return ForgetReport(
300
+ forgotten=forgotten, untracked=tuple(record_id for record_id in requested if record_id not in tracked)
301
+ )
302
+
303
+
304
+ def _state_is_absent(config: CliConfig) -> bool:
305
+ return config.state.backend == "sqlite" and not config.state.database.exists()
306
+
307
+
308
+ async def _release_state(manager: AsyncStateManager) -> None:
309
+ aclose = getattr(manager, "aclose", None)
310
+ if aclose is not None:
311
+ await aclose()
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+
6
+ import click
7
+ from rich.markup import escape
8
+ from rich.table import Table
9
+
10
+ from sci_etl_cli import assembly
11
+ from sci_etl_cli.options import config_argument
12
+ from sci_etl_cli.output import print_json, stdout_console
13
+ from sci_etl_cli.settings import CliConfig, load_cli_config
14
+
15
+
16
+ @click.command("forget", short_help="Hand processed or failed records back to the next rescan.")
17
+ @config_argument
18
+ @click.argument("record_ids", metavar="RECORD_ID...", nargs=-1, required=True)
19
+ @click.option("--json", "as_json", is_flag=True, help="Print JSON instead of a table.")
20
+ def forget_command(config_path: Path, record_ids: tuple[str, ...], as_json: bool) -> None:
21
+ """Forget RECORD_IDs in CONFIG's state so the next rescan processes them again.
22
+
23
+ Clears each record's processed mark and failed attempts, for example after
24
+ a prompt or model change. The saved offset is kept, so follow it with
25
+ `run --rescan`, which starts at offset 0 and skips every other processed
26
+ record by id. A record's rows in the export CSV stay until it is processed
27
+ again, which replaces them. Use it between runs: a run reads the processed
28
+ ids when it starts. An id the state does not track is reported, not an
29
+ error.
30
+ """
31
+ config = load_cli_config(config_path)
32
+ report = asyncio.run(assembly.forget_records(config, record_ids))
33
+ if as_json:
34
+ print_json({"forgotten": list(report.forgotten), "untracked": list(report.untracked)})
35
+ return
36
+
37
+ table = Table(show_header=False, box=None, pad_edge=False)
38
+ table.add_column(no_wrap=True)
39
+ table.add_column(no_wrap=True)
40
+ for record_id in report.forgotten:
41
+ table.add_row(escape(record_id), "[green]forgotten[/]")
42
+ for record_id in report.untracked:
43
+ table.add_row(escape(record_id), "[yellow]not tracked[/]")
44
+ console = stdout_console()
45
+ console.print(table)
46
+ console.print(f"Forgot {len(report.forgotten)} of {describe_count(report.requested)}.")
47
+ console.print(escape(next_step(config, config_path)), soft_wrap=True)
48
+
49
+
50
+ def describe_count(count: int) -> str:
51
+ return f"{count} record{'' if count == 1 else 's'}"
52
+
53
+
54
+ def next_step(config: CliConfig, config_path: Path) -> str:
55
+ """Name the run that processes the forgotten records again, which depends on ``pipeline.newest_first``."""
56
+ command = f"sci-etl run {config_path} --rescan"
57
+ if config.pipeline.newest_first:
58
+ return (
59
+ f"Next: pipeline.newest_first is set and a rescan cannot be combined with it, "
60
+ f"so turn it off for one run and use: {command}"
61
+ )
62
+ return f"Next: {command}"