remdb 0.3.103__py3-none-any.whl → 0.3.141__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.

Potentially problematic release.


This version of remdb might be problematic. Click here for more details.

Files changed (74) hide show
  1. rem/agentic/agents/sse_simulator.py +2 -0
  2. rem/agentic/context.py +51 -27
  3. rem/agentic/mcp/tool_wrapper.py +155 -18
  4. rem/agentic/otel/setup.py +93 -4
  5. rem/agentic/providers/phoenix.py +371 -108
  6. rem/agentic/providers/pydantic_ai.py +195 -46
  7. rem/agentic/schema.py +361 -21
  8. rem/agentic/tools/rem_tools.py +3 -3
  9. rem/api/main.py +85 -16
  10. rem/api/mcp_router/resources.py +1 -1
  11. rem/api/mcp_router/server.py +18 -4
  12. rem/api/mcp_router/tools.py +394 -16
  13. rem/api/routers/admin.py +218 -1
  14. rem/api/routers/chat/completions.py +280 -7
  15. rem/api/routers/chat/models.py +81 -7
  16. rem/api/routers/chat/otel_utils.py +33 -0
  17. rem/api/routers/chat/sse_events.py +17 -1
  18. rem/api/routers/chat/streaming.py +177 -3
  19. rem/api/routers/feedback.py +142 -329
  20. rem/api/routers/query.py +360 -0
  21. rem/api/routers/shared_sessions.py +13 -13
  22. rem/cli/commands/README.md +237 -64
  23. rem/cli/commands/cluster.py +1808 -0
  24. rem/cli/commands/configure.py +4 -7
  25. rem/cli/commands/db.py +354 -143
  26. rem/cli/commands/experiments.py +436 -30
  27. rem/cli/commands/process.py +14 -8
  28. rem/cli/commands/schema.py +92 -45
  29. rem/cli/commands/session.py +336 -0
  30. rem/cli/dreaming.py +2 -2
  31. rem/cli/main.py +29 -6
  32. rem/config.py +8 -1
  33. rem/models/core/experiment.py +54 -0
  34. rem/models/core/rem_query.py +5 -2
  35. rem/models/entities/ontology.py +1 -1
  36. rem/models/entities/ontology_config.py +1 -1
  37. rem/models/entities/shared_session.py +2 -28
  38. rem/registry.py +10 -4
  39. rem/schemas/agents/examples/contract-analyzer.yaml +1 -1
  40. rem/schemas/agents/examples/contract-extractor.yaml +1 -1
  41. rem/schemas/agents/examples/cv-parser.yaml +1 -1
  42. rem/services/content/service.py +30 -8
  43. rem/services/embeddings/api.py +4 -4
  44. rem/services/embeddings/worker.py +16 -16
  45. rem/services/phoenix/client.py +59 -18
  46. rem/services/postgres/README.md +151 -26
  47. rem/services/postgres/__init__.py +2 -1
  48. rem/services/postgres/diff_service.py +531 -0
  49. rem/services/postgres/pydantic_to_sqlalchemy.py +427 -129
  50. rem/services/postgres/schema_generator.py +205 -4
  51. rem/services/postgres/service.py +6 -6
  52. rem/services/rem/parser.py +44 -9
  53. rem/services/rem/service.py +36 -2
  54. rem/services/session/compression.py +7 -0
  55. rem/services/session/reload.py +1 -1
  56. rem/settings.py +288 -16
  57. rem/sql/background_indexes.sql +19 -24
  58. rem/sql/migrations/001_install.sql +252 -69
  59. rem/sql/migrations/002_install_models.sql +2197 -619
  60. rem/sql/migrations/003_optional_extensions.sql +326 -0
  61. rem/sql/migrations/004_cache_system.sql +548 -0
  62. rem/utils/__init__.py +18 -0
  63. rem/utils/date_utils.py +2 -2
  64. rem/utils/schema_loader.py +110 -15
  65. rem/utils/sql_paths.py +146 -0
  66. rem/utils/vision.py +1 -1
  67. rem/workers/__init__.py +3 -1
  68. rem/workers/db_listener.py +579 -0
  69. rem/workers/unlogged_maintainer.py +463 -0
  70. {remdb-0.3.103.dist-info → remdb-0.3.141.dist-info}/METADATA +300 -215
  71. {remdb-0.3.103.dist-info → remdb-0.3.141.dist-info}/RECORD +73 -64
  72. rem/sql/migrations/003_seed_default_user.sql +0 -48
  73. {remdb-0.3.103.dist-info → remdb-0.3.141.dist-info}/WHEEL +0 -0
  74. {remdb-0.3.103.dist-info → remdb-0.3.141.dist-info}/entry_points.txt +0 -0
