feedback-manager 0.1.0__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.
Files changed (56) hide show
  1. feedback_manager/__init__.py +75 -0
  2. feedback_manager/_logging.py +31 -0
  3. feedback_manager/api/__init__.py +7 -0
  4. feedback_manager/api/manager.py +339 -0
  5. feedback_manager/api/queries.py +11 -0
  6. feedback_manager/api/subscription.py +27 -0
  7. feedback_manager/contracts/__init__.py +32 -0
  8. feedback_manager/contracts/correlator.py +20 -0
  9. feedback_manager/contracts/handler.py +46 -0
  10. feedback_manager/contracts/policy.py +33 -0
  11. feedback_manager/contracts/router.py +27 -0
  12. feedback_manager/contracts/serializer.py +35 -0
  13. feedback_manager/contracts/store.py +106 -0
  14. feedback_manager/contracts/subscriber.py +22 -0
  15. feedback_manager/core/__init__.py +32 -0
  16. feedback_manager/core/_open_value.py +37 -0
  17. feedback_manager/core/categories.py +55 -0
  18. feedback_manager/core/context.py +60 -0
  19. feedback_manager/core/events.py +71 -0
  20. feedback_manager/core/lifecycle.py +114 -0
  21. feedback_manager/core/provenance.py +34 -0
  22. feedback_manager/core/sources.py +42 -0
  23. feedback_manager/core/status.py +36 -0
  24. feedback_manager/core/targets.py +65 -0
  25. feedback_manager/correlation/__init__.py +5 -0
  26. feedback_manager/correlation/correlator.py +41 -0
  27. feedback_manager/errors/__init__.py +27 -0
  28. feedback_manager/errors/exceptions.py +99 -0
  29. feedback_manager/handlers/__init__.py +5 -0
  30. feedback_manager/handlers/audit.py +41 -0
  31. feedback_manager/integrations/__init__.py +5 -0
  32. feedback_manager/integrations/langchain/__init__.py +6 -0
  33. feedback_manager/integrations/langchain/adapter.py +24 -0
  34. feedback_manager/integrations/langchain/callbacks.py +118 -0
  35. feedback_manager/integrations/langchain/tools.py +48 -0
  36. feedback_manager/integrations/langgraph/__init__.py +7 -0
  37. feedback_manager/integrations/langgraph/adapter.py +54 -0
  38. feedback_manager/integrations/langgraph/interrupt.py +93 -0
  39. feedback_manager/integrations/langgraph/streaming.py +32 -0
  40. feedback_manager/integrations/xai/__init__.py +5 -0
  41. feedback_manager/integrations/xai/adapter.py +96 -0
  42. feedback_manager/observability/__init__.py +29 -0
  43. feedback_manager/observability/hooks.py +78 -0
  44. feedback_manager/policies/__init__.py +7 -0
  45. feedback_manager/policies/delivery.py +23 -0
  46. feedback_manager/policies/failure.py +96 -0
  47. feedback_manager/policies/retention.py +35 -0
  48. feedback_manager/py.typed +0 -0
  49. feedback_manager/routing/__init__.py +19 -0
  50. feedback_manager/routing/default_router.py +50 -0
  51. feedback_manager/routing/rules.py +51 -0
  52. feedback_manager/storage/__init__.py +5 -0
  53. feedback_manager/storage/memory.py +94 -0
  54. feedback_manager-0.1.0.dist-info/METADATA +380 -0
  55. feedback_manager-0.1.0.dist-info/RECORD +56 -0
  56. feedback_manager-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,75 @@
