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.
- feedback_manager/__init__.py +75 -0
- feedback_manager/_logging.py +31 -0
- feedback_manager/api/__init__.py +7 -0
- feedback_manager/api/manager.py +339 -0
- feedback_manager/api/queries.py +11 -0
- feedback_manager/api/subscription.py +27 -0
- feedback_manager/contracts/__init__.py +32 -0
- feedback_manager/contracts/correlator.py +20 -0
- feedback_manager/contracts/handler.py +46 -0
- feedback_manager/contracts/policy.py +33 -0
- feedback_manager/contracts/router.py +27 -0
- feedback_manager/contracts/serializer.py +35 -0
- feedback_manager/contracts/store.py +106 -0
- feedback_manager/contracts/subscriber.py +22 -0
- feedback_manager/core/__init__.py +32 -0
- feedback_manager/core/_open_value.py +37 -0
- feedback_manager/core/categories.py +55 -0
- feedback_manager/core/context.py +60 -0
- feedback_manager/core/events.py +71 -0
- feedback_manager/core/lifecycle.py +114 -0
- feedback_manager/core/provenance.py +34 -0
- feedback_manager/core/sources.py +42 -0
- feedback_manager/core/status.py +36 -0
- feedback_manager/core/targets.py +65 -0
- feedback_manager/correlation/__init__.py +5 -0
- feedback_manager/correlation/correlator.py +41 -0
- feedback_manager/errors/__init__.py +27 -0
- feedback_manager/errors/exceptions.py +99 -0
- feedback_manager/handlers/__init__.py +5 -0
- feedback_manager/handlers/audit.py +41 -0
- feedback_manager/integrations/__init__.py +5 -0
- feedback_manager/integrations/langchain/__init__.py +6 -0
- feedback_manager/integrations/langchain/adapter.py +24 -0
- feedback_manager/integrations/langchain/callbacks.py +118 -0
- feedback_manager/integrations/langchain/tools.py +48 -0
- feedback_manager/integrations/langgraph/__init__.py +7 -0
- feedback_manager/integrations/langgraph/adapter.py +54 -0
- feedback_manager/integrations/langgraph/interrupt.py +93 -0
- feedback_manager/integrations/langgraph/streaming.py +32 -0
- feedback_manager/integrations/xai/__init__.py +5 -0
- feedback_manager/integrations/xai/adapter.py +96 -0
- feedback_manager/observability/__init__.py +29 -0
- feedback_manager/observability/hooks.py +78 -0
- feedback_manager/policies/__init__.py +7 -0
- feedback_manager/policies/delivery.py +23 -0
- feedback_manager/policies/failure.py +96 -0
- feedback_manager/policies/retention.py +35 -0
- feedback_manager/py.typed +0 -0
- feedback_manager/routing/__init__.py +19 -0
- feedback_manager/routing/default_router.py +50 -0
- feedback_manager/routing/rules.py +51 -0
- feedback_manager/storage/__init__.py +5 -0
- feedback_manager/storage/memory.py +94 -0
- feedback_manager-0.1.0.dist-info/METADATA +380 -0
- feedback_manager-0.1.0.dist-info/RECORD +56 -0
- 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"]
|