ragx-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. ragx_cli-0.1.0/.github/workflows/publish.yml +34 -0
  2. ragx_cli-0.1.0/.gitignore +9 -0
  3. ragx_cli-0.1.0/AGENTS.md +1 -0
  4. ragx_cli-0.1.0/CLAUDE.md +112 -0
  5. ragx_cli-0.1.0/CONTRACTS-PHASE23.md +166 -0
  6. ragx_cli-0.1.0/CONTRACTS.md +229 -0
  7. ragx_cli-0.1.0/LICENSE +21 -0
  8. ragx_cli-0.1.0/PKG-INFO +297 -0
  9. ragx_cli-0.1.0/README.md +271 -0
  10. ragx_cli-0.1.0/docs/feature-temporal-weighting.md +39 -0
  11. ragx_cli-0.1.0/pyproject.toml +52 -0
  12. ragx_cli-0.1.0/ragx-cli-plan.md +138 -0
  13. ragx_cli-0.1.0/src/ragx/__init__.py +1 -0
  14. ragx_cli-0.1.0/src/ragx/cli/__init__.py +0 -0
  15. ragx_cli-0.1.0/src/ragx/cli/app.py +105 -0
  16. ragx_cli-0.1.0/src/ragx/cli/eval_cmd.py +74 -0
  17. ragx_cli-0.1.0/src/ragx/cli/inspect_cmd.py +122 -0
  18. ragx_cli-0.1.0/src/ragx/cli/output.py +19 -0
  19. ragx_cli-0.1.0/src/ragx/cli/pipeline.py +93 -0
  20. ragx_cli-0.1.0/src/ragx/core/__init__.py +0 -0
  21. ragx_cli-0.1.0/src/ragx/core/chunking.py +183 -0
  22. ragx_cli-0.1.0/src/ragx/core/config.py +130 -0
  23. ragx_cli-0.1.0/src/ragx/core/discovery.py +79 -0
  24. ragx_cli-0.1.0/src/ragx/core/errors.py +10 -0
  25. ragx_cli-0.1.0/src/ragx/core/eval.py +93 -0
  26. ragx_cli-0.1.0/src/ragx/core/expansion.py +89 -0
  27. ragx_cli-0.1.0/src/ragx/core/fusion.py +14 -0
  28. ragx_cli-0.1.0/src/ragx/core/graph.py +37 -0
  29. ragx_cli-0.1.0/src/ragx/core/indexer.py +118 -0
  30. ragx_cli-0.1.0/src/ragx/core/models.py +67 -0
  31. ragx_cli-0.1.0/src/ragx/core/query.py +181 -0
  32. ragx_cli-0.1.0/src/ragx/core/scoring.py +48 -0
  33. ragx_cli-0.1.0/src/ragx/core/store.py +161 -0
  34. ragx_cli-0.1.0/src/ragx/core/traversal.py +68 -0
  35. ragx_cli-0.1.0/src/ragx/core/vectors.py +105 -0
  36. ragx_cli-0.1.0/src/ragx/providers/__init__.py +0 -0
  37. ragx_cli-0.1.0/src/ragx/providers/base.py +46 -0
  38. ragx_cli-0.1.0/src/ragx/providers/openai_compat.py +117 -0
  39. ragx_cli-0.1.0/src/ragx/providers/registry.py +81 -0
  40. ragx_cli-0.1.0/src/ragx/providers/st_reranker.py +28 -0
  41. ragx_cli-0.1.0/tests/__init__.py +0 -0
  42. ragx_cli-0.1.0/tests/test_chunking.py +99 -0
  43. ragx_cli-0.1.0/tests/test_cli.py +86 -0
  44. ragx_cli-0.1.0/tests/test_discovery.py +125 -0
  45. ragx_cli-0.1.0/tests/test_eval.py +237 -0
  46. ragx_cli-0.1.0/tests/test_expansion.py +65 -0
  47. ragx_cli-0.1.0/tests/test_fusion.py +72 -0
  48. ragx_cli-0.1.0/tests/test_graph.py +84 -0
  49. ragx_cli-0.1.0/tests/test_inspect.py +159 -0
  50. ragx_cli-0.1.0/tests/test_integration.py +109 -0
  51. ragx_cli-0.1.0/tests/test_providers.py +229 -0
  52. ragx_cli-0.1.0/tests/test_store.py +142 -0
  53. ragx_cli-0.1.0/tests/test_traversal.py +159 -0
  54. ragx_cli-0.1.0/tests/test_vectors.py +112 -0
