ragx-cli 0.3.0__tar.gz → 0.3.3__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 (90) hide show
  1. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/.github/workflows/publish.yml +4 -4
  2. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/CHANGELOG.md +23 -0
  3. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/CLAUDE.md +5 -4
  4. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/PKG-INFO +7 -5
  5. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/README.md +6 -4
  6. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/pyproject.toml +1 -1
  7. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/src/ragx/cli/app.py +2 -2
  8. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/src/ragx/cli/eval_cmd.py +2 -2
  9. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/src/ragx/cli/output.py +11 -0
  10. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/src/ragx/cli/pipeline.py +3 -3
  11. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/src/ragx/core/config.py +32 -7
  12. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/src/ragx/providers/st_reranker.py +31 -0
  13. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/tests/test_cli.py +23 -3
  14. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/tests/test_config_rc.py +38 -3
  15. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/tests/test_eval.py +2 -2
  16. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/tests/test_providers.py +36 -0
  17. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/.agents/skills/zvec/SKILL.md +0 -0
  18. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/.agents/skills/zvec/api-cheatsheet.md +0 -0
  19. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/.agents/skills/zvec/collection-management/python.md +0 -0
  20. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/.agents/skills/zvec/collection-management/typescript.md +0 -0
  21. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/.agents/skills/zvec/configuration.md +0 -0
  22. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/.agents/skills/zvec/data-model.md +0 -0
  23. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/.agents/skills/zvec/data-operations/python.md +0 -0
  24. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/.agents/skills/zvec/data-operations/typescript.md +0 -0
  25. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/.agents/skills/zvec/embedding.md +0 -0
  26. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/.agents/skills/zvec/hybrid-search/python.md +0 -0
  27. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/.agents/skills/zvec/hybrid-search/typescript.md +0 -0
  28. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/.agents/skills/zvec/multimodal-search/python.md +0 -0
  29. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/.agents/skills/zvec/multimodal-search/typescript.md +0 -0
  30. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/.agents/skills/zvec/quick-start/python.md +0 -0
  31. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/.agents/skills/zvec/quick-start/typescript.md +0 -0
  32. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/.agents/skills/zvec/rag-system/python.md +0 -0
  33. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/.agents/skills/zvec/rag-system/typescript.md +0 -0
  34. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/.agents/skills/zvec/reranker.md +0 -0
  35. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/.agents/skills/zvec/troubleshooting.md +0 -0
  36. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/.agents/skills/zvec/vector-search/python.md +0 -0
  37. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/.agents/skills/zvec/vector-search/typescript.md +0 -0
  38. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/.gitignore +0 -0
  39. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/AGENTS.md +0 -0
  40. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/CONTRACTS-PHASE23.md +0 -0
  41. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/CONTRACTS.md +0 -0
  42. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/LICENSE +0 -0
  43. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/docs/feature-temporal-weighting.md +0 -0
  44. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/ragx-cli-plan.md +0 -0
  45. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/research/bge-m3-colbert-multi-vector-late-interaction-for-ragx-rerank-alternative-and-token-level-graph-edges.md +0 -0
  46. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/research/bge-m3-dense-embeddings-as-ragx-provider-multilingual-quality-and-threshold-calibration.md +0 -0
  47. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/research/bge-m3-dense-q8-vs-nomic-q4-benchmark-2026-07-12-worktree-eval-results.md +0 -0
  48. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/research/bge-m3-sparse-lexical-weights-hybrid-retrieval-leg-for-ragx-vs-sqlite-fts5-bm25.md +0 -0
  49. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/research/bge-m3-subchunk-edge-ablation-2026-07-13-ties-again-and-beta-zero-shortlist-mechanism.md +0 -0
  50. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/research/fine-grained-sub-chunk-edges-with-coarse-chunk-nodes-multi-granularity-graph-rag-literature-validation.md +0 -0
  51. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/research/subchunk-edge-ablation-2026-07-12-two-round-experiment-protocol-results-and-verdict.md +0 -0
  52. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/skills-lock.json +0 -0
  53. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/src/ragx/__init__.py +0 -0
  54. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/src/ragx/cli/__init__.py +0 -0
  55. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/src/ragx/cli/detect.py +0 -0
  56. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/src/ragx/cli/init_prompt.py +0 -0
  57. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/src/ragx/cli/inspect_cmd.py +0 -0
  58. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/src/ragx/core/__init__.py +0 -0
  59. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/src/ragx/core/chunking.py +0 -0
  60. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/src/ragx/core/communities.py +0 -0
  61. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/src/ragx/core/discovery.py +0 -0
  62. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/src/ragx/core/errors.py +0 -0
  63. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/src/ragx/core/eval.py +0 -0
  64. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/src/ragx/core/expansion.py +0 -0
  65. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/src/ragx/core/fusion.py +0 -0
  66. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/src/ragx/core/graph.py +0 -0
  67. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/src/ragx/core/indexer.py +0 -0
  68. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/src/ragx/core/models.py +0 -0
  69. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/src/ragx/core/query.py +0 -0
  70. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/src/ragx/core/scoring.py +0 -0
  71. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/src/ragx/core/store.py +0 -0
  72. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/src/ragx/core/traversal.py +0 -0
  73. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/src/ragx/core/vectors.py +0 -0
  74. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/src/ragx/providers/__init__.py +0 -0
  75. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/src/ragx/providers/base.py +0 -0
  76. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/src/ragx/providers/openai_compat.py +0 -0
  77. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/src/ragx/providers/registry.py +0 -0
  78. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/tests/__init__.py +0 -0
  79. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/tests/conftest.py +0 -0
  80. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/tests/test_chunking.py +0 -0
  81. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/tests/test_communities.py +0 -0
  82. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/tests/test_discovery.py +0 -0
  83. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/tests/test_expansion.py +0 -0
  84. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/tests/test_fusion.py +0 -0
  85. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/tests/test_graph.py +0 -0
  86. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/tests/test_inspect.py +0 -0
  87. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/tests/test_integration.py +0 -0
  88. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/tests/test_store.py +0 -0
  89. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/tests/test_traversal.py +0 -0
  90. {ragx_cli-0.3.0 → ragx_cli-0.3.3}/tests/test_vectors.py +0 -0
