sci-etl-cli 0.1.0__py3-none-any.whl
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/__init__.py +1 -0
- sci_etl_cli/__main__.py +3 -0
- sci_etl_cli/app.py +70 -0
- sci_etl_cli/assembly.py +143 -0
- sci_etl_cli/commands/__init__.py +0 -0
- sci_etl_cli/commands/init.py +45 -0
- sci_etl_cli/commands/parse.py +71 -0
- sci_etl_cli/commands/run.py +135 -0
- sci_etl_cli/commands/search.py +80 -0
- sci_etl_cli/commands/status.py +76 -0
- sci_etl_cli/commands/validate.py +142 -0
- sci_etl_cli/errors.py +61 -0
- sci_etl_cli/options.py +35 -0
- sci_etl_cli/output.py +57 -0
- sci_etl_cli/py.typed +0 -0
- sci_etl_cli/reporting.py +30 -0
- sci_etl_cli/settings.py +164 -0
- sci_etl_cli/shutdown.py +36 -0
- sci_etl_cli/templates/config.yaml +40 -0
- sci_etl_cli/templates/env.example +1 -0
- sci_etl_cli/templates/extraction.txt +12 -0
- sci_etl_cli/templates/relevance.txt +5 -0
- sci_etl_cli-0.1.0.dist-info/METADATA +227 -0
- sci_etl_cli-0.1.0.dist-info/RECORD +28 -0
- sci_etl_cli-0.1.0.dist-info/WHEEL +5 -0
- sci_etl_cli-0.1.0.dist-info/entry_points.txt +2 -0
- sci_etl_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- sci_etl_cli-0.1.0.dist-info/top_level.txt +1 -0
sci_etl_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command-line tool for running sci-etl-core extraction pipelines from a YAML file."""
|
sci_etl_cli/__main__.py
ADDED
sci_etl_cli/app.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from sci_etl_core.exceptions import SciEtlError
|
|
8
|
+
|
|
9
|
+
from sci_etl_cli.commands.init import init_command
|
|
10
|
+
from sci_etl_cli.commands.parse import parse_command
|
|
11
|
+
from sci_etl_cli.commands.run import run_command
|
|
12
|
+
from sci_etl_cli.commands.search import search_command
|
|
13
|
+
from sci_etl_cli.commands.status import status_command
|
|
14
|
+
from sci_etl_cli.commands.validate import validate_command
|
|
15
|
+
from sci_etl_cli.errors import to_cli_failure
|
|
16
|
+
|
|
17
|
+
_EXIT_CODES = """\b
|
|
18
|
+
Exit codes:
|
|
19
|
+
0 success
|
|
20
|
+
1 run aborted, or a request or parse failed
|
|
21
|
+
2 invalid command-line usage
|
|
22
|
+
3 configuration problem
|
|
23
|
+
4 missing Python package
|
|
24
|
+
130 interrupted after saving state"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SciEtlGroup(click.Group):
|
|
28
|
+
"""Command group that reports library errors as one line and an exit code."""
|
|
29
|
+
|
|
30
|
+
def invoke(self, ctx: click.Context) -> Any:
|
|
31
|
+
try:
|
|
32
|
+
return super().invoke(ctx)
|
|
33
|
+
except (SciEtlError, ModuleNotFoundError) as error:
|
|
34
|
+
raise to_cli_failure(error) from error
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _print_version(ctx: click.Context, _param: click.Parameter, value: bool) -> None:
|
|
38
|
+
if not value or ctx.resilient_parsing:
|
|
39
|
+
return
|
|
40
|
+
click.echo(f"sci-etl {version('sci-etl-cli')} (sci-etl-core {version('sci-etl-core')})")
|
|
41
|
+
ctx.exit()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@click.group(
|
|
45
|
+
cls=SciEtlGroup,
|
|
46
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
47
|
+
epilog=_EXIT_CODES,
|
|
48
|
+
)
|
|
49
|
+
@click.option(
|
|
50
|
+
"--version",
|
|
51
|
+
is_flag=True,
|
|
52
|
+
expose_value=False,
|
|
53
|
+
is_eager=True,
|
|
54
|
+
callback=_print_version,
|
|
55
|
+
help="Show the CLI and library versions and exit.",
|
|
56
|
+
)
|
|
57
|
+
def cli() -> None:
|
|
58
|
+
"""Run sci-etl-core extraction pipelines from a YAML config file."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
cli.add_command(init_command)
|
|
62
|
+
cli.add_command(validate_command)
|
|
63
|
+
cli.add_command(search_command)
|
|
64
|
+
cli.add_command(run_command)
|
|
65
|
+
cli.add_command(status_command)
|
|
66
|
+
cli.add_command(parse_command)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main() -> None:
|
|
70
|
+
cli(prog_name="sci-etl")
|
sci_etl_cli/assembly.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING, Callable
|
|
5
|
+
|
|
6
|
+
from sci_etl_core.exceptions import ConfigurationError
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from sci_etl_core import (
|
|
13
|
+
AsyncETLPipeline,
|
|
14
|
+
AsyncExporter,
|
|
15
|
+
AsyncExtractor,
|
|
16
|
+
AsyncLLMClient,
|
|
17
|
+
AsyncOpenAICompatibleClient,
|
|
18
|
+
AsyncStateManager,
|
|
19
|
+
PipelineMetadata,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from sci_etl_cli.settings import CliConfig
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_prompt(path: Path) -> str:
|
|
26
|
+
"""Return a prompt file's text.
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
ConfigurationError: The file is missing, unreadable, not UTF-8, or blank.
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
text = path.read_text(encoding="utf-8")
|
|
33
|
+
except FileNotFoundError as exc:
|
|
34
|
+
raise ConfigurationError(f"Prompt file not found: {path}") from exc
|
|
35
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
36
|
+
raise ConfigurationError(f"Prompt file could not be read: {path}: {exc}") from exc
|
|
37
|
+
if not text.strip():
|
|
38
|
+
raise ConfigurationError(f"Prompt file is empty: {path}")
|
|
39
|
+
return text
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_http_client(config: CliConfig) -> httpx.AsyncClient:
|
|
43
|
+
from sci_etl_core.http_async import build_async_client
|
|
44
|
+
|
|
45
|
+
return build_async_client(timeout=config.http.timeout, user_agent=config.http.user_agent)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_llm_client(config: CliConfig) -> AsyncOpenAICompatibleClient:
|
|
49
|
+
from sci_etl_core import AsyncOpenAICompatibleClient
|
|
50
|
+
|
|
51
|
+
return AsyncOpenAICompatibleClient(
|
|
52
|
+
api_key=config.llm.api_key,
|
|
53
|
+
base_url=config.llm.base_url,
|
|
54
|
+
model=config.llm.model,
|
|
55
|
+
default_timeout=config.llm.timeout,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_extractor(
|
|
60
|
+
config: CliConfig, http_client: httpx.AsyncClient, log: Callable[[str], None]
|
|
61
|
+
) -> AsyncExtractor:
|
|
62
|
+
from sci_etl_core import AsyncArxivExtractor
|
|
63
|
+
from sci_etl_core.parsers import LatexTarballParser, PdfPlumberParser
|
|
64
|
+
|
|
65
|
+
return AsyncArxivExtractor(
|
|
66
|
+
client=http_client,
|
|
67
|
+
pdf_parser=PdfPlumberParser(),
|
|
68
|
+
latex_parser=LatexTarballParser(),
|
|
69
|
+
max_retries=config.http.max_retries,
|
|
70
|
+
backoff_factor=config.http.backoff_factor,
|
|
71
|
+
sleep_before_search=config.pipeline.search_delay,
|
|
72
|
+
logger=log,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_state_manager(config: CliConfig) -> AsyncStateManager:
|
|
77
|
+
if config.state.backend == "sqlite":
|
|
78
|
+
from sci_etl_core import AsyncSqliteStateManager
|
|
79
|
+
|
|
80
|
+
return AsyncSqliteStateManager(config.state.database)
|
|
81
|
+
from sci_etl_core import AsyncFileStateManager
|
|
82
|
+
|
|
83
|
+
return AsyncFileStateManager(config.state.processed_ids, config.state.metadata)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def build_exporter(config: CliConfig) -> AsyncExporter:
|
|
87
|
+
from sci_etl_core import AsyncCsvUpsertExporter
|
|
88
|
+
from sci_etl_core.processors import DefaultKeyNormalizer
|
|
89
|
+
|
|
90
|
+
export = config.export
|
|
91
|
+
return AsyncCsvUpsertExporter(
|
|
92
|
+
key_column=export.key_column,
|
|
93
|
+
value_columns=list(export.value_columns),
|
|
94
|
+
normalizer=DefaultKeyNormalizer(),
|
|
95
|
+
numeric_clip=dict(export.numeric_clip),
|
|
96
|
+
escape_formulas=export.escape_formulas,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def build_pipeline(
|
|
101
|
+
config: CliConfig,
|
|
102
|
+
log: logging.Logger,
|
|
103
|
+
http_client: httpx.AsyncClient,
|
|
104
|
+
llm_client: AsyncLLMClient,
|
|
105
|
+
state_manager: AsyncStateManager,
|
|
106
|
+
relevance_prompt: str,
|
|
107
|
+
extraction_prompt: str,
|
|
108
|
+
) -> AsyncETLPipeline:
|
|
109
|
+
from sci_etl_core import AsyncETLPipeline, AsyncLLMEntityExtractor, AsyncLLMRelevanceFilter
|
|
110
|
+
|
|
111
|
+
from sci_etl_cli.reporting import ListingReporter
|
|
112
|
+
|
|
113
|
+
return AsyncETLPipeline(
|
|
114
|
+
extractor=ListingReporter(build_extractor(config, http_client, log.info), log.info),
|
|
115
|
+
relevance_filter=AsyncLLMRelevanceFilter(llm_client=llm_client, system_prompt=relevance_prompt),
|
|
116
|
+
entity_extractor=AsyncLLMEntityExtractor(
|
|
117
|
+
llm_client=llm_client,
|
|
118
|
+
system_prompt=extraction_prompt,
|
|
119
|
+
result_key=config.prompts.result_key,
|
|
120
|
+
timeout=config.llm.timeout,
|
|
121
|
+
),
|
|
122
|
+
exporter=build_exporter(config),
|
|
123
|
+
state_manager=state_manager,
|
|
124
|
+
destination=str(config.export.destination),
|
|
125
|
+
max_concurrency=config.pipeline.max_workers,
|
|
126
|
+
logger=log.warning,
|
|
127
|
+
closeables=[http_client, llm_client, state_manager],
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def read_state(config: CliConfig) -> tuple[set[str], PipelineMetadata]:
|
|
132
|
+
"""Load processed ids and metadata without creating state that does not exist yet."""
|
|
133
|
+
from sci_etl_core.models import PipelineMetadata
|
|
134
|
+
|
|
135
|
+
if config.state.backend == "sqlite" and not config.state.database.exists():
|
|
136
|
+
return set(), PipelineMetadata()
|
|
137
|
+
manager = build_state_manager(config)
|
|
138
|
+
try:
|
|
139
|
+
return await manager.load_processed_ids(), await manager.load_metadata()
|
|
140
|
+
finally:
|
|
141
|
+
aclose = getattr(manager, "aclose", None)
|
|
142
|
+
if aclose is not None:
|
|
143
|
+
await aclose()
|
|
File without changes
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from importlib import resources
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from rich.markup import escape
|
|
8
|
+
|
|
9
|
+
from sci_etl_cli.output import stdout_console
|
|
10
|
+
|
|
11
|
+
_TEMPLATES: tuple[tuple[str, str], ...] = (
|
|
12
|
+
("config.yaml", "config.yaml"),
|
|
13
|
+
("relevance.txt", "prompts/relevance.txt"),
|
|
14
|
+
("extraction.txt", "prompts/extraction.txt"),
|
|
15
|
+
("env.example", ".env.example"),
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.command("init", short_help="Create a starter project.")
|
|
20
|
+
@click.argument("directory", required=False, default=".", type=click.Path(file_okay=False, path_type=Path))
|
|
21
|
+
@click.option("--force", is_flag=True, help="Overwrite files that already exist.")
|
|
22
|
+
def init_command(directory: Path, force: bool) -> None:
|
|
23
|
+
"""Create a starter project in DIRECTORY (default: the current folder).
|
|
24
|
+
|
|
25
|
+
Writes config.yaml, prompts/relevance.txt, prompts/extraction.txt and
|
|
26
|
+
.env.example. The example collects hot Jupiter measurements from arXiv;
|
|
27
|
+
change the query, prompts and export columns for your own subject.
|
|
28
|
+
"""
|
|
29
|
+
targets = [(template, directory / relative) for template, relative in _TEMPLATES]
|
|
30
|
+
existing = [path for _template, path in targets if path.exists()]
|
|
31
|
+
if existing and not force:
|
|
32
|
+
names = ", ".join(str(path) for path in existing)
|
|
33
|
+
raise click.UsageError(f"Refusing to overwrite {names}; pass --force to replace them.")
|
|
34
|
+
|
|
35
|
+
templates = resources.files("sci_etl_cli").joinpath("templates")
|
|
36
|
+
console = stdout_console()
|
|
37
|
+
for template, path in targets:
|
|
38
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
path.write_text(templates.joinpath(template).read_text(encoding="utf-8"), encoding="utf-8")
|
|
40
|
+
console.print(f"[green]wrote[/] {escape(str(path))}", soft_wrap=True)
|
|
41
|
+
console.print(
|
|
42
|
+
"\nNext: copy .env.example to .env, set LLM_API_KEY, then run "
|
|
43
|
+
f"[bold]sci-etl validate {escape(str(directory / 'config.yaml'))}[/]",
|
|
44
|
+
soft_wrap=True,
|
|
45
|
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from sci_etl_core.parsers.reference_trimmer import trim_after_references
|
|
8
|
+
|
|
9
|
+
from sci_etl_cli.errors import CliFailure, ExitCode
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from sci_etl_core.parsers.base import Parser
|
|
13
|
+
|
|
14
|
+
_FORMATS = ("auto", "pdf", "latex", "html")
|
|
15
|
+
_PDF_MAGIC = b"%PDF"
|
|
16
|
+
_GZIP_MAGIC = b"\x1f\x8b"
|
|
17
|
+
_TAR_MAGIC = b"ustar"
|
|
18
|
+
_TAR_MAGIC_OFFSET = 257
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@click.command("parse", short_help="Print the text extracted from a PDF, e-print or HTML file.")
|
|
22
|
+
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
23
|
+
@click.option(
|
|
24
|
+
"--format",
|
|
25
|
+
"file_format",
|
|
26
|
+
type=click.Choice(_FORMATS),
|
|
27
|
+
default="auto",
|
|
28
|
+
show_default=True,
|
|
29
|
+
help="How to read FILE. auto recognizes PDFs, arXiv e-prints and HTML.",
|
|
30
|
+
)
|
|
31
|
+
@click.option("--trim-references", is_flag=True, help="Cut the text at the references or acknowledgments.")
|
|
32
|
+
def parse_command(file: Path, file_format: str, trim_references: bool) -> None:
|
|
33
|
+
"""Print the text a bundled parser extracts from FILE.
|
|
34
|
+
|
|
35
|
+
Accepts a PDF, an arXiv e-print (a tarball or gzipped TeX file) or an HTML
|
|
36
|
+
page. Use it to check full-text quality without running a pipeline.
|
|
37
|
+
"""
|
|
38
|
+
content = file.read_bytes()
|
|
39
|
+
parser = build_parser(detect_format(content) if file_format == "auto" else file_format)
|
|
40
|
+
text = parser.extract_text(content)
|
|
41
|
+
if trim_references:
|
|
42
|
+
text = trim_after_references(text) or text
|
|
43
|
+
if not text.strip():
|
|
44
|
+
raise CliFailure(f"No text could be extracted from {file}", ExitCode.FAILURE)
|
|
45
|
+
click.echo(text)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def detect_format(content: bytes) -> str:
|
|
49
|
+
stripped = content.lstrip()
|
|
50
|
+
if stripped.startswith(_PDF_MAGIC):
|
|
51
|
+
return "pdf"
|
|
52
|
+
tar_magic = content[_TAR_MAGIC_OFFSET : _TAR_MAGIC_OFFSET + len(_TAR_MAGIC)]
|
|
53
|
+
if content.startswith(_GZIP_MAGIC) or tar_magic == _TAR_MAGIC:
|
|
54
|
+
return "latex"
|
|
55
|
+
if stripped.startswith(b"<"):
|
|
56
|
+
return "html"
|
|
57
|
+
raise click.UsageError("Could not tell the file's format; pass --format pdf, latex or html.")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_parser(file_format: str) -> Parser:
|
|
61
|
+
if file_format == "pdf":
|
|
62
|
+
from sci_etl_core.parsers.pdf import PdfPlumberParser
|
|
63
|
+
|
|
64
|
+
return PdfPlumberParser()
|
|
65
|
+
if file_format == "latex":
|
|
66
|
+
from sci_etl_core.parsers.latex import LatexTarballParser
|
|
67
|
+
|
|
68
|
+
return LatexTarballParser()
|
|
69
|
+
from sci_etl_core.parsers.html import HtmlTextParser
|
|
70
|
+
|
|
71
|
+
return HtmlTextParser()
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from sci_etl_core.exceptions import PipelineAborted
|
|
9
|
+
from sci_etl_core.signals import ShutdownSignal
|
|
10
|
+
|
|
11
|
+
from sci_etl_cli import assembly, shutdown
|
|
12
|
+
from sci_etl_cli.errors import ExitCode, describe_abort
|
|
13
|
+
from sci_etl_cli.options import config_argument, require_api_key, require_query
|
|
14
|
+
from sci_etl_cli.output import run_logger, stderr_console
|
|
15
|
+
from sci_etl_cli.settings import CliConfig, load_cli_config
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@click.command("run")
|
|
19
|
+
@config_argument
|
|
20
|
+
@click.option(
|
|
21
|
+
"--limit",
|
|
22
|
+
type=click.IntRange(min=0),
|
|
23
|
+
help="Stop after this many relevant records. Overrides pipeline.max_records.",
|
|
24
|
+
)
|
|
25
|
+
@click.option(
|
|
26
|
+
"--page-size",
|
|
27
|
+
type=click.IntRange(min=1),
|
|
28
|
+
help="Listing entries per request. Overrides pipeline.page_size.",
|
|
29
|
+
)
|
|
30
|
+
@click.option(
|
|
31
|
+
"--workers",
|
|
32
|
+
type=click.IntRange(min=1),
|
|
33
|
+
help="Records processed at once. Overrides pipeline.max_workers.",
|
|
34
|
+
)
|
|
35
|
+
@click.option(
|
|
36
|
+
"--rescan",
|
|
37
|
+
is_flag=True,
|
|
38
|
+
help="Start at offset 0 to pick up new submissions; processed records are skipped.",
|
|
39
|
+
)
|
|
40
|
+
@click.option(
|
|
41
|
+
"--start-index",
|
|
42
|
+
type=click.IntRange(min=0),
|
|
43
|
+
help="Start at this listing offset instead of the saved one.",
|
|
44
|
+
)
|
|
45
|
+
@click.option(
|
|
46
|
+
"--log-file",
|
|
47
|
+
type=click.Path(dir_okay=False, path_type=Path),
|
|
48
|
+
help="Write the log to this file. Overrides logging.file.",
|
|
49
|
+
)
|
|
50
|
+
def run_command(
|
|
51
|
+
config_path: Path,
|
|
52
|
+
limit: int | None,
|
|
53
|
+
page_size: int | None,
|
|
54
|
+
workers: int | None,
|
|
55
|
+
rescan: bool,
|
|
56
|
+
start_index: int | None,
|
|
57
|
+
log_file: Path | None,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Run the pipeline for CONFIG, resuming from its saved state.
|
|
60
|
+
|
|
61
|
+
Searches arXiv, asks the LLM which papers are relevant, extracts entities
|
|
62
|
+
from their full text and upserts them into the export CSV. Press Ctrl+C
|
|
63
|
+
once to stop after saving state; the next run retries unfinished records.
|
|
64
|
+
"""
|
|
65
|
+
if rescan and start_index is not None:
|
|
66
|
+
raise click.UsageError("Use either --rescan or --start-index, not both.")
|
|
67
|
+
config = apply_overrides(
|
|
68
|
+
load_cli_config(config_path), limit=limit, page_size=page_size, workers=workers, log_file=log_file
|
|
69
|
+
)
|
|
70
|
+
require_query(config.pipeline.search_query)
|
|
71
|
+
require_api_key(config)
|
|
72
|
+
relevance_prompt = assembly.read_prompt(config.prompts.relevance)
|
|
73
|
+
extraction_prompt = assembly.read_prompt(config.prompts.extraction)
|
|
74
|
+
|
|
75
|
+
ctx = click.get_current_context()
|
|
76
|
+
with run_logger(config.logging.level, config.logging.file, stderr_console()) as log:
|
|
77
|
+
try:
|
|
78
|
+
processed = asyncio.run(
|
|
79
|
+
execute(config, log, relevance_prompt, extraction_prompt, 0 if rescan else start_index)
|
|
80
|
+
)
|
|
81
|
+
except PipelineAborted as aborted:
|
|
82
|
+
log.error(f"Run aborted: {describe_abort(aborted)}")
|
|
83
|
+
ctx.exit(ExitCode.FAILURE)
|
|
84
|
+
if processed is None:
|
|
85
|
+
log.warning("Interrupted; state was saved. Run the same command again to continue.")
|
|
86
|
+
ctx.exit(ExitCode.INTERRUPTED)
|
|
87
|
+
log.info(f"Processed {describe_count(processed)} into {config.export.destination}")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def apply_overrides(
|
|
91
|
+
config: CliConfig,
|
|
92
|
+
*,
|
|
93
|
+
limit: int | None,
|
|
94
|
+
page_size: int | None,
|
|
95
|
+
workers: int | None,
|
|
96
|
+
log_file: Path | None,
|
|
97
|
+
) -> CliConfig:
|
|
98
|
+
requested = {"max_records": limit, "page_size": page_size, "max_workers": workers}
|
|
99
|
+
pipeline = config.pipeline.model_copy(update={name: value for name, value in requested.items() if value is not None})
|
|
100
|
+
logging_config = config.logging if log_file is None else config.logging.model_copy(update={"file": log_file})
|
|
101
|
+
return config.model_copy(update={"pipeline": pipeline, "logging": logging_config})
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def describe_count(count: int) -> str:
|
|
105
|
+
return f"{count} relevant record{'' if count == 1 else 's'}"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def execute(
|
|
109
|
+
config: CliConfig,
|
|
110
|
+
log: logging.Logger,
|
|
111
|
+
relevance_prompt: str,
|
|
112
|
+
extraction_prompt: str,
|
|
113
|
+
start_index: int | None,
|
|
114
|
+
) -> int | None:
|
|
115
|
+
http_client = assembly.build_http_client(config)
|
|
116
|
+
llm_client = assembly.build_llm_client(config)
|
|
117
|
+
state_manager = assembly.build_state_manager(config)
|
|
118
|
+
pipeline = assembly.build_pipeline(
|
|
119
|
+
config, log, http_client, llm_client, state_manager, relevance_prompt, extraction_prompt
|
|
120
|
+
)
|
|
121
|
+
log.info(
|
|
122
|
+
f"Starting run for {config.pipeline.search_query!r}, up to {describe_count(config.pipeline.max_records)}"
|
|
123
|
+
)
|
|
124
|
+
async with pipeline:
|
|
125
|
+
return await shutdown.run_until_interrupted(
|
|
126
|
+
pipeline.run(
|
|
127
|
+
query=config.pipeline.search_query,
|
|
128
|
+
page_size=config.pipeline.page_size,
|
|
129
|
+
total_limit=config.pipeline.max_records,
|
|
130
|
+
sleep_between=config.pipeline.sleep_between,
|
|
131
|
+
start_index=start_index,
|
|
132
|
+
),
|
|
133
|
+
ShutdownSignal(logger=log.warning),
|
|
134
|
+
state_manager,
|
|
135
|
+
)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from dataclasses import asdict, dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from rich.markup import escape
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from sci_etl_cli import assembly
|
|
12
|
+
from sci_etl_cli.options import config_argument, require_query
|
|
13
|
+
from sci_etl_cli.output import discard, print_json, stdout_console
|
|
14
|
+
from sci_etl_cli.settings import CliConfig, load_cli_config
|
|
15
|
+
|
|
16
|
+
_ARXIV_MAX_RESULTS = 2000
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class ListedRecord:
|
|
21
|
+
record_id: str
|
|
22
|
+
title: str
|
|
23
|
+
url: str | None
|
|
24
|
+
processed: bool
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@click.command("search", short_help="Preview arXiv results without calling the LLM.")
|
|
28
|
+
@config_argument
|
|
29
|
+
@click.option("--query", help="Search this instead of pipeline.search_query.")
|
|
30
|
+
@click.option(
|
|
31
|
+
"--limit",
|
|
32
|
+
type=click.IntRange(1, _ARXIV_MAX_RESULTS),
|
|
33
|
+
default=10,
|
|
34
|
+
show_default=True,
|
|
35
|
+
help="Number of entries to request.",
|
|
36
|
+
)
|
|
37
|
+
@click.option(
|
|
38
|
+
"--start-index",
|
|
39
|
+
type=click.IntRange(min=0),
|
|
40
|
+
default=0,
|
|
41
|
+
show_default=True,
|
|
42
|
+
help="Listing offset to start from.",
|
|
43
|
+
)
|
|
44
|
+
@click.option("--json", "as_json", is_flag=True, help="Print JSON instead of a table.")
|
|
45
|
+
def search_command(config_path: Path, query: str | None, limit: int, start_index: int, as_json: bool) -> None:
|
|
46
|
+
"""Show one page of arXiv results for CONFIG without calling the LLM.
|
|
47
|
+
|
|
48
|
+
Each entry is marked new or processed according to the saved state, so a
|
|
49
|
+
query can be tuned before spending tokens. Nothing is written.
|
|
50
|
+
"""
|
|
51
|
+
config = load_cli_config(config_path)
|
|
52
|
+
search_query = require_query(query or config.pipeline.search_query)
|
|
53
|
+
listed = asyncio.run(fetch_page(config, search_query, limit, start_index))
|
|
54
|
+
if as_json:
|
|
55
|
+
print_json([asdict(record) for record in listed])
|
|
56
|
+
return
|
|
57
|
+
console = stdout_console()
|
|
58
|
+
if not listed:
|
|
59
|
+
console.print(f"No entries at offset {start_index}.")
|
|
60
|
+
return
|
|
61
|
+
table = Table("id", "status", "title")
|
|
62
|
+
for record in listed:
|
|
63
|
+
status = "[yellow]processed[/]" if record.processed else "[green]new[/]"
|
|
64
|
+
table.add_row(record.record_id, status, escape(record.title))
|
|
65
|
+
console.print(table)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def fetch_page(config: CliConfig, query: str, limit: int, start_index: int) -> list[ListedRecord]:
|
|
69
|
+
processed_ids, _metadata = await assembly.read_state(config)
|
|
70
|
+
http_client = assembly.build_http_client(config)
|
|
71
|
+
try:
|
|
72
|
+
extractor = assembly.build_extractor(config, http_client, discard)
|
|
73
|
+
raw_listing = await extractor.search(query, limit, start_index)
|
|
74
|
+
records, _entries = extractor.parse_listing(raw_listing, set())
|
|
75
|
+
finally:
|
|
76
|
+
await http_client.aclose()
|
|
77
|
+
return [
|
|
78
|
+
ListedRecord(record.record_id, record.title, record.source_url, record.record_id in processed_ids)
|
|
79
|
+
for record in records
|
|
80
|
+
]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import csv
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.markup import escape
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from sci_etl_cli import assembly
|
|
13
|
+
from sci_etl_cli.errors import CliFailure, ExitCode
|
|
14
|
+
from sci_etl_cli.options import config_argument
|
|
15
|
+
from sci_etl_cli.output import print_json, stdout_console
|
|
16
|
+
from sci_etl_cli.settings import CliConfig, load_cli_config
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.command("status", short_help="Show processed records, saved offset and export rows.")
|
|
20
|
+
@config_argument
|
|
21
|
+
@click.option("--json", "as_json", is_flag=True, help="Print JSON instead of a table.")
|
|
22
|
+
def status_command(config_path: Path, as_json: bool) -> None:
|
|
23
|
+
"""Show CONFIG's progress: processed records, saved offset, last run and export rows.
|
|
24
|
+
|
|
25
|
+
Reads state without creating it, so this is safe before the first run.
|
|
26
|
+
"""
|
|
27
|
+
config = load_cli_config(config_path)
|
|
28
|
+
processed_ids, metadata = asyncio.run(assembly.read_state(config))
|
|
29
|
+
report: dict[str, Any] = {
|
|
30
|
+
"config": str(config_path.resolve()),
|
|
31
|
+
"backend": config.state.backend,
|
|
32
|
+
"state": [str(path) for path in state_paths(config)],
|
|
33
|
+
"processed": len(processed_ids),
|
|
34
|
+
"saved_offset": metadata.last_start_index,
|
|
35
|
+
"last_run": metadata.last_run_at,
|
|
36
|
+
"export": str(config.export.destination),
|
|
37
|
+
"export_rows": count_rows(config.export.destination),
|
|
38
|
+
}
|
|
39
|
+
if as_json:
|
|
40
|
+
print_json(report)
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
rows = report["export_rows"]
|
|
44
|
+
table = Table(show_header=False, box=None, pad_edge=False)
|
|
45
|
+
table.add_column(no_wrap=True)
|
|
46
|
+
table.add_column(overflow="fold")
|
|
47
|
+
table.add_row("config", escape(report["config"]))
|
|
48
|
+
table.add_row("backend", report["backend"])
|
|
49
|
+
table.add_row("state", escape("\n".join(report["state"])))
|
|
50
|
+
table.add_row("processed", f"{report['processed']} records")
|
|
51
|
+
table.add_row("saved offset", str(report["saved_offset"]))
|
|
52
|
+
table.add_row("last run", escape(report["last_run"] or "never"))
|
|
53
|
+
table.add_row("export", escape(report["export"]))
|
|
54
|
+
table.add_row("rows", "not created yet" if rows is None else str(rows))
|
|
55
|
+
stdout_console().print(table)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def state_paths(config: CliConfig) -> tuple[Path, ...]:
|
|
59
|
+
if config.state.backend == "sqlite":
|
|
60
|
+
return (config.state.database,)
|
|
61
|
+
return (config.state.processed_ids, config.state.metadata)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def count_rows(path: Path) -> int | None:
|
|
65
|
+
"""Count data rows in the export CSV, or return ``None`` when it does not exist yet.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
CliFailure: The file exists but cannot be read as UTF-8 CSV.
|
|
69
|
+
"""
|
|
70
|
+
if not path.is_file():
|
|
71
|
+
return None
|
|
72
|
+
try:
|
|
73
|
+
with path.open("r", encoding="utf-8", newline="") as handle:
|
|
74
|
+
return max(sum(1 for _row in csv.reader(handle)) - 1, 0)
|
|
75
|
+
except (OSError, UnicodeDecodeError, csv.Error) as exc:
|
|
76
|
+
raise CliFailure(f"Export file could not be read: {path}: {exc}", ExitCode.FAILURE) from exc
|