@@ -0,0 +1,34 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: astral-sh/setup-uv@v6
13
+ - run: uv sync --group dev
14
+ - run: uv run pytest -q
15
+ - run: uv run ruff check src tests
16
+
17
+ publish:
18
+ needs: test
19
+ runs-on: ubuntu-latest
20
+ environment: pypi
21
+ permissions:
22
+ id-token: write
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+ - uses: astral-sh/setup-uv@v6
26
+ - name: Check tag matches package version
27
+ run: |
28
+ VERSION=$(uv version --short)
29
+ [ "v$VERSION" = "$GITHUB_REF_NAME" ] || {
30
+ echo "Tag $GITHUB_REF_NAME does not match pyproject version $VERSION" >&2
31
+ exit 1
32
+ }
33
+ - run: uv build
34
+ - run: uv publish --trusted-publishing always
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ .ragx/
8
+ uv.lock
9
+ .claude/worktrees/
@@ -0,0 +1 @@
1
+ @CLAUDE.md
@@ -0,0 +1,112 @@
1
+ # CLAUDE.md — ragx
2
+
3
+ ## What this is
4
+
5
+ `ragx` is a local CLI that indexes a corpus of files into a chunk-level **embedding similarity
6
+ graph** and answers queries via vector search + **heat-propagation graph traversal** +
7
+ **cross-encoder reranking**. Indexing is LLM-free (embeddings only); an LLM is optional at
8
+ query time for multi-query/HyDE expansion. Built agent-first: single JSON doc on stdout
9
+ (versioned schemas), logs on stderr, exit codes `0` results / `1` empty / `2` error,
10
+ byte-exact source locations, `--explain` traces justifying every result.
11
+
12
+ Design plan: `ragx-cli-plan.md` (phases, parameters, risks). Module build specs:
13
+ `CONTRACTS.md` (Phase 0–1) and `CONTRACTS-PHASE23.md` (Phase 2–3) — historical but accurate
14
+ API contracts for every core module. Deferred feature specs live in `docs/`.
15
+
16
+ ## Stack
17
+
18
+ - Python 3.12, managed with **uv** (`uv sync --group dev`, `uv run pytest`). NOT Bun/TS —
19
+ deliberate exception, see plan §2.
20
+ - typer (CLI), hnswlib (HNSW vector index, cosine), SQLite (chunks/edges/manifest, WAL),
21
+ httpx (+respx in tests), xxhash (incremental hashing), pathspec (gitignore globs).
22
+ - Providers speak **OpenAI-compatible HTTP**. Local default: LM Studio at
23
+ `http://localhost:1234/v1`. `provider="ollama"` auto-switches to `:11434/v1`.
24
+ Env fallbacks: `OPENAI_BASE_URL` (only while base_url is still the default) and
25
+ `OPENAI_API_KEY` (only when `api_key_env` is unset); explicit config always wins.
26
+ - Reranker is always local: sentence-transformers CrossEncoder `BAAI/bge-reranker-v2-m3`
27
+ (optional extra `ragx-cli[rerank]`). LM Studio has NO /v1/rerank endpoint — verified; don't try.
28
+
29
+ ## Layout (core/CLI split is the one architectural rule — MCP server later rides on it)
30
+
31
+ ```
32
+ src/ragx/
33
+ core/ # all logic lives here, CLI-independent
34
+ config.py # .ragx/config.toml load/save, DEFAULTS dict, find_root/require_root
35
+ models.py # Chunk, ChunkDraft, FileRecord, Edge, ScoredChunk, QueryOutput
36
+ store.py # SQLite: files/chunks/edges/meta; replace_edges normalizes src<dst
37
+ vectors.py # hnswlib wrapper; soft-delete via JSON sidecar; get_vectors for graph
38
+ discovery.py # file walk, root-level .gitignore, binary/size/junk-dir filters, xxh64
39
+ chunking.py # markdown/code/recursive splitters; byte-exact slices; tiny-fragment merge
40
+ graph.py # knn_edges + affected_ids (incremental edge maintenance)
41
+ traversal.py # propagate_heat: max-aggregation, query floor, frontier cap, trace
42
+ expansion.py # one LLM call -> variants + HyDE; NEVER raises (degrades to no-op)
43
+ fusion.py # Reciprocal Rank Fusion
44
+ scoring.py # min-max normalize + alpha/beta/gamma combine (renormalizes w/o rerank)
45
+ indexer.py # run_index: discover->hash->chunk->embed->HNSW+store->kNN edges
46
+ query.py # run_query: expansion->fan-out->RRF->traversal->rerank->combine; JSON serializers
47
+ eval.py # recall@5/@10 + MRR over file-level ranking, injected query_fn
48
+ providers/ # base.py protocols (Embedder/Generator/Reranker); openai_compat.py;
49
+ # st_reranker.py; registry.py factories (env-var + api_key_env resolution)
50
+ cli/ # thin shells only: app.py (init/status/config + registration),
51
+ # pipeline.py (index/query), inspect_cmd.py, eval_cmd.py, output.py
52
+ tests/ # 126 tests; mocked HTTP (respx), FakeEmbedder integration tests, no live network
53
+ ```
54
+
55
+ ## Flows
56
+
57
+ **Index** (`ragx-cli index [--changed]`): discover files (include/exclude globs, root .gitignore,
58
+ skip node_modules/.venv/hidden/binaries/>2MB) → xxhash diff (`--changed` = incremental, default
59
+ = full rebuild) → chunk (~800 tok target, chunks are exact byte slices, sub-100-char fragments
60
+ merge into neighbors) → embed batched → HNSW add → kNN edges per new chunk (k=8, cos ≥ 0.55)
61
+ + recompute edge lists of pre-existing neighbors that appear in new lists.
62
+
63
+ **Query** (`ragx-cli query "..."`): optional expansion (1 LLM call, strict-JSON parse, graceful
64
+ no-op on failure) → embed all variants, HNSW top-20 each → RRF merge = seeds → heat propagation
65
+ (2 hops, decay 0.5, heat = max not sum, neighbor admitted only if cos(query) ≥ 0.35 floor,
66
+ frontier ≤ 150) → cross-encoder rerank of top-100 shortlist → final =
67
+ 0.6·rerank + 0.25·heat + 0.15·vector (each min-max normalized; weights renormalize when a
68
+ stage is off). `--no-expand/--no-graph/--no-rerank` skip stages; `--files-only` ranks files
69
+ by sum of top-3 chunk scores; `--explain` attaches seed→parent→edge_weight→hop traces.
70
+
71
+ ## State & benchmarks (as of 2026-07-10)
72
+
73
+ Phases 0–3 of the plan are DONE and validated. Eval corpus: Albert's wiki
74
+ (`~/projects/alber70g/notes/wiki`, .md only, 636 files → 1,323 chunks → 5,246 edges); labeled
75
+ queries at `<corpus>/.ragx/queries.jsonl` (18, EN+NL). Results (recall@5 / recall@10 / MRR):
76
+ baseline .833/.833/.593 · graph-only .778/.833/.522 · graph+rerank .759/**.889**/.568 ·
77
+ full .759/**.889**/**.613**. Key finding: graph-only HURTS precision (near-duplicate neighbors
78
+ displace direct hits — parameter sweeps plateau below baseline MRR; corpus property, not a
79
+ tuning miss), but graph+rerank together deliver the recall win — one Dutch query's target is
80
+ unreachable by vector search or rerank-alone, surfaced only via a hop-1 edge then reranked
81
+ 19→4. Ship graph+rerank together; `--no-graph --no-rerank` is the explicit fast mode.
82
+
83
+ ## Gotchas
84
+
85
+ - Ornith (`mlx-community/Ornith-1.0-35B-3bit`, Albert's required expansion LLM) is a
86
+ *reasoning* model: answers land in `content` only after thinking; expansion uses
87
+ max_tokens=4096 and takes ~40 s/call. Don't lower it.
88
+ - Changing `embeddings.model` invalidates the index; run_query fails loud on manifest
89
+ mismatch; full `ragx-cli index` rebuilds.
90
+ - Chunk ids are SQLite AUTOINCREMENT rowids AND hnswlib labels — never reused after delete.
91
+ hnswlib can't enumerate deletions, hence the `vectors.hnsw.meta.json` sidecar.
92
+ - `Config.set` rejects keys not in `DEFAULTS` — add new config keys to DEFAULTS first.
93
+ - Tests must not hit the network; one provider test self-skips when sentence-transformers
94
+ is installed. Pyright import errors in editors = interpreter not set to `.venv`.
95
+ - When running ragx against the wiki corpus: `cd` there, then
96
+ `uv run --project /Users/albert/projects/alber70g/ragx ragx-cli …` — and NEVER `git add`/commit
97
+ from that directory (it's Albert's personal notes repo; `.ragx/` is gitignored there).
98
+
99
+ ## What's next / deferred
100
+
101
+ - Phase 4 (plan §5): Leiden communities over the edge list, `query --global`.
102
+ - Phase 5: MCP server as a second thin shell over `ragx.core` (split already enforced).
103
+ - Temporal weighting: full decided spec in `docs/feature-temporal-weighting.md` — opt-in
104
+ `--since/--until/--temporal recent|oldest`, date cascade filename/frontmatter → git → mtime,
105
+ schema v2 migration. Build only after it can be measured against the eval baseline.
106
+ - README packaging claims (`uvx ragx-cli`; PyPI name ragx-cli, plain `ragx` is name-blocked) are aspirational — not yet published to PyPI.
107
+
108
+ ## Dev loop
109
+
110
+ `uv sync --group dev --extra rerank` · `uv run pytest -q` (126 pass, ~5 s) ·
111
+ `uv run ruff check src tests` · file soft cap ~150 lines · expected failures raise `RagxError`
112
+ (CLI maps to exit 2). Live smoke: LM Studio must be running with the configured embedding model.
@@ -0,0 +1,166 @@
1
+ # ragx module contracts — Phase 2–3 build
2
+
3
+ Same ground rules as CONTRACTS.md (read its "Global rules" and "Report protocol" — they apply
4
+ verbatim). Own ONLY your listed files; never modify shared/core files or other modules' files.
5
+ Shared APIs you may rely on (already implemented — read them first):
6
+ `core/models.py`, `core/errors.py`, `core/config.py`, `core/store.py` (esp. `neighbors`,
7
+ `replace_edges`), `core/vectors.py` (esp. `search`, `get_vectors`), `core/query.py`
8
+ (`QueryOptions`, `QueryOutput`, `run_query`, `to_files_json`), `providers/base.py`,
9
+ `cli/output.py`. NOTE: `cli/app.py` wiring is integration-owned — CLI modules expose
10
+ `register(app)` and their tests build a fresh `typer.Typer()` and call `register` themselves.
11
+
12
+ ---
13
+
14
+ ## Module F — kNN edge construction
15
+
16
+ **Files:** `src/ragx/core/graph.py`, `tests/test_graph.py`
17
+
18
+ ```python
19
+ def knn_edges(index: VectorIndex, ids: Sequence[int], k: int, min_sim: float) -> dict[int, list[tuple[int, float]]]:
20
+ # for each id: search its own stored vector (index.get_vectors) for k+1 hits,
21
+ # drop self, keep sim >= min_sim, cap at k. Returns id -> [(neighbor_id, sim), ...] sim-desc.
22
+ def affected_ids(new_edges: dict[int, list[tuple[int, float]]]) -> set[int]:
23
+ # every neighbor id referenced in new_edges values, minus new_edges' own keys —
24
+ # the incremental-maintenance set whose edge lists the indexer must recompute.
25
+ ```
26
+
27
+ Pure functions over VectorIndex; no Store access. Batch `get_vectors` in one call.
28
+ Tests: constructed unit vectors with known cosines — self excluded, min_sim filter, k cap,
29
+ sim-desc order, affected_ids correctness, empty ids -> {}.
30
+
31
+ ---
32
+
33
+ ## Module G — heat-propagation traversal
34
+
35
+ **Files:** `src/ragx/core/traversal.py`, `tests/test_traversal.py`
36
+
37
+ ```python
38
+ @dataclass
39
+ class TraversalResult:
40
+ heat: dict[int, float] # node -> final heat
41
+ trace: dict[int, dict] # node -> {"seed": int, "parent": int | None, "edge_weight": float, "hop": int}
42
+
43
+ def propagate_heat(
44
+ seeds: dict[int, float],
45
+ neighbors_fn: Callable[[int], list[tuple[int, float]]],
46
+ query_sim_fn: Callable[[Sequence[int]], dict[int, float]], # BATCH: ids -> cosine vs query
47
+ *, hops: int = 2, decay: float = 0.5, query_floor: float = 0.35, max_frontier: int = 150,
48
+ ) -> TraversalResult:
49
+ ```
50
+
51
+ Semantics (exact):
52
+ - Init: every seed gets heat = its seed score, trace {"seed": itself, "parent": None,
53
+ "edge_weight": 0.0, "hop": 0}. Seeds are exempt from the query floor.
54
+ - For hop h = 1..hops: frontier = nodes whose heat was set/improved in the previous hop
55
+ (hop 1: the seeds). For each frontier node u and each (v, w) from neighbors_fn(u):
56
+ contribution = heat[u] * w * decay. A non-seed v is admissible only if its query similarity
57
+ >= query_floor — batch query_sim_fn over all first-seen candidates of the hop, cache results.
58
+ Aggregation is MAX, not sum: heat[v] = max(existing, contribution); on improvement, v's trace
59
+ becomes {"seed": trace[u]["seed"], "parent": u, "edge_weight": w, "hop": h}.
60
+ - Frontier cap: after each hop keep only the top `max_frontier` improved nodes by heat as the
61
+ next frontier (others keep their heat but don't propagate). Ties break by lower node id.
62
+ - A seed's heat never decreases below its seed score. hops=0 -> seeds only.
63
+ - Deterministic for identical inputs; no randomness.
64
+
65
+ Tests (hand-built adjacency dicts, no Store): max-not-sum at a hub node; per-hop decay values
66
+ exact (seed 1.0, w 0.8 -> hop1 0.4, hop2 chain 0.16); query-floor exclusion (and that an excluded
67
+ node also doesn't relay); frontier cap; trace parent-chain always reaches a seed; hops=0.
68
+
69
+ ---
70
+
71
+ ## Module H — expansion, RRF fusion, score combination
72
+
73
+ **Files:** `src/ragx/core/expansion.py`, `src/ragx/core/fusion.py`, `src/ragx/core/scoring.py`,
74
+ `tests/test_expansion.py`, `tests/test_fusion.py`
75
+
76
+ ```python
77
+ # expansion.py
78
+ @dataclass
79
+ class Expansion:
80
+ variants: list[str] # reformulations/sub-queries, excludes the original
81
+ hyde: str | None
82
+
83
+ def expand_query(gen: Generator, query: str, *, variants: int = 3, hyde: bool = True) -> Expansion:
84
+ # ONE gen.generate() call. Prompt demands strict JSON: {"queries": [...], "hyde": "..."}
85
+ # (omit hyde from the prompt when hyde=False). Parse defensively: strip markdown fences,
86
+ # take first '{' .. last '}'. On ANY failure (generator exception, bad JSON, wrong shape):
87
+ # log a warning to stderr and return Expansion([], None) — NEVER raise. Clamp to `variants`
88
+ # items; drop empty strings and case-insensitive duplicates of the original query.
89
+
90
+ # fusion.py
91
+ def rrf(rankings: Sequence[Sequence[int]], k: int = 60) -> dict[int, float]:
92
+ # Reciprocal Rank Fusion: score(d) = sum over rankings containing d of 1/(k + rank), rank 1-based.
93
+
94
+ # scoring.py
95
+ def normalize(scores: dict[int, float]) -> dict[int, float]:
96
+ # min-max to [0,1]; empty -> {}; all-equal -> all 1.0
97
+ def combine(candidates: Iterable[int], vector: dict[int, float], heat: dict[int, float],
98
+ rerank: dict[int, float] | None, *, alpha: float, beta: float, gamma: float) -> dict[int, float]:
99
+ # missing candidate scores are 0.0 raw; each component min-max normalized over candidates first.
100
+ # with rerank: alpha*r + beta*h + gamma*v
101
+ # without rerank: weights renormalized -> (beta/(beta+gamma))*h + (gamma/(beta+gamma))*v
102
+ ```
103
+
104
+ Tests: expansion with a mock Generator returning valid JSON / fenced JSON / garbage / raising;
105
+ rrf hand-computed values and stable ordering; normalize edge cases; combine with and without
106
+ rerank (weight renormalization exact).
107
+
108
+ ---
109
+
110
+ ## Module I — eval harness
111
+
112
+ **Files:** `src/ragx/core/eval.py`, `src/ragx/cli/eval_cmd.py`, `tests/test_eval.py`
113
+
114
+ `queries.jsonl`: one JSON object per line: `{"query": str, "relevant_files": [str, ...]}`
115
+ (paths relative to corpus root). Blank lines skipped; malformed line -> RagxError naming the line number.
116
+
117
+ ```python
118
+ # eval.py — decoupled from the pipeline via an injected callable
119
+ QueryFn = Callable[[str, QueryOptions], QueryOutput]
120
+
121
+ def load_queries(path: Path) -> list[dict]
122
+ def evaluate(queries: list[dict], configs: list[tuple[str, QueryOptions]], query_fn: QueryFn,
123
+ *, top: int = 10) -> dict:
124
+ # For each config and query: out = query_fn(query, opts); rank FILES via
125
+ # ragx.core.query.to_files_json(out)["files"] order. Metrics per config, averaged over queries:
126
+ # recall_at_5, recall_at_10 (fraction of relevant_files present in top k files)
127
+ # mrr (reciprocal rank of the FIRST relevant file, 0 if absent)
128
+ # Returns {"schema": "ragx.eval.v1", "top": top, "query_count": n,
129
+ # "configs": [{"name", "recall_at_5", "recall_at_10", "mrr"}, ...]}
130
+ # plus per-query detail under "queries": [{"query", "per_config": {name: {"hit_ranks": [...]}}}]
131
+
132
+ # eval_cmd.py
133
+ def register(app: typer.Typer) -> None: ...
134
+ # ragx eval QUERIES_FILE [--json] [--top N] [--configs csv]
135
+ # built-in configs: baseline (expand/graph/rerank all off), graph (only graph on),
136
+ # full (all on). Default: run all three.
137
+ # Constructs QueryOptions accordingly and query_fn from run_query + make_embedder(cfg) —
138
+ # keep this thin; integration may extend it. Human output: aligned table of config x metrics.
139
+ # Exit codes: 0 ok, 2 on missing/malformed file.
140
+ ```
141
+
142
+ Tests: metric math against a fake query_fn with canned QueryOutput fixtures (verify recall@5/10
143
+ and MRR by hand-computed values); load_queries error cases; CLI registered on a fresh Typer with
144
+ a monkeypatched evaluate/query_fn (no embeddings, no network).
145
+
146
+ ---
147
+
148
+ ## Module J — inspect commands
149
+
150
+ **Files:** `src/ragx/cli/inspect_cmd.py`, `tests/test_inspect.py`
151
+
152
+ ```python
153
+ def register(app: typer.Typer) -> None: # app.add_typer(inspect_app, name="inspect")
154
+ # ragx inspect chunk ID [--json] -> chunk text, file, line/byte range + its edges
155
+ # (neighbor id, weight, neighbor's file path)
156
+ # ragx inspect file PATH [--json] -> file record + its chunks (id, line range, first 80 chars)
157
+ # ragx inspect neighbors ID [--json] -> store.neighbors(ID) enriched with each neighbor's file/lines
158
+ ```
159
+
160
+ All read-only over Store (find root via require_root; no embeddings, no vectors file needed).
161
+ JSON schemas: `ragx.inspect.chunk.v1`, `ragx.inspect.file.v1`, `ragx.inspect.neighbors.v1`.
162
+ Exit codes: unknown chunk id or file path -> stderr message, exit 2; entity exists but has no
163
+ edges/chunks -> exit 1 (success-but-empty), matching the CLI conventions.
164
+ Tests: CliRunner on a fresh Typer + `register`; build a real Store in tmp_path with a few files/
165
+ chunks/edges directly (no embedding provider); cover all three subcommands, --json shape,
166
+ exit codes 0/1/2.
@@ -0,0 +1,229 @@
1
+ # ragx module contracts (Phase 0–1 build)
2
+
3
+ Ground truth for the module-parallel build. Each module owns exactly the files listed for it.
4
+ **Do not create or edit files outside your module** — shared types live in `core/models.py`,
5
+ provider protocols in `providers/base.py`, config in `core/config.py`, errors in `core/errors.py`
6
+ (all already written; read them first, import from them, never modify them).
7
+
8
+ Global rules:
9
+
10
+ - Python 3.12, `uv sync --group dev` to set up, `uv run pytest tests/<your tests> -q` must pass,
11
+ `uv run ruff check <your files>` must be clean.
12
+ - Soft cap ~150 lines per file; split if you blow past it.
13
+ - No live network in tests (mock HTTP with `respx`; models/backends must not be required).
14
+ - Raise `RagxError` (or a subclass) for expected failures; let bugs raise naturally.
15
+
16
+ ---
17
+
18
+ ## Module A — SQLite store
19
+
20
+ **Files:** `src/ragx/core/store.py`, `tests/test_store.py`
21
+
22
+ Single-file SQLite store for files, chunks, edges, and a key-value manifest.
23
+
24
+ ```python
25
+ class Store:
26
+ def __init__(self, path: Path): ... # open/create, apply schema, WAL mode, foreign_keys ON
27
+ def close(self) -> None: ... # also __enter__/__exit__
28
+ # manifest (embedding model, dim, schema info)
29
+ def get_meta(self, key: str) -> str | None: ...
30
+ def set_meta(self, key: str, value: str) -> None: ...
31
+ # files
32
+ def get_file_hashes(self) -> dict[str, str]: ... # path -> content_hash
33
+ def upsert_file(self, rec: FileRecord) -> None: ...
34
+ def delete_file(self, path: str) -> list[int]: ... # cascades chunks + incident edges; returns removed chunk ids
35
+ def file_count(self) -> int: ...
36
+ # chunks
37
+ def insert_chunks(self, file_path: str, drafts: Sequence[ChunkDraft]) -> list[int]: ...
38
+ def get_chunks(self, ids: Sequence[int]) -> list[Chunk]: ... # input order preserved; unknown ids skipped
39
+ def chunk_ids_for_file(self, path: str) -> list[int]: ...
40
+ def all_chunk_ids(self) -> list[int]: ...
41
+ def chunk_count(self) -> int: ...
42
+ # edges (undirected; store normalized src < dst)
43
+ def replace_edges(self, src: int, neighbors: Sequence[tuple[int, float]]) -> None: ...
44
+ def neighbors(self, chunk_id: int) -> list[tuple[int, float]]: ... # both directions, weight desc
45
+ def delete_edges_incident(self, ids: Sequence[int]) -> None: ...
46
+ def edge_count(self) -> int: ...
47
+ ```
48
+
49
+ Schema v1 (set `PRAGMA user_version = 1`):
50
+
51
+ ```sql
52
+ CREATE TABLE meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
53
+ CREATE TABLE files(path TEXT PRIMARY KEY, content_hash TEXT NOT NULL,
54
+ mtime REAL NOT NULL, chunk_count INTEGER NOT NULL);
55
+ CREATE TABLE chunks(id INTEGER PRIMARY KEY AUTOINCREMENT,
56
+ file_path TEXT NOT NULL REFERENCES files(path) ON DELETE CASCADE,
57
+ text TEXT NOT NULL, byte_start INTEGER NOT NULL, byte_end INTEGER NOT NULL,
58
+ line_start INTEGER NOT NULL, line_end INTEGER NOT NULL);
59
+ CREATE INDEX chunks_file ON chunks(file_path);
60
+ CREATE TABLE edges(src INTEGER NOT NULL, dst INTEGER NOT NULL, weight REAL NOT NULL,
61
+ PRIMARY KEY(src, dst)) WITHOUT ROWID;
62
+ CREATE INDEX edges_dst ON edges(dst);
63
+ ```
64
+
65
+ `AUTOINCREMENT` is deliberate: chunk ids are HNSW labels and must never be reused after deletion.
66
+ `insert_chunks` requires the file row to exist first (upsert_file before insert_chunks).
67
+
68
+ Tests must cover: roundtrip of every entity; delete_file cascade removes chunks and edges touching
69
+ them (both src and dst side); replace_edges normalization + dedup (inserting (5,3,w) then querying
70
+ neighbors(3) and neighbors(5) both work); id monotonicity after delete + reinsert; meta roundtrip.
71
+
72
+ ---
73
+
74
+ ## Module B — Providers
75
+
76
+ **Files:** `src/ragx/providers/openai_compat.py`, `src/ragx/providers/st_reranker.py`,
77
+ `src/ragx/providers/registry.py`, `tests/test_providers.py`
78
+
79
+ ```python
80
+ # openai_compat.py — used for LM Studio (localhost:1234/v1), Ollama's /v1, and OpenAI itself
81
+ class OpenAICompatEmbedder: # implements providers.base.Embedder
82
+ def __init__(self, base_url: str, model: str, doc_prefix: str = "", query_prefix: str = "",
83
+ api_key: str | None = None, batch_size: int = 32, timeout: float = 120.0): ...
84
+ # POST {base_url}/embeddings with {"model", "input": [prefixed texts]}
85
+ # batches of batch_size; 3 retries with exponential backoff on 5xx/connect errors
86
+ # dimension(): lazily embeds one probe string, caches the length
87
+ # empty input list -> [] without any HTTP call
88
+
89
+ class OpenAICompatGenerator: # implements providers.base.Generator
90
+ def __init__(self, base_url: str, model: str, api_key: str | None = None,
91
+ timeout: float = 300.0): ...
92
+ # POST {base_url}/chat/completions, messages=[system, user], temperature=0.3
93
+ # returns choices[0].message.content
94
+
95
+ # st_reranker.py — lazy `import sentence_transformers` inside __init__;
96
+ # ImportError -> RagxError("... install with: uv pip install 'ragx[rerank]'")
97
+ class STReranker: # implements providers.base.Reranker
98
+ def __init__(self, model: str): ...
99
+ def score(self, query: str, texts: Sequence[str]) -> list[float]: ... # CrossEncoder.predict
100
+
101
+ # registry.py — factories reading Config (see core/config.py DEFAULTS for keys)
102
+ def make_embedder(cfg: Config) -> Embedder: ...
103
+ # embeddings.provider: "openai" -> OpenAICompatEmbedder with [embeddings] settings
104
+ # "ollama" -> same class, base_url defaulting to http://localhost:11434/v1
105
+ # unknown provider -> RagxError
106
+ def make_generator(cfg: Config) -> Generator | None: ... # None when expansion.enabled is false
107
+ def make_reranker(cfg: Config) -> Reranker | None: ...
108
+ # None when rerank.enabled is false; on RagxError from missing extra: log warning to stderr, return None
109
+ ```
110
+
111
+ Tests: respx-mocked embeddings (prefix application, batching — e.g. batch_size=2 with 5 inputs →
112
+ 3 requests; order preserved across batches), retry on a 500 then success, dimension() caching
113
+ (one probe call), generator payload shape, registry dispatch incl. disabled → None. No real network.
114
+
115
+ ---
116
+
117
+ ## Module C — Discovery + chunking
118
+
119
+ **Files:** `src/ragx/core/discovery.py`, `src/ragx/core/chunking.py`,
120
+ `tests/test_discovery.py`, `tests/test_chunking.py`
121
+
122
+ ```python
123
+ # discovery.py
124
+ def discover_files(root: Path, include: list[str], exclude: list[str],
125
+ respect_gitignore: bool = True) -> list[str]: ...
126
+ # sorted relative POSIX paths; include/exclude are gitwildmatch globs (use pathspec)
127
+ # always skipped: .ragx/, .git/, hidden dirs, binaries (NUL byte in first 8KB), files > 2MB
128
+ # gitignore: root-level .gitignore only (MVP; document the limitation)
129
+ def hash_file(path: Path) -> str: ... # xxhash.xxh64 hexdigest of file bytes
130
+
131
+ # chunking.py
132
+ def chunk_text(text: str, path: str, size_tokens: int = 800, overlap: float = 0.15) -> list[ChunkDraft]: ...
133
+ ```
134
+
135
+ Chunking rules:
136
+
137
+ - Token estimate: 1 token ≈ 4 chars → target = size_tokens*4 chars, hard max 1.5× target.
138
+ - Dispatch on extension: `.md`/`.markdown` → markdown splitter; code
139
+ (`.py .ts .js .tsx .jsx .go .rs .java .rb`) → code splitter; anything else → recursive splitter.
140
+ - Markdown: split at heading lines (`^#{1,6} `), heading stays with its section; adjacent small
141
+ sections merge until ≈target; oversized sections fall through to the recursive splitter.
142
+ - Code: split at top-level `def`/`class`/`function`/`fn`/`func` boundaries (regex, no tree-sitter
143
+ in MVP); oversized pieces fall through to the recursive splitter.
144
+ - Recursive: split by separators ["\n\n", "\n", ". ", " "] to fit max, with `overlap` fraction of
145
+ the previous chunk's tail prepended as range overlap.
146
+ - **Invariant (test this):** every draft's `text` == `text_bytes[byte_start:byte_end].decode("utf-8")`
147
+ where text_bytes = the original file's utf-8 bytes — chunks are exact slices of the source
148
+ (overlap = overlapping ranges, never synthesized text). line_start/line_end are 1-based inclusive.
149
+ - Empty/whitespace-only files → []. Never emit empty-text drafts.
150
+
151
+ Tests: the slice invariant on multi-byte (emoji/accented) content; markdown heading split + small-
152
+ section merging; oversized-section fallback; overlap ranges actually overlap; discovery honors
153
+ include/exclude/.gitignore, skips binaries and .ragx.
154
+
155
+ ---
156
+
157
+ ## Module D — HNSW vector index
158
+
159
+ **Files:** `src/ragx/core/vectors.py`, `tests/test_vectors.py`
160
+
161
+ ```python
162
+ class VectorIndex:
163
+ @classmethod
164
+ def create(cls, path: Path, dim: int, capacity: int = 2048) -> VectorIndex: ...
165
+ @classmethod
166
+ def load(cls, path: Path, dim: int) -> VectorIndex: ... # missing file -> RagxError
167
+ def add(self, ids: Sequence[int], vectors: Sequence[Sequence[float]]) -> None: ...
168
+ def search(self, vector: Sequence[float], k: int) -> list[tuple[int, float]]: ...
169
+ def mark_deleted(self, ids: Sequence[int]) -> None: ...
170
+ def save(self) -> None: ...
171
+ @property
172
+ def count(self) -> int: ... # active (non-deleted) element count
173
+ ```
174
+
175
+ - hnswlib `space="cosine"`, M=16, ef_construction=200, query-time ef = max(100, 2k).
176
+ - `search` returns (id, cosine_similarity) sorted desc; similarity = 1 − hnswlib distance,
177
+ clamped to [0,1]; k silently clamped to active count; empty index → [].
178
+ - `add` auto-resizes (double capacity) when full. ids are arbitrary non-reused ints (SQLite rowids).
179
+ - Deleted elements never appear in results (hnswlib `mark_deleted`); deleting unknown ids is a no-op.
180
+ - save/load roundtrip preserves search results and deletions.
181
+
182
+ Tests: nearest-neighbor sanity on constructed vectors (a query nearest to its own cluster),
183
+ resize past initial capacity, delete-then-search exclusion, save/load roundtrip, empty-index search.
184
+
185
+ ---
186
+
187
+ ## Module E — CLI shell
188
+
189
+ **Files:** `src/ragx/cli/app.py`, `src/ragx/cli/output.py`, `tests/test_cli.py`
190
+
191
+ typer app exposing `init`, `status`, `config`. (`index`/`query` are wired in a later integration
192
+ pass — the app must be importable and testable without them.)
193
+
194
+ Conventions (these ARE the product for agent callers — be exact):
195
+
196
+ - `--json` on `status`: emit exactly one JSON document to stdout, nothing else on stdout.
197
+ - All logging/diagnostics → stderr (configure `logging` to stderr in `main()`).
198
+ - Exit codes: 0 = success with results, 1 = success but empty result, 2 = error.
199
+ Map `RagxError` → stderr message + exit 2 (no traceback); unexpected exceptions may traceback.
200
+
201
+ ```python
202
+ # output.py
203
+ def emit_json(doc: dict) -> None: ... # json.dumps to stdout with trailing newline
204
+ def fail(msg: str, code: int = 2) -> NoReturn: ...
205
+
206
+ # app.py
207
+ app = typer.Typer(...)
208
+ # ragx init [path] -> write_default_config at path (default cwd); error (exit 2) if .ragx exists;
209
+ # prints created config path
210
+ # ragx status [--json] -> root path, embedding model/provider, counts (files/chunks/edges) read via
211
+ # Store if index.db exists else zeros, schema "ragx.status.v1" in JSON mode
212
+ # ragx config get KEY / ragx config set KEY VALUE -> Config.get / Config.set + save
213
+ def main() -> None: ... # entry point declared in pyproject [project.scripts]
214
+ ```
215
+
216
+ Module E may import Store (module A) **only** inside `status` behind a
217
+ `try/except ImportError` fallback to zeros, so the module builds independently; the import will
218
+ resolve after integration. Tests: use `typer.testing.CliRunner` + tmp_path; cover init (fresh +
219
+ already-initialized), config get/set roundtrip persisted to disk, status --json shape + stdout
220
+ purity (stdout parses as JSON), exit codes incl. `config get bogus.key` → 2.
221
+
222
+ ---
223
+
224
+ ## Report protocol (all modules)
225
+
226
+ When done: `git add -A && git commit -m "<module>: <summary>"` in your worktree, then report:
227
+ branch (`git rev-parse --abbrev-ref HEAD`), commit SHA, pytest pass/fail counts, ruff status,
228
+ and any deviations from this contract. On any blocker: STOP — report the exact error output and
229
+ what you attempted instead of improvising around the contract.
ragx_cli-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Albert Groothedde
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.