@@ -1,35 +1,74 @@
1
1
  """
2
- Feedback endpoints for chat message and session feedback.
2
+ Message feedback endpoint.
3
3
 
4
- Provides endpoints for:
5
- - Submitting feedback on messages or sessions
6
- - Listing feedback with filters
7
- - Syncing feedback to Phoenix as annotations (async)
4
+ Provides endpoint for submitting feedback on messages.
8
5
 
9
6
  Endpoints:
10
- POST /api/v1/feedback - Submit feedback
11
- GET /api/v1/feedback - List feedback with filters
12
- GET /api/v1/feedback/{id} - Get specific feedback
13
- GET /api/v1/feedback/categories - List available categories
7
+ POST /api/v1/messages/feedback - Submit feedback on a message
14
8
 
15
9
  Trace Integration:
16
- - Feedback can reference trace_id/span_id for OTEL integration
17
- - Phoenix sync attaches feedback as span annotations
10
+ - Feedback auto-resolves trace_id/span_id from the message in the database
11
+ - Phoenix sync attaches feedback as span annotations when trace info is available
12
+
13
+ HTTP Status Codes:
14
+ - 201: Feedback saved AND synced to Phoenix as annotation (phoenix_synced=true)
15
+ - 200: Feedback accepted and saved to DB, but NOT synced to Phoenix
16
+ (missing trace_id/span_id, Phoenix disabled, or sync failed)
17
+
18
+ IMPORTANT - Testing Requirements:
19
+ ╔════════════════════════════════════════════════════════════════════════════════════════════════════╗
20
+ ║ 1. Use 'rem' agent (NOT 'simulator') - only real agents capture traces ║
21
+ ║ 2. Session IDs MUST be UUIDs - use python3 -c "import uuid; print(uuid.uuid4())" ║
22
+ ║ 3. Port-forward OTEL collector: kubectl port-forward -n observability ║
23
+ ║ svc/otel-collector-collector 4318:4318 ║
24
+ ║ 4. Port-forward Phoenix: kubectl port-forward -n siggy svc/phoenix 6006:6006 ║
25
+ ║ 5. Set environment variables when starting the API: ║
26
+ ║ OTEL__ENABLED=true PHOENIX__ENABLED=true PHOENIX_API_KEY=<jwt> uvicorn ... ║
27
+ ║ 6. Get PHOENIX_API_KEY: ║
28
+ ║ kubectl get secret -n siggy rem-phoenix-api-key -o jsonpath='{.data.PHOENIX_API_KEY}' ║
29
+ ║ | base64 -d ║
30
+ ╚════════════════════════════════════════════════════════════════════════════════════════════════════╝
31
+
32
+ Usage:
33
+ # 1. Send a chat message with X-Session-Id header (MUST be UUID!)
34
+ SESSION_ID=$(python3 -c "import uuid; print(uuid.uuid4())")
35
+ curl -X POST http://localhost:8000/api/v1/chat/completions \\
36
+ -H "Content-Type: application/json" \\
37
+ -H "X-Session-Id: $SESSION_ID" \\
38
+ -H "X-Agent-Schema: rem" \\
39
+ -d '{"messages": [{"role": "user", "content": "hello"}], "stream": true}'
40
+
41
+ # 2. Extract message_id from the 'metadata' SSE event:
42
+ # event: metadata
43
+ # data: {"message_id": "728882f8-...", "trace_id": "e53c701c...", ...}
44
+
45
+ # 3. Submit feedback referencing that message (trace_id auto-resolved from DB)
46
+ curl -X POST http://localhost:8000/api/v1/messages/feedback \\
47
+ -H "Content-Type: application/json" \\
48
+ -H "X-Tenant-Id: default" \\
49
+ -d '{
50
+ "session_id": "'$SESSION_ID'",
51
+ "message_id": "<message-id-from-metadata>",
52
+ "rating": 1,
53
+ "categories": ["helpful"],
54
+ "comment": "Great response!"
55
+ }'
56
+
57
+ # 4. Check response:
58
+ # - 201 + phoenix_synced=true = annotation synced to Phoenix (check Phoenix UI at :6006)
59
+ # - 200 + phoenix_synced=false = feedback saved but not synced (missing trace info)
18
60
  """
