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,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
@@ -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
+ )