longprobe 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.
longprobe/__init__.py ADDED
@@ -0,0 +1,122 @@
1
+ """
2
+ LongProbe — RAG retrieval regression testing harness.
3
+
4
+ Define Golden Questions once. Run ``longprobe check`` on every commit.
5
+ Get an exact diff of which document chunks were lost — before your users notice.
6
+ """
7
+
8
+ from longprobe.core.golden import GoldenQuestion, GoldenSet
9
+ from longprobe.core.scorer import RecallScorer, QuestionResult, ProbeReport
10
+ from longprobe.core.embedder import QueryEmbedder
11
+ from longprobe.core.baseline import BaselineStore
12
+ from longprobe.core.diff import DiffReporter, RegressionDiff
13
+ from longprobe.config import ProbeConfig
14
+ from longprobe.adapters.base import AbstractRetrieverAdapter
15
+ from longprobe.adapters import create_adapter
16
+
17
+ __version__ = "0.1.0"
18
+
19
+ __all__ = [
20
+ "GoldenQuestion",
21
+ "GoldenSet",
22
+ "RecallScorer",
23
+ "QuestionResult",
24
+ "ProbeReport",
25
+ "QueryEmbedder",
26
+ "BaselineStore",
27
+ "DiffReporter",
28
+ "RegressionDiff",
29
+ "ProbeConfig",
30
+ "AbstractRetrieverAdapter",
31
+ "create_adapter",
32
+ ]
33
+
34
+
35
+ class LongProbe:
36
+ """
37
+ High-level facade for running RAG regression probes.
38
+
39
+ Usage::
40
+
41
+ from longprobe import LongProbe, ChromaAdapter
42
+
43
+ adapter = ChromaAdapter(collection_name="my_docs", persist_directory="./db")
44
+ probe = LongProbe(adapter=adapter, goldens_path="goldens.yaml")
45
+ report = probe.run()
46
+ print(f"Overall recall: {report.overall_recall:.2f}")
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ adapter: "AbstractRetrieverAdapter",
52
+ goldens_path: str = "goldens.yaml",
53
+ config_path: str = "longprobe.yaml",
54
+ recall_threshold: float | None = None,
55
+ ):
56
+ from pathlib import Path
57
+
58
+ self.adapter = adapter
59
+ self.golden_set = GoldenSet.from_yaml(goldens_path)
60
+
61
+ config_file = Path(config_path)
62
+ if config_file.exists():
63
+ self.config = ProbeConfig.from_yaml(str(config_file))
64
+ else:
65
+ self.config = ProbeConfig.defaults()
66
+
67
+ if recall_threshold is not None:
68
+ self.config.scoring.recall_threshold = recall_threshold
69
+
70
+ self.scorer = RecallScorer(
71
+ recall_threshold=self.config.scoring.recall_threshold
72
+ )
73
+ self.baseline_store = BaselineStore(
74
+ db_path=self.config.baseline.db_path
75
+ )
76
+ self.diff_reporter = DiffReporter()
77
+ self._last_report: ProbeReport | None = None
78
+
79
+ def run(self, top_k_override: int | None = None) -> ProbeReport:
80
+ """Run the probe against the golden set and return a report."""
81
+ report = self.scorer.score_all(
82
+ self.golden_set,
83
+ self.adapter.retrieve,
84
+ top_k_override=top_k_override,
85
+ )
86
+
87
+ # Auto-compare against baseline
88
+ if self.config.baseline.auto_compare:
89
+ baseline = self.baseline_store.load("latest")
90
+ if baseline is not None:
91
+ report.baseline_recall = baseline.overall_recall
92
+ report.recall_delta = report.overall_recall - baseline.overall_recall
93
+ diff_result = self.baseline_store.diff(report, baseline)
94
+ report.regression_detected = len(diff_result["regressions"]) > 0
95
+
96
+ self._last_report = report
97
+ return report
98
+
99
+ def save_baseline(self, label: str = "latest") -> None:
100
+ """Save the last run report as a baseline."""
101
+ if self._last_report is None:
102
+ raise RuntimeError("No report to save. Run probe.run() first.")
103
+ self.baseline_store.save(self._last_report, label=label)
104
+
105
+ def diff(self, baseline_label: str = "latest") -> dict:
106
+ """Compare last run against a saved baseline."""
107
+ if self._last_report is None:
108
+ raise RuntimeError("No report to diff. Run probe.run() first.")
109
+ baseline = self.baseline_store.load(baseline_label)
110
+ if baseline is None:
111
+ raise ValueError(f"Baseline '{baseline_label}' not found.")
112
+ return self.baseline_store.diff(self._last_report, baseline)
113
+
114
+ def get_missing_chunks(self) -> dict[str, list[str]]:
115
+ """Return a dict mapping question_id to its missing chunks."""
116
+ if self._last_report is None:
117
+ raise RuntimeError("No report available. Run probe.run() first.")
118
+ return {
119
+ r.question_id: r.missing_chunks
120
+ for r in self._last_report.results
121
+ if r.missing_chunks
122
+ }
@@ -0,0 +1,56 @@
1
+ from .base import AbstractRetrieverAdapter
2
+ from .http import HttpAdapter
3
+ from .langchain import LangChainRetrieverAdapter
4
+ from .llamaindex import LlamaIndexRetrieverAdapter
5
+ from .pinecone import PineconeAdapter
6
+ from .qdrant import QdrantAdapter
7
+ from .chroma import ChromaAdapter
8
+
9
+
10
+ def create_adapter(adapter_type: str, **kwargs) -> AbstractRetrieverAdapter:
11
+ """
12
+ Factory function to create a retriever adapter.
13
+
14
+ Args:
15
+ adapter_type: One of "langchain", "llamaindex", "pinecone", "qdrant",
16
+ "chroma", "http"
17
+ **kwargs: Passed to the adapter constructor.
18
+ For "langchain" and "llamaindex": pass "retriever" kwarg
19
+ For "http": pass "config" kwarg (HttpRetrieverConfig)
20
+ For others: pass connection config kwargs
21
+
22
+ Returns:
23
+ AbstractRetrieverAdapter instance
24
+
25
+ Raises:
26
+ ValueError: If adapter_type is unknown
27
+ """
28
+ adapters = {
29
+ "langchain": LangChainRetrieverAdapter,
30
+ "llamaindex": LlamaIndexRetrieverAdapter,
31
+ "pinecone": PineconeAdapter,
32
+ "qdrant": QdrantAdapter,
33
+ "chroma": ChromaAdapter,
34
+ "http": HttpAdapter,
35
+ }
36
+
37
+ adapter_cls = adapters.get(adapter_type)
38
+ if adapter_cls is None:
39
+ raise ValueError(
40
+ f"Unknown adapter type '{adapter_type}'. "
41
+ f"Must be one of: {', '.join(adapters.keys())}"
42
+ )
43
+
44
+ return adapter_cls(**kwargs)
45
+
46
+
47
+ __all__ = [
48
+ "AbstractRetrieverAdapter",
49
+ "HttpAdapter",
50
+ "LangChainRetrieverAdapter",
51
+ "LlamaIndexRetrieverAdapter",
52
+ "PineconeAdapter",
53
+ "QdrantAdapter",
54
+ "ChromaAdapter",
55
+ "create_adapter",
56
+ ]
@@ -0,0 +1,23 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any
3
+
4
+
5
+ class AbstractRetrieverAdapter(ABC):
6
+ """Base class for all retriever adapters."""
7
+
8
+ @abstractmethod
9
+ def retrieve(self, query: str, top_k: int = 5) -> list[dict[str, Any]]:
10
+ """
11
+ Retrieve documents for a query.
12
+
13
+ Returns list of dicts with keys:
14
+ - "id": str - document/chunk identifier
15
+ - "text": str - document text content
16
+ - "score": float - relevance score
17
+ - "metadata": dict - additional metadata
18
+ """
19
+ ...
20
+
21
+ def health_check(self) -> bool:
22
+ """Check if the retriever is reachable. Override for custom health checks."""
23
+ return True
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ from .base import AbstractRetrieverAdapter
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class ChromaAdapter(AbstractRetrieverAdapter):
12
+ """Adapter for Chroma vector databases.
13
+
14
+ Supports both text-based and embedding-based queries. The Chroma
15
+ client SDK is imported lazily and cached after the first access.
16
+ """
17
+
18
+ def __init__(
19
+ self,
20
+ collection_name: str = "default",
21
+ persist_directory: str = "",
22
+ ) -> None:
23
+ self.collection_name = collection_name
24
+ self.persist_directory = persist_directory
25
+ self._client: Any = None
26
+
27
+ def _get_client(self) -> Any:
28
+ """Lazily create and cache a Chroma client."""
29
+ if self._client is not None:
30
+ return self._client
31
+
32
+ try:
33
+ import chromadb
34
+ except ImportError:
35
+ raise ImportError(
36
+ "chromadb is required for ChromaAdapter. "
37
+ "Install it with: pip install chromadb"
38
+ )
39
+
40
+ if self.persist_directory:
41
+ self._client = chromadb.PersistentClient(path=self.persist_directory)
42
+ else:
43
+ self._client = chromadb.Client()
44
+
45
+ return self._client
46
+
47
+ def retrieve(
48
+ self,
49
+ query: str | list[float],
50
+ top_k: int = 5,
51
+ ) -> list[dict[str, Any]]:
52
+ """Query a Chroma collection.
53
+
54
+ Args:
55
+ query: Either a text string or a pre-computed embedding vector.
56
+ top_k: Number of results to return.
57
+
58
+ Returns:
59
+ List of result dicts normalised to the LongProbe format.
60
+ Scores are converted from Chroma distances (``1 - distance``).
61
+ """
62
+ client = self._get_client()
63
+ collection = client.get_collection(self.collection_name)
64
+
65
+ if isinstance(query, str):
66
+ results = collection.query(
67
+ query_texts=[query],
68
+ n_results=top_k,
69
+ include=["documents", "metadatas", "distances"],
70
+ )
71
+ elif isinstance(query, list):
72
+ results = collection.query(
73
+ query_embeddings=[query],
74
+ n_results=top_k,
75
+ include=["documents", "metadatas", "distances"],
76
+ )
77
+ else:
78
+ raise TypeError(
79
+ f"Query must be a str or list[float], got {type(query).__name__}"
80
+ )
81
+
82
+ ids = results.get("ids", [[]])[0]
83
+ documents = results.get("documents", [[]])[0]
84
+ distances = results.get("distances", [[]])[0]
85
+ metadatas = results.get("metadatas", [[]])[0]
86
+
87
+ mapped: list[dict[str, Any]] = []
88
+ for i in range(len(ids)):
89
+ mapped.append(
90
+ {
91
+ "id": ids[i],
92
+ "text": documents[i] if documents and i < len(documents) else "",
93
+ "score": 1.0 - (distances[i] if distances and i < len(distances) else 0.0),
94
+ "metadata": (
95
+ metadatas[i]
96
+ if metadatas and i < len(metadatas)
97
+ else {}
98
+ ),
99
+ }
100
+ )
101
+
102
+ return mapped
103
+
104
+ def health_check(self) -> bool:
105
+ """Check connectivity by listing collections."""
106
+ try:
107
+ client = self._get_client()
108
+ client.list_collections()
109
+ return True
110
+ except Exception:
111
+ logger.debug("Chroma health check failed", exc_info=True)
112
+ return False
@@ -0,0 +1,234 @@
1
+ """Generic HTTP retriever adapter for LongProbe.
2
+
3
+ Sends queries to any HTTP-based RAG API endpoint and maps the JSON
4
+ response back to LongProbe's standard format. This adapter lets LongProbe
5
+ test the *entire* retrieval pipeline (including any reranking, prompt
6
+ rewriting, or filtering) rather than just the raw vector database.
7
+
8
+ Typical configuration in ``longprobe.yaml``::
9
+
10
+ retriever:
11
+ type: http
12
+ http:
13
+ url: "http://localhost:8000/api/retrieve"
14
+ method: POST
15
+ body_template: '{"query": "{question}", "top_k": {top_k}}'
16
+ headers:
17
+ Authorization: "Bearer ${API_KEY}"
18
+ response_mapping:
19
+ results_path: "data.chunks"
20
+ id_field: "chunk_id"
21
+ text_field: "content"
22
+ score_field: "similarity"
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import logging
29
+ from typing import Any
30
+
31
+ import requests
32
+
33
+ from .base import AbstractRetrieverAdapter
34
+ from ..config import HttpRetrieverConfig
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ class HttpAdapter(AbstractRetrieverAdapter):
40
+ """Adapter that queries an HTTP-based RAG API endpoint.
41
+
42
+ The request body is built from ``body_template`` by replacing
43
+ ``{question}`` and ``{top_k}`` placeholders. The JSON response is
44
+ parsed using ``response_mapping`` to locate the results array and
45
+ extract per-result fields.
46
+
47
+ Args:
48
+ config: HTTP-specific configuration (URL, method, headers, etc.).
49
+ """
50
+
51
+ def __init__(self, config: HttpRetrieverConfig) -> None:
52
+ self._config = config
53
+
54
+ # ------------------------------------------------------------------
55
+ # Public API
56
+ # ------------------------------------------------------------------
57
+
58
+ def retrieve(self, query: str, top_k: int = 5) -> list[dict[str, Any]]:
59
+ """Send *query* to the configured HTTP endpoint and return normalised
60
+ results.
61
+
62
+ Args:
63
+ query: Natural-language question to send.
64
+ top_k: Number of results to request.
65
+
66
+ Returns:
67
+ List of dicts with ``id``, ``text``, ``score``, and ``metadata``
68
+ keys.
69
+
70
+ Raises:
71
+ RuntimeError: If the endpoint returns a non-2xx status.
72
+ ValueError: If the body template produces invalid JSON after
73
+ placeholder substitution.
74
+ requests.Timeout: If the request exceeds the configured timeout.
75
+ """
76
+ cfg = self._config
77
+
78
+ # 1. Build request body from template.
79
+ body = self._build_body(cfg.body_template, query, top_k)
80
+
81
+ # 2. Make HTTP request.
82
+ try:
83
+ response = requests.request(
84
+ method=cfg.method,
85
+ url=cfg.url,
86
+ json=body,
87
+ headers=cfg.headers,
88
+ timeout=cfg.timeout,
89
+ )
90
+ except requests.Timeout as exc:
91
+ raise requests.Timeout(
92
+ f"HTTP adapter request timed out after {cfg.timeout}s "
93
+ f"for URL '{cfg.url}'."
94
+ ) from exc
95
+
96
+ # 3. Check status.
97
+ if response.status_code >= 300:
98
+ snippet = response.text[:500] if response.text else "(empty body)"
99
+ raise RuntimeError(
100
+ f"HTTP adapter received status {response.status_code} from "
101
+ f"'{cfg.url}': {snippet}"
102
+ )
103
+
104
+ # 4. Parse response.
105
+ try:
106
+ data = response.json()
107
+ except (json.JSONDecodeError, ValueError) as exc:
108
+ raise RuntimeError(
109
+ f"HTTP adapter received non-JSON response from '{cfg.url}': "
110
+ f"{response.text[:200]}"
111
+ ) from exc
112
+
113
+ # 5. Resolve results path.
114
+ mapping = cfg.response_mapping
115
+ items = self._resolve_path(data, mapping.results_path)
116
+
117
+ if not isinstance(items, list):
118
+ logger.warning(
119
+ "HTTP adapter: path '%s' did not resolve to a list "
120
+ "(got %s). Returning empty results.",
121
+ mapping.results_path,
122
+ type(items).__name__,
123
+ )
124
+ return []
125
+
126
+ # 6. Map each item to LongProbe format.
127
+ results: list[dict[str, Any]] = []
128
+ for item in items:
129
+ if not isinstance(item, dict):
130
+ logger.warning(
131
+ "HTTP adapter: skipping non-dict item in results array."
132
+ )
133
+ continue
134
+ results.append(
135
+ {
136
+ "id": str(item.get(mapping.id_field, "")),
137
+ "text": str(item.get(mapping.text_field, "")),
138
+ "score": float(item.get(mapping.score_field, 0.0)),
139
+ "metadata": {
140
+ k: v
141
+ for k, v in item.items()
142
+ if k not in (mapping.id_field, mapping.text_field, mapping.score_field)
143
+ },
144
+ }
145
+ )
146
+
147
+ return results
148
+
149
+ def health_check(self) -> bool:
150
+ """Check if the configured endpoint is reachable.
151
+
152
+ Sends a HEAD (or GET for endpoints that don't support HEAD) to the
153
+ URL and returns ``True`` for any response with status < 400.
154
+ Timeouts and connection errors return ``False``.
155
+ """
156
+ try:
157
+ # Try HEAD first (lighter).
158
+ resp = requests.head(
159
+ self._config.url,
160
+ headers=self._config.headers,
161
+ timeout=min(self._config.timeout, 10),
162
+ )
163
+ if resp.status_code == 405:
164
+ # Fallback to GET if HEAD is not allowed.
165
+ resp = requests.get(
166
+ self._config.url,
167
+ headers=self._config.headers,
168
+ timeout=min(self._config.timeout, 10),
169
+ )
170
+ return resp.status_code < 400
171
+ except (requests.RequestException, OSError):
172
+ logger.debug("HTTP adapter health check failed", exc_info=True)
173
+ return False
174
+
175
+ # ------------------------------------------------------------------
176
+ # Internal helpers
177
+ # ------------------------------------------------------------------
178
+
179
+ @staticmethod
180
+ def _build_body(
181
+ template: str,
182
+ query: str,
183
+ top_k: int,
184
+ ) -> Any:
185
+ """Substitute ``{question}`` and ``{top_k}`` in *template* and
186
+ parse the result as JSON.
187
+
188
+ ``{question}`` is replaced with a JSON-safe escaped string.
189
+ ``{top_k}`` is replaced with the integer value.
190
+
191
+ Returns:
192
+ The parsed JSON object (usually a dict).
193
+
194
+ Raises:
195
+ ValueError: If the substituted template is not valid JSON.
196
+ """
197
+ # JSON-escape the question string, then strip surrounding quotes
198
+ # so it fits naturally inside the template's own quoting.
199
+ escaped_question = json.dumps(query)[1:-1]
200
+ body_str = template.replace("{question}", escaped_question)
201
+ body_str = body_str.replace("{top_k}", str(top_k))
202
+
203
+ try:
204
+ return json.loads(body_str)
205
+ except json.JSONDecodeError as exc:
206
+ raise ValueError(
207
+ f"HTTP adapter body template produced invalid JSON after "
208
+ f"substitution: {exc}\n"
209
+ f"Template: {template}\n"
210
+ f"Result: {body_str[:200]}"
211
+ ) from exc
212
+
213
+ @staticmethod
214
+ def _resolve_path(data: Any, path: str) -> Any:
215
+ """Traverse *data* using a dot-notation *path*.
216
+
217
+ ``\"data.chunks\"`` resolves to ``data[\"data\"][\"chunks\"]``.
218
+ If any segment is missing, an empty list is returned.
219
+
220
+ Args:
221
+ data: The parsed JSON response.
222
+ path: Dot-separated key path.
223
+
224
+ Returns:
225
+ The value at the path, or an empty list if not found.
226
+ """
227
+ current = data
228
+ parts = path.split(".") if path else []
229
+ for part in parts:
230
+ if isinstance(current, dict) and part in current:
231
+ current = current[part]
232
+ else:
233
+ return []
234
+ return current
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ from .base import AbstractRetrieverAdapter
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class LangChainRetrieverAdapter(AbstractRetrieverAdapter):
12
+ """Adapter for LangChain BaseRetriever objects.
13
+
14
+ Accepts any LangChain retriever (duck-typed) and normalises its
15
+ output into the LongProbe result format.
16
+ """
17
+
18
+ def __init__(self, retriever: Any) -> None:
19
+ self.retriever = retriever
20
+
21
+ def retrieve(self, query: str, top_k: int = 5) -> list[dict[str, Any]]:
22
+ """Retrieve documents using a LangChain retriever.
23
+
24
+ Tries the modern ``invoke`` API first, then falls back to the
25
+ legacy ``get_relevant_documents`` method.
26
+ """
27
+ try:
28
+ from langchain_core.documents import Document # noqa: F401 – ensure importable
29
+ except ImportError:
30
+ raise ImportError(
31
+ "langchain-core is required for LangChainRetrieverAdapter. "
32
+ "Install it with: pip install langchain-core"
33
+ )
34
+
35
+ # Invoke the retriever
36
+ documents = []
37
+ try:
38
+ documents = self.retriever.invoke(query)
39
+ except AttributeError:
40
+ # Legacy LangChain API
41
+ documents = self.retriever.get_relevant_documents(query)
42
+
43
+ results: list[dict[str, Any]] = []
44
+ for i, d in enumerate(documents):
45
+ doc_id = d.metadata.get("chunk_id", d.metadata.get("source", f"doc_{i}"))
46
+ results.append(
47
+ {
48
+ "id": doc_id,
49
+ "text": d.page_content,
50
+ "score": float(d.metadata.get("score", 0.0)),
51
+ "metadata": dict(d.metadata),
52
+ }
53
+ )
54
+
55
+ return results[:top_k]
56
+
57
+ def health_check(self) -> bool:
58
+ """Check that the underlying retriever is callable."""
59
+ try:
60
+ return (
61
+ hasattr(self.retriever, "invoke")
62
+ or hasattr(self.retriever, "get_relevant_documents")
63
+ )
64
+ except Exception:
65
+ return False
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ from .base import AbstractRetrieverAdapter
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class LlamaIndexRetrieverAdapter(AbstractRetrieverAdapter):
12
+ """Adapter for LlamaIndex retriever objects.
13
+
14
+ Accepts any LlamaIndex retriever and normalises its
15
+ ``NodeWithScore`` results into the LongProbe format.
16
+ """
17
+
18
+ def __init__(self, retriever: Any) -> None:
19
+ self.retriever = retriever
20
+
21
+ def retrieve(self, query: str, top_k: int = 5) -> list[dict[str, Any]]:
22
+ """Retrieve documents using a LlamaIndex retriever."""
23
+ try:
24
+ from llama_index.core import QueryBundle # noqa: F401
25
+ except ImportError:
26
+ raise ImportError(
27
+ "llama-index-core is required for LlamaIndexRetrieverAdapter. "
28
+ "Install it with: pip install llama-index-core"
29
+ )
30
+
31
+ nodes_with_scores = self.retriever.retrieve(query)
32
+
33
+ results: list[dict[str, Any]] = []
34
+ for node in nodes_with_scores:
35
+ results.append(
36
+ {
37
+ "id": node.node.node_id,
38
+ "text": node.node.get_content(),
39
+ "score": float(node.score or 0.0),
40
+ "metadata": dict(node.node.metadata),
41
+ }
42
+ )
43
+
44
+ return results[:top_k]
45
+
46
+ def health_check(self) -> bool:
47
+ """Check that the underlying retriever has a ``retrieve`` method."""
48
+ return hasattr(self.retriever, "retrieve")