@@ -8,8 +8,8 @@ jobs:
8
8
  test:
9
9
  runs-on: ubuntu-latest
10
10
  steps:
11
- - uses: actions/checkout@v4
12
- - uses: astral-sh/setup-uv@v6
11
+ - uses: actions/checkout@v7
12
+ - uses: astral-sh/setup-uv@v8.3.2
13
13
  - run: uv sync --group dev
14
14
  - run: uv run pytest -q
15
15
  - run: uv run ruff check src tests
@@ -21,8 +21,8 @@ jobs:
21
21
  permissions:
22
22
  id-token: write
23
23
  steps:
24
- - uses: actions/checkout@v4
25
- - uses: astral-sh/setup-uv@v6
24
+ - uses: actions/checkout@v7
25
+ - uses: astral-sh/setup-uv@v8.3.2
26
26
  - name: Check tag matches package version
27
27
  run: |
28
28
  VERSION=$(uv version --short)
@@ -4,6 +4,27 @@ All notable changes to `ragx-cli` are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.3.3] — 2026-07-15
8
+
9
+ ### Added
10
+
11
+ - **Early hint when the reranker model download stalls**: the huggingface_hub retry
12
+ loop inside the cross-encoder load can spin for minutes before failing. The first
13
+ retry warning now also prints a one-shot stderr pointer to the README's "Reranker on
14
+ restricted networks" section (manual copy, mirror, or `rerank.enabled false`).
15
+
16
+ ## [0.3.2] — 2026-07-15
17
+
18
+ _0.3.1 was tagged but never published — its release workflow referenced a nonexistent
19
+ action tag; 0.3.2 is the same change plus the CI fix._
20
+
21
+ ### Changed
22
+
23
+ - **Legacy config self-heals**: finding a pre-0.3.0 `.ragx/config.toml` no longer fails
24
+ loud — on a TTY, `ragx-cli` asks before moving it to `ragx.toml` (declining prints the
25
+ `mv` and exits); non-interactive runs migrate automatically with a stderr notice. Both
26
+ files existing at once is still a hard error.
27
+
7
28
  ## [0.3.0] — 2026-07-14
8
29
 
9
30
  ### Changed (BREAKING)
@@ -83,6 +104,8 @@ Initial release: Phases 0–3 of [the plan](ragx-cli-plan.md), built and validat
83
104
  on stderr, exit codes `0` results / `1` empty / `2` error.
84
105
  - MIT license; PyPI packaging as `ragx-cli` with trusted-publishing release workflow.
