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.
@@ -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)
@@ -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")