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 ADDED
@@ -0,0 +1,40 @@
1
+ """
2
+ patternmem
3
+ ~~~~~~~~~~
4
+ Framework-agnostic RAG middleware that makes any pipeline self-improving
5
+ via persistent cross-query failure-pattern memory.
6
+
7
+ One-line integration::
8
+
9
+ answer = await PatternMemMiddleware(pipeline=my_rag_pipeline).ainvoke(query)
10
+
11
+ Public API
12
+ ----------
13
+ The following names are the stable, versioned public surface of this package.
14
+ Anything not listed here is considered internal and may change without notice.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from patternmem.backend import MemoryBackend
20
+ from patternmem.middleware import PatternMemMiddleware
21
+ from patternmem.types import (
22
+ FailurePattern,
23
+ FailureSignal,
24
+ FailureType,
25
+ LLMResolverError,
26
+ )
27
+
28
+ __all__ = [
29
+ "PatternMemMiddleware",
30
+ # data contracts
31
+ "FailureSignal",
32
+ "FailureType",
33
+ "FailurePattern",
34
+ # errors
35
+ "LLMResolverError",
36
+ # ABC (for community backend implementors)
37
+ "MemoryBackend",
38
+ ]
39
+
40
+ __version__ = "0.1.0"
patternmem/_utils.py ADDED
@@ -0,0 +1,25 @@
1
+ """
2
+ patternmem._utils
3
+ ~~~~~~~~~~~~~~~~~~
4
+ Internal utility helpers shared across backends.
5
+
6
+ All functions here are *pure* (no I/O, no async, no side-effects).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import numpy as np
12
+
13
+
14
+ def cosine_similarity(a: list[float], b: list[float]) -> float:
15
+ """Return cosine similarity in [−1, 1] between two L2-normalised vectors.
16
+
17
+ Returns 0.0 if either vector has zero norm (avoids division-by-zero).
18
+ """
19
+ va = np.array(a, dtype=np.float32)
20
+ vb = np.array(b, dtype=np.float32)
21
+ norm_a = float(np.linalg.norm(va))
22
+ norm_b = float(np.linalg.norm(vb))
23
+ if norm_a == 0.0 or norm_b == 0.0:
24
+ return 0.0
25
+ return float(np.dot(va, vb) / (norm_a * norm_b))
@@ -0,0 +1,155 @@
1
+ """
2
+ patternmem.augmenter
3
+ ~~~~~~~~~~~~~~~~~~~~~
4
+ Augmenter — builds the ``augmented_input`` dict from retrieved ``FailurePattern``
5
+ objects and injects it into the pipeline call for Phase 1 of the 3-phase loop.
6
+
7
+ Invariants enforced here
8
+ ------------------------
9
+ - **Invariant 2**: Never mutates the user's prompt template. All influence
10
+ flows through ``augmented_input``.
11
+ - **Invariant 4**: Retrieval-type hints → ``augmented_input["retrieval_hint"]``
12
+ only. Generation-type constraints → ``augmented_input["generation_constraint"]``
13
+ only. No hint ever appears in the wrong key.
14
+
15
+ Framework detection
16
+ -------------------
17
+ Duck-typing only — no ``isinstance`` against framework types, no framework imports.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Any
23
+
24
+ from patternmem.types import (
25
+ FailurePattern,
26
+ is_generation_failure,
27
+ is_retrieval_failure,
28
+ )
29
+
30
+
31
+ class Augmenter:
32
+ """Builds ``augmented_input`` dicts from failure patterns.
33
+
34
+ Parameters
35
+ ----------
36
+ pipeline:
37
+ The wrapped pipeline object. Used for framework-specific augmentation
38
+ (duck-typing only).
39
+ rewrite_feedback:
40
+ If ``True``, add a ``rewrite_constraint`` key to ``augmented_input``
41
+ when retrieval hints are present. Never touches the prompt template.
42
+ allow_param_override:
43
+ If ``True``, add ``llm_call_kwargs`` (e.g. ``temperature=0.1``) to
44
+ ``augmented_input`` for grounding-sensitive failure types.
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ pipeline: Any,
50
+ rewrite_feedback: bool = False,
51
+ allow_param_override: bool = False,
52
+ ) -> None:
53
+ self._pipeline = pipeline
54
+ self._rewrite_feedback = rewrite_feedback
55
+ self._allow_param_override = allow_param_override
56
+
57
+ # ------------------------------------------------------------------
58
+ # Framework detection helpers (duck-typing, no imports)
59
+ # ------------------------------------------------------------------
60
+
61
+ def _is_langchain(self) -> bool:
62
+ """Heuristic: pipeline has a ``retriever`` attribute with ``search_kwargs``."""
63
+ try:
64
+ return hasattr(self._pipeline, "retriever") and hasattr(
65
+ self._pipeline.retriever, "search_kwargs"
66
+ )
67
+ except Exception: # noqa: BLE001
68
+ return False
69
+
70
+ def _is_llamaindex(self) -> bool:
71
+ """Heuristic: pipeline responds to ``as_query_engine()`` call."""
72
+ try:
73
+ return callable(getattr(self._pipeline, "as_query_engine", None))
74
+ except Exception: # noqa: BLE001
75
+ return False
76
+
77
+ # ------------------------------------------------------------------
78
+ # Public method
79
+ # ------------------------------------------------------------------
80
+
81
+ def build(self, patterns: list[FailurePattern]) -> dict[str, Any]:
82
+ """Build the ``augmented_input`` dict from the given patterns.
83
+
84
+ Parameters
85
+ ----------
86
+ patterns:
87
+ Matched patterns from ``backend.lookup_patterns()``. May contain
88
+ both retrieval-type and generation-type failures (compound case).
89
+
90
+ Returns
91
+ -------
92
+ dict[str, Any]
93
+ Keys set only as needed:
94
+ - ``"retrieval_hint"`` — present only if retrieval-type patterns exist
95
+ - ``"generation_constraint"`` — present only if generation-type patterns exist
96
+ - ``"rewrite_constraint"`` — present only if ``rewrite_feedback=True``
97
+ and a retrieval hint was produced
98
+ - ``"llm_call_kwargs"`` — present only if ``allow_param_override=True``
99
+ and a generation-type constraint was produced
100
+ - ``"langchain_retriever_hint"`` — present only for LangChain pipelines
101
+ - ``"llamaindex_query_bundle"`` — present only for LlamaIndex pipelines
102
+
103
+ Notes (Invariant 4)
104
+ -------------------
105
+ Retrieval-type ``FailureType``s → ``"retrieval_hint"`` only.
106
+ Generation-type ``FailureType``s → ``"generation_constraint"`` only.
107
+ This is enforced by ``is_retrieval_failure`` / ``is_generation_failure``
108
+ defined in ``patternmem.types``.
109
+ """
110
+ retrieval_hints: list[str] = []
111
+ generation_constraints: list[str] = []
112
+
113
+ for p in patterns:
114
+ if is_retrieval_failure(p.failure_type):
115
+ retrieval_hints.append(p.hint_text)
116
+ elif is_generation_failure(p.failure_type):
117
+ generation_constraints.append(p.hint_text)
118
+ # UNKNOWN failure type → no augmentation
119
+
120
+ augmented: dict[str, Any] = {}
121
+
122
+ # --- Retrieval lane ---
123
+ if retrieval_hints:
124
+ combined_hint = "; ".join(retrieval_hints)
125
+ augmented["retrieval_hint"] = combined_hint
126
+
127
+ # Framework-specific injection
128
+ if self._is_langchain():
129
+ try:
130
+ self._pipeline.retriever.search_kwargs["hint"] = combined_hint
131
+ augmented["langchain_retriever_hint"] = combined_hint
132
+ except Exception: # noqa: BLE001
133
+ pass
134
+ elif self._is_llamaindex():
135
+ augmented["llamaindex_query_bundle"] = {
136
+ "custom_embedding_strs": [combined_hint]
137
+ }
138
+
139
+ # Rewrite feedback lane (only if explicitly enabled)
140
+ if self._rewrite_feedback:
141
+ # Invariant 2: never patch the prompt object — append via context key only
142
+ augmented["rewrite_constraint"] = (
143
+ f"Constraint (auto): {combined_hint}"
144
+ )
145
+
146
+ # --- Generation lane ---
147
+ if generation_constraints:
148
+ combined_constraint = "; ".join(generation_constraints)
149
+ augmented["generation_constraint"] = combined_constraint
150
+
151
+ # Optional temperature override for grounding-sensitive queries
152
+ if self._allow_param_override:
153
+ augmented["llm_call_kwargs"] = {"temperature": 0.1}
154
+
155
+ return augmented
patternmem/backend.py ADDED
@@ -0,0 +1,138 @@
1
+ """
2
+ patternmem.backend
3
+ ~~~~~~~~~~~~~~~~~~
4
+ Abstract base class for all PatternMem memory backends.
5
+
6
+ Every storage backend (JSON, SQLite, NetworkX, Neo4j) must subclass
7
+ ``MemoryBackend`` and implement every abstract method. The contract test suite
8
+ in ``tests/contract/test_backend_contract.py`` asserts identical behaviour
9
+ across all implementations, making them provably interchangeable.
10
+
11
+ Design rules
12
+ ------------
13
+ - All methods are ``async`` — implementations may use any I/O strategy
14
+ (asyncio-native, ``asyncio.to_thread``, etc.) as long as they do not block
15
+ the event loop for more than ~10 ms.
16
+ - The ABC deliberately does *not* import any storage driver — backends stay
17
+ in ``patternmem/backends/`` and import drivers themselves.
18
+ - ``update_pattern`` and ``delete_pattern`` are required by the decay loop
19
+ (Invariant 6) but are *not* exported from ``patternmem.__init__``.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from abc import ABC, abstractmethod
25
+ from typing import Any
26
+
27
+ from patternmem.types import FailurePattern
28
+
29
+
30
+ class MemoryBackend(ABC):
31
+ """Abstract base class that every storage backend must implement.
32
+
33
+ The public surface (``write_pattern``, ``lookup_patterns``, ``get_stats``)
34
+ is exported from ``patternmem.__init__``. The lifecycle methods
35
+ (``update_pattern``, ``delete_pattern``) are required internally for the
36
+ decay/eviction loop but are not part of the user-facing API.
37
+ """
38
+
39
+ # ------------------------------------------------------------------
40
+ # Public API — exported via patternmem.__init__
41
+ # ------------------------------------------------------------------
42
+
43
+ @abstractmethod
44
+ async def write_pattern(self, pattern: FailurePattern) -> None:
45
+ """Persist a new ``FailurePattern`` to the backend.
46
+
47
+ Parameters
48
+ ----------
49
+ pattern:
50
+ The failure pattern to store. The ``id`` field is already set by
51
+ ``FailurePattern``'s ``__post_init__``.
52
+
53
+ Notes
54
+ -----
55
+ Implementations must be idempotent on ``pattern.id``: writing the same
56
+ ``id`` twice should update the existing record, not create a duplicate.
57
+ """
58
+
59
+ @abstractmethod
60
+ async def lookup_patterns(
61
+ self,
62
+ query_embedding: list[float],
63
+ top_k: int = 3,
64
+ ) -> list[FailurePattern]:
65
+ """Return the top-*k* patterns whose embeddings are similar to *query_embedding*.
66
+
67
+ Parameters
68
+ ----------
69
+ query_embedding:
70
+ Dense query vector produced by the MiniLM encoder.
71
+ top_k:
72
+ Maximum number of patterns to return. Implementations must apply
73
+ the similarity threshold (0.82 by default, configurable via the
74
+ middleware's ``similarity_threshold`` parameter) before ranking.
75
+
76
+ Returns
77
+ -------
78
+ list[FailurePattern]
79
+ Ordered by descending similarity score. Empty list if no patterns
80
+ exceed the similarity threshold.
81
+ """
82
+
83
+ @abstractmethod
84
+ async def get_stats(self) -> dict[str, Any]:
85
+ """Return backend health and bookkeeping statistics.
86
+
87
+ The returned dict must contain at minimum:
88
+
89
+ .. code-block:: python
90
+
91
+ {"count": int} # total patterns stored
92
+
93
+ Implementations may include additional keys (e.g. ``"path"``,
94
+ ``"uri"``, ``"evictions"``).
95
+ """
96
+
97
+ # ------------------------------------------------------------------
98
+ # Lifecycle API — required by decay/eviction loop (Invariant 6)
99
+ # ------------------------------------------------------------------
100
+
101
+ @abstractmethod
102
+ async def update_pattern(self, pattern_id: str, decay_weight: float) -> None:
103
+ """Update the ``decay_weight`` of an existing pattern.
104
+
105
+ Parameters
106
+ ----------
107
+ pattern_id:
108
+ UUID string of the pattern to update.
109
+ decay_weight:
110
+ New decay weight value. Must be stored verbatim; no clamping is
111
+ performed here — callers (``BackgroundReflector``) own that logic.
112
+
113
+ Raises
114
+ ------
115
+ KeyError
116
+ If *pattern_id* does not exist in the backend.
117
+ """
118
+
119
+ @abstractmethod
120
+ async def delete_pattern(self, pattern_id: str) -> None:
121
+ """Permanently evict a pattern from the backend.
122
+
123
+ Parameters
124
+ ----------
125
+ pattern_id:
126
+ UUID string of the pattern to delete.
127
+
128
+ Raises
129
+ ------
130
+ KeyError
131
+ If *pattern_id* does not exist in the backend.
132
+
133
+ Notes
134
+ -----
135
+ This is called by the decay loop when ``decay_weight < 0.1``.
136
+ Implementations should ensure the deletion is durable (e.g. flushed
137
+ to disk for file-based backends) before returning.
138
+ """
@@ -0,0 +1 @@
1
+ """patternmem.backends — internal package."""
@@ -0,0 +1,215 @@
1
+ """
2
+ patternmem.backends.chroma_backend
3
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4
+ ChromaDB backend for PatternMem.
5
+
6
+ Uses ChromaDB's native vector index for similarity search — no brute-force
7
+ numpy cosine loop is needed. This makes it the best choice for large
8
+ pattern memories (> 10 000 patterns) when you don't need Neo4j's graph model.
9
+
10
+ Storage layout
11
+ --------------
12
+ A single ChromaDB collection named ``"patternmem_patterns"`` (configurable).
13
+ Pattern metadata (failure_type, root_cause, hint_text, score, created_at,
14
+ decay_weight) is stored in Chroma's document metadata dict.
15
+ The query embedding is stored as the collection document embedding.
16
+
17
+ Similarity
18
+ ----------
19
+ ChromaDB runs its own HNSW index for approximate nearest-neighbour search.
20
+ PatternMem's ``similarity_threshold`` is applied as a post-filter on the
21
+ returned cosine distances (Chroma returns distances not similarities, so
22
+ we convert: similarity = 1 − distance).
23
+
24
+ Requirements
25
+ ------------
26
+ pip install patternmem-rag[chroma]
27
+ # or
28
+ pip install chromadb>=0.4
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import asyncio
34
+ from datetime import datetime, timezone
35
+ from typing import Any
36
+
37
+ from patternmem.backend import MemoryBackend
38
+ from patternmem.types import FailurePattern, FailureType
39
+
40
+ try:
41
+ import chromadb
42
+
43
+ _CHROMA_AVAILABLE = True
44
+ except ImportError:
45
+ _CHROMA_AVAILABLE = False
46
+
47
+
48
+ def _pattern_to_chroma(p: FailurePattern) -> dict[str, Any]:
49
+ """Flatten FailurePattern into a Chroma metadata dict (scalar values only)."""
50
+ return {
51
+ "failure_type": p.failure_type.name,
52
+ "root_cause": p.root_cause,
53
+ "hint_text": p.hint_text,
54
+ "score": p.score,
55
+ "created_at": p.created_at.isoformat(),
56
+ "decay_weight": p.decay_weight,
57
+ }
58
+
59
+
60
+ def _chroma_to_pattern(
61
+ pattern_id: str,
62
+ meta: dict[str, Any],
63
+ embedding: list[float],
64
+ ) -> FailurePattern:
65
+ return FailurePattern(
66
+ id=pattern_id,
67
+ query_embedding=embedding,
68
+ failure_type=FailureType[meta["failure_type"]],
69
+ root_cause=meta["root_cause"],
70
+ hint_text=meta["hint_text"],
71
+ score=float(meta["score"]),
72
+ created_at=datetime.fromisoformat(meta["created_at"]).replace(
73
+ tzinfo=timezone.utc
74
+ ),
75
+ decay_weight=float(meta["decay_weight"]),
76
+ )
77
+
78
+
79
+ class ChromaBackend(MemoryBackend):
80
+ """ChromaDB-backed vector store for PatternMem failure patterns.
81
+
82
+ Uses ChromaDB's native HNSW index for fast approximate nearest-neighbour
83
+ search. Recommended for pattern stores > 10 000 entries or when you
84
+ already run a Chroma server.
85
+
86
+ Parameters
87
+ ----------
88
+ collection_name:
89
+ Name of the Chroma collection to use. Created if it does not exist.
90
+ persist_directory:
91
+ Path for the persistent Chroma client. ``None`` uses an ephemeral
92
+ (in-memory) client — useful for tests and notebooks.
93
+ host / port:
94
+ If provided, connects to a running Chroma HTTP server instead of a
95
+ local file-backed client. ``host`` takes precedence over
96
+ ``persist_directory``.
97
+ similarity_threshold:
98
+ Minimum cosine similarity (0–1) for a pattern to be returned.
99
+ Chroma returns L2 or cosine distances; PatternMem converts and filters.
100
+ """
101
+
102
+ def __init__(
103
+ self,
104
+ collection_name: str = "patternmem_patterns",
105
+ persist_directory: str | None = None,
106
+ host: str | None = None,
107
+ port: int = 8000,
108
+ similarity_threshold: float = 0.82,
109
+ ) -> None:
110
+ if not _CHROMA_AVAILABLE:
111
+ raise ImportError(
112
+ "ChromaBackend requires 'chromadb'. "
113
+ "Install it with: pip install patternmem-rag[chroma]"
114
+ )
115
+ self._threshold = similarity_threshold
116
+ self._collection_name = collection_name
117
+
118
+ if host is not None:
119
+ # Connect to a running Chroma HTTP server
120
+ self._client = chromadb.HttpClient(host=host, port=port)
121
+ elif persist_directory is not None:
122
+ # File-backed persistent client
123
+ self._client = chromadb.PersistentClient(path=persist_directory)
124
+ else:
125
+ # Ephemeral in-memory client (tests / notebooks)
126
+ self._client = chromadb.EphemeralClient()
127
+
128
+ # Get-or-create the collection with cosine similarity space
129
+ self._collection = self._client.get_or_create_collection(
130
+ name=collection_name,
131
+ metadata={"hnsw:space": "cosine"},
132
+ )
133
+ self._lock = asyncio.Lock()
134
+
135
+ # ------------------------------------------------------------------
136
+ # MemoryBackend implementation
137
+ # ------------------------------------------------------------------
138
+
139
+ async def write_pattern(self, pattern: FailurePattern) -> None:
140
+ async with self._lock:
141
+ await asyncio.to_thread(self._write_sync, pattern)
142
+
143
+ def _write_sync(self, pattern: FailurePattern) -> None:
144
+ """Upsert pattern into the Chroma collection (idempotent on pattern.id)."""
145
+ self._collection.upsert(
146
+ ids=[pattern.id],
147
+ embeddings=[pattern.query_embedding],
148
+ metadatas=[_pattern_to_chroma(pattern)],
149
+ documents=[pattern.root_cause or " "], # Chroma requires non-empty docs
150
+ )
151
+
152
+ async def lookup_patterns(
153
+ self,
154
+ query_embedding: list[float],
155
+ top_k: int = 3,
156
+ ) -> list[FailurePattern]:
157
+ # Fetch more candidates than top_k so the threshold filter has room to work
158
+ n_results = min(top_k * 4, max(1, self._collection.count()))
159
+ if n_results == 0:
160
+ return []
161
+
162
+ results = await asyncio.to_thread(
163
+ self._collection.query,
164
+ query_embeddings=[query_embedding],
165
+ n_results=n_results,
166
+ include=["embeddings", "metadatas", "distances"],
167
+ )
168
+
169
+ ids: list[str] = results["ids"][0]
170
+ metadatas: list[dict[str, Any]] = results["metadatas"][0]
171
+ distances: list[float] = results["distances"][0]
172
+ embeddings: list[list[float]] = results["embeddings"][0]
173
+
174
+ # Chroma cosine space returns distances in [0, 2]; convert to similarity
175
+ # similarity = 1 − (distance / 2) (for cosine distance in [0, 2])
176
+ # OR similarity = 1 − distance (for normalized cosine in [0, 1])
177
+ # ChromaDB cosine distance = 1 − cosine_similarity → similarity = 1 − dist
178
+ patterns: list[FailurePattern] = []
179
+ for pid, meta, dist, emb in zip(ids, metadatas, distances, embeddings):
180
+ similarity = 1.0 - float(dist)
181
+ if similarity >= self._threshold:
182
+ patterns.append(_chroma_to_pattern(pid, meta, list(emb)))
183
+
184
+ # Already sorted by distance (ascending) = similarity descending
185
+ return patterns[:top_k]
186
+
187
+ async def get_stats(self) -> dict[str, Any]:
188
+ count = await asyncio.to_thread(self._collection.count)
189
+ return {
190
+ "count": count,
191
+ "collection": self._collection_name,
192
+ "backend": "chroma",
193
+ }
194
+
195
+ async def update_pattern(self, pattern_id: str, decay_weight: float) -> None:
196
+ async with self._lock:
197
+ await asyncio.to_thread(self._update_sync, pattern_id, decay_weight)
198
+
199
+ def _update_sync(self, pattern_id: str, decay_weight: float) -> None:
200
+ result = self._collection.get(ids=[pattern_id], include=["metadatas"])
201
+ if not result["ids"]:
202
+ raise KeyError(f"Pattern {pattern_id!r} not found in Chroma backend")
203
+ meta = result["metadatas"][0]
204
+ meta["decay_weight"] = decay_weight
205
+ self._collection.update(ids=[pattern_id], metadatas=[meta])
206
+
207
+ async def delete_pattern(self, pattern_id: str) -> None:
208
+ async with self._lock:
209
+ await asyncio.to_thread(self._delete_sync, pattern_id)
210
+
211
+ def _delete_sync(self, pattern_id: str) -> None:
212
+ result = self._collection.get(ids=[pattern_id])
213
+ if not result["ids"]:
214
+ raise KeyError(f"Pattern {pattern_id!r} not found in Chroma backend")
215
+ self._collection.delete(ids=[pattern_id])