85
106
 
107
+ [0.3.3]: https://github.com/alber70g/ragx-cli/compare/v0.3.2...v0.3.3
108
+ [0.3.2]: https://github.com/alber70g/ragx-cli/compare/v0.3.0...v0.3.2
86
109
  [0.3.0]: https://github.com/alber70g/ragx-cli/compare/v0.2.0...v0.3.0
87
110
  [0.2.0]: https://github.com/alber70g/ragx-cli/compare/v0.1.1...v0.2.0
88
111
  [0.1.1]: https://github.com/alber70g/ragx-cli/compare/v0.1.0...v0.1.1
@@ -39,7 +39,8 @@ src/ragx/
39
39
  core/ # all logic lives here, CLI-independent
40
40
  config.py # ragx.toml at corpus root (committable; 0.3.0 moved it out of .ragx/) +
41
41
  # ~/.ragxrc (provider sections; rc wins, warns), DEFAULTS, find_root;
42
- # legacy .ragx/config.toml fails loud with a migration hint
42
+ # legacy .ragx/config.toml self-heals: auto-moved on load (CLI passes a
43
+ # TTY confirm via output.migrate_confirm; both-files-exist fails loud)
43
44
  models.py # Chunk, ChunkDraft, FileRecord, Edge, ScoredChunk, QueryOutput
44
45
  store.py # SQLite: files/chunks/edges/meta; replace_edges normalizes src<dst
45
46
  vectors.py # hnswlib wrapper; soft-delete via JSON sidecar; get_vectors for graph
@@ -60,7 +61,7 @@ src/ragx/
60
61
  # st_reranker.py; registry.py factories (env-var + api_key_env resolution)
61
62
  cli/ # thin shells only: app.py (init/status/config + registration),
62
63
  # pipeline.py (index/query), inspect_cmd.py, eval_cmd.py, output.py
63
- tests/ # 173 tests; mocked HTTP (respx), FakeEmbedder integration tests, no live network
64
+ tests/ # 177 tests; mocked HTTP (respx), FakeEmbedder integration tests, no live network
64
65
  ```
65
66
 
66
67
  ## Flows
@@ -141,11 +142,11 @@ mostly on Dutch/multilingual queries. Details: `research/bge-m3-dense-q8-vs-nomi
141
142
  - Temporal weighting: full decided spec in `docs/feature-temporal-weighting.md` — opt-in
142
143
  `--since/--until/--temporal recent|oldest`, date cascade filename/frontmatter → git → mtime,
143
144
  schema v2 migration. Build only after it can be measured against the eval baseline.
144
- - README packaging claims (`uvx ragx-cli`; PyPI name ragx-cli, plain `ragx` is name-blocked) are aspirational — not yet published to PyPI.
145
+ - Published to PyPI as `ragx-cli` (plain `ragx` is name-blocked); releases go out via the `v*`-tag trusted-publishing workflow.
145
146
 
146
147
  ## Dev loop
147
148
 
148
- `uv sync --group dev --extra rerank` · `uv run pytest -q` (173 pass, ~5 s) ·
149
+ `uv sync --group dev --extra rerank` · `uv run pytest -q` (177 pass, ~5 s) ·
149
150
  `uv run ruff check src tests` · file soft cap ~150 lines · expected failures raise `RagxError`
150
151
  (CLI maps to exit 2). Live smoke: LM Studio must be running with the configured embedding model.
151
152
  Changes that impact usage (CLI flags, config keys/precedence, output schemas, install steps)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ragx-cli
3
- Version: 0.3.0
3
+ Version: 0.3.3
4
4
  Summary: Similarity-graph RAG CLI: index a corpus into a chunk-level embedding graph and query it
5
5
  Project-URL: Repository, https://github.com/alber70g/ragx-cli
6
6
  Project-URL: Issues, https://github.com/alber70g/ragx-cli/issues
@@ -466,8 +466,10 @@ committed with your corpus; `.ragx/` (index data) is safe to gitignore — a fre
466
466
  runs `ragx-cli index`. The config file itself is never indexed as corpus content.
467
467
 
468
468
  > **Upgrading from ≤0.2.x:** the config moved from `.ragx/config.toml` to `ragx.toml` at the