19
61
 
20
- from typing import Literal
21
-
22
- from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
62
+ from fastapi import APIRouter, Header, HTTPException, Request, Response
23
63
  from loguru import logger
24
64
  from pydantic import BaseModel, Field
25
65
 
26
- from ..deps import get_current_user, get_user_filter, get_user_id_from_request, is_admin
27
- from ...models.entities import Feedback, FeedbackCategory, Message
66
+ from ..deps import get_user_id_from_request
67
+ from ...models.entities import Feedback
28
68
  from ...services.postgres import Repository
29
69
  from ...settings import settings
30
- from ...utils.date_utils import utc_now
31
70
 
32
- router = APIRouter(prefix="/api/v1", tags=["feedback"])
71
+ router = APIRouter(prefix="/api/v1", tags=["messages"])
33
72
 
34
73
 
35
74
  # =============================================================================
@@ -51,10 +90,9 @@ class FeedbackCreateRequest(BaseModel):
51
90
  description="Rating: -1 (thumbs down), 1 (thumbs up), or 1-5 scale",
52
91
  )
53
92
  categories: list[str] = Field(
54
- default_factory=list, description="Selected feedback categories"
93
+ default_factory=list, description="Feedback categories"
55
94
  )
56
95
  comment: str | None = Field(default=None, description="Free-text comment")
57
- # Optional trace reference (can be auto-resolved from message)
58
96
  trace_id: str | None = Field(
59
97
  default=None, description="OTEL trace ID (auto-resolved if message has it)"
60
98
  )
@@ -78,134 +116,15 @@ class FeedbackResponse(BaseModel):
78
116
  created_at: str
79
117
 
80
118
 
