patternmem-rag 0.1.1__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.
- patternmem/__init__.py +40 -0
- patternmem/_utils.py +25 -0
- patternmem/augmenter.py +155 -0
- patternmem/backend.py +138 -0
- patternmem/backends/__init__.py +1 -0
- patternmem/backends/chroma_backend.py +215 -0
- patternmem/backends/faiss_backend.py +302 -0
- patternmem/backends/json_backend.py +173 -0
- patternmem/backends/neo4j_backend.py +236 -0
- patternmem/backends/networkx_backend.py +157 -0
- patternmem/backends/sqlite_backend.py +197 -0
- patternmem/decay.py +90 -0
- patternmem/eval_router.py +316 -0
- patternmem/middleware.py +336 -0
- patternmem/observability.py +101 -0
- patternmem/reflector.py +211 -0
- patternmem/resolver.py +138 -0
- patternmem/types.py +212 -0
- patternmem_rag-0.1.1.dist-info/METADATA +238 -0
- patternmem_rag-0.1.1.dist-info/RECORD +23 -0
- patternmem_rag-0.1.1.dist-info/WHEEL +5 -0
- patternmem_rag-0.1.1.dist-info/licenses/LICENSE +21 -0
- patternmem_rag-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""
|
|
2
|
+
patternmem.eval_router
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
EvalRouter — the *only* permitted translation boundary between external eval
|
|
5
|
+
libraries (RAGAS, DeepEval) and the rest of the PatternMem system.
|
|
6
|
+
|
|
7
|
+
Invariant 7: ``EvalRouter`` output is **always** a ``list[FailureSignal]``.
|
|
8
|
+
No code outside this module ever touches a raw RAGAS or DeepEval object.
|
|
9
|
+
|
|
10
|
+
Behaviour summary
|
|
11
|
+
-----------------
|
|
12
|
+
1. **Short-circuit**: if the pipeline output already contains a ``"reflect"``
|
|
13
|
+
key or a ``FaithfulnessEvaluator`` result dict, that score is used directly
|
|
14
|
+
and neither RAGAS nor DeepEval is invoked.
|
|
15
|
+
2. **``eval="auto"``**: tries RAGAS, falls back to DeepEval, falls back to
|
|
16
|
+
``"none"`` mode if neither is installed.
|
|
17
|
+
3. **``eval="none"``**: returns a single ``FailureSignal`` with
|
|
18
|
+
``FailureType.UNKNOWN`` and ``score=0.0`` — no external calls.
|
|
19
|
+
4. **Compound failure**: when both ``faithfulness < LOW_THRESHOLD`` and
|
|
20
|
+
``context_precision < LOW_THRESHOLD``, returns **two** ``FailureSignal``s
|
|
21
|
+
(one ``HALLUCINATION``, one ``RETRIEVAL_MISS``).
|
|
22
|
+
5. Return type is always ``list[FailureSignal]`` — length 1 for single
|
|
23
|
+
failures, 2 for compound.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import uuid
|
|
29
|
+
from typing import Any, Literal
|
|
30
|
+
|
|
31
|
+
from patternmem.types import FailureSignal, FailureType
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Numeric thresholds (module-level constants, not magic numbers)
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
LOW_THRESHOLD: float = 0.4 # below this → metric is "low"
|
|
38
|
+
HIGH_THRESHOLD: float = 0.7 # above this → metric is "high"
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Internal helpers — RAGAS adapter
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _try_ragas(
|
|
46
|
+
query: str,
|
|
47
|
+
chunks: list[str],
|
|
48
|
+
answer: str,
|
|
49
|
+
) -> tuple[float, float] | None:
|
|
50
|
+
"""Return ``(faithfulness, context_precision)`` via RAGAS, or ``None`` if unavailable."""
|
|
51
|
+
try:
|
|
52
|
+
from ragas import evaluate as ragas_evaluate
|
|
53
|
+
from ragas.metrics import faithfulness, context_precision
|
|
54
|
+
from datasets import Dataset
|
|
55
|
+
|
|
56
|
+
ds = Dataset.from_dict(
|
|
57
|
+
{
|
|
58
|
+
"question": [query],
|
|
59
|
+
"answer": [answer],
|
|
60
|
+
"contexts": [chunks],
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
result = ragas_evaluate(ds, metrics=[faithfulness, context_precision])
|
|
64
|
+
faith = float(result["faithfulness"])
|
|
65
|
+
cp = float(result["context_precision"])
|
|
66
|
+
return faith, cp
|
|
67
|
+
except Exception: # noqa: BLE001
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Internal helpers — DeepEval adapter
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _try_deepeval(
|
|
77
|
+
query: str,
|
|
78
|
+
chunks: list[str],
|
|
79
|
+
answer: str,
|
|
80
|
+
) -> tuple[float, float] | None:
|
|
81
|
+
"""Return ``(faithfulness, context_precision)`` via DeepEval, or ``None``."""
|
|
82
|
+
try:
|
|
83
|
+
from deepeval.metrics import (
|
|
84
|
+
FaithfulnessMetric,
|
|
85
|
+
ContextualPrecisionMetric,
|
|
86
|
+
)
|
|
87
|
+
from deepeval.test_case import LLMTestCase
|
|
88
|
+
|
|
89
|
+
test_case = LLMTestCase(
|
|
90
|
+
input=query,
|
|
91
|
+
actual_output=answer,
|
|
92
|
+
retrieval_context=chunks,
|
|
93
|
+
)
|
|
94
|
+
fm = FaithfulnessMetric(threshold=0.5)
|
|
95
|
+
cm = ContextualPrecisionMetric(threshold=0.5)
|
|
96
|
+
fm.measure(test_case)
|
|
97
|
+
cm.measure(test_case)
|
|
98
|
+
return float(fm.score), float(cm.score)
|
|
99
|
+
except Exception: # noqa: BLE001
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# Short-circuit helper
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _extract_existing_reflection(
|
|
109
|
+
pipeline_output: Any,
|
|
110
|
+
) -> tuple[float, float] | None:
|
|
111
|
+
"""Return ``(faithfulness, context_precision)`` from an existing reflection key.
|
|
112
|
+
|
|
113
|
+
Checks for:
|
|
114
|
+
- ``pipeline_output["reflect"]`` — a float score assumed to be faithfulness
|
|
115
|
+
- ``pipeline_output["faithfulness_eval"]`` — LlamaIndex FaithfulnessEvaluator result
|
|
116
|
+
|
|
117
|
+
If found, sets ``context_precision = 1.0`` (no retrieval signal available from
|
|
118
|
+
reflection node) so classification routes to a generation-type failure only.
|
|
119
|
+
"""
|
|
120
|
+
if not isinstance(pipeline_output, dict):
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
# Key "reflect" — a raw float (0–1) indicating faithfulness
|
|
124
|
+
if "reflect" in pipeline_output:
|
|
125
|
+
score = pipeline_output["reflect"]
|
|
126
|
+
try:
|
|
127
|
+
return float(score), 1.0
|
|
128
|
+
except (TypeError, ValueError):
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
# LlamaIndex FaithfulnessEvaluator result — dict with "score" key
|
|
132
|
+
if "faithfulness_eval" in pipeline_output:
|
|
133
|
+
fe = pipeline_output["faithfulness_eval"]
|
|
134
|
+
if isinstance(fe, dict) and "score" in fe:
|
|
135
|
+
try:
|
|
136
|
+
return float(fe["score"]), 1.0
|
|
137
|
+
except (TypeError, ValueError):
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# Classification matrix
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _classify(
|
|
149
|
+
faithfulness: float,
|
|
150
|
+
context_precision: float,
|
|
151
|
+
query: str,
|
|
152
|
+
hint_override: str = "",
|
|
153
|
+
) -> list[FailureSignal]:
|
|
154
|
+
"""Map (faithfulness, context_precision) → list[FailureSignal] per the spec matrix.
|
|
155
|
+
|
|
156
|
+
Compound case: both low → two independent signals.
|
|
157
|
+
"""
|
|
158
|
+
faith_low = faithfulness < LOW_THRESHOLD
|
|
159
|
+
cp_low = context_precision < LOW_THRESHOLD
|
|
160
|
+
faith_high = faithfulness >= HIGH_THRESHOLD
|
|
161
|
+
cp_high = context_precision >= HIGH_THRESHOLD
|
|
162
|
+
|
|
163
|
+
signals: list[FailureSignal] = []
|
|
164
|
+
trace_id = str(uuid.uuid4())
|
|
165
|
+
|
|
166
|
+
if faith_low and cp_low:
|
|
167
|
+
# Compound case — two independent signals
|
|
168
|
+
signals.append(
|
|
169
|
+
FailureSignal(
|
|
170
|
+
score=faithfulness,
|
|
171
|
+
failure_type=FailureType.HALLUCINATION,
|
|
172
|
+
root_cause="Both faithfulness and context precision are low; "
|
|
173
|
+
"generation is not grounded in retrieved context.",
|
|
174
|
+
hint="Add explicit grounding instructions to the generator; "
|
|
175
|
+
"check that context is attached to the prompt.",
|
|
176
|
+
trace_id=trace_id,
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
signals.append(
|
|
180
|
+
FailureSignal(
|
|
181
|
+
score=context_precision,
|
|
182
|
+
failure_type=FailureType.RETRIEVAL_MISS,
|
|
183
|
+
root_cause="Context precision is critically low; retrieved chunks "
|
|
184
|
+
"are not relevant to the query.",
|
|
185
|
+
hint="Broaden retrieval query; increase top-k; check index freshness.",
|
|
186
|
+
trace_id=str(uuid.uuid4()),
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
elif faith_low and not cp_low:
|
|
190
|
+
# Retrieval found relevant docs but generator drifted
|
|
191
|
+
ft = (
|
|
192
|
+
FailureType.FAITHFULNESS_DRIFT
|
|
193
|
+
if faithfulness >= 0.2 # partially faithful
|
|
194
|
+
else FailureType.HALLUCINATION
|
|
195
|
+
)
|
|
196
|
+
signals.append(
|
|
197
|
+
FailureSignal(
|
|
198
|
+
score=faithfulness,
|
|
199
|
+
failure_type=ft,
|
|
200
|
+
root_cause="Generator answer diverges from retrieved context despite "
|
|
201
|
+
"relevant retrieval.",
|
|
202
|
+
hint="Add citation constraints; reduce temperature; force CoT.",
|
|
203
|
+
trace_id=trace_id,
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
elif not faith_low and cp_low:
|
|
207
|
+
# Generator is faithful to what it has, but retrieval quality is poor
|
|
208
|
+
ft = FailureType.RETRIEVAL_MISS if context_precision < 0.2 else FailureType.RETRIEVAL_RANK
|
|
209
|
+
signals.append(
|
|
210
|
+
FailureSignal(
|
|
211
|
+
score=context_precision,
|
|
212
|
+
failure_type=ft,
|
|
213
|
+
root_cause="Retrieved context has low precision relative to the query.",
|
|
214
|
+
hint="Refine retrieval query; add MMR re-ranking; expand synonyms.",
|
|
215
|
+
trace_id=trace_id,
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
else:
|
|
219
|
+
# Both high → no actionable failure
|
|
220
|
+
signals.append(
|
|
221
|
+
FailureSignal(
|
|
222
|
+
score=min(faithfulness, context_precision),
|
|
223
|
+
failure_type=FailureType.UNKNOWN,
|
|
224
|
+
root_cause="No clear failure detected.",
|
|
225
|
+
hint="",
|
|
226
|
+
trace_id=trace_id,
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
return signals
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ---------------------------------------------------------------------------
|
|
234
|
+
# Public class
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class EvalRouter:
|
|
239
|
+
"""Normalises RAGAS and DeepEval outputs into ``FailureSignal`` lists.
|
|
240
|
+
|
|
241
|
+
Parameters
|
|
242
|
+
----------
|
|
243
|
+
mode:
|
|
244
|
+
One of ``"ragas"``, ``"deepeval"``, ``"auto"``, ``"none"``.
|
|
245
|
+
``"auto"`` tries RAGAS → DeepEval → none in order.
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
def __init__(self, mode: Literal["ragas", "deepeval", "auto", "none"]) -> None:
|
|
249
|
+
self._mode = mode
|
|
250
|
+
|
|
251
|
+
def evaluate(
|
|
252
|
+
self,
|
|
253
|
+
query: str,
|
|
254
|
+
chunks: list[str],
|
|
255
|
+
answer: str,
|
|
256
|
+
pipeline_output: Any = None,
|
|
257
|
+
) -> list[FailureSignal]:
|
|
258
|
+
"""Evaluate a single query/answer/context triple.
|
|
259
|
+
|
|
260
|
+
Parameters
|
|
261
|
+
----------
|
|
262
|
+
query:
|
|
263
|
+
The original user query.
|
|
264
|
+
chunks:
|
|
265
|
+
Retrieved context chunks passed to the generator.
|
|
266
|
+
answer:
|
|
267
|
+
The generated answer.
|
|
268
|
+
pipeline_output:
|
|
269
|
+
Raw return value from the pipeline call. Inspected for existing
|
|
270
|
+
reflection keys before invoking any eval library.
|
|
271
|
+
|
|
272
|
+
Returns
|
|
273
|
+
-------
|
|
274
|
+
list[FailureSignal]
|
|
275
|
+
Length 1 for single failures; length 2 for compound (both faithfulness
|
|
276
|
+
and context_precision low). Always returns at least one signal.
|
|
277
|
+
"""
|
|
278
|
+
# --- Short-circuit: existing reflection in pipeline output ---
|
|
279
|
+
existing = _extract_existing_reflection(pipeline_output)
|
|
280
|
+
if existing is not None:
|
|
281
|
+
faith, cp = existing
|
|
282
|
+
return _classify(faith, cp, query)
|
|
283
|
+
|
|
284
|
+
# --- eval="none" ---
|
|
285
|
+
if self._mode == "none":
|
|
286
|
+
return [
|
|
287
|
+
FailureSignal(
|
|
288
|
+
score=0.0,
|
|
289
|
+
failure_type=FailureType.UNKNOWN,
|
|
290
|
+
root_cause="Evaluation disabled (eval='none').",
|
|
291
|
+
hint="",
|
|
292
|
+
)
|
|
293
|
+
]
|
|
294
|
+
|
|
295
|
+
# --- Try the configured adapter(s) ---
|
|
296
|
+
scores: tuple[float, float] | None = None
|
|
297
|
+
|
|
298
|
+
if self._mode in ("ragas", "auto"):
|
|
299
|
+
scores = _try_ragas(query, chunks, answer)
|
|
300
|
+
|
|
301
|
+
if scores is None and self._mode in ("deepeval", "auto"):
|
|
302
|
+
scores = _try_deepeval(query, chunks, answer)
|
|
303
|
+
|
|
304
|
+
if scores is None:
|
|
305
|
+
# Neither library available — fall back to none behaviour
|
|
306
|
+
return [
|
|
307
|
+
FailureSignal(
|
|
308
|
+
score=0.0,
|
|
309
|
+
failure_type=FailureType.UNKNOWN,
|
|
310
|
+
root_cause="No evaluation library available (RAGAS and DeepEval not installed).",
|
|
311
|
+
hint="",
|
|
312
|
+
)
|
|
313
|
+
]
|
|
314
|
+
|
|
315
|
+
faith, cp = scores
|
|
316
|
+
return _classify(faith, cp, query)
|
patternmem/middleware.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""
|
|
2
|
+
patternmem.middleware
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
PatternMemMiddleware — the public one-line integration point.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
from patternmem import PatternMemMiddleware
|
|
9
|
+
|
|
10
|
+
middleware = PatternMemMiddleware(pipeline=my_rag_pipeline)
|
|
11
|
+
answer = await middleware.ainvoke("What is the capital of France?")
|
|
12
|
+
|
|
13
|
+
# Or as an async context manager (recommended for lifecycle management):
|
|
14
|
+
async with PatternMemMiddleware(pipeline=my_rag_pipeline) as m:
|
|
15
|
+
answer = await m.ainvoke("What is the capital of France?")
|
|
16
|
+
|
|
17
|
+
The 3-phase loop
|
|
18
|
+
----------------
|
|
19
|
+
Phase 1 (sync, ~50 ms target):
|
|
20
|
+
Embed query (MiniLM) → lookup patterns → augment → call pipeline → return.
|
|
21
|
+
The caller receives the answer *immediately* upon pipeline return.
|
|
22
|
+
|
|
23
|
+
Phase 2 (async background, non-blocking):
|
|
24
|
+
EvalRouter evaluates (query, chunks, answer).
|
|
25
|
+
FailureSignal(s) are enqueued into the BackgroundReflector queue.
|
|
26
|
+
|
|
27
|
+
Phase 3 (async background, queue consumer):
|
|
28
|
+
BackgroundReflector dequeues → LLM extracts pattern → backend write/decay.
|
|
29
|
+
|
|
30
|
+
Invariants enforced here
|
|
31
|
+
------------------------
|
|
32
|
+
- Invariant 1: LLMResolverError is raised at __init__, not at ainvoke time.
|
|
33
|
+
- Invariant 3: ainvoke() returns as soon as the pipeline returns.
|
|
34
|
+
- Invariant 5: backend="json", eval="none", observability=None works with zero deps.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import asyncio
|
|
40
|
+
import inspect
|
|
41
|
+
import logging
|
|
42
|
+
from typing import Any, Callable, Literal, Optional
|
|
43
|
+
|
|
44
|
+
from sentence_transformers import SentenceTransformer
|
|
45
|
+
|
|
46
|
+
from patternmem.augmenter import Augmenter
|
|
47
|
+
from patternmem.backend import MemoryBackend
|
|
48
|
+
from patternmem.eval_router import EvalRouter
|
|
49
|
+
from patternmem.observability import ObservabilityEmitter
|
|
50
|
+
from patternmem.reflector import BackgroundReflector
|
|
51
|
+
from patternmem.resolver import LLMResolver
|
|
52
|
+
from patternmem.types import FailurePattern, FailureSignal
|
|
53
|
+
|
|
54
|
+
logger = logging.getLogger(__name__)
|
|
55
|
+
|
|
56
|
+
# MiniLM model name — fully local, no API key
|
|
57
|
+
_EMBED_MODEL = "all-MiniLM-L6-v2"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _build_backend(
|
|
61
|
+
kind: str,
|
|
62
|
+
similarity_threshold: float,
|
|
63
|
+
) -> MemoryBackend:
|
|
64
|
+
"""Instantiate the requested backend with sensible defaults."""
|
|
65
|
+
if kind == "json":
|
|
66
|
+
from patternmem.backends.json_backend import JSONBackend
|
|
67
|
+
return JSONBackend(similarity_threshold=similarity_threshold)
|
|
68
|
+
if kind == "sqlite":
|
|
69
|
+
from patternmem.backends.sqlite_backend import SQLiteBackend
|
|
70
|
+
return SQLiteBackend(similarity_threshold=similarity_threshold)
|
|
71
|
+
if kind == "networkx":
|
|
72
|
+
from patternmem.backends.networkx_backend import NetworkXBackend
|
|
73
|
+
return NetworkXBackend(similarity_threshold=similarity_threshold)
|
|
74
|
+
if kind == "chroma":
|
|
75
|
+
from patternmem.backends.chroma_backend import ChromaBackend
|
|
76
|
+
return ChromaBackend(similarity_threshold=similarity_threshold)
|
|
77
|
+
if kind == "faiss":
|
|
78
|
+
from patternmem.backends.faiss_backend import FAISSBackend
|
|
79
|
+
return FAISSBackend(similarity_threshold=similarity_threshold)
|
|
80
|
+
if kind == "neo4j":
|
|
81
|
+
raise ValueError(
|
|
82
|
+
"Neo4jBackend requires additional configuration (uri, auth). "
|
|
83
|
+
"Instantiate it directly and pass a MemoryBackend instance instead."
|
|
84
|
+
)
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"Unknown backend: {kind!r}. "
|
|
87
|
+
"Choose from: json, sqlite, networkx, chroma, faiss, neo4j."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _extract_chunks(pipeline_output: Any) -> list[str]:
|
|
92
|
+
"""Best-effort extraction of retrieved context chunks from pipeline output."""
|
|
93
|
+
if isinstance(pipeline_output, dict):
|
|
94
|
+
for key in ("chunks", "context", "source_documents", "contexts"):
|
|
95
|
+
val = pipeline_output.get(key)
|
|
96
|
+
if isinstance(val, list):
|
|
97
|
+
return [str(v) for v in val]
|
|
98
|
+
return []
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _extract_answer(pipeline_output: Any) -> str:
|
|
102
|
+
"""Best-effort extraction of the generated answer from pipeline output."""
|
|
103
|
+
if isinstance(pipeline_output, str):
|
|
104
|
+
return pipeline_output
|
|
105
|
+
if isinstance(pipeline_output, dict):
|
|
106
|
+
for key in ("answer", "result", "output", "response", "text"):
|
|
107
|
+
val = pipeline_output.get(key)
|
|
108
|
+
if isinstance(val, str):
|
|
109
|
+
return val
|
|
110
|
+
return str(pipeline_output)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class PatternMemMiddleware:
|
|
114
|
+
"""Framework-agnostic RAG middleware with persistent failure-pattern memory.
|
|
115
|
+
|
|
116
|
+
Parameters
|
|
117
|
+
----------
|
|
118
|
+
pipeline:
|
|
119
|
+
Any callable that accepts a query string (and optional kwargs) and
|
|
120
|
+
returns an answer. May be synchronous or asynchronous.
|
|
121
|
+
llm:
|
|
122
|
+
Optional explicit LLM. If ``None``, ``LLMResolver`` probes the
|
|
123
|
+
pipeline for a detectable LLM. Raises ``LLMResolverError`` at init
|
|
124
|
+
if neither succeeds.
|
|
125
|
+
backend:
|
|
126
|
+
Storage backend identifier, or an existing ``MemoryBackend`` instance.
|
|
127
|
+
Accepts ``"json"`` (default), ``"sqlite"``, ``"networkx"``.
|
|
128
|
+
For Neo4j, pass an instantiated ``Neo4jBackend`` object directly.
|
|
129
|
+
eval:
|
|
130
|
+
Evaluation mode. ``"auto"`` (default) tries RAGAS then DeepEval then
|
|
131
|
+
falls back to ``"none"`` behaviour.
|
|
132
|
+
observability:
|
|
133
|
+
``"langfuse"``, ``"otel"``, or ``None`` (default, no-op).
|
|
134
|
+
similarity_threshold:
|
|
135
|
+
Cosine similarity floor for Phase 1 pattern lookup.
|
|
136
|
+
allow_param_override:
|
|
137
|
+
If ``True``, Augmenter may set ``llm_call_kwargs`` (e.g. temperature).
|
|
138
|
+
rewrite_feedback:
|
|
139
|
+
If ``True``, Augmenter adds a ``rewrite_constraint`` key.
|
|
140
|
+
|
|
141
|
+
Raises
|
|
142
|
+
------
|
|
143
|
+
LLMResolverError
|
|
144
|
+
At construction time if the LLM cannot be resolved.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
def __init__(
|
|
148
|
+
self,
|
|
149
|
+
pipeline: Callable[..., Any],
|
|
150
|
+
llm: Optional[Any] = None,
|
|
151
|
+
backend: "Literal['neo4j', 'sqlite', 'json', 'networkx', 'chroma', 'faiss'] | MemoryBackend" = "json",
|
|
152
|
+
eval: Literal["ragas", "deepeval", "auto", "none"] = "auto",
|
|
153
|
+
observability: Optional[Literal["langfuse", "otel"]] = None,
|
|
154
|
+
similarity_threshold: float = 0.82,
|
|
155
|
+
allow_param_override: bool = False,
|
|
156
|
+
rewrite_feedback: bool = False,
|
|
157
|
+
) -> None:
|
|
158
|
+
self._pipeline = pipeline
|
|
159
|
+
self._similarity_threshold = similarity_threshold
|
|
160
|
+
|
|
161
|
+
# --- Backend ---
|
|
162
|
+
if isinstance(backend, MemoryBackend):
|
|
163
|
+
self._backend: MemoryBackend = backend
|
|
164
|
+
else:
|
|
165
|
+
self._backend = _build_backend(str(backend), similarity_threshold)
|
|
166
|
+
|
|
167
|
+
# --- LLM Resolution (Invariant 1: fail here, not at runtime) ---
|
|
168
|
+
# When eval="none", we still attempt resolution so the user gets an
|
|
169
|
+
# early error if they forget llm=. They can pass llm=None explicitly
|
|
170
|
+
# only when building a test double.
|
|
171
|
+
self._resolver = LLMResolver(pipeline=pipeline, llm=llm)
|
|
172
|
+
|
|
173
|
+
# --- EvalRouter ---
|
|
174
|
+
self._eval_router = EvalRouter(mode=eval)
|
|
175
|
+
|
|
176
|
+
# --- Augmenter ---
|
|
177
|
+
self._augmenter = Augmenter(
|
|
178
|
+
pipeline=pipeline,
|
|
179
|
+
rewrite_feedback=rewrite_feedback,
|
|
180
|
+
allow_param_override=allow_param_override,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# --- Observability ---
|
|
184
|
+
self._emitter = ObservabilityEmitter(mode=observability)
|
|
185
|
+
|
|
186
|
+
# --- BackgroundReflector (started lazily on first ainvoke) ---
|
|
187
|
+
self._reflector = BackgroundReflector(
|
|
188
|
+
backend=self._backend,
|
|
189
|
+
llm=self._resolver.llm,
|
|
190
|
+
emitter=self._emitter,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# --- Embedding model (MiniLM, fully local) ---
|
|
194
|
+
self._encoder: SentenceTransformer | None = None # lazy init
|
|
195
|
+
|
|
196
|
+
def _get_encoder(self) -> SentenceTransformer:
|
|
197
|
+
if self._encoder is None:
|
|
198
|
+
# token=False forces anonymous download, bypassing any cached/expired
|
|
199
|
+
# HuggingFace tokens. The model is freely public.
|
|
200
|
+
self._encoder = SentenceTransformer(_EMBED_MODEL, token=False)
|
|
201
|
+
return self._encoder
|
|
202
|
+
|
|
203
|
+
def _embed(self, text: str) -> list[float]:
|
|
204
|
+
enc = self._get_encoder()
|
|
205
|
+
vec = enc.encode(text, normalize_embeddings=True)
|
|
206
|
+
result: list[float] = vec.tolist()
|
|
207
|
+
return result
|
|
208
|
+
|
|
209
|
+
# ------------------------------------------------------------------
|
|
210
|
+
# Async context manager
|
|
211
|
+
# ------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
async def __aenter__(self) -> "PatternMemMiddleware":
|
|
214
|
+
self._reflector.start()
|
|
215
|
+
return self
|
|
216
|
+
|
|
217
|
+
async def __aexit__(self, *_: Any) -> None:
|
|
218
|
+
await self._reflector.stop()
|
|
219
|
+
|
|
220
|
+
# ------------------------------------------------------------------
|
|
221
|
+
# Core public method
|
|
222
|
+
# ------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
async def ainvoke(self, query: str, **kwargs: Any) -> Any:
|
|
225
|
+
"""Invoke the wrapped pipeline with optional pattern-based augmentation.
|
|
226
|
+
|
|
227
|
+
Phase 1 runs synchronously in this coroutine; Phases 2 and 3 are fired
|
|
228
|
+
as background tasks. The caller receives the pipeline's answer as soon
|
|
229
|
+
as it is available (Invariant 3).
|
|
230
|
+
|
|
231
|
+
Parameters
|
|
232
|
+
----------
|
|
233
|
+
query:
|
|
234
|
+
The user's natural-language query.
|
|
235
|
+
**kwargs:
|
|
236
|
+
Additional keyword arguments forwarded to the pipeline.
|
|
237
|
+
|
|
238
|
+
Returns
|
|
239
|
+
-------
|
|
240
|
+
Any
|
|
241
|
+
Whatever the wrapped pipeline returns.
|
|
242
|
+
"""
|
|
243
|
+
# Ensure the reflector is running
|
|
244
|
+
self._reflector.start()
|
|
245
|
+
|
|
246
|
+
# ------------------------------------------------------------------
|
|
247
|
+
# Phase 1 — Embed → Lookup → Augment → Call pipeline
|
|
248
|
+
# ------------------------------------------------------------------
|
|
249
|
+
with self._emitter.span("patternmem.phase1", {"query": query[:200]}):
|
|
250
|
+
query_embedding = await asyncio.to_thread(self._embed, query)
|
|
251
|
+
|
|
252
|
+
matched_patterns: list[FailurePattern] = await self._backend.lookup_patterns(
|
|
253
|
+
query_embedding, top_k=3
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
if matched_patterns:
|
|
257
|
+
augmented = self._augmenter.build(matched_patterns)
|
|
258
|
+
logger.debug(
|
|
259
|
+
"Phase 1 HIT: %d patterns matched, augmented keys: %s",
|
|
260
|
+
len(matched_patterns),
|
|
261
|
+
list(augmented.keys()),
|
|
262
|
+
)
|
|
263
|
+
pipeline_kwargs = {**kwargs, **augmented}
|
|
264
|
+
else:
|
|
265
|
+
logger.debug("Phase 1 MISS: no patterns above threshold")
|
|
266
|
+
pipeline_kwargs = kwargs
|
|
267
|
+
|
|
268
|
+
# Call the pipeline (sync, async function, or async callable instance)
|
|
269
|
+
if inspect.iscoroutinefunction(self._pipeline) or inspect.iscoroutinefunction(
|
|
270
|
+
getattr(self._pipeline, "__call__", None)
|
|
271
|
+
):
|
|
272
|
+
pipeline_output = await self._pipeline(query, **pipeline_kwargs)
|
|
273
|
+
else:
|
|
274
|
+
pipeline_output = await asyncio.to_thread(
|
|
275
|
+
self._pipeline, query, **pipeline_kwargs
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# Return to caller immediately (Invariant 3)
|
|
279
|
+
# ------------------------------------------------------------------
|
|
280
|
+
# Phase 2 — Background eval → enqueue to reflector
|
|
281
|
+
# ------------------------------------------------------------------
|
|
282
|
+
asyncio.create_task(
|
|
283
|
+
self._phase2(
|
|
284
|
+
query=query,
|
|
285
|
+
query_embedding=query_embedding,
|
|
286
|
+
pipeline_output=pipeline_output,
|
|
287
|
+
matched_patterns=matched_patterns,
|
|
288
|
+
),
|
|
289
|
+
name="patternmem.phase2",
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
return pipeline_output
|
|
293
|
+
|
|
294
|
+
# ------------------------------------------------------------------
|
|
295
|
+
# Phase 2 (background)
|
|
296
|
+
# ------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
async def _phase2(
|
|
299
|
+
self,
|
|
300
|
+
query: str,
|
|
301
|
+
query_embedding: list[float],
|
|
302
|
+
pipeline_output: Any,
|
|
303
|
+
matched_patterns: list[FailurePattern],
|
|
304
|
+
) -> None:
|
|
305
|
+
"""Run EvalRouter in background and enqueue signals for Phase 3."""
|
|
306
|
+
try:
|
|
307
|
+
with self._emitter.span("patternmem.phase2", {"query": query[:200]}):
|
|
308
|
+
chunks = _extract_chunks(pipeline_output)
|
|
309
|
+
answer = _extract_answer(pipeline_output)
|
|
310
|
+
|
|
311
|
+
signals: list[FailureSignal] = await asyncio.to_thread(
|
|
312
|
+
self._eval_router.evaluate,
|
|
313
|
+
query,
|
|
314
|
+
chunks,
|
|
315
|
+
answer,
|
|
316
|
+
pipeline_output,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
for signal in signals:
|
|
320
|
+
# Match signal to an existing pattern by failure_type
|
|
321
|
+
matched = next(
|
|
322
|
+
(
|
|
323
|
+
p
|
|
324
|
+
for p in matched_patterns
|
|
325
|
+
if p.failure_type == signal.failure_type
|
|
326
|
+
),
|
|
327
|
+
None,
|
|
328
|
+
)
|
|
329
|
+
# matched=None → new pattern will be written in Phase 3
|
|
330
|
+
self._reflector.enqueue(
|
|
331
|
+
signal=signal,
|
|
332
|
+
query_embedding=query_embedding,
|
|
333
|
+
matched_pattern=matched,
|
|
334
|
+
)
|
|
335
|
+
except Exception: # noqa: BLE001
|
|
336
|
+
logger.exception("Phase 2 background task failed")
|