469
- > corpus root. Commands fail with a migration hint until you
470
- > `mv .ragx/config.toml ragx.toml`. The index itself is untouched.
469
+ > corpus root, and ragx-cli migrates it for you on first use: on a TTY it asks before moving
470
+ > the file (declining leaves everything untouched and prints the `mv` to run yourself);
471
+ > piped/agent runs migrate automatically with a notice on stderr. If both files exist,
472
+ > commands fail loud — keep one and delete the other. The index itself is untouched.
471
473
 
472
474
  `ragx-cli init` is **interactive when run on a TTY**: it probes the default LM Studio
473
475
  (`localhost:1234`) and Ollama (`localhost:11434`) ports, lists the models each server
@@ -509,7 +511,7 @@ unchecked ones are next up. Release history: [CHANGELOG.md](CHANGELOG.md).
509
511
  - [ ] **query --global** for corpus-level questions (answer from community summaries, not individual chunks)
510
512
  - [ ] **MCP server**: a second thin shell over `ragx.core` (the core/CLI split it needs is already enforced)
511
513
  - [ ] **[Temporal weighting](docs/feature-temporal-weighting.md)**: opt-in `--since`/`--until`/`--temporal recent|oldest`, date cascade filename/frontmatter → git → mtime
512
- - [ ] **Release**: publish to PyPI as `ragx-cli` (plain `ragx` is name-blocked, too similar to an existing project) so `uvx ragx-cli` works out of the box
514
+ - [x] **Release**: publish to PyPI as `ragx-cli` (plain `ragx` is name-blocked, too similar to an existing project) so `uvx ragx-cli` works out of the box
513
515
 
514
- Development: `uv sync --group dev && uv run pytest`. 173 tests; module contracts live in
516
+ Development: `uv sync --group dev && uv run pytest`. 177 tests; module contracts live in
515
517
  `CONTRACTS.md` / `CONTRACTS-PHASE23.md`.
@@ -438,8 +438,10 @@ committed with your corpus; `.ragx/` (index data) is safe to gitignore — a fre
438
438
  runs `ragx-cli index`. The config file itself is never indexed as corpus content.
439
439
 
440
440
  > **Upgrading from ≤0.2.x:** the config moved from `.ragx/config.toml` to `ragx.toml` at the
441
- > corpus root. Commands fail with a migration hint until you
442
- > `mv .ragx/config.toml ragx.toml`. The index itself is untouched.
441
+ > corpus root, and ragx-cli migrates it for you on first use: on a TTY it asks before moving
442
+ > the file (declining leaves everything untouched and prints the `mv` to run yourself);
443
+ > piped/agent runs migrate automatically with a notice on stderr. If both files exist,
444
+ > commands fail loud — keep one and delete the other. The index itself is untouched.
443
445
 
444
446
  `ragx-cli init` is **interactive when run on a TTY**: it probes the default LM Studio
445
447
  (`localhost:1234`) and Ollama (`localhost:11434`) ports, lists the models each server
@@ -481,7 +483,7 @@ unchecked ones are next up. Release history: [CHANGELOG.md](CHANGELOG.md).
481
483
  - [ ] **query --global** for corpus-level questions (answer from community summaries, not individual chunks)
482
484
  - [ ] **MCP server**: a second thin shell over `ragx.core` (the core/CLI split it needs is already enforced)
483
485
  - [ ] **[Temporal weighting](docs/feature-temporal-weighting.md)**: opt-in `--since`/`--until`/`--temporal recent|oldest`, date cascade filename/frontmatter → git → mtime
484
- - [ ] **Release**: publish to PyPI as `ragx-cli` (plain `ragx` is name-blocked, too similar to an existing project) so `uvx ragx-cli` works out of the box
486
+ - [x] **Release**: publish to PyPI as `ragx-cli` (plain `ragx` is name-blocked, too similar to an existing project) so `uvx ragx-cli` works out of the box
485
487
 
486
- Development: `uv sync --group dev && uv run pytest`. 173 tests; module contracts live in
488
+ Development: `uv sync --group dev && uv run pytest`. 177 tests; module contracts live in
487
489
  `CONTRACTS.md` / `CONTRACTS-PHASE23.md`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ragx-cli"
3
- version = "0.3.0"
3
+ version = "0.3.3"
4
4
  description = "Similarity-graph RAG CLI: index a corpus into a chunk-level embedding graph and query it"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -9,7 +9,7 @@ from pathlib import Path
9
9
  import typer
10
10
 
11
11
  from ragx.cli.init_prompt import collect_answers
