1"""Feedback collection system for continuous learning.
2
3Collects user feedback on AI/ML predictions for model improvement.
4"""
5
6from __future__ import annotations
7
8from typing import Any
9
10from lexigram.ai.feedback.constants import MAX_CONTEXT_SIZE, MAX_FEEDBACK_TEXT_LENGTH
11from lexigram.ai.feedback.exceptions import FeedbackTooLargeError
12from lexigram.ai.feedback.storage.protocols import FeedbackStoreProtocol
13from lexigram.ai.feedback.types import FeedbackItem, FeedbackType
14from lexigram.serialization import dumps_str
15
16
17class FeedbackCollector:
18 """Collects and stores user feedback.
19
20 Provides methods to capture different types of feedback and
21 integrate with ML retraining pipelines.
22
23 Example:
24 >>> collector = FeedbackCollector()
25 >>> # Collect rating
26 >>> await collector.collect_rating(
27 ... rating=5,
28 ... context={"model": "gpt-4", "input": "Hello"}
29 ... )
30 >>> # Collect correction
31 >>> await collector.collect_correction(
32 ... original="incorrect output",
33 ... corrected="correct output",
34 ... context={"model_id": "123"}
35 ... )
36 >>> # Get all feedback
37 >>> items = collector.get_feedback()
38 """
39
40 def __init__(self, storage: FeedbackStoreProtocol | None = None):
41 """Initialize feedback collector.
42
43 Args:
44 storage: Optional storage backend (e.g., database, file)
45 If None, uses in-memory storage
46 """
47 self.storage = storage
48 self._feedback: list[FeedbackItem] = []
49
50 async def collect_rating(
51 self,
52 rating: float,
53 *,
54 owner_id: str,
55 context: dict[str, Any] | None = None,
56 metadata: dict[str, Any] | None = None,
57 ) -> str:
58 """Collect a rating feedback.
59
60 Args:
61 rating: Numeric rating value.
62 owner_id: Owner scope; the item is recorded under this owner.
63 context: Context about what was rated.
64 metadata: Additional metadata.
65
66 Returns:
67 Feedback ID.
68 """
69 item = FeedbackItem(
70 feedback_type=FeedbackType.RATING,
71 value=rating,
72 owner_id=owner_id,
73 context=context or {},
74 metadata=metadata or {},
75 )
76
77 await self._store(item)
78 return item.id
79
80 async def collect_text(
81 self,
82 text: str,
83 *,
84 owner_id: str,
85 context: dict[str, Any] | None = None,
86 metadata: dict[str, Any] | None = None,
87 ) -> str:
88 """Collect text feedback.
89
90 Args:
91 text: Feedback text.
92 owner_id: Owner scope; the item is recorded under this owner.
93 context: Context about what feedback is for.
94 metadata: Additional metadata.
95
96 Returns:
97 Feedback ID.
98 """
99 item = FeedbackItem(
100 feedback_type=FeedbackType.TEXT,
101 value=text,
102 owner_id=owner_id,
103 context=context or {},
104 metadata=metadata or {},
105 )
106
107 await self._store(item)
108 return item.id
109
110 async def collect_correction(
111 self,
112 original: str,
113 corrected: str,
114 *,
115 owner_id: str,
116 context: dict[str, Any] | None = None,
117 metadata: dict[str, Any] | None = None,
118 ) -> str:
119 """Collect a correction feedback.
120
121 Args:
122 original: Original (incorrect) output.
123 corrected: Corrected output.
124 owner_id: Owner scope; the item is recorded under this owner.
125 context: Context.
126 metadata: Additional metadata.
127
128 Returns:
129 Feedback ID.
130 """
131 item = FeedbackItem(
132 feedback_type=FeedbackType.CORRECTION,
133 value={"original": original, "corrected": corrected},
134 owner_id=owner_id,
135 context=context or {},
136 metadata=metadata or {},
137 )
138
139 await self._store(item)
140 return item.id
141
142 async def collect_label(
143 self,
144 label: Any,
145 input_data: Any,
146 *,
147 owner_id: str,
148 context: dict[str, Any] | None = None,
149 metadata: dict[str, Any] | None = None,
150 ) -> str:
151 """Collect ground truth label.
152
153 Useful for supervised learning and model retraining.
154
155 Args:
156 label: Ground truth label.
157 input_data: Input that this label corresponds to.
158 owner_id: Owner scope; the item is recorded under this owner.
159 context: Context.
160 metadata: Additional metadata.
161
162 Returns:
163 Feedback ID.
164 """
165 item = FeedbackItem(
166 feedback_type=FeedbackType.LABEL,
167 value={"label": label, "input": input_data},
168 owner_id=owner_id,
169 context=context or {},
170 metadata=metadata or {},
171 )
172
173 await self._store(item)
174 return item.id
175
176 async def _store(self, item: FeedbackItem) -> None:
177 """Store feedback item, enforcing the declared size limits.
178
179 Rejects payloads exceeding MAX_FEEDBACK_TEXT_LENGTH (TEXT values)
180 or MAX_CONTEXT_SIZE (serialized context/metadata) with
181 FeedbackTooLargeError before any mutation.
182
183 Args:
184 item: Feedback item to store
185
186 Raises:
187 FeedbackTooLargeError: If the item exceeds the size limits.
188 """
189 if (
190 item.feedback_type == FeedbackType.TEXT
191 and isinstance(item.value, str)
192 and len(item.value) > MAX_FEEDBACK_TEXT_LENGTH
193 ):
194 raise FeedbackTooLargeError(
195 f"feedback text exceeds the {MAX_FEEDBACK_TEXT_LENGTH}-character limit"
196 )
197 if len(dumps_str(item.context, default=str)) > MAX_CONTEXT_SIZE:
198 raise FeedbackTooLargeError(
199 f"serialized context exceeds the {MAX_CONTEXT_SIZE}-character limit"
200 )
201 if len(dumps_str(item.metadata, default=str)) > MAX_CONTEXT_SIZE:
202 raise FeedbackTooLargeError(
203 f"serialized metadata exceeds the {MAX_CONTEXT_SIZE}-character limit"
204 )
205 self._feedback.append(item)
206 if self.storage:
207 await self.storage.save(item)
208
209 async def get_feedback(
210 self,
211 *,
212 owner_id: str,
213 feedback_type: FeedbackType | None = None,
214 limit: int | None = None,
215 ) -> list[FeedbackItem]:
216 """Get feedback items for an owner.
217
218 Args:
219 owner_id: Owner scope; only this owner's items are returned.
220 feedback_type: Optional filter by type.
221 limit: Maximum number of items to return.
222
223 Returns:
224 List of feedback items from memory or the storage backend.
225 """
226 if self.storage:
227 return await self._get_feedback_from_storage(
228 owner_id=owner_id,
229 feedback_type=feedback_type,
230 limit=limit,
231 )
232 return self._filter_memory_feedback(
233 owner_id=owner_id, feedback_type=feedback_type, limit=limit
234 )
235
236 async def _get_feedback_from_storage(
237 self,
238 *,
239 owner_id: str,
240 feedback_type: FeedbackType | None,
241 limit: int | None,
242 ) -> list[FeedbackItem]:
243 if limit == 0:
244 return []
245
246 if self.storage is None:
247 return []
248
249 if feedback_type:
250 per_type_limit = limit if limit is not None else 100
251 return await self.storage.find_by_type(
252 feedback_type,
253 owner_id=owner_id,
254 limit=per_type_limit,
255 )
256
257 per_type_limit = limit if limit is not None else 100
258 results: list[FeedbackItem] = []
259 for ft in FeedbackType:
260 batch = await self.storage.find_by_type(
261 ft, owner_id=owner_id, limit=per_type_limit
262 )
263 results.extend(batch)
264
265 results.sort(key=lambda item: item.created_at, reverse=True)
266 if limit:
267 return results[:limit]
268 return results
269
270 def _filter_memory_feedback(
271 self,
272 *,
273 owner_id: str,
274 feedback_type: FeedbackType | None,
275 limit: int | None,
276 ) -> list[FeedbackItem]:
277 items = [item for item in self._feedback if item.owner_id == owner_id]
278 if feedback_type:
279 items = [item for item in items if item.feedback_type == feedback_type]
280 if limit is not None:
281 items = items[:limit]
282 return items
283
284 async def get_feedback_dict(
285 self,
286 *,
287 owner_id: str,
288 feedback_type: FeedbackType | None = None,
289 limit: int | None = None,
290 ) -> list[dict[str, Any]]:
291 """Get feedback as dictionaries for an owner.
292
293 Args:
294 owner_id: Owner scope; only this owner's items are returned.
295 feedback_type: Optional filter by type.
296 limit: Maximum number of items.
297
298 Returns:
299 List of feedback dictionaries.
300 """
301 items = await self.get_feedback(
302 owner_id=owner_id, feedback_type=feedback_type, limit=limit
303 )
304 return [item.to_dict() for item in items]
305
306 def clear(self) -> None:
307 """Clear all feedback from the in-memory buffer."""
308 self._feedback.clear()
309
310 def __len__(self) -> int:
311 """Number of feedback items."""
312 return len(self._feedback)
313
314 def __repr__(self) -> str:
315 """String representation."""
316 return f"FeedbackCollector(items={len(self._feedback)})"