1
+ """FeedbackManager: feedback infrastructure for LangChain/LangGraph applications.
2
+
3
+ FeedbackManager captures, correlates, persists, routes, and manages the
4
+ lifecycle of feedback generated during or around agent execution. It is a
5
+ library, not a runtime: LangGraph/LangChain continue to own execution,
6
+ state, checkpoints, interrupts, and streaming.
7
+
8
+ The public surface is intentionally small. Most applications only need::
9
+
10
+ from feedback_manager import FeedbackManager, FeedbackSource, FeedbackCategory, FeedbackTarget, FeedbackTargetType
11
+
12
+ manager = FeedbackManager()
13
+ event = await manager.submit(
14
+ source=FeedbackSource.HUMAN,
15
+ category=FeedbackCategory.CORRECTION,
16
+ target=FeedbackTarget(type=FeedbackTargetType.GENERATION, id="gen-1"),
17
+ payload={"corrected_text": "..."},
18
+ )
19
+
20
+ See ``docs/`` for the full architecture, extension points, and framework
21
+ integrations (``feedback_manager.integrations``).
22
+ """
23
+
24
+ from feedback_manager.api.manager import FeedbackManager
25
+ from feedback_manager.api.queries import FeedbackQuery
26
+ from feedback_manager.api.subscription import Subscription
27
+ from feedback_manager.core.categories import FeedbackCategory
28
+ from feedback_manager.core.context import CorrelationContext, ExecutionContext
29
+ from feedback_manager.core.events import FeedbackEvent
30
+ from feedback_manager.core.lifecycle import validate_transition
31
+ from feedback_manager.core.provenance import FeedbackProvenanceReference
32
+ from feedback_manager.core.sources import FeedbackSource
33
+ from feedback_manager.core.status import FeedbackStatus
34
+ from feedback_manager.core.targets import FeedbackTarget, FeedbackTargetType
35
+ from feedback_manager.errors.exceptions import (
36
+ FeedbackConfigurationError,
37
+ FeedbackCorrelationError,
38
+ FeedbackHandlerError,
39
+ FeedbackLifecycleError,
40
+ FeedbackManagerError,
41
+ FeedbackNotFoundError,
42
+ FeedbackRoutingError,
43
+ FeedbackSerializationError,
44
+ FeedbackStoreError,
45
+ FeedbackValidationError,
46
+ )
47
+
48
+ __version__ = "0.1.0"
49
+
50
+ __all__ = [
51
+ "CorrelationContext",
52
+ "ExecutionContext",
53
+ "FeedbackCategory",
54
+ "FeedbackConfigurationError",
55
+ "FeedbackCorrelationError",
56
+ "FeedbackEvent",
57
+ "FeedbackHandlerError",
58
+ "FeedbackLifecycleError",
59
+ "FeedbackManager",
60
+ "FeedbackManagerError",
61
+ "FeedbackNotFoundError",
62
+ "FeedbackProvenanceReference",
63
+ "FeedbackQuery",
64
+ "FeedbackRoutingError",
65
+ "FeedbackSerializationError",
66
+ "FeedbackSource",
67
+ "FeedbackStatus",
68
+ "FeedbackStoreError",
69
+ "FeedbackTarget",
70
+ "FeedbackTargetType",
71
+ "FeedbackValidationError",
72
+ "Subscription",
73
+ "__version__",
74
+ "validate_transition",
75
+ ]
@@ -0,0 +1,31 @@
1
+ """Internal ``structlog`` wiring shared by the bundled logging sinks/handlers.
2
+
3
+ Library code must not call ``structlog.configure()`` globally -- that is an
4
+ application-level decision. Each logger created here instead wraps the
5
+ equivalent stdlib :class:`logging.Logger` directly via
6
+ :func:`structlog.wrap_logger`, so host applications that configure Python's
7
+ standard ``logging`` module (handlers, filters, levels, ``caplog`` in tests,
8
+ ...) continue to see feedback-manager's structured log output without any
9
+ extra wiring.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from typing import Any
16
+
17
+ import structlog
18
+
19
+
20
+ def get_logger(name: str) -> Any:
21
+ """Return a ``structlog`` logger bound to the stdlib logger named ``name``."""
22
+ return structlog.wrap_logger(
23
+ logging.getLogger(name),
24
+ processors=[
25
+ structlog.stdlib.add_log_level,
26
+ structlog.processors.KeyValueRenderer(key_order=["event"]),
27
+ ],
28
+ )
29
+
30
+
31
+ __all__ = ["get_logger"]
@@ -0,0 +1,7 @@
1
+ """The small, stable public application service."""
2
+
3
+ from feedback_manager.api.manager import FeedbackManager
4
+ from feedback_manager.api.queries import FeedbackQuery
5
+ from feedback_manager.api.subscription import Subscription
6
+
7
+ __all__ = ["FeedbackManager", "FeedbackQuery", "Subscription"]
@@ -0,0 +1,339 @@
1
+ """``FeedbackManager``: the small, stable public application service.
2
+
3
+ This is the single entry point most applications need. It wires together
4
+ the extension points (store, router, correlator, failure policy,
5
+ observability sink) plus provenance from ``langgraph-xai`` via dependency
6
+ injection -- there is no hidden global state, and every
7
+ :class:`FeedbackManager` instance is fully independent of every other one
8
+ (Section 29 of the spec).
9
+
10
+ Pass your ``langgraph_xai.XAIRuntime`` directly as ``xai_runtime`` and
11
+ ``FeedbackManager`` wires up :class:`XAIProvenanceAdapter` automatically
12
+ (``langgraph-xai`` is the mandatory, sole provenance source, so there is
13
+ nothing else to choose between). ``provenance_adapter`` remains available
14
+ for tests or advanced call sites that already hold a constructed adapter.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ from collections.abc import AsyncIterator, Sequence
21
+ from typing import Any
22
+ from uuid import UUID
23
+
24
+ from langgraph_xai import XAIRuntime
25
+
26
+ from feedback_manager.api.queries import FeedbackQuery
27
+ from feedback_manager.api.subscription import Subscription
28
+ from feedback_manager.contracts.correlator import FeedbackCorrelator
29
+ from feedback_manager.contracts.handler import FeedbackContext
30
+ from feedback_manager.contracts.policy import FeedbackLifecyclePolicy, FeedbackPolicy
31
+ from feedback_manager.contracts.router import FeedbackRouter
32
+ from feedback_manager.contracts.store import FeedbackStore
33
+ from feedback_manager.contracts.subscriber import FeedbackSubscriber
34
+ from feedback_manager.core.categories import FeedbackCategory
35
+ from feedback_manager.core.context import ExecutionContext
36
+ from feedback_manager.core.events import FeedbackEvent
37
+ from feedback_manager.core.sources import FeedbackSource
38
+ from feedback_manager.core.status import FeedbackStatus
39
+ from feedback_manager.core.targets import FeedbackTarget
40
+ from feedback_manager.correlation.correlator import DefaultFeedbackCorrelator
41
+ from feedback_manager.errors import FeedbackNotFoundError, FeedbackStoreError
42
+ from feedback_manager.integrations.xai.adapter import XAIProvenanceAdapter
43
+ from feedback_manager.observability.hooks import (
44
+ FEEDBACK_ACKNOWLEDGED,
45
+ FEEDBACK_CREATED,
46
+ FEEDBACK_HANDLED,
47
+ FEEDBACK_RECEIVED,
48
+ FEEDBACK_RESOLVED,
49
+ FEEDBACK_ROUTED,
50
+ LoggingObservabilitySink,
51
+ ObservabilityEvent,
52
+ ObservabilitySink,
53
+ )
54
+ from feedback_manager.policies.failure import FailurePolicy, FeedbackStage
55
+ from feedback_manager.routing.default_router import DefaultFeedbackRouter
56
+ from feedback_manager.storage.memory import InMemoryFeedbackStore
57
+
58
+ _STATUS_EVENT_NAMES: dict[FeedbackStatus, str] = {
59
+ FeedbackStatus.ACKNOWLEDGED: FEEDBACK_ACKNOWLEDGED,
60
+ FeedbackStatus.HANDLED: FEEDBACK_HANDLED,
61
+ FeedbackStatus.RESOLVED: FEEDBACK_RESOLVED,
62
+ FeedbackStatus.REJECTED: FEEDBACK_RESOLVED,
63
+ FeedbackStatus.CANCELLED: FEEDBACK_RESOLVED,
64
+ FeedbackStatus.EXPIRED: FEEDBACK_RESOLVED,
65
+ }
66
+
67
+
68
+ class FeedbackManager:
69
+ """Capture, correlate, persist, route, and resolve feedback.
70
+
71
+ Every dependency is optional and defaults to an in-memory/no-op
72
+ implementation, so ``FeedbackManager()`` is immediately usable; pass in
73
+ your own :class:`~feedback_manager.contracts.store.FeedbackStore`,
74
+ :class:`~feedback_manager.contracts.router.FeedbackRouter`, etc. to
75
+ plug in production infrastructure without modifying this class.
76
+
77
+ For provenance, pass your own ``langgraph_xai.XAIRuntime`` as
78
+ ``xai_runtime`` -- ``FeedbackManager`` builds the
79
+ :class:`~feedback_manager.integrations.xai.adapter.XAIProvenanceAdapter`
80
+ for you automatically.
81
+ """
82
+
83
+ def __init__(
84
+ self,
85
+ *,
86
+ store: FeedbackStore | None = None,
87
+ router: FeedbackRouter | None = None,
88
+ correlator: FeedbackCorrelator | None = None,
89
+ xai_runtime: XAIRuntime | None = None,
90
+ provenance_adapter: XAIProvenanceAdapter | None = None,
91
+ lifecycle_policy: FeedbackLifecyclePolicy | None = None,
92
+ redaction_policy: FeedbackPolicy | None = None,
93
+ failure_policy: FailurePolicy | None = None,
94
+ observability_sink: ObservabilitySink | None = None,
95
+ ) -> None:
96
+ if provenance_adapter is not None and xai_runtime is not None:
97
+ raise ValueError("pass either xai_runtime or provenance_adapter, not both")
98
+ self._store: FeedbackStore = store if store is not None else InMemoryFeedbackStore()
99
+ self._router: FeedbackRouter = router if router is not None else DefaultFeedbackRouter()
100
+ self._correlator: FeedbackCorrelator = (
101
+ correlator if correlator is not None else DefaultFeedbackCorrelator()
102
+ )
103
+ if provenance_adapter is not None:
104
+ self._provenance_adapter: XAIProvenanceAdapter | None = provenance_adapter
105
+ elif xai_runtime is not None:
106
+ self._provenance_adapter = XAIProvenanceAdapter(xai_runtime)
107
+ else:
108
+ self._provenance_adapter = None
109
+ self._lifecycle_policy = lifecycle_policy
110
+ self._redaction_policy = redaction_policy
111
+ self._failure_policy = failure_policy if failure_policy is not None else FailurePolicy()
112
+ self._observability_sink: ObservabilitySink = (
113
+ observability_sink if observability_sink is not None else LoggingObservabilitySink()
114
+ )
115
+ self._subscribers: list[FeedbackSubscriber] = []
116
+ self._stream_queues: list[asyncio.Queue[FeedbackEvent]] = []
117
+
118
+ # -- submission -----------------------------------------------------
119
+
120
+ async def submit(
121
+ self,
122
+ *,
123
+ source: FeedbackSource | str,
124
+ category: FeedbackCategory | str,
125
+ target: FeedbackTarget,
126
+ payload: dict[str, Any] | None = None,
127
+ execution_context: ExecutionContext | None = None,
128
+ idempotency_key: str | None = None,
129
+ feedback_type: str | None = None,
130
+ metadata: dict[str, Any] | None = None,
131
+ ) -> FeedbackEvent:
132
+ """Submit a new piece of feedback and run it through the pipeline.
133
+
134
+ Pipeline: build -> correlate -> resolve provenance (best-effort) ->
135
+ persist -> transition to ``RECEIVED`` -> notify subscribers -> route
136
+ to handlers (best-effort, isolated per handler).
137
+ """
138
+ event = FeedbackEvent(
139
+ source=FeedbackSource(source),
140
+ category=FeedbackCategory(category),
141
+ target=target,
142
+ payload=payload or {},
143
+ execution_context=execution_context,
144
+ idempotency_key=idempotency_key,
145
+ feedback_type=feedback_type,
146
+ metadata=metadata or {},
147
+ )
148
+ if self._redaction_policy is not None:
149
+ event = self._redaction_policy.apply(event)
150
+
151
+ correlation = await self._correlator.correlate(event, execution_context)
152
+ event = event.with_correlation(correlation)
153
+
154
+ if self._provenance_adapter is not None:
155
+ provenance_adapter = self._provenance_adapter
156
+ provenance = await self._failure_policy.run_stage(
157
+ FeedbackStage.PROVENANCE, lambda: provenance_adapter.resolve(correlation)
158
+ )
159
+ if provenance is not None:
160
+ event = event.with_provenance(provenance)
161
+
162
+ created = await self._failure_policy.run_stage(
163
+ FeedbackStage.STORE, lambda: self._store.create(event)
164
+ )
165
+ if created is None:
166
+ raise FeedbackStoreError(
167
+ "failed to persist feedback event", feedback_id=event.feedback_id
168
+ )
169
+ if created.feedback_id != event.feedback_id:
170
+ # Idempotency-key hit: an event with this key already existed and may
171
+ # already be anywhere in its lifecycle. Submission is a no-op that
172
+ # simply returns the current state, per Section 28 of the spec.
173
+ return created
174
+ self._emit(FEEDBACK_CREATED, created)
175
+
176
+ received = await self._failure_policy.run_stage(
177
+ FeedbackStage.STORE,
178
+ lambda: self._store.transition(created.feedback_id, FeedbackStatus.RECEIVED),
179
+ )
180
+ if received is None:
181
+ raise FeedbackStoreError(
182
+ "failed to transition feedback to RECEIVED", feedback_id=created.feedback_id
183
+ )
184
+ self._emit(FEEDBACK_RECEIVED, received)
185
+ await self._notify_subscribers(received)
186
+ await self._route(received)
187
+ return received
188
+
189
+ async def _route(self, feedback: FeedbackEvent) -> None:
190
+ handlers = await self._failure_policy.run_stage(
191
+ FeedbackStage.ROUTING, lambda: self._router.route(feedback)
192
+ )
193
+ if not handlers:
194
+ return
195
+ context = FeedbackContext(correlation=feedback.correlation)
196
+ for handler in handlers:
197
+
198
+ async def _run(handler: Any = handler) -> None:
199
+ await handler.handle(feedback, context)
200
+
201
+ await self._failure_policy.run_stage(FeedbackStage.HANDLER, _run)
202
+ self._emit(FEEDBACK_ROUTED, feedback, attributes={"handler_count": len(handlers)})
203
+
204
+ # -- lifecycle --------------------------------------------------------
205
+
206
+ async def acknowledge(self, feedback_id: UUID) -> FeedbackEvent:
207
+ """Transition feedback to ``ACKNOWLEDGED`` (a consumer has seen it)."""
208
+ return await self._transition(feedback_id, FeedbackStatus.ACKNOWLEDGED)
209
+
210
+ async def mark_handled(self, feedback_id: UUID) -> FeedbackEvent:
211
+ """Transition feedback to ``HANDLED`` (processing is complete, pending resolution)."""
212
+ return await self._transition(feedback_id, FeedbackStatus.HANDLED)
213
+
214
+ async def resolve(
215
+ self, feedback_id: UUID, *, resolution: dict[str, Any] | None = None
216
+ ) -> FeedbackEvent:
217
+ """Transition feedback to the terminal ``RESOLVED`` state."""
218
+ return await self._transition(feedback_id, FeedbackStatus.RESOLVED, resolution=resolution)
219
+
220
+ async def reject(self, feedback_id: UUID, *, reason: str | None = None) -> FeedbackEvent:
221
+ """Transition feedback to the terminal ``REJECTED`` state."""
222
+ return await self._transition(
223
+ feedback_id, FeedbackStatus.REJECTED, resolution={"reason": reason} if reason else None
224
+ )
225
+
226
+ async def cancel(self, feedback_id: UUID, *, reason: str | None = None) -> FeedbackEvent:
227
+ """Transition feedback to the terminal ``CANCELLED`` state."""
228
+ return await self._transition(
229
+ feedback_id, FeedbackStatus.CANCELLED, resolution={"reason": reason} if reason else None
230
+ )
231
+
232
+ async def expire(self, feedback_id: UUID) -> FeedbackEvent:
233
+ """Transition feedback to the terminal ``EXPIRED`` state."""
234
+ return await self._transition(feedback_id, FeedbackStatus.EXPIRED)
235
+
236
+ async def _transition(
237
+ self,
238
+ feedback_id: UUID,
239
+ status: FeedbackStatus,
240
+ *,
241
+ resolution: dict[str, Any] | None = None,
242
+ ) -> FeedbackEvent:
243
+ current = await self._store.get(feedback_id)
244
+ if current is None:
245
+ raise FeedbackNotFoundError("unknown feedback id", feedback_id=feedback_id)
246
+ if self._lifecycle_policy is not None:
247
+ self._lifecycle_policy.authorize_transition(current, status)
248
+ if resolution:
249
+ merged = current.model_copy(
250
+ update={"metadata": {**current.metadata, "resolution": resolution}}
251
+ )
252
+ current = await self._failure_policy.run_stage(
253
+ FeedbackStage.STORE, lambda: self._store.update(merged)
254
+ )
255
+ if current is None:
256
+ raise FeedbackStoreError(
257
+ "failed to persist resolution metadata", feedback_id=feedback_id
258
+ )
259
+
260
+ updated = await self._failure_policy.run_stage(
261
+ FeedbackStage.STORE, lambda: self._store.transition(feedback_id, status)
262
+ )
263
+ if updated is None:
264
+ raise FeedbackStoreError(
265
+ "failed to persist lifecycle transition", feedback_id=feedback_id
266
+ )
267
+ self._emit(_STATUS_EVENT_NAMES.get(status, FEEDBACK_HANDLED), updated)
268
+ await self._notify_subscribers(updated)
269
+ return updated
270
+
271
+ # -- retrieval --------------------------------------------------------
272
+
273
+ async def get(self, feedback_id: UUID) -> FeedbackEvent | None:
274
+ """Pull-based retrieval of a single feedback event."""
275
+ return await self._store.get(feedback_id)
276
+
277
+ async def query(self, query: FeedbackQuery) -> Sequence[FeedbackEvent]:
278
+ """Pull-based retrieval of every feedback event matching ``query``."""
279
+ return await self._store.query(query)
280
+
281
+ async def list(self) -> Sequence[FeedbackEvent]:
282
+ """Pull-based retrieval of every stored feedback event."""
283
+ return await self._store.list()
284
+
285
+ # -- subscriptions ----------------------------------------------------
286
+
287
+ def subscribe(self, subscriber: FeedbackSubscriber) -> Subscription:
288
+ """Register a push-based subscriber, notified on every create/transition."""
289
+ self._subscribers.append(subscriber)
290
+
291
+ def _cancel() -> None:
292
+ if subscriber in self._subscribers:
293
+ self._subscribers.remove(subscriber)
294
+
295
+ return Subscription(_cancel)
296
+
297
+ async def stream(self, query: FeedbackQuery | None = None) -> AsyncIterator[FeedbackEvent]:
298
+ """Stream feedback events (optionally filtered) as they occur."""
299
+ queue: asyncio.Queue[FeedbackEvent] = asyncio.Queue()
300
+ self._stream_queues.append(queue)
301
+ try:
302
+ while True:
303
+ event = await queue.get()
304
+ if query is None or query.matches(event):
305
+ yield event
306
+ finally:
307
+ if queue in self._stream_queues:
308
+ self._stream_queues.remove(queue)
309
+
310
+ async def _notify_subscribers(self, event: FeedbackEvent) -> None:
311
+ for subscriber in list(self._subscribers):
312
+
313
+ async def _notify(subscriber: FeedbackSubscriber = subscriber) -> None:
314
+ await subscriber(event)
315
+
316
+ await self._failure_policy.run_stage(FeedbackStage.SUBSCRIBER, _notify)
317
+ for queue in list(self._stream_queues):
318
+ queue.put_nowait(event)
319
+
320
+ # -- observability ----------------------------------------------------
321
+
322
+ def _emit(
323
+ self, name: str, event: FeedbackEvent, *, attributes: dict[str, Any] | None = None
324
+ ) -> None:
325
+ self._observability_sink.emit(
326
+ ObservabilityEvent(
327
+ name=name,
328
+ feedback_id=event.feedback_id,
329
+ attributes={
330
+ "source": str(event.source),
331
+ "category": str(event.category),
332
+ "status": str(event.status),
333
+ **(attributes or {}),
334
+ },
335
+ )
336
+ )
337
+
338
+
339
+ __all__ = ["FeedbackManager"]
@@ -0,0 +1,11 @@
1
+ """Feedback query re-export.
2
+
3
+ ``FeedbackQuery`` is defined alongside the :class:`FeedbackStore` contract
4
+ (:mod:`feedback_manager.contracts.store`) since the two are tightly
5
+ coupled, but it is part of the public application-facing API, so it is
6
+ re-exported here as well.
7
+ """
8
+
9
+ from feedback_manager.contracts.store import FeedbackQuery
10
+
11
+ __all__ = ["FeedbackQuery"]
@@ -0,0 +1,27 @@
1
+ """Subscription handle returned by :meth:`FeedbackManager.subscribe`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass(slots=True)
10
+ class Subscription:
11
+ """A handle that unsubscribes a :class:`FeedbackSubscriber` when cancelled."""
12
+
13
+ _cancel: Callable[[], None]
14
+ _active: bool = True
15
+
16
+ def cancel(self) -> None:
17
+ """Stop receiving further feedback notifications."""
18
+ if self._active:
19
+ self._cancel()
20
+ self._active = False
21
+
22
+ @property
23
+ def active(self) -> bool:
24
+ return self._active
25
+
26
+
27
+ __all__ = ["Subscription"]
@@ -0,0 +1,32 @@
1
+ """Extension-point contracts (ABCs and Protocols) for feedback_manager.
2
+
3
+ See ``docs/architecture/EXTENSIBILITY_MODEL.md`` for the rationale behind
4
+ each ABC vs. Protocol choice.
5
+ """
6
+
7
+ from feedback_manager.contracts.correlator import FeedbackCorrelator
8
+ from feedback_manager.contracts.handler import (
9
+ FeedbackContext,
10
+ FeedbackHandler,
11
+ FeedbackHandlerResult,
12
+ )
13
+ from feedback_manager.contracts.policy import FeedbackLifecyclePolicy, FeedbackPolicy
14
+ from feedback_manager.contracts.router import FeedbackRouter
15
+ from feedback_manager.contracts.serializer import DefaultFeedbackSerializer, FeedbackSerializer
16
+ from feedback_manager.contracts.store import FeedbackQuery, FeedbackStore
17
+ from feedback_manager.contracts.subscriber import FeedbackSubscriber
18
+
19
+ __all__ = [
20
+ "DefaultFeedbackSerializer",
21
+ "FeedbackContext",
22
+ "FeedbackCorrelator",
23
+ "FeedbackHandler",
24
+ "FeedbackHandlerResult",
25
+ "FeedbackLifecyclePolicy",
26
+ "FeedbackPolicy",
27
+ "FeedbackQuery",
28
+ "FeedbackRouter",
29
+ "FeedbackSerializer",
30
+ "FeedbackStore",
31
+ "FeedbackSubscriber",
32
+ ]
@@ -0,0 +1,20 @@
1
+ """Structural contract for deriving correlation context at submission time."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol, runtime_checkable
6
+
7
+ from feedback_manager.core.context import CorrelationContext, ExecutionContext
8
+ from feedback_manager.core.events import FeedbackEvent
9
+
10
+
11
+ @runtime_checkable
12
+ class FeedbackCorrelator(Protocol):
13
+ """Derives a :class:`CorrelationContext` for an about-to-be-submitted event."""
14
+
15
+ async def correlate(
16
+ self, feedback: FeedbackEvent, execution_context: ExecutionContext | None
17
+ ) -> CorrelationContext: ...
18
+
19
+
20
+ __all__ = ["FeedbackCorrelator"]
@@ -0,0 +1,46 @@
1
+ """Abstract contract for feedback handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ from feedback_manager.core.context import CorrelationContext
10
+ from feedback_manager.core.events import FeedbackEvent
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class FeedbackContext:
15
+ """Ambient context passed to a handler alongside the feedback event."""
16
+
17
+ correlation: CorrelationContext | None = None
18
+ metadata: dict[str, Any] = field(default_factory=dict)
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class FeedbackHandlerResult:
23
+ """The outcome of a single handler invocation."""
24
+
25
+ handled: bool
26
+ detail: str | None = None
27
+ metadata: dict[str, Any] = field(default_factory=dict)
28
+
29
+
30
+ class FeedbackHandler(ABC):
31
+ """Abstract contract for something that reacts to a :class:`FeedbackEvent`.
32
+
33
+ Handlers are independently replaceable and must not raise for
34
+ business-as-usual outcomes (e.g. "feedback rejected") -- only for
35
+ genuine handler failures, which the router/manager isolate per the
36
+ configured :class:`~feedback_manager.policies.failure.FailurePolicy`.
37
+ """
38
+
39
+ @abstractmethod
40
+ async def handle(
41
+ self, feedback: FeedbackEvent, context: FeedbackContext
42
+ ) -> FeedbackHandlerResult:
43
+ """React to ``feedback`` and report the outcome."""
44
+
45
+
46
+ __all__ = ["FeedbackContext", "FeedbackHandler", "FeedbackHandlerResult"]
@@ -0,0 +1,33 @@
1
+ """Abstract contracts for feedback and lifecycle policy hooks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+
7
+ from feedback_manager.core.events import FeedbackEvent
8
+ from feedback_manager.core.status import FeedbackStatus
9
+
10
+
11
+ class FeedbackLifecyclePolicy(ABC):
12
+ """Allows application code to add business rules on top of the base state machine.
13
+
14
+ The base transition table in :mod:`feedback_manager.core.lifecycle`
15
+ encodes structural legality only. A ``FeedbackLifecyclePolicy`` can
16
+ reject an otherwise-legal transition for business reasons (e.g. "only
17
+ the original requester may resolve their own feedback").
18
+ """
19
+
20
+ @abstractmethod
21
+ def authorize_transition(self, feedback: FeedbackEvent, target: FeedbackStatus) -> None:
22
+ """Raise if the transition should not be allowed; return normally otherwise."""
23
+
24
+
25
+ class FeedbackPolicy(ABC):
26
+ """General extension point for redaction/filtering before persistence or serialization."""
27
+
28
+ @abstractmethod
29
+ def apply(self, feedback: FeedbackEvent) -> FeedbackEvent:
30
+ """Return a (possibly modified) copy of ``feedback``."""
31
+
32
+
33
+ __all__ = ["FeedbackLifecyclePolicy", "FeedbackPolicy"]
@@ -0,0 +1,27 @@
1
+ """Abstract contract for routing feedback events to handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from collections.abc import Sequence
7
+
8
+ from feedback_manager.contracts.handler import FeedbackHandler
9
+ from feedback_manager.core.events import FeedbackEvent
10
+
11
+
12
+ class FeedbackRouter(ABC):
13
+ """Decides which :class:`FeedbackHandler` instances should see an event.
14
+
15
+ Routing may consider any attribute of the event (source, category,
16
+ target, execution context, lifecycle status, metadata). Implementations
17
+ must be side-effect free -- routing only *selects* handlers, it does not
18
+ invoke them (that is the manager's job, so failures in one handler can be
19
+ isolated from others per the configured failure policy).
20
+ """
21
+
22
+ @abstractmethod
23
+ async def route(self, feedback: FeedbackEvent) -> Sequence[FeedbackHandler]:
24
+ """Return the ordered sequence of handlers that should process ``feedback``."""
25
+
26
+
27
+ __all__ = ["FeedbackRouter"]