81
- class FeedbackListResponse(BaseModel):
82
- """Response for feedback list endpoint."""
83
-
84
- object: Literal["list"] = "list"
85
- data: list[Feedback]
86
- total: int
87
- has_more: bool
88
-
89
-
90
- class CategoryInfo(BaseModel):
91
- """Information about a feedback category."""
92
-
93
- value: str
94
- label: str
95
- description: str
96
- sentiment: Literal["positive", "negative", "neutral"]
97
-
98
-
99
- class CategoriesResponse(BaseModel):
100
- """Response for categories endpoint."""
101
-
102
- categories: list[CategoryInfo]
103
-
104
-
105
- # =============================================================================
106
- # Category Definitions
107
- # =============================================================================
108
-
109
- CATEGORY_INFO: dict[str, CategoryInfo] = {
110
- FeedbackCategory.INCOMPLETE.value: CategoryInfo(
111
- value=FeedbackCategory.INCOMPLETE.value,
112
- label="Incomplete",
113
- description="Response lacks expected information",
114
- sentiment="negative",
115
- ),
116
- FeedbackCategory.INACCURATE.value: CategoryInfo(
117
- value=FeedbackCategory.INACCURATE.value,
118
- label="Inaccurate",
119
- description="Response contains factual errors",
120
- sentiment="negative",
121
- ),
122
- FeedbackCategory.POOR_TONE.value: CategoryInfo(
123
- value=FeedbackCategory.POOR_TONE.value,
124
- label="Poor Tone",
125
- description="Inappropriate or unprofessional tone",
126
- sentiment="negative",
127
- ),
128
- FeedbackCategory.OFF_TOPIC.value: CategoryInfo(
129
- value=FeedbackCategory.OFF_TOPIC.value,
130
- label="Off Topic",
131
- description="Response doesn't address the question",
132
- sentiment="negative",
133
- ),
134
- FeedbackCategory.TOO_VERBOSE.value: CategoryInfo(
135
- value=FeedbackCategory.TOO_VERBOSE.value,
136
- label="Too Verbose",
137
- description="Unnecessarily long response",
138
- sentiment="negative",
139
- ),
140
- FeedbackCategory.TOO_BRIEF.value: CategoryInfo(
141
- value=FeedbackCategory.TOO_BRIEF.value,
142
- label="Too Brief",
143
- description="Insufficiently detailed response",
144
- sentiment="negative",
145
- ),
146
- FeedbackCategory.CONFUSING.value: CategoryInfo(
147
- value=FeedbackCategory.CONFUSING.value,
148
- label="Confusing",
149
- description="Hard to understand or unclear",
150
- sentiment="negative",
151
- ),
152
- FeedbackCategory.UNSAFE.value: CategoryInfo(
153
- value=FeedbackCategory.UNSAFE.value,
154
- label="Unsafe",
155
- description="Contains potentially harmful content",
156
- sentiment="negative",
157
- ),
158
- FeedbackCategory.HELPFUL.value: CategoryInfo(
159
- value=FeedbackCategory.HELPFUL.value,
160
- label="Helpful",
161
- description="Response was useful and addressed the need",
162
- sentiment="positive",
163
- ),
164
- FeedbackCategory.EXCELLENT.value: CategoryInfo(
165
- value=FeedbackCategory.EXCELLENT.value,
166
- label="Excellent",
167
- description="Exceptionally good response",
168
- sentiment="positive",
169
- ),
170
- FeedbackCategory.ACCURATE.value: CategoryInfo(
171
- value=FeedbackCategory.ACCURATE.value,
172
- label="Accurate",
173
- description="Factually correct and precise",
174
- sentiment="positive",
175
- ),
176
- FeedbackCategory.WELL_WRITTEN.value: CategoryInfo(
177
- value=FeedbackCategory.WELL_WRITTEN.value,
178
- label="Well Written",
179
- description="Clear, well-structured response",
180
- sentiment="positive",
181
- ),
182
- FeedbackCategory.OTHER.value: CategoryInfo(
183
- value=FeedbackCategory.OTHER.value,
184
- label="Other",
185
- description="Other feedback not covered by categories",
186
- sentiment="neutral",
187
- ),
188
- }
189
-
190
-
191
119
  # =============================================================================
192
- # Feedback Endpoints
120
+ # Feedback Endpoint
193
121
  # =============================================================================
194
122
 
195
123
 
196
- @router.get("/feedback/categories", response_model=CategoriesResponse)
197
- async def list_categories() -> CategoriesResponse:
198
- """
199
- List available feedback categories.
200
-
201
- Returns predefined categories with labels, descriptions, and sentiment.
202
- """
203
- return CategoriesResponse(categories=list(CATEGORY_INFO.values()))
204
-
205
-
206
- @router.post("/feedback", response_model=FeedbackResponse, status_code=201)
124
+ @router.post("/messages/feedback", response_model=FeedbackResponse)
207
125
  async def submit_feedback(
208
126
  request: Request,
127
+ response: Response,
209
128
  request_body: FeedbackCreateRequest,
210
129
  x_tenant_id: str = Header(alias="X-Tenant-Id", default="default"),
211
130
  ) -> FeedbackResponse:
@@ -219,15 +138,16 @@ async def submit_feedback(
219
138
  - Provided explicitly in the request
220
139
  - Auto-resolved from the message if message_id is provided
221
140
 
222
- Phoenix sync happens asynchronously after feedback is stored.
141
+ HTTP Status Codes:
142
+ - 201: Feedback saved AND synced to Phoenix (phoenix_synced=true)
143
+ - 200: Feedback accepted but NOT synced (missing trace info, disabled, or failed)
223
144
 
224
145
  Returns:
225
- Created feedback object
146
+ Created feedback object with phoenix_synced indicating sync status
226
147
  """
227
148
  if not settings.postgres.enabled:
228
149
  raise HTTPException(status_code=503, detail="Database not enabled")
229
150
 
230
- # Get effective user_id from auth or anonymous tracking
231
151
  effective_user_id = get_user_id_from_request(request)
232
152
 
233
153
  # Resolve trace_id/span_id from message if not provided
@@ -235,14 +155,45 @@ async def submit_feedback(
235
155
  span_id = request_body.span_id
236
156
 
237
157
  if request_body.message_id and (not trace_id or not span_id):
238
- # Try to get trace info from the message
239
- message_repo = Repository(Message, table_name="messages")
240
- message = await message_repo.get_by_id(request_body.message_id, x_tenant_id)
241
- if message:
242
- trace_id = trace_id or message.trace_id
243
- span_id = span_id or message.span_id
244
-
245
- # Create feedback entity
158
+ # Look up message by ID to get trace context
159
+ # Note: Messages are stored with tenant_id=user_id (not x_tenant_id header)
160
+ # so we query by ID only - UUIDs are globally unique
161
+ from ...services.postgres import PostgresService
162
+ import uuid
163
+
164
+ logger.info(f"Looking up trace context for message_id={request_body.message_id}")
165
+
166
+ # Convert message_id string to UUID for database query
167
+ try:
168
+ message_uuid = uuid.UUID(request_body.message_id)
169
+ except ValueError as e:
170
+ logger.warning(f"Invalid message_id format '{request_body.message_id}': {e}")
171
+ message_uuid = None
172
+
173
+ if message_uuid:
174
+ db = PostgresService()
175
+ # Ensure connection (same pattern as Repository)
176
+ if not db.pool:
177
+ await db.connect()
178
+
179
+ if db.pool:
180
+ query = """
181
+ SELECT trace_id, span_id FROM messages
182
+ WHERE id = $1 AND deleted_at IS NULL
183
+ LIMIT 1
184
+ """
185
+ async with db.pool.acquire() as conn:
186
+ row = await conn.fetchrow(query, message_uuid)
187
+ logger.info(f"Database query result for message {request_body.message_id}: row={row}")
188
+ if row:
189
+ trace_id = trace_id or row["trace_id"]
190
+ span_id = span_id or row["span_id"]
191
+ logger.info(f"Found trace context for message {request_body.message_id}: trace_id={trace_id}, span_id={span_id}")
192
+ else:
193
+ logger.warning(f"No message found in database with id={request_body.message_id}")
194
+ else:
195
+ logger.warning(f"Database pool not available for message lookup after connect attempt")
196
+
246
197
  feedback = Feedback(
247
198
  session_id=request_body.session_id,
248
199
  message_id=request_body.message_id,
@@ -257,22 +208,51 @@ async def submit_feedback(
257
208
  tenant_id=x_tenant_id,
258
209
  )
259
210
 
260
- # Store feedback (table is "feedbacks" - plural)
261
211
  repo = Repository(Feedback, table_name="feedbacks")
262
212
  result = await repo.upsert(feedback)
263
213
 
264
214
  logger.info(
265
215
  f"Feedback submitted: session={request_body.session_id}, "
266
- f"message={request_body.message_id}, rating={request_body.rating}, "
267
- f"categories={request_body.categories}"
216
+ f"message={request_body.message_id}, rating={request_body.rating}"
268
217
  )
269
218
 
270
- # TODO: Async sync to Phoenix if trace_id/span_id available
271
- # This would be done via a background task or queue
272
- if trace_id and span_id:
273
- logger.debug(f"Feedback has trace info: trace={trace_id}, span={span_id}")
274
- # TODO: Queue for Phoenix annotation sync
275
- # await sync_feedback_to_phoenix(feedback)
219
+ # Sync to Phoenix if trace_id/span_id available and Phoenix is enabled
220
+ phoenix_synced = False
221
+ phoenix_annotation_id = None
222
+
223
+ if trace_id and span_id and settings.phoenix.enabled:
224
+ try:
225
+ from ...services.phoenix import PhoenixClient
226
+
227
+ phoenix_client = PhoenixClient()
228
+ phoenix_annotation_id = phoenix_client.sync_user_feedback(
229
+ span_id=span_id,
230
+ rating=request_body.rating,
231
+ categories=request_body.categories,
232
+ comment=request_body.comment,
233
+ feedback_id=str(result.id),
234
+ trace_id=trace_id,
235
+ )
236
+
237
+ if phoenix_annotation_id:
238
+ phoenix_synced = True
239
+ # Update the feedback record with sync status
240
+ result.phoenix_synced = True
241
+ result.phoenix_annotation_id = phoenix_annotation_id
242
+ await repo.upsert(result)
243
+ logger.info(f"Feedback synced to Phoenix: annotation_id={phoenix_annotation_id}")
244
+ else:
245
+ logger.warning(f"Phoenix sync returned no annotation ID for feedback {result.id}")
246
+
247
+ except Exception as e:
248
+ logger.error(f"Failed to sync feedback to Phoenix: {e}")
249
+ # Don't fail the request if Phoenix sync fails
250
+ elif trace_id and span_id:
251
+ logger.debug(f"Feedback has trace info but Phoenix disabled: trace={trace_id}, span={span_id}")
252
+
253
+ # Set HTTP status code based on Phoenix sync result
254
+ # 201 = synced to Phoenix, 200 = accepted but not synced
255
+ response.status_code = 201 if phoenix_synced else 200
276
256
 
277
257
  return FeedbackResponse(
278
258
  id=str(result.id),
@@ -286,170 +266,3 @@ async def submit_feedback(
286
266
  phoenix_synced=result.phoenix_synced,
287
267
  created_at=result.created_at.isoformat() if result.created_at else "",
288
268
  )
289
-
290
-
291
- @router.get("/feedback", response_model=FeedbackListResponse)
292
- async def list_feedback(
293
- request: Request,
294
- session_id: str | None = Query(default=None, description="Filter by session ID"),
295
- message_id: str | None = Query(default=None, description="Filter by message ID"),
296
- rating: int | None = Query(default=None, description="Filter by rating"),
297
- category: str | None = Query(default=None, description="Filter by category"),
298
- phoenix_synced: bool | None = Query(
299
- default=None, description="Filter by Phoenix sync status"
300
- ),
301
- limit: int = Query(default=50, ge=1, le=100, description="Max results"),
302
- offset: int = Query(default=0, ge=0, description="Offset for pagination"),
303
- x_tenant_id: str = Header(alias="X-Tenant-Id", default="default"),
304
- ) -> FeedbackListResponse:
305
- """
306
- List feedback with optional filters.
307
-
308
- Access Control:
309
- - Regular users: Only see feedback they submitted
310
- - Admin users: Can see all feedback
311
-
312
- Filters:
313
- - session_id: Filter by session
314
- - message_id: Filter by specific message
315
- - rating: Filter by rating value
316
- - category: Filter by category (checks if category in list)
317
- - phoenix_synced: Filter by sync status
318
- """
319
- if not settings.postgres.enabled:
320
- raise HTTPException(status_code=503, detail="Database not enabled")
321
-
322
- repo = Repository(Feedback, table_name="feedbacks")
323
-
324
- # Build user-scoped filters (uses anon_id for anonymous users)
325
- filters = await get_user_filter(request, x_tenant_id=x_tenant_id)
326
-
327
- # Apply optional filters
328
- if session_id:
329
- filters["session_id"] = session_id
330
- if message_id:
331
- filters["message_id"] = message_id
332
- if rating is not None:
333
- filters["rating"] = rating
334
- if phoenix_synced is not None:
335
- filters["phoenix_synced"] = phoenix_synced
336
- # TODO: category filter requires array contains query
337
-
338
- feedback_list = await repo.find(
339
- filters,
340
- order_by="created_at DESC",
341
- limit=limit + 1,
342
- offset=offset,
343
- )
344
-
345
- # Filter by category in Python if specified (until Repository supports array contains)
346
- if category:
347
- feedback_list = [f for f in feedback_list if category in f.categories]
348
-
349
- has_more = len(feedback_list) > limit
350
- if has_more:
351
- feedback_list = feedback_list[:limit]
352
-
353
- total = await repo.count(filters)
354
-
355
- return FeedbackListResponse(data=feedback_list, total=total, has_more=has_more)
356
-
357
-
358
- @router.get("/feedback/{feedback_id}", response_model=Feedback)
359
- async def get_feedback(
360
- request: Request,
361
- feedback_id: str,
362
- x_tenant_id: str = Header(alias="X-Tenant-Id", default="default"),
363
- ) -> Feedback:
364
- """
365
- Get specific feedback by ID.
366
-
367
- Access Control:
368
- - Regular users: Only access their own feedback
369
- - Admin users: Can access any feedback
370
- """
371
- if not settings.postgres.enabled:
372
- raise HTTPException(status_code=503, detail="Database not enabled")
373
-
374
- repo = Repository(Feedback, table_name="feedbacks")
375
- feedback = await repo.get_by_id(feedback_id, x_tenant_id)
376
-
377
- if not feedback:
378
- raise HTTPException(status_code=404, detail=f"Feedback '{feedback_id}' not found")
379
-
380
- # Check access
381
- current_user = get_current_user(request)
382
- if not is_admin(current_user):
383
- user_id = current_user.get("id") if current_user else None
384
- if feedback.user_id and feedback.user_id != user_id:
385
- raise HTTPException(status_code=403, detail="Access denied: not owner")
386
-
387
- return feedback
388
-
389
-
390
- # =============================================================================
391
- # Phoenix Sync (Stub - TODO: Implement background task)
392
- # =============================================================================
393
-
394
-
395
- async def sync_feedback_to_phoenix(feedback: Feedback) -> bool:
396
- """
397
- Sync feedback to Phoenix as a span annotation.
398
-
399
- TODO: Implement this as a background task.
400
-
401
- This should:
402
- 1. Connect to Phoenix client
403
- 2. Resolve trace/span from feedback
404
- 3. Create annotation with feedback data
405
- 4. Update feedback.phoenix_synced = True
406
- 5. Store phoenix_annotation_id
407
-
408
- Args:
409
- feedback: Feedback entity to sync
410
-
411
- Returns:
412
- True if synced successfully
413
- """
414
- if not feedback.span_id:
415
- logger.warning(f"Cannot sync feedback {feedback.id}: no span_id")
416
- return False
417
-
418
- try:
419
- # TODO: Import and use Phoenix client
420
- # from ...services.phoenix import PhoenixClient
421
- # client = PhoenixClient()
422
- #
423
- # # Build annotation from feedback
424
- # label = None
425
- # if feedback.categories:
426
- # label = feedback.categories[0] # Primary category
427
- #
428
- # score = None
429
- # if feedback.rating:
430
- # # Normalize to 0-1 scale
431
- # if feedback.rating == -1:
432
- # score = 0.0
433
- # elif feedback.rating >= 1 and feedback.rating <= 5:
434
- # score = feedback.rating / 5.0
435
- #
436
- # client.add_span_feedback(
437
- # span_id=feedback.span_id,
438
- # annotation_name="user_feedback",
439
- # annotator_kind=feedback.annotator_kind,
440
- # label=label,
441
- # score=score,
442
- # explanation=feedback.comment,
443
- # )
444
- #
445
- # # Update feedback record
446
- # feedback.phoenix_synced = True
447
- # repo = Repository(Feedback, table_name="feedbacks")
448
- # await repo.update(feedback)
449
-
450
- logger.info(f"TODO: Sync feedback {feedback.id} to Phoenix span {feedback.span_id}")
451
- return True
452
-
453
- except Exception as e:
454
- logger.error(f"Failed to sync feedback to Phoenix: {e}")
455
- return False