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,101 @@
|
|
|
1
|
+
"""
|
|
2
|
+
patternmem.observability
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Thin, no-op-safe wrappers around Langfuse and OpenTelemetry.
|
|
5
|
+
|
|
6
|
+
Design rules
|
|
7
|
+
------------
|
|
8
|
+
- If the requested observability library is not installed, every function
|
|
9
|
+
silently becomes a no-op. This keeps zero-credential mode working with
|
|
10
|
+
``observability=None`` (Invariant 5).
|
|
11
|
+
- No observability call may block ``ainvoke()`` (Invariant 3). All emits
|
|
12
|
+
happen inside the already-async background Phase 3 task.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from contextlib import contextmanager
|
|
18
|
+
from typing import Any, Generator, Literal, Optional
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
ObservabilityMode = Optional[Literal["langfuse", "otel"]]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ObservabilityEmitter:
|
|
25
|
+
"""Emits trace spans and scores to the configured observability backend.
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
mode:
|
|
30
|
+
``"langfuse"``, ``"otel"``, or ``None`` (no-op).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, mode: ObservabilityMode = None) -> None:
|
|
34
|
+
self._mode = mode
|
|
35
|
+
self._langfuse: Any = None
|
|
36
|
+
self._tracer: Any = None
|
|
37
|
+
|
|
38
|
+
if mode == "langfuse":
|
|
39
|
+
self._langfuse = self._init_langfuse()
|
|
40
|
+
elif mode == "otel":
|
|
41
|
+
self._tracer = self._init_otel()
|
|
42
|
+
|
|
43
|
+
# ------------------------------------------------------------------
|
|
44
|
+
# Init helpers (no-op on ImportError)
|
|
45
|
+
# ------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
def _init_langfuse(self) -> Any:
|
|
48
|
+
try:
|
|
49
|
+
from langfuse import Langfuse
|
|
50
|
+
return Langfuse()
|
|
51
|
+
except ImportError:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
def _init_otel(self) -> Any:
|
|
55
|
+
try:
|
|
56
|
+
from opentelemetry import trace
|
|
57
|
+
return trace.get_tracer("patternmem")
|
|
58
|
+
except ImportError:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
# Public API
|
|
63
|
+
# ------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
def emit_score(
|
|
66
|
+
self,
|
|
67
|
+
trace_id: str,
|
|
68
|
+
score: float,
|
|
69
|
+
failure_type: str,
|
|
70
|
+
name: str = "patternmem.failure_score",
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Emit a score observation (non-blocking; called from Phase 3 background)."""
|
|
73
|
+
if self._mode == "langfuse" and self._langfuse is not None:
|
|
74
|
+
try:
|
|
75
|
+
self._langfuse.score(
|
|
76
|
+
trace_id=trace_id,
|
|
77
|
+
name=name,
|
|
78
|
+
value=score,
|
|
79
|
+
comment=f"failure_type={failure_type}",
|
|
80
|
+
)
|
|
81
|
+
except Exception: # noqa: BLE001
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
@contextmanager
|
|
85
|
+
def span(
|
|
86
|
+
self,
|
|
87
|
+
name: str,
|
|
88
|
+
attributes: dict[str, Any] | None = None,
|
|
89
|
+
) -> Generator[Any, None, None]:
|
|
90
|
+
"""Context manager that wraps a code block in a trace span.
|
|
91
|
+
|
|
92
|
+
No-op if observability is disabled or the library is not installed.
|
|
93
|
+
"""
|
|
94
|
+
if self._mode == "otel" and self._tracer is not None:
|
|
95
|
+
with self._tracer.start_as_current_span(name) as span:
|
|
96
|
+
if attributes:
|
|
97
|
+
for k, v in attributes.items():
|
|
98
|
+
span.set_attribute(k, str(v))
|
|
99
|
+
yield span
|
|
100
|
+
else:
|
|
101
|
+
yield None
|
patternmem/reflector.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""
|
|
2
|
+
patternmem.reflector
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
BackgroundReflector — asyncio queue consumer for Phase 3 of the 3-phase loop.
|
|
5
|
+
|
|
6
|
+
Responsibilities
|
|
7
|
+
----------------
|
|
8
|
+
1. Dequeue ``(FailureSignal, query, chunks, answer, matched_pattern_id_or_None)``
|
|
9
|
+
tuples from the internal asyncio queue.
|
|
10
|
+
2. Use the resolved LLM to extract a ``FailurePattern`` from the signal.
|
|
11
|
+
3. Write the pattern to the backend (or update+decay an existing one).
|
|
12
|
+
4. Emit the score to the configured observability backend.
|
|
13
|
+
|
|
14
|
+
Lifecycle
|
|
15
|
+
---------
|
|
16
|
+
- Started as an ``asyncio.Task`` on the first ``ainvoke()`` call.
|
|
17
|
+
- Cancelled and drained when ``PatternMemMiddleware.__aexit__`` is called.
|
|
18
|
+
- Never blocks ``ainvoke()`` (Invariant 3).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import logging
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from typing import Any, Optional
|
|
27
|
+
|
|
28
|
+
from patternmem.backend import MemoryBackend
|
|
29
|
+
from patternmem.decay import compute_new_decay_weight, should_evict
|
|
30
|
+
from patternmem.observability import ObservabilityEmitter
|
|
31
|
+
from patternmem.types import FailurePattern, FailureSignal
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
_SENTINEL = object() # Signals queue shutdown
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class _ReflectorJob:
|
|
40
|
+
signal: FailureSignal
|
|
41
|
+
query_embedding: list[float]
|
|
42
|
+
matched_pattern: Optional[FailurePattern] # None if Phase 1 was a miss
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BackgroundReflector:
|
|
46
|
+
"""Async queue consumer that persists failure patterns to the backend.
|
|
47
|
+
|
|
48
|
+
Parameters
|
|
49
|
+
----------
|
|
50
|
+
backend:
|
|
51
|
+
The storage backend to write/update/delete patterns in.
|
|
52
|
+
llm:
|
|
53
|
+
The resolved LLM. Used to extract ``root_cause`` and ``hint_text``
|
|
54
|
+
from the raw ``FailureSignal``.
|
|
55
|
+
emitter:
|
|
56
|
+
Observability emitter. No-op if observability is disabled.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
backend: MemoryBackend,
|
|
62
|
+
llm: Any,
|
|
63
|
+
emitter: ObservabilityEmitter,
|
|
64
|
+
) -> None:
|
|
65
|
+
self._backend = backend
|
|
66
|
+
self._llm = llm
|
|
67
|
+
self._emitter = emitter
|
|
68
|
+
self._queue: asyncio.Queue[Any] = asyncio.Queue(maxsize=1000)
|
|
69
|
+
self._task: asyncio.Task[None] | None = None
|
|
70
|
+
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
# Lifecycle
|
|
73
|
+
# ------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def start(self) -> None:
|
|
76
|
+
"""Start the background consumer task."""
|
|
77
|
+
if self._task is None or self._task.done():
|
|
78
|
+
self._task = asyncio.create_task(self._consume(), name="patternmem.reflector")
|
|
79
|
+
|
|
80
|
+
async def stop(self) -> None:
|
|
81
|
+
"""Signal shutdown and wait for the queue to drain."""
|
|
82
|
+
await self._queue.put(_SENTINEL)
|
|
83
|
+
if self._task is not None:
|
|
84
|
+
try:
|
|
85
|
+
await asyncio.wait_for(self._task, timeout=5.0)
|
|
86
|
+
except asyncio.TimeoutError:
|
|
87
|
+
self._task.cancel()
|
|
88
|
+
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
# Enqueue (called from Phase 2 background task in middleware)
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def enqueue(
|
|
94
|
+
self,
|
|
95
|
+
signal: FailureSignal,
|
|
96
|
+
query_embedding: list[float],
|
|
97
|
+
matched_pattern: FailurePattern | None = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Non-blocking enqueue — never raises even if the queue is full."""
|
|
100
|
+
try:
|
|
101
|
+
self._queue.put_nowait(
|
|
102
|
+
_ReflectorJob(
|
|
103
|
+
signal=signal,
|
|
104
|
+
query_embedding=query_embedding,
|
|
105
|
+
matched_pattern=matched_pattern,
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
except asyncio.QueueFull:
|
|
109
|
+
logger.warning("PatternMem reflector queue is full; dropping signal %s", signal.trace_id)
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
# Consumer loop
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
async def _consume(self) -> None:
|
|
116
|
+
while True:
|
|
117
|
+
item = await self._queue.get()
|
|
118
|
+
if item is _SENTINEL:
|
|
119
|
+
self._queue.task_done()
|
|
120
|
+
break
|
|
121
|
+
try:
|
|
122
|
+
await self._process(item)
|
|
123
|
+
except Exception: # noqa: BLE001
|
|
124
|
+
logger.exception("BackgroundReflector error processing job")
|
|
125
|
+
finally:
|
|
126
|
+
self._queue.task_done()
|
|
127
|
+
|
|
128
|
+
async def _process(self, job: _ReflectorJob) -> None:
|
|
129
|
+
signal = job.signal
|
|
130
|
+
matched = job.matched_pattern
|
|
131
|
+
|
|
132
|
+
if matched is not None:
|
|
133
|
+
# --- Pattern exists: apply decay or reinforce ---
|
|
134
|
+
new_weight = compute_new_decay_weight(
|
|
135
|
+
stored=matched.decay_weight,
|
|
136
|
+
old_score=matched.score,
|
|
137
|
+
new_score=signal.score,
|
|
138
|
+
)
|
|
139
|
+
if should_evict(new_weight):
|
|
140
|
+
logger.debug("Evicting pattern %s (weight %.3f)", matched.id, new_weight)
|
|
141
|
+
try:
|
|
142
|
+
await self._backend.delete_pattern(matched.id)
|
|
143
|
+
except KeyError:
|
|
144
|
+
pass # Already deleted — idempotent
|
|
145
|
+
else:
|
|
146
|
+
logger.debug(
|
|
147
|
+
"Updating pattern %s decay_weight %.3f → %.3f",
|
|
148
|
+
matched.id,
|
|
149
|
+
matched.decay_weight,
|
|
150
|
+
new_weight,
|
|
151
|
+
)
|
|
152
|
+
await self._backend.update_pattern(matched.id, new_weight)
|
|
153
|
+
else:
|
|
154
|
+
# --- New pattern: extract hint via LLM and write ---
|
|
155
|
+
root_cause, hint_text = await self._extract_from_llm(signal)
|
|
156
|
+
|
|
157
|
+
pattern = FailurePattern(
|
|
158
|
+
query_embedding=job.query_embedding,
|
|
159
|
+
failure_type=signal.failure_type,
|
|
160
|
+
root_cause=root_cause,
|
|
161
|
+
hint_text=hint_text,
|
|
162
|
+
score=signal.score,
|
|
163
|
+
decay_weight=1.0,
|
|
164
|
+
)
|
|
165
|
+
await self._backend.write_pattern(pattern)
|
|
166
|
+
logger.debug("Wrote new pattern %s (%s)", pattern.id, signal.failure_type.name)
|
|
167
|
+
|
|
168
|
+
# Emit observability score
|
|
169
|
+
self._emitter.emit_score(
|
|
170
|
+
trace_id=signal.trace_id,
|
|
171
|
+
score=signal.score,
|
|
172
|
+
failure_type=signal.failure_type.name,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
async def _extract_from_llm(self, signal: FailureSignal) -> tuple[str, str]:
|
|
176
|
+
"""Ask the LLM to refine root_cause and hint_text from the signal.
|
|
177
|
+
|
|
178
|
+
Falls back to the signal's own fields if LLM call fails.
|
|
179
|
+
"""
|
|
180
|
+
if signal.hint:
|
|
181
|
+
# Signal already carries a hint (from the classification matrix)
|
|
182
|
+
return signal.root_cause, signal.hint
|
|
183
|
+
|
|
184
|
+
prompt = (
|
|
185
|
+
f"A RAG pipeline produced a failure of type '{signal.failure_type.name}'.\n"
|
|
186
|
+
f"Score: {signal.score:.3f}\n"
|
|
187
|
+
f"Root cause description: {signal.root_cause}\n\n"
|
|
188
|
+
"In one sentence each:\n"
|
|
189
|
+
"1. What is the root cause?\n"
|
|
190
|
+
"2. What retrieval or generation hint would prevent this failure next time?\n"
|
|
191
|
+
"Reply in JSON: {\"root_cause\": \"...\", \"hint\": \"...\"}"
|
|
192
|
+
)
|
|
193
|
+
try:
|
|
194
|
+
import json as _json
|
|
195
|
+
|
|
196
|
+
# Duck-typed LLM call — supports langchain .invoke() and raw callables
|
|
197
|
+
if hasattr(self._llm, "invoke"):
|
|
198
|
+
raw = self._llm.invoke(prompt)
|
|
199
|
+
if hasattr(raw, "content"):
|
|
200
|
+
raw = raw.content
|
|
201
|
+
elif callable(self._llm):
|
|
202
|
+
raw = self._llm(prompt)
|
|
203
|
+
else:
|
|
204
|
+
raw = str(self._llm)
|
|
205
|
+
|
|
206
|
+
parsed = _json.loads(str(raw))
|
|
207
|
+
return str(parsed.get("root_cause", signal.root_cause)), str(
|
|
208
|
+
parsed.get("hint", signal.hint)
|
|
209
|
+
)
|
|
210
|
+
except Exception: # noqa: BLE001
|
|
211
|
+
return signal.root_cause, signal.hint
|
patternmem/resolver.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
patternmem.resolver
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
LLMResolver — discovers a usable LLM from the wrapped pipeline at init time.
|
|
5
|
+
|
|
6
|
+
Design rules
|
|
7
|
+
------------
|
|
8
|
+
- Duck-typing only. No ``isinstance`` against any framework type.
|
|
9
|
+
- No framework imports (langchain, llama_index, langgraph, etc.).
|
|
10
|
+
- All attribute probes are wrapped in ``try/except AttributeError`` — probing
|
|
11
|
+
must never raise an unexpected exception.
|
|
12
|
+
- If all probe paths fail and no ``llm=`` kwarg was supplied, raises
|
|
13
|
+
``LLMResolverError`` immediately (Invariant 1: fail at init, not at runtime).
|
|
14
|
+
|
|
15
|
+
Probe priority order (do not reorder)
|
|
16
|
+
--------------------------------------
|
|
17
|
+
1. ``pipeline.llm``
|
|
18
|
+
2. ``pipeline.combine_docs_chain.llm_chain.llm``
|
|
19
|
+
3. ``pipeline.nodes["generate"].llm``
|
|
20
|
+
4. ``pipeline._llm``
|
|
21
|
+
5. ``pipeline.as_query_engine()._llm``
|
|
22
|
+
6. Explicit ``llm=`` parameter
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from patternmem.types import LLMResolverError
|
|
30
|
+
|
|
31
|
+
# Human-readable descriptions of each probe path (used in error messages)
|
|
32
|
+
_PROBE_DESCRIPTIONS = [
|
|
33
|
+
"pipeline.llm",
|
|
34
|
+
"pipeline.combine_docs_chain.llm_chain.llm",
|
|
35
|
+
'pipeline.nodes["generate"].llm',
|
|
36
|
+
"pipeline._llm",
|
|
37
|
+
"pipeline.as_query_engine()._llm",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _probe(pipeline: Any) -> Any | None:
|
|
42
|
+
"""Attempt all probe paths and return the first non-None result, or None."""
|
|
43
|
+
|
|
44
|
+
# Path 1: pipeline.llm (LangChain LLMChain, simple case)
|
|
45
|
+
try:
|
|
46
|
+
llm = pipeline.llm
|
|
47
|
+
if llm is not None:
|
|
48
|
+
return llm
|
|
49
|
+
except AttributeError:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
# Path 2: pipeline.combine_docs_chain.llm_chain.llm (LangChain RetrievalQA)
|
|
53
|
+
try:
|
|
54
|
+
llm = pipeline.combine_docs_chain.llm_chain.llm
|
|
55
|
+
if llm is not None:
|
|
56
|
+
return llm
|
|
57
|
+
except AttributeError:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
# Path 3: pipeline.nodes["generate"].llm (LangGraph compiled graph)
|
|
61
|
+
try:
|
|
62
|
+
nodes = pipeline.nodes
|
|
63
|
+
if isinstance(nodes, dict) and "generate" in nodes:
|
|
64
|
+
llm = nodes["generate"].llm
|
|
65
|
+
if llm is not None:
|
|
66
|
+
return llm
|
|
67
|
+
except AttributeError:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
# Path 4: pipeline._llm (LlamaIndex query engine)
|
|
71
|
+
try:
|
|
72
|
+
llm = pipeline._llm # noqa: SLF001
|
|
73
|
+
if llm is not None:
|
|
74
|
+
return llm
|
|
75
|
+
except AttributeError:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
# Path 5: pipeline.as_query_engine()._llm (LlamaIndex index)
|
|
79
|
+
try:
|
|
80
|
+
engine = pipeline.as_query_engine()
|
|
81
|
+
llm = engine._llm # noqa: SLF001
|
|
82
|
+
if llm is not None:
|
|
83
|
+
return llm
|
|
84
|
+
except (AttributeError, Exception): # as_query_engine may raise
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class LLMResolver:
|
|
91
|
+
"""Resolves and holds a reference to the LLM used by the wrapped pipeline.
|
|
92
|
+
|
|
93
|
+
Parameters
|
|
94
|
+
----------
|
|
95
|
+
pipeline:
|
|
96
|
+
The user's pipeline object. May be any callable or object — resolver
|
|
97
|
+
uses duck-typing only.
|
|
98
|
+
llm:
|
|
99
|
+
Explicit LLM override. If provided, all probe paths are skipped.
|
|
100
|
+
|
|
101
|
+
Raises
|
|
102
|
+
------
|
|
103
|
+
LLMResolverError
|
|
104
|
+
If no LLM can be resolved from the pipeline and ``llm`` is ``None``.
|
|
105
|
+
This is raised at construction time, never at call time.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(self, pipeline: Any, llm: Any | None = None) -> None:
|
|
109
|
+
if llm is not None:
|
|
110
|
+
self._llm = llm
|
|
111
|
+
self._source = "explicit llm= parameter"
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
resolved = _probe(pipeline)
|
|
115
|
+
if resolved is None:
|
|
116
|
+
raise LLMResolverError(
|
|
117
|
+
"PatternMemMiddleware could not resolve an LLM from the pipeline. "
|
|
118
|
+
"Tried probe paths:\n"
|
|
119
|
+
+ "\n".join(f" {i + 1}. {desc}" for i, desc in enumerate(_PROBE_DESCRIPTIONS))
|
|
120
|
+
+ "\n\nFix: pass llm= explicitly:\n"
|
|
121
|
+
" PatternMemMiddleware(pipeline=my_pipeline, llm=my_llm)"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
self._llm = resolved
|
|
125
|
+
self._source = "auto-resolved from pipeline"
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def llm(self) -> Any:
|
|
129
|
+
"""The resolved LLM object."""
|
|
130
|
+
return self._llm
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def source(self) -> str:
|
|
134
|
+
"""Human-readable description of how the LLM was resolved."""
|
|
135
|
+
return self._source
|
|
136
|
+
|
|
137
|
+
def __repr__(self) -> str:
|
|
138
|
+
return f"LLMResolver(llm={self._llm!r}, source={self._source!r})"
|
patternmem/types.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""
|
|
2
|
+
patternmem.types
|
|
3
|
+
~~~~~~~~~~~~~~~~
|
|
4
|
+
Core data contracts for PatternMem RAG middleware.
|
|
5
|
+
|
|
6
|
+
This module is the **only** place where ``FailureType``, ``FailureSignal``,
|
|
7
|
+
and ``FailurePattern`` are defined. Everything else in the codebase imports
|
|
8
|
+
from here — never redefines these types locally.
|
|
9
|
+
|
|
10
|
+
No logic lives here: only ``@dataclass`` / ``Enum`` definitions and docstrings.
|
|
11
|
+
The module must remain importable with zero external dependencies.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import uuid
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from enum import Enum, auto
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# FailureType
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FailureType(Enum):
|
|
29
|
+
"""Taxonomy of RAG failure modes detected by ``EvalRouter``.
|
|
30
|
+
|
|
31
|
+
Members
|
|
32
|
+
-------
|
|
33
|
+
RETRIEVAL_MISS
|
|
34
|
+
The retriever returned no documents relevant to the query.
|
|
35
|
+
RETRIEVAL_RANK
|
|
36
|
+
Relevant documents were retrieved but ranked too low to influence the
|
|
37
|
+
generated answer.
|
|
38
|
+
RETRIEVAL_STALE
|
|
39
|
+
Retrieved documents are outdated relative to the query's time context.
|
|
40
|
+
RETRIEVAL_ENTITY_DROP
|
|
41
|
+
A key named entity in the query was dropped from the retrieved context.
|
|
42
|
+
HALLUCINATION
|
|
43
|
+
The generated answer contains facts not supported by retrieved context.
|
|
44
|
+
FAITHFULNESS_DRIFT
|
|
45
|
+
The generated answer is partially faithful but drifts from context in
|
|
46
|
+
meaningful ways (softer than outright hallucination).
|
|
47
|
+
REFUSAL
|
|
48
|
+
The generator refused to answer (e.g. safety filter, out-of-scope).
|
|
49
|
+
INCOMPLETE
|
|
50
|
+
The generator produced a truncated or partial answer.
|
|
51
|
+
UNKNOWN
|
|
52
|
+
Failure mode cannot be determined from available signals. This is also
|
|
53
|
+
the sentinel value used by ``eval="none"`` mode.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
RETRIEVAL_MISS = auto()
|
|
57
|
+
RETRIEVAL_RANK = auto()
|
|
58
|
+
RETRIEVAL_STALE = auto()
|
|
59
|
+
RETRIEVAL_ENTITY_DROP = auto()
|
|
60
|
+
HALLUCINATION = auto()
|
|
61
|
+
FAITHFULNESS_DRIFT = auto()
|
|
62
|
+
REFUSAL = auto()
|
|
63
|
+
INCOMPLETE = auto()
|
|
64
|
+
UNKNOWN = auto()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# Injection-lane classification helpers
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
#: Failure types that should route to the *retrieval* injection lane.
|
|
72
|
+
RETRIEVAL_FAILURE_TYPES: frozenset[FailureType] = frozenset(
|
|
73
|
+
{
|
|
74
|
+
FailureType.RETRIEVAL_MISS,
|
|
75
|
+
FailureType.RETRIEVAL_RANK,
|
|
76
|
+
FailureType.RETRIEVAL_STALE,
|
|
77
|
+
FailureType.RETRIEVAL_ENTITY_DROP,
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
#: Failure types that should route to the *generation* injection lane.
|
|
82
|
+
GENERATION_FAILURE_TYPES: frozenset[FailureType] = frozenset(
|
|
83
|
+
{
|
|
84
|
+
FailureType.HALLUCINATION,
|
|
85
|
+
FailureType.FAITHFULNESS_DRIFT,
|
|
86
|
+
FailureType.REFUSAL,
|
|
87
|
+
FailureType.INCOMPLETE,
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def is_retrieval_failure(ft: FailureType) -> bool:
|
|
93
|
+
"""Return ``True`` if *ft* should route to ``augmented_input["retrieval_hint"]``."""
|
|
94
|
+
return ft in RETRIEVAL_FAILURE_TYPES
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def is_generation_failure(ft: FailureType) -> bool:
|
|
98
|
+
"""Return ``True`` if *ft* should route to ``augmented_input["generation_constraint"]``."""
|
|
99
|
+
return ft in GENERATION_FAILURE_TYPES
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# LLMResolverError
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class LLMResolverError(RuntimeError):
|
|
108
|
+
"""Raised at ``PatternMemMiddleware.__init__`` time when no LLM can be resolved.
|
|
109
|
+
|
|
110
|
+
This is an *init-time* error, never a runtime error (see Invariant 1).
|
|
111
|
+
The message always includes which probe paths were attempted so the user
|
|
112
|
+
can quickly diagnose the issue.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# FailureSignal
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass
|
|
122
|
+
class FailureSignal:
|
|
123
|
+
"""Normalised failure signal produced exclusively by ``EvalRouter``.
|
|
124
|
+
|
|
125
|
+
``EvalRouter`` is the *only* permitted source of ``FailureSignal`` objects.
|
|
126
|
+
No other code in the codebase constructs them directly except in tests.
|
|
127
|
+
|
|
128
|
+
Attributes
|
|
129
|
+
----------
|
|
130
|
+
score : float
|
|
131
|
+
Evaluation score in [0.0, 1.0]. Lower scores indicate worse quality.
|
|
132
|
+
Set to ``0.0`` when ``eval="none"`` mode is active.
|
|
133
|
+
failure_type : FailureType
|
|
134
|
+
Classified failure mode. ``FailureType.UNKNOWN`` when the mode cannot
|
|
135
|
+
be determined.
|
|
136
|
+
root_cause : str
|
|
137
|
+
Human-readable description of *why* the failure occurred, extracted by
|
|
138
|
+
the ``BackgroundReflector`` using the resolved LLM.
|
|
139
|
+
hint : str
|
|
140
|
+
Actionable hint text to be injected in the next Phase 1 pass via the
|
|
141
|
+
appropriate augmentation lane.
|
|
142
|
+
trace_id : str
|
|
143
|
+
UUID4 string correlating this signal across logs, Langfuse, and OTel.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
score: float
|
|
147
|
+
failure_type: FailureType
|
|
148
|
+
root_cause: str
|
|
149
|
+
hint: str
|
|
150
|
+
trace_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
151
|
+
|
|
152
|
+
def __post_init__(self) -> None:
|
|
153
|
+
if not (0.0 <= self.score <= 1.0):
|
|
154
|
+
raise ValueError(
|
|
155
|
+
f"FailureSignal.score must be in [0.0, 1.0], got {self.score!r}"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
# FailurePattern
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass
|
|
165
|
+
class FailurePattern:
|
|
166
|
+
"""Persistent failure pattern stored in a ``MemoryBackend``.
|
|
167
|
+
|
|
168
|
+
``FailurePattern`` is the persisted form of a ``FailureSignal`` enriched
|
|
169
|
+
with an embedding and decay bookkeeping fields. It represents a *recurring*
|
|
170
|
+
failure that the middleware has learned to anticipate and pre-empt.
|
|
171
|
+
|
|
172
|
+
Attributes
|
|
173
|
+
----------
|
|
174
|
+
id : str
|
|
175
|
+
UUID4 string, unique per pattern.
|
|
176
|
+
query_embedding : List[float]
|
|
177
|
+
Dense embedding of the query that triggered this failure, produced by
|
|
178
|
+
the MiniLM encoder. Used for cosine-similarity lookup in Phase 1.
|
|
179
|
+
failure_type : FailureType
|
|
180
|
+
Classified failure mode (mirrors ``FailureSignal.failure_type``).
|
|
181
|
+
root_cause : str
|
|
182
|
+
Human-readable root-cause explanation.
|
|
183
|
+
hint_text : str
|
|
184
|
+
Actionable hint to inject during Phase 1 augmentation.
|
|
185
|
+
score : float
|
|
186
|
+
Evaluation score at the time this pattern was last written/updated.
|
|
187
|
+
created_at : datetime
|
|
188
|
+
UTC timestamp of pattern creation.
|
|
189
|
+
decay_weight : float
|
|
190
|
+
Starts at ``1.0`` on creation. Increases on reinforcement (+0.1),
|
|
191
|
+
decreases on non-improvement (−0.2). Patterns below ``0.1`` are evicted.
|
|
192
|
+
See ``patternmem.decay`` for the exact update logic.
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
196
|
+
query_embedding: list[float] = field(default_factory=list)
|
|
197
|
+
failure_type: FailureType = FailureType.UNKNOWN
|
|
198
|
+
root_cause: str = ""
|
|
199
|
+
hint_text: str = ""
|
|
200
|
+
score: float = 0.0
|
|
201
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
202
|
+
decay_weight: float = 1.0
|
|
203
|
+
|
|
204
|
+
def __post_init__(self) -> None:
|
|
205
|
+
if not (0.0 <= self.score <= 1.0):
|
|
206
|
+
raise ValueError(
|
|
207
|
+
f"FailurePattern.score must be in [0.0, 1.0], got {self.score!r}"
|
|
208
|
+
)
|
|
209
|
+
if self.decay_weight < 0.0:
|
|
210
|
+
raise ValueError(
|
|
211
|
+
f"FailurePattern.decay_weight must be >= 0.0, got {self.decay_weight!r}"
|
|
212
|
+
)
|