12
- from ragx.cli.output import emit_json, fail
12
+ from ragx.cli.output import emit_json, fail, migrate_confirm
13
13
  from ragx.core.config import (
14
14
  DEFAULTS,
15
15
  Config,
@@ -43,7 +43,7 @@ def _require_root() -> Path:
43
43
 
44
44
  def _load_config(root: Path) -> Config:
45
45
  try:
46
- return Config.load(root)
46
+ return Config.load(root, confirm=migrate_confirm())
47
47
  except RagxError as exc:
48
48
  fail(str(exc))
49
49
 
@@ -7,7 +7,7 @@ from pathlib import Path
7
7
 
8
8
  import typer
9
9
 
10
- from ragx.cli.output import emit_json, fail
10
+ from ragx.cli.output import emit_json, fail, migrate_confirm
11
11
  from ragx.core.config import Config, require_root
12
12
  from ragx.core.errors import RagxError
13
13
  from ragx.core.eval import evaluate, load_queries
@@ -45,7 +45,7 @@ def eval_cmd(
45
45
 
46
46
  try:
47
47
  root = require_root()
48
- cfg = Config.load(root)
48
+ cfg = Config.load(root, confirm=migrate_confirm())
49
49
  embedder = make_embedder(cfg)
50
50
  generator = make_generator(cfg) if any(_BUILTIN_CONFIGS[n].expand for n in names) else None
51
51
  reranker = make_reranker(cfg) if any(_BUILTIN_CONFIGS[n].rerank for n in names) else None
@@ -5,8 +5,11 @@ from __future__ import annotations
5
5
 
6
6
  import json
7
7
  import sys
8
+ from collections.abc import Callable
8
9
  from typing import NoReturn
9
10
 
11
+ import typer
12
+
10
13
 
11
14
  def emit_json(doc: dict) -> None:
12
15
  """Write exactly one JSON document to stdout, nothing else on stdout."""
@@ -17,3 +20,11 @@ def fail(msg: str, code: int = 2) -> NoReturn:
17
20
  """Print an error message to stderr and exit with `code`."""
18
21
  print(msg, file=sys.stderr)
19
22
  raise SystemExit(code)
23
+
24
+
25
+ def migrate_confirm() -> Callable[[str], bool] | None:
26
+ """Confirm callback for Config.load's legacy-config migration: prompt on a TTY,
27
+ None (migrate silently) for piped/agent runs."""
28
+ if not sys.stdin.isatty():
29
+ return None
30
+ return lambda prompt: typer.confirm(prompt, default=True)
@@ -7,7 +7,7 @@ from pathlib import Path
7
7
 
8
8
  import typer
9
9
 
10
- from ragx.cli.output import emit_json, fail
10
+ from ragx.cli.output import emit_json, fail, migrate_confirm
11
11
  from ragx.core.config import Config, require_root
12
12
  from ragx.core.errors import RagxError
13
13
  from ragx.core.indexer import run_index
@@ -28,7 +28,7 @@ def index(
28
28
  """Chunk, embed, and index the corpus (full rebuild unless --changed)."""
29
29
  try:
30
30
  root = require_root(path)
31
- cfg = Config.load(root)
31
+ cfg = Config.load(root, confirm=migrate_confirm())
32
32
  stats = run_index(root, cfg, make_embedder(cfg), changed_only=changed)
33
33
  except RagxError as exc:
34
34
  fail(str(exc))
@@ -59,7 +59,7 @@ def query(
59
59
  text = sys.stdin.read().strip()
60
60
  try:
61
61
  root = require_root()
62
- cfg = Config.load(root)
62
+ cfg = Config.load(root, confirm=migrate_confirm())
63
63
  opts = QueryOptions(
64
64
  top=top if top is not None else cfg.get("query.top"),
65
65
  files_only=files_only,
@@ -6,6 +6,7 @@ from __future__ import annotations
6
6
 
7
7
  import logging
8
8
  import tomllib
9
+ from collections.abc import Callable
9
10
  from pathlib import Path
10
11
  from typing import Any
11
12
 
@@ -88,6 +89,30 @@ def vectors_path(root: Path) -> Path:
88
89
  return root / RAGX_DIR / "vectors.hnsw"
89
90
 
90
91
 
92
+ def _migrate_legacy(root: Path, confirm: Callable[[str], bool] | None) -> None:
93
+ """Self-heal a pre-0.3.0 corpus: move .ragx/config.toml to ragx.toml at the root.
94
+
95
+ `confirm` (interactive callers) is asked before touching anything; declining keeps
96
+ the old fail-loud error. Without it (agents, library use) the move just happens,
97
+ with a stderr notice. Both files existing stays a hard error — picking one could
98
+ silently discard edits."""
99
+ legacy = root / RAGX_DIR / "config.toml"
100
+ if not legacy.exists():
101
+ return
102
+ target = root / CONFIG_FILE
103
+ if target.exists():
104
+ raise RagxError(
105
+ f"both {target} and legacy {legacy} exist — config moved to ragx.toml "
106
+ f"in 0.3.0; keep one and delete the other"
107
+ )
108
+ if confirm is not None and not confirm(
109
+ f"config moved in 0.3.0: move legacy {legacy} to {target}?"
110
+ ):
111
+ raise RagxError(f"config not migrated — run `mv {legacy} {target}` to proceed")
112
+ legacy.rename(target)
113
+ log.warning("config moved in 0.3.0: migrated %s -> %s", legacy, target)
114
+
115
+
91
116
  def _deep_merge(base: dict, override: dict) -> dict:
92
117
  out = {k: dict(v) for k, v in base.items()}
93
118
  for section, values in override.items():
@@ -160,13 +185,13 @@ class Config:
160
185
  self.file_data = file_data if file_data is not None else data
161
186
 
162
187
  @classmethod
163
- def load(cls, root: Path, rc: Path | None = None) -> Config:
164
- legacy = root / RAGX_DIR / "config.toml"
165
- if legacy.exists():
166
- raise RagxError(
167
- f"config moved in 0.3.0: found legacy {legacy} — "
168
- f"run `mv {legacy} {root / CONFIG_FILE}`"
169
- )
188
+ def load(
189
+ cls,
190
+ root: Path,
191
+ rc: Path | None = None,
192
+ confirm: Callable[[str], bool] | None = None,
193
+ ) -> Config:
194
+ _migrate_legacy(root, confirm)
170
195
  path = config_path(root)
171
196
  file_data: dict = {}
172
197
  if path.exists():
@@ -2,10 +2,36 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import logging
6
+ import sys
5
7
  from typing import Sequence
6
8
 
7
9
  from ragx.core.errors import RagxError
8
10
 
11
+ _MANUAL_INSTALL_HINT = (
12
+ "downloading the reranker model from huggingface.co is failing — to install the "
13
+ "model manually, use a mirror, or disable reranking, see "
14
+ "https://github.com/alber70g/ragx-cli#reranker-on-restricted-networks-no-huggingfaceco"
15
+ )
16
+
17
+
18
+ class _DownloadHintHandler(logging.Handler):
19
+ """Emits the manual-install hint once, on the first huggingface_hub retry warning."""
20
+
21
+ def __init__(self) -> None:
22
+ super().__init__(level=logging.WARNING)
23
+ self._fired = False
24
+
25
+ def emit(self, record: logging.LogRecord) -> None:
26
+ if self._fired:
27
+ return
28
+ message = record.getMessage().lower()
29
+ if "retry" in message or "thrown while requesting" in message:
30
+ self._fired = True
31
+ # logging drops re-entrant calls made while a record is being
32
+ # handled, so the hint must go to stderr directly.
33
+ print(f"WARNING: {_MANUAL_INSTALL_HINT}", file=sys.stderr)
34
+
9
35
 
10
36
  class STReranker:
11
37
  """Reranker implementing providers.base.Reranker via a sentence-transformers CrossEncoder."""
@@ -20,6 +46,9 @@ class STReranker:
20
46
  "sentence-transformers not installed — install with: uv tool install ragx-cli --with ragx-cli[rerank]"
21
47
  ) from exc
22
48
  self.model = model
49
+ hint = _DownloadHintHandler()
50
+ hub_logger = logging.getLogger("huggingface_hub")
51
+ hub_logger.addHandler(hint)
23
52
  try:
24
53
  self._encoder = CrossEncoder(model)
25
54
  except Exception as exc:
@@ -30,6 +59,8 @@ class STReranker:
30
59
  "reranking (rerank.enabled false); see README 'Reranker on restricted "
31
60
  "networks (no huggingface.co)'"
32
61
  ) from exc
62
+ finally:
63
+ hub_logger.removeHandler(hint)
33
64
 
34
65
  def score(self, query: str, texts: Sequence[str]) -> list[float]:
35
66
  pairs = [[query, text] for text in texts]
@@ -8,6 +8,7 @@ import respx
8
8
  from typer.testing import CliRunner
9
9
 
10
10
  from ragx.cli.app import app
11
+ from ragx.cli.output import migrate_confirm
11
12
  from ragx.core.config import CONFIG_FILE, Config
12
13
 
13
14
  runner = CliRunner()
@@ -124,14 +125,33 @@ def test_config_get_bogus_key(tmp_path, monkeypatch):
124
125
  assert result.exit_code == 2
125
126
 
126
127
 
127
- def test_legacy_config_location_exits_2_with_hint(tmp_path, monkeypatch):
128
+ def test_legacy_config_auto_migrates_when_not_a_tty(tmp_path, monkeypatch):
129
+ # piped stdin (agents): the pre-0.3.0 config self-heals without a prompt
128
130
  (tmp_path / ".ragx").mkdir()
129
131
  (tmp_path / ".ragx" / "config.toml").write_text("[graph]\nk = 12\n")
130
132
  monkeypatch.chdir(tmp_path)
131
133
 
132
134
  result = runner.invoke(app, ["config", "get", "graph.k"])
133
- assert result.exit_code == 2
134
- assert "config moved" in result.output
135
+ assert result.exit_code == 0
136
+ assert result.output.strip() == "12"
137
+ assert (tmp_path / CONFIG_FILE).exists()
138
+ assert not (tmp_path / ".ragx" / "config.toml").exists()
139
+
140
+
141
+ def test_migrate_confirm_prompts_only_on_tty(monkeypatch):
142
+ class Tty:
143
+ def isatty(self):
144
+ return True
145
+
146
+ monkeypatch.setattr("sys.stdin", Tty())
147
+ assert migrate_confirm() is not None
148
+
149
+ class Pipe:
150
+ def isatty(self):
151
+ return False
152
+
153
+ monkeypatch.setattr("sys.stdin", Pipe())
154
+ assert migrate_confirm() is None
135
155
 
136
156
 
137
157
  def test_config_without_init(tmp_path, monkeypatch):
@@ -55,12 +55,47 @@ def test_rc_applies_without_corpus_file(tmp_path, rc, caplog):
55
55
  assert not caplog.records
56
56
 
57
57
 
58
- def test_legacy_config_location_fails_loud(tmp_path, rc):
59
- # pre-0.3.0 corpora kept config at .ragx/config.toml; loading must demand the move
58
+ def _write_legacy(tmp_path):
60
59
  (tmp_path / ".ragx").mkdir()
61
60
  (tmp_path / ".ragx" / "config.toml").write_text("[graph]\nk = 12\n")
62
- with pytest.raises(RagxError, match="config moved"):
61
+
62
+
63
+ def test_legacy_config_auto_migrates_without_confirm(tmp_path, rc, caplog):
64
+ # pre-0.3.0 corpora self-heal: non-interactive loads just move the file, with a notice
65
+ _write_legacy(tmp_path)
66
+ with caplog.at_level(logging.WARNING, logger="ragx.config"):
67
+ cfg = Config.load(tmp_path)
68
+ assert cfg.get("graph.k") == 12
69
+ assert config_path(tmp_path).exists()
70
+ assert not (tmp_path / ".ragx" / "config.toml").exists()
71
+ assert any("migrated" in r.getMessage() for r in caplog.records)
72
+
73
+
74
+ def test_legacy_config_confirm_accepted_migrates(tmp_path, rc):
75
+ _write_legacy(tmp_path)
76
+ prompts: list[str] = []
77
+ cfg = Config.load(tmp_path, confirm=lambda msg: prompts.append(msg) or True)
78
+ assert cfg.get("graph.k") == 12
79
+ assert config_path(tmp_path).exists()
80
+ assert len(prompts) == 1 and "config moved in 0.3.0" in prompts[0]
81
+
82
+
83
+ def test_legacy_config_confirm_declined_fails_loud(tmp_path, rc):
84
+ # declining the prompt keeps the old fail-loud behavior and touches nothing
85
+ _write_legacy(tmp_path)
86
+ with pytest.raises(RagxError, match="not migrated"):
87
+ Config.load(tmp_path, confirm=lambda msg: False)
88
+ assert (tmp_path / ".ragx" / "config.toml").exists()
89
+ assert not config_path(tmp_path).exists()
90
+
91
+
92
+ def test_legacy_and_new_config_both_present_fails_loud(tmp_path, rc):
93
+ # migrating over an existing ragx.toml could discard edits — never pick silently
94
+ _write_legacy(tmp_path)
95
+ config_path(tmp_path).write_text("[graph]\nk = 5\n")
96
+ with pytest.raises(RagxError, match="keep one"):
63
97
  Config.load(tmp_path)
98
+ assert (tmp_path / ".ragx" / "config.toml").exists()
64
99
 
65
100
 
66
101
  def test_root_found_via_config_file_alone(tmp_path):
@@ -197,7 +197,7 @@ def test_eval_cmd_json_output(tmp_path, monkeypatch):
197
197
  }
198
198
  monkeypatch.setattr(eval_cmd_mod, "load_queries", lambda path: [{"query": "q", "relevant_files": []}])
199
199
  monkeypatch.setattr(eval_cmd_mod, "require_root", lambda: tmp_path)
200
- monkeypatch.setattr(eval_cmd_mod.Config, "load", classmethod(lambda cls, root: object()))
200
+ monkeypatch.setattr(eval_cmd_mod.Config, "load", classmethod(lambda cls, root, confirm=None: object()))
201
201
  monkeypatch.setattr(eval_cmd_mod, "make_embedder", lambda cfg: object())
202
202
  monkeypatch.setattr(
203
203
  eval_cmd_mod,
@@ -223,7 +223,7 @@ def test_eval_cmd_human_table(tmp_path, monkeypatch):
223
223
  }
224
224
  monkeypatch.setattr(eval_cmd_mod, "load_queries", lambda path: [{"query": "q", "relevant_files": []}])
225
225
  monkeypatch.setattr(eval_cmd_mod, "require_root", lambda: tmp_path)
226
- monkeypatch.setattr(eval_cmd_mod.Config, "load", classmethod(lambda cls, root: object()))
226
+ monkeypatch.setattr(eval_cmd_mod.Config, "load", classmethod(lambda cls, root, confirm=None: object()))
227
227
  monkeypatch.setattr(eval_cmd_mod, "make_embedder", lambda cfg: object())
228
228
  monkeypatch.setattr(
229
229
  eval_cmd_mod,
@@ -200,6 +200,42 @@ def test_make_reranker_model_load_failure_returns_none_and_warns(monkeypatch, ca
200
200
  assert "HF_ENDPOINT" in err
201
201
 
202
202
 
203
+ def test_st_reranker_first_retry_warning_emits_manual_install_hint(monkeypatch, capsys):
204
+ """The first HF download-retry warning triggers a one-shot README pointer."""
205
+ import logging
206
+ import sys
207
+ import types
208
+
209
+ class _RetryingCrossEncoder:
210
+ def __init__(self, model):
211
+ hub_log = logging.getLogger("huggingface_hub.utils._http")
212
+ for _ in range(2):
213
+ hub_log.warning(
214
+ "'_ssl.c:1015: The handshake operation timed out' thrown while "
215
+ "requesting HEAD https://huggingface.co/x/resolve/main/modules.json"
216
+ )
217
+ hub_log.warning("Retrying in 1s [Retry 1/5].")
218
+ raise OSError("timed out")
219
+
220
+ fake = types.ModuleType("sentence_transformers")
221
+ fake.CrossEncoder = _RetryingCrossEncoder
222
+ monkeypatch.setitem(sys.modules, "sentence_transformers", fake)
223
+
224
+ from ragx.core.errors import RagxError
225
+ from ragx.providers.st_reranker import STReranker, _DownloadHintHandler
226
+
227
+ with pytest.raises(RagxError):
228
+ STReranker("BAAI/bge-reranker-v2-m3")
229
+
230
+ err = capsys.readouterr().err
231
+ assert err.count("install the model manually") == 1
232
+ assert "#reranker-on-restricted-networks" in err
233
+ assert not any(
234
+ isinstance(h, _DownloadHintHandler)
235
+ for h in logging.getLogger("huggingface_hub").handlers
236
+ )
237
+
238
+
203
239
  def test_api_key_env_resolved_and_sent(monkeypatch):
204
240
  monkeypatch.setenv("RAGX_TEST_KEY", "sk-test-123")
205
241
  cfg = _config({"embeddings": {"api_key_env": "RAGX_TEST_KEY"}})
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes