attune-rag 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.
@@ -0,0 +1,17 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Licensed under the Apache License, Version 2.0 (the "License");
6
+ you may not use this file except in compliance with the License.
7
+ You may obtain a copy of the License at
8
+
9
+ http://www.apache.org/licenses/LICENSE-2.0
10
+
11
+ Unless required by applicable law or agreed to in writing, software
12
+ distributed under the License is distributed on an "AS IS" BASIS,
13
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ See the License for the specific language governing permissions and
15
+ limitations under the License.
16
+
17
+ Copyright 2026 Patrick Roebuck / Smart AI Memory
@@ -0,0 +1,167 @@
1
+ Metadata-Version: 2.4
2
+ Name: attune-rag
3
+ Version: 0.1.0
4
+ Summary: Lightweight, LLM-agnostic RAG pipeline with pluggable corpora. Works with Claude, OpenAI, Gemini, or any LLM.
5
+ Author-email: Patrick Roebuck <admin@smartaimemory.com>
6
+ License: Apache License
7
+ Version 2.0, January 2004
8
+ http://www.apache.org/licenses/
9
+
10
+ Licensed under the Apache License, Version 2.0 (the "License");
11
+ you may not use this file except in compliance with the License.
12
+ You may obtain a copy of the License at
13
+
14
+ http://www.apache.org/licenses/LICENSE-2.0
15
+
16
+ Unless required by applicable law or agreed to in writing, software
17
+ distributed under the License is distributed on an "AS IS" BASIS,
18
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
19
+ See the License for the specific language governing permissions and
20
+ limitations under the License.
21
+
22
+ Copyright 2026 Patrick Roebuck / Smart AI Memory
23
+
24
+ Project-URL: Homepage, https://github.com/Smart-AI-Memory/attune-rag
25
+ Project-URL: Repository, https://github.com/Smart-AI-Memory/attune-rag
26
+ Project-URL: Documentation, https://github.com/Smart-AI-Memory/attune-rag/blob/main/README.md
27
+ Project-URL: Changelog, https://github.com/Smart-AI-Memory/attune-rag/blob/main/CHANGELOG.md
28
+ Project-URL: Issues, https://github.com/Smart-AI-Memory/attune-rag/issues
29
+ Keywords: rag,retrieval-augmented-generation,llm,claude,openai,gemini,grounding,provenance,attune
30
+ Classifier: Development Status :: 3 - Alpha
31
+ Classifier: Intended Audience :: Developers
32
+ Classifier: License :: OSI Approved :: Apache Software License
33
+ Classifier: Programming Language :: Python :: 3
34
+ Classifier: Programming Language :: Python :: 3.10
35
+ Classifier: Programming Language :: Python :: 3.11
36
+ Classifier: Programming Language :: Python :: 3.12
37
+ Classifier: Programming Language :: Python :: 3.13
38
+ Classifier: Topic :: Software Development :: Libraries
39
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
40
+ Requires-Python: >=3.10
41
+ Description-Content-Type: text/markdown
42
+ License-File: LICENSE
43
+ Requires-Dist: structlog>=24.0
44
+ Requires-Dist: jinja2>=3.1.0
45
+ Requires-Dist: pyyaml>=6.0
46
+ Provides-Extra: attune-help
47
+ Requires-Dist: attune-help<0.6,>=0.5.1; extra == "attune-help"
48
+ Provides-Extra: claude
49
+ Requires-Dist: anthropic<1.0,>=0.40.0; extra == "claude"
50
+ Provides-Extra: openai
51
+ Requires-Dist: openai<2.0,>=1.40.0; extra == "openai"
52
+ Provides-Extra: gemini
53
+ Requires-Dist: google-genai<2.0,>=1.0; extra == "gemini"
54
+ Provides-Extra: all
55
+ Requires-Dist: attune-help<0.6,>=0.5.1; extra == "all"
56
+ Requires-Dist: anthropic<1.0,>=0.40.0; extra == "all"
57
+ Requires-Dist: openai<2.0,>=1.40.0; extra == "all"
58
+ Requires-Dist: google-genai<2.0,>=1.0; extra == "all"
59
+ Provides-Extra: dev
60
+ Requires-Dist: pytest>=8.0; extra == "dev"
61
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
62
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
63
+ Requires-Dist: ruff>=0.4.0; extra == "dev"
64
+ Requires-Dist: black>=24.0; extra == "dev"
65
+ Requires-Dist: build>=1.0; extra == "dev"
66
+ Requires-Dist: twine>=5.0; extra == "dev"
67
+ Requires-Dist: attune-help<0.6,>=0.5.1; extra == "dev"
68
+ Requires-Dist: anthropic<1.0,>=0.40.0; extra == "dev"
69
+ Requires-Dist: openai<2.0,>=1.40.0; extra == "dev"
70
+ Requires-Dist: google-genai<2.0,>=1.0; extra == "dev"
71
+ Dynamic: license-file
72
+
73
+ # attune-rag
74
+
75
+ Lightweight, LLM-agnostic RAG pipeline with pluggable
76
+ corpora. Works with Claude, OpenAI, Gemini, or any LLM.
77
+
78
+ - **No LLM SDK at install time.** All provider deps are
79
+ optional extras.
80
+ - **Pluggable corpus.** Use attune-help (the default), any
81
+ markdown directory, or your own `CorpusProtocol`.
82
+ - **Returns a prompt string** by default — send it to
83
+ whatever LLM you like. Optional provider adapters ship
84
+ convenience wrappers.
85
+
86
+ ## Install
87
+
88
+ ```bash
89
+ pip install attune-rag # core only
90
+ pip install 'attune-rag[attune-help]' # + bundled help corpus
91
+ pip install 'attune-rag[claude]' # + Claude adapter
92
+ pip install 'attune-rag[openai]' # + OpenAI adapter
93
+ pip install 'attune-rag[gemini]' # + Gemini adapter
94
+ pip install 'attune-rag[all]' # everything
95
+ ```
96
+
97
+ ## Quick start — Claude
98
+
99
+ ```bash
100
+ pip install 'attune-rag[attune-help,claude]'
101
+ ```
102
+
103
+ ```python
104
+ import asyncio
105
+ from attune_rag import RagPipeline
106
+
107
+ async def main():
108
+ pipeline = RagPipeline() # defaults to AttuneHelpCorpus
109
+ response, result = await pipeline.run_and_generate(
110
+ "How do I run a security audit with attune?",
111
+ provider="claude",
112
+ )
113
+ print(response)
114
+ print("\nSources:", [h.entry.path for h in result.citation.hits])
115
+
116
+ asyncio.run(main())
117
+ ```
118
+
119
+ ## Quick start — OpenAI
120
+
121
+ ```bash
122
+ pip install 'attune-rag[attune-help,openai]'
123
+ ```
124
+
125
+ ```python
126
+ response, result = await pipeline.run_and_generate(
127
+ "...", provider="openai", model="gpt-4o",
128
+ )
129
+ ```
130
+
131
+ ## Quick start — Gemini
132
+
133
+ ```bash
134
+ pip install 'attune-rag[attune-help,gemini]'
135
+ ```
136
+
137
+ ```python
138
+ response, result = await pipeline.run_and_generate(
139
+ "...", provider="gemini", model="gemini-1.5-pro",
140
+ )
141
+ ```
142
+
143
+ ## Quick start — custom corpus, any LLM
144
+
145
+ ```python
146
+ from pathlib import Path
147
+ from attune_rag import RagPipeline, DirectoryCorpus
148
+
149
+ pipeline = RagPipeline(corpus=DirectoryCorpus(Path("./my-docs")))
150
+ result = pipeline.run("How do I...?")
151
+
152
+ # Send result.augmented_prompt to whatever LLM you use.
153
+ # The pipeline itself does NOT call an LLM unless you use
154
+ # run_and_generate or call a provider adapter yourself.
155
+ ```
156
+
157
+ ## Status
158
+
159
+ v0.1.0 — initial release. Part of the attune ecosystem
160
+ ([attune-ai](https://github.com/Smart-AI-Memory/attune-ai),
161
+ [attune-help](https://github.com/Smart-AI-Memory/attune-help),
162
+ [attune-author](https://github.com/Smart-AI-Memory/attune-author)).
163
+
164
+ ## License
165
+
166
+ Apache 2.0. See
167
+ [LICENSE](https://github.com/Smart-AI-Memory/attune-rag/blob/main/LICENSE).
@@ -0,0 +1,95 @@
1
+ # attune-rag
2
+
3
+ Lightweight, LLM-agnostic RAG pipeline with pluggable
4
+ corpora. Works with Claude, OpenAI, Gemini, or any LLM.
5
+
6
+ - **No LLM SDK at install time.** All provider deps are
7
+ optional extras.
8
+ - **Pluggable corpus.** Use attune-help (the default), any
9
+ markdown directory, or your own `CorpusProtocol`.
10
+ - **Returns a prompt string** by default — send it to
11
+ whatever LLM you like. Optional provider adapters ship
12
+ convenience wrappers.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pip install attune-rag # core only
18
+ pip install 'attune-rag[attune-help]' # + bundled help corpus
19
+ pip install 'attune-rag[claude]' # + Claude adapter
20
+ pip install 'attune-rag[openai]' # + OpenAI adapter
21
+ pip install 'attune-rag[gemini]' # + Gemini adapter
22
+ pip install 'attune-rag[all]' # everything
23
+ ```
24
+
25
+ ## Quick start — Claude
26
+
27
+ ```bash
28
+ pip install 'attune-rag[attune-help,claude]'
29
+ ```
30
+
31
+ ```python
32
+ import asyncio
33
+ from attune_rag import RagPipeline
34
+
35
+ async def main():
36
+ pipeline = RagPipeline() # defaults to AttuneHelpCorpus
37
+ response, result = await pipeline.run_and_generate(
38
+ "How do I run a security audit with attune?",
39
+ provider="claude",
40
+ )
41
+ print(response)
42
+ print("\nSources:", [h.entry.path for h in result.citation.hits])
43
+
44
+ asyncio.run(main())
45
+ ```
46
+
47
+ ## Quick start — OpenAI
48
+
49
+ ```bash
50
+ pip install 'attune-rag[attune-help,openai]'
51
+ ```
52
+
53
+ ```python
54
+ response, result = await pipeline.run_and_generate(
55
+ "...", provider="openai", model="gpt-4o",
56
+ )
57
+ ```
58
+
59
+ ## Quick start — Gemini
60
+
61
+ ```bash
62
+ pip install 'attune-rag[attune-help,gemini]'
63
+ ```
64
+
65
+ ```python
66
+ response, result = await pipeline.run_and_generate(
67
+ "...", provider="gemini", model="gemini-1.5-pro",
68
+ )
69
+ ```
70
+
71
+ ## Quick start — custom corpus, any LLM
72
+
73
+ ```python
74
+ from pathlib import Path
75
+ from attune_rag import RagPipeline, DirectoryCorpus
76
+
77
+ pipeline = RagPipeline(corpus=DirectoryCorpus(Path("./my-docs")))
78
+ result = pipeline.run("How do I...?")
79
+
80
+ # Send result.augmented_prompt to whatever LLM you use.
81
+ # The pipeline itself does NOT call an LLM unless you use
82
+ # run_and_generate or call a provider adapter yourself.
83
+ ```
84
+
85
+ ## Status
86
+
87
+ v0.1.0 — initial release. Part of the attune ecosystem
88
+ ([attune-ai](https://github.com/Smart-AI-Memory/attune-ai),
89
+ [attune-help](https://github.com/Smart-AI-Memory/attune-help),
90
+ [attune-author](https://github.com/Smart-AI-Memory/attune-author)).
91
+
92
+ ## License
93
+
94
+ Apache 2.0. See
95
+ [LICENSE](https://github.com/Smart-AI-Memory/attune-rag/blob/main/LICENSE).
@@ -0,0 +1,95 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "attune-rag"
7
+ version = "0.1.0"
8
+ description = "Lightweight, LLM-agnostic RAG pipeline with pluggable corpora. Works with Claude, OpenAI, Gemini, or any LLM."
9
+ readme = {file = "README.md", content-type = "text/markdown"}
10
+ requires-python = ">=3.10"
11
+ license = {file = "LICENSE"}
12
+ authors = [
13
+ {name = "Patrick Roebuck", email = "admin@smartaimemory.com"}
14
+ ]
15
+ keywords = [
16
+ "rag",
17
+ "retrieval-augmented-generation",
18
+ "llm",
19
+ "claude",
20
+ "openai",
21
+ "gemini",
22
+ "grounding",
23
+ "provenance",
24
+ "attune",
25
+ ]
26
+ classifiers = [
27
+ "Development Status :: 3 - Alpha",
28
+ "Intended Audience :: Developers",
29
+ "License :: OSI Approved :: Apache Software License",
30
+ "Programming Language :: Python :: 3",
31
+ "Programming Language :: Python :: 3.10",
32
+ "Programming Language :: Python :: 3.11",
33
+ "Programming Language :: Python :: 3.12",
34
+ "Programming Language :: Python :: 3.13",
35
+ "Topic :: Software Development :: Libraries",
36
+ "Topic :: Text Processing :: Markup :: Markdown",
37
+ ]
38
+ dependencies = [
39
+ "structlog>=24.0",
40
+ "jinja2>=3.1.0",
41
+ "pyyaml>=6.0",
42
+ ]
43
+
44
+ [project.optional-dependencies]
45
+ attune-help = ["attune-help>=0.5.1,<0.6"]
46
+ claude = ["anthropic>=0.40.0,<1.0"]
47
+ openai = ["openai>=1.40.0,<2.0"]
48
+ gemini = ["google-genai>=1.0,<2.0"]
49
+ all = [
50
+ "attune-help>=0.5.1,<0.6",
51
+ "anthropic>=0.40.0,<1.0",
52
+ "openai>=1.40.0,<2.0",
53
+ "google-genai>=1.0,<2.0",
54
+ ]
55
+ dev = [
56
+ "pytest>=8.0",
57
+ "pytest-asyncio>=0.23",
58
+ "pytest-cov>=4.0",
59
+ "ruff>=0.4.0",
60
+ "black>=24.0",
61
+ "build>=1.0",
62
+ "twine>=5.0",
63
+ "attune-help>=0.5.1,<0.6",
64
+ "anthropic>=0.40.0,<1.0",
65
+ "openai>=1.40.0,<2.0",
66
+ "google-genai>=1.0,<2.0",
67
+ ]
68
+
69
+ [project.scripts]
70
+ attune-rag = "attune_rag.cli:main"
71
+
72
+ [project.urls]
73
+ Homepage = "https://github.com/Smart-AI-Memory/attune-rag"
74
+ Repository = "https://github.com/Smart-AI-Memory/attune-rag"
75
+ Documentation = "https://github.com/Smart-AI-Memory/attune-rag/blob/main/README.md"
76
+ Changelog = "https://github.com/Smart-AI-Memory/attune-rag/blob/main/CHANGELOG.md"
77
+ Issues = "https://github.com/Smart-AI-Memory/attune-rag/issues"
78
+
79
+ [tool.setuptools.packages.find]
80
+ where = ["src"]
81
+
82
+ [tool.pytest.ini_options]
83
+ testpaths = ["tests"]
84
+ asyncio_mode = "auto"
85
+
86
+ [tool.ruff]
87
+ line-length = 100
88
+ target-version = "py310"
89
+
90
+ [tool.ruff.lint]
91
+ select = ["E", "F", "W", "I", "B", "BLE", "UP"]
92
+
93
+ [tool.black]
94
+ line-length = 100
95
+ target-version = ["py310"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,44 @@
1
+ """Lightweight, LLM-agnostic RAG pipeline.
2
+
3
+ Core public API:
4
+
5
+ - RagPipeline — orchestrates retrieval + prompt assembly
6
+ - RagResult — return value of RagPipeline.run
7
+ - CitationRecord, CitedSource — provenance records
8
+ - CorpusProtocol, RetrievalEntry — pluggable corpus interface
9
+ - DirectoryCorpus — generic markdown directory loader
10
+ - AttuneHelpCorpus — attune-help bundled corpus (opt. dep)
11
+ - KeywordRetriever — default retriever (no embedding)
12
+ - build_augmented_prompt — prompt template helper
13
+
14
+ See spec: attune-ai/.claude/plans/feature-rag-code-
15
+ grounding-2026-04-17.md (v4.0).
16
+
17
+ Subsequent tasks in the spec fill in each module.
18
+ """
19
+
20
+ __version__ = "0.1.0"
21
+
22
+ # NOTE: Imports are added incrementally as tasks 1.2-1.8
23
+ # land. For task 1.1 (scaffold only) the public names
24
+ # below resolve to minimal stubs so imports work for CI.
25
+ from .corpus import CorpusProtocol, DirectoryCorpus, RetrievalEntry
26
+ from .pipeline import RagPipeline, RagResult
27
+ from .prompts import build_augmented_prompt
28
+ from .provenance import CitationRecord, CitedSource, format_citations_markdown
29
+ from .retrieval import KeywordRetriever, RetrievalHit, RetrieverProtocol
30
+
31
+ __all__ = [
32
+ "RagPipeline",
33
+ "RagResult",
34
+ "CitationRecord",
35
+ "CitedSource",
36
+ "format_citations_markdown",
37
+ "CorpusProtocol",
38
+ "RetrievalEntry",
39
+ "DirectoryCorpus",
40
+ "KeywordRetriever",
41
+ "RetrievalHit",
42
+ "RetrieverProtocol",
43
+ "build_augmented_prompt",
44
+ ]
@@ -0,0 +1,182 @@
1
+ """Retrieval-quality benchmark runner.
2
+
3
+ Run via::
4
+
5
+ python -m attune_rag.benchmark
6
+ python -m attune_rag.benchmark --queries path/to/queries.yaml
7
+ python -m attune_rag.benchmark --min-precision 0.70
8
+
9
+ Prints precision@1, recall@3, and mean latency. Exits 1 if
10
+ precision@1 falls below ``--min-precision`` so CI can gate.
11
+
12
+ Queries file format matches tests/golden/queries.yaml.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import sys
19
+ import time
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+
24
+ def _default_queries_path() -> Path:
25
+ return Path(__file__).resolve().parent.parent.parent / "tests" / "golden" / "queries.yaml"
26
+
27
+
28
+ def _load_queries(path: Path) -> list[dict[str, Any]]:
29
+ import yaml
30
+
31
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
32
+ queries = data.get("queries", [])
33
+ if not queries:
34
+ raise ValueError(f"No queries found in {path}")
35
+ return queries
36
+
37
+
38
+ def _run_benchmark(
39
+ queries: list[dict[str, Any]],
40
+ k: int,
41
+ ) -> dict[str, Any]:
42
+ from . import RagPipeline
43
+
44
+ pipeline = RagPipeline()
45
+ retriever_name = type(pipeline.retriever).__name__
46
+ corpus_name = pipeline.corpus.name
47
+
48
+ precision_hits = 0 # top-1 matches an expected path
49
+ recall_hits = 0 # any expected path is in top-k hits
50
+ latencies_ms: list[float] = []
51
+ per_query_results: list[dict[str, Any]] = []
52
+
53
+ for entry in queries:
54
+ expected = set(entry.get("expected_in_top_3", []))
55
+ start = time.perf_counter()
56
+ result = pipeline.run(entry["query"], k=k)
57
+ latencies_ms.append((time.perf_counter() - start) * 1000.0)
58
+
59
+ hit_paths = [h.template_path for h in result.citation.hits]
60
+ top1_hit = bool(hit_paths and hit_paths[0] in expected)
61
+ topk_hit = bool(set(hit_paths) & expected)
62
+
63
+ if top1_hit:
64
+ precision_hits += 1
65
+ if topk_hit:
66
+ recall_hits += 1
67
+
68
+ per_query_results.append(
69
+ {
70
+ "id": entry["id"],
71
+ "difficulty": entry.get("difficulty", ""),
72
+ "query": entry["query"],
73
+ "expected": sorted(expected),
74
+ "actual": hit_paths,
75
+ "top1_match": top1_hit,
76
+ "topk_match": topk_hit,
77
+ }
78
+ )
79
+
80
+ total = len(queries)
81
+ return {
82
+ "retriever": retriever_name,
83
+ "corpus": corpus_name,
84
+ "total_queries": total,
85
+ "precision_at_1": precision_hits / total if total else 0.0,
86
+ "recall_at_k": recall_hits / total if total else 0.0,
87
+ "k": k,
88
+ "mean_latency_ms": sum(latencies_ms) / len(latencies_ms) if latencies_ms else 0.0,
89
+ "max_latency_ms": max(latencies_ms) if latencies_ms else 0.0,
90
+ "per_query": per_query_results,
91
+ }
92
+
93
+
94
+ def _print_summary(report: dict[str, Any], verbose: bool) -> None:
95
+ total = report["total_queries"]
96
+ p1 = report["precision_at_1"]
97
+ rk = report["recall_at_k"]
98
+ k = report["k"]
99
+ print(f"Retriever: {report['retriever']}")
100
+ print(f"Corpus: {report['corpus']}")
101
+ print(f"Queries: {total}")
102
+ print(f"Precision@1: {p1:.2%} ({int(p1 * total)}/{total})")
103
+ print(f"Recall@{k}: {rk:.2%} ({int(rk * total)}/{total})")
104
+ print(f"Mean latency: {report['mean_latency_ms']:.2f}ms")
105
+ print(f"Max latency: {report['max_latency_ms']:.2f}ms")
106
+
107
+ by_difficulty: dict[str, dict[str, int]] = {}
108
+ for q in report["per_query"]:
109
+ d = q.get("difficulty", "unknown") or "unknown"
110
+ bucket = by_difficulty.setdefault(d, {"total": 0, "top1": 0, "topk": 0})
111
+ bucket["total"] += 1
112
+ if q["top1_match"]:
113
+ bucket["top1"] += 1
114
+ if q["topk_match"]:
115
+ bucket["topk"] += 1
116
+
117
+ if by_difficulty:
118
+ print("\nBreakdown by difficulty:")
119
+ for d in ("easy", "medium", "hard"):
120
+ b = by_difficulty.get(d)
121
+ if not b:
122
+ continue
123
+ print(f" {d:7} P@1={b['top1']}/{b['total']} R@{k}={b['topk']}/{b['total']}")
124
+
125
+ if verbose:
126
+ print("\nPer-query detail:")
127
+ for q in report["per_query"]:
128
+ mark = "OK" if q["topk_match"] else "MISS"
129
+ top1 = "*" if q["top1_match"] else " "
130
+ print(f" [{mark}{top1}] {q['id']} ({q['difficulty']}): {q['query']!r}")
131
+ if not q["topk_match"]:
132
+ print(f" expected: {q['expected']}")
133
+ print(f" actual: {q['actual']}")
134
+
135
+
136
+ def main(argv: list[str] | None = None) -> int:
137
+ parser = argparse.ArgumentParser(
138
+ prog="attune-rag-benchmark",
139
+ description="Measure retrieval precision@1 / recall@k against a golden-query set.",
140
+ )
141
+ parser.add_argument(
142
+ "--queries",
143
+ type=Path,
144
+ default=_default_queries_path(),
145
+ help=f"Path to queries.yaml (default: {_default_queries_path()})",
146
+ )
147
+ parser.add_argument("-k", type=int, default=3, help="Top-k for recall (default 3)")
148
+ parser.add_argument(
149
+ "--min-precision",
150
+ type=float,
151
+ default=0.70,
152
+ help="Fail (exit 1) if precision@1 falls below this (default 0.70)",
153
+ )
154
+ parser.add_argument(
155
+ "--verbose",
156
+ "-v",
157
+ action="store_true",
158
+ help="Print per-query detail including misses",
159
+ )
160
+ args = parser.parse_args(argv)
161
+
162
+ if not args.queries.is_file():
163
+ print(f"Queries file not found: {args.queries}", file=sys.stderr)
164
+ return 2
165
+
166
+ queries = _load_queries(args.queries)
167
+ report = _run_benchmark(queries, k=args.k)
168
+ _print_summary(report, verbose=args.verbose)
169
+
170
+ if report["precision_at_1"] < args.min_precision:
171
+ print(
172
+ f"\nFAIL: precision@1 {report['precision_at_1']:.2%} "
173
+ f"< gate {args.min_precision:.2%}",
174
+ file=sys.stderr,
175
+ )
176
+ return 1
177
+ print(f"\nPASS: precision@1 meets gate ({args.min_precision:.2%}).")
178
+ return 0
179
+
180
+
181
+ if __name__ == "__main__":
182
+ sys.exit(main())