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.
- sci_etl_cli-0.4.0/PKG-INFO +139 -0
- sci_etl_cli-0.4.0/README.md +93 -0
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/pyproject.toml +10 -5
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/app.py +2 -0
- sci_etl_cli-0.4.0/src/sci_etl_cli/assembly.py +311 -0
- sci_etl_cli-0.4.0/src/sci_etl_cli/commands/forget.py +62 -0
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/commands/init.py +39 -10
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/commands/run.py +44 -26
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/commands/search.py +16 -10
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/commands/status.py +6 -1
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/commands/validate.py +48 -32
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/errors.py +3 -0
- sci_etl_cli-0.4.0/src/sci_etl_cli/options.py +62 -0
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/output.py +17 -11
- sci_etl_cli-0.4.0/src/sci_etl_cli/plugins.py +84 -0
- sci_etl_cli-0.4.0/src/sci_etl_cli/reporting.py +57 -0
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/settings.py +112 -17
- sci_etl_cli-0.4.0/src/sci_etl_cli/templates/config-openalex.yaml +48 -0
- sci_etl_cli-0.4.0/src/sci_etl_cli/templates/config-pubmed.yaml +48 -0
- sci_etl_cli-0.4.0/src/sci_etl_cli/templates/config-semantic_scholar.yaml +48 -0
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/templates/config.yaml +5 -4
- sci_etl_cli-0.4.0/src/sci_etl_cli/templates/env.example +3 -0
- sci_etl_cli-0.4.0/src/sci_etl_cli/templates/extraction-trials.txt +12 -0
- sci_etl_cli-0.4.0/src/sci_etl_cli/templates/relevance-trials.txt +5 -0
- sci_etl_cli-0.4.0/src/sci_etl_cli.egg-info/PKG-INFO +139 -0
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli.egg-info/SOURCES.txt +8 -3
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli.egg-info/requires.txt +6 -1
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/tests/test_app.py +1 -1
- sci_etl_cli-0.4.0/tests/test_assembly.py +184 -0
- sci_etl_cli-0.4.0/tests/test_forget.py +151 -0
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/tests/test_output.py +19 -1
- sci_etl_cli-0.4.0/tests/test_plugins.py +81 -0
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/tests/test_run.py +81 -17
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/tests/test_settings.py +60 -7
- sci_etl_cli-0.4.0/tests/test_sources.py +316 -0
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/tests/test_validate.py +3 -3
- sci_etl_cli-0.2.0/PKG-INFO +0 -296
- sci_etl_cli-0.2.0/README.md +0 -254
- sci_etl_cli-0.2.0/src/sci_etl_cli/assembly.py +0 -205
- sci_etl_cli-0.2.0/src/sci_etl_cli/entity_filter.py +0 -36
- sci_etl_cli-0.2.0/src/sci_etl_cli/options.py +0 -32
- sci_etl_cli-0.2.0/src/sci_etl_cli/plugins.py +0 -53
- sci_etl_cli-0.2.0/src/sci_etl_cli/reporting.py +0 -30
- sci_etl_cli-0.2.0/src/sci_etl_cli/shutdown.py +0 -36
- sci_etl_cli-0.2.0/src/sci_etl_cli/templates/env.example +0 -1
- sci_etl_cli-0.2.0/src/sci_etl_cli.egg-info/PKG-INFO +0 -296
- sci_etl_cli-0.2.0/tests/test_assembly.py +0 -126
- sci_etl_cli-0.2.0/tests/test_plugins.py +0 -38
- sci_etl_cli-0.2.0/tests/test_shutdown.py +0 -67
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/LICENSE +0 -0
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/setup.cfg +0 -0
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/__init__.py +0 -0
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/__main__.py +0 -0
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/commands/__init__.py +0 -0
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/commands/parse.py +0 -0
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/py.typed +0 -0
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/templates/extraction.txt +0 -0
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/templates/relevance.txt +0 -0
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli/usage.py +0 -0
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli.egg-info/dependency_links.txt +0 -0
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli.egg-info/entry_points.txt +0 -0
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/src/sci_etl_cli.egg-info/top_level.txt +0 -0
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/tests/test_errors.py +0 -0
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/tests/test_init.py +0 -0
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/tests/test_parse.py +0 -0
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/tests/test_search.py +0 -0
- {sci_etl_cli-0.2.0 → sci_etl_cli-0.4.0}/tests/test_status.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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 = "
|
|
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}"
|