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
@@ -318,6 +318,15 @@ class ExperimentConfig(BaseModel):
318
318
  )
319
319
  )
320
320
 
321
+ task: str = Field(
322
+ default="general",
323
+ description=(
324
+ "Task name for organizing experiments by purpose.\n"
325
+ "Used with agent name to form directory: {agent}/{task}/\n"
326
+ "Examples: 'risk-assessment', 'classification', 'general'"
327
+ )
328
+ )
329
+
321
330
  description: str = Field(
322
331
  description="Human-readable description of experiment purpose and goals"
323
332
  )
@@ -410,6 +419,24 @@ class ExperimentConfig(BaseModel):
410
419
 
411
420
  return v
412
421
 
422
+ @field_validator("task")
423
+ @classmethod
424
+ def validate_task(cls, v: str) -> str:
425
+ """Validate task name follows conventions."""
426
+ if not v:
427
+ return "general" # Default value
428
+
429
+ if not v.islower():
430
+ raise ValueError("Task name must be lowercase")
431
+
432
+ if " " in v:
433
+ raise ValueError("Task name cannot contain spaces (use hyphens)")
434
+
435
+ if not all(c.isalnum() or c == "-" for c in v):
436
+ raise ValueError("Task name can only contain lowercase letters, numbers, and hyphens")
437
+
438
+ return v
439
+
413
440
  @field_validator("tags")
414
441
  @classmethod
415
442
  def validate_tags(cls, v: list[str]) -> list[str]:
@@ -420,6 +447,15 @@ class ExperimentConfig(BaseModel):
420
447
  """Get the experiment directory path."""
421
448
  return Path(base_path) / self.name
422
449
 
450
+ def get_agent_task_dir(self, base_path: str = ".experiments") -> Path:
451
+ """
452
+ Get the experiment directory path organized by agent/task.
453
+
454
+ Returns: Path like .experiments/{agent}/{task}/
455
+ This is the recommended structure for S3 export compatibility.
456
+ """
457
+ return Path(base_path) / self.agent_schema_ref.name / self.task
458
+
423
459
  def get_config_path(self, base_path: str = ".experiments") -> Path:
424
460
  """Get the path to experiment.yaml file."""
425
461
  return self.get_experiment_dir(base_path) / "experiment.yaml"
@@ -428,6 +464,22 @@ class ExperimentConfig(BaseModel):
428
464
  """Get the path to README.md file."""
429
465
  return self.get_experiment_dir(base_path) / "README.md"
430
466
 
467
+ def get_evaluator_filename(self) -> str:
468
+ """
469
+ Get the evaluator filename with task prefix.
470
+
471
+ Returns: {agent_name}-{task}.yaml (e.g., siggy-risk-assessment.yaml)
472
+ """
473
+ return f"{self.agent_schema_ref.name}-{self.task}.yaml"
474
+
475
+ def get_s3_export_path(self, bucket: str, version: str = "v0") -> str:
476
+ """
477
+ Get the S3 path for exporting this experiment.
478
+
479
+ Returns: s3://{bucket}/{version}/datasets/calibration/experiments/{agent}/{task}/
480
+ """
481
+ return f"s3://{bucket}/{version}/datasets/calibration/experiments/{self.agent_schema_ref.name}/{self.task}"
482
+
431
483
  def to_yaml(self) -> str:
432
484
  """Export configuration as YAML string."""
433
485
  import yaml
@@ -483,6 +535,7 @@ class ExperimentConfig(BaseModel):
483
535
  ## Configuration
484
536
 
485
537
  **Status**: `{self.status.value}`
538
+ **Task**: `{self.task}`
486
539
  **Tags**: {', '.join(f'`{tag}`' for tag in self.tags) if self.tags else 'None'}
487
540
 
488
541
  ## Agent Schema
@@ -494,6 +547,7 @@ class ExperimentConfig(BaseModel):
494
547
  ## Evaluator Schema
495
548
 
496
549
  - **Name**: `{self.evaluator_schema_ref.name}`
550
+ - **File**: `{self.get_evaluator_filename()}`
497
551
  - **Type**: `{self.evaluator_schema_ref.type}`
498
552
 
499
553
  ## Datasets
@@ -112,7 +112,7 @@ class SearchParameters(BaseModel):
112
112
  table_name: str = Field(..., description="Table to search (resources, moments, etc.)")
113
113
  limit: int = Field(default=10, gt=0, description="Maximum results")
114
114
  min_similarity: float = Field(
115
- default=0.7, ge=0.0, le=1.0, description="Minimum similarity score"
115
+ default=0.3, ge=0.0, le=1.0, description="Minimum similarity score (0.3 recommended for general queries)"
116
116
  )
117
117
 
118
118
 
@@ -198,7 +198,10 @@ class RemQuery(BaseModel):
198
198
  | SQLParameters
199
199
  | TraverseParameters
200
200
  ) = Field(..., description="Query parameters")
201
- user_id: str = Field(..., description="User identifier for isolation")
201
+ user_id: Optional[str] = Field(
202
+ default=None,
203
+ description="User identifier (UUID5 hash of email). None = anonymous (shared/public data only)"
204
+ )
202
205
 
203
206
 
204
207
  class TraverseStage(BaseModel):
@@ -129,7 +129,7 @@ class Ontology(CoreModel):
129
129
  file_id="file-uuid-456",
130
130
  agent_schema_id="contract-parser-v2",
131
131
  provider_name="openai",
132
- model_name="gpt-4o",
132
+ model_name="gpt-4.1",
133
133
  extracted_data={
134
134
  "contract_type": "supplier_agreement",
135
135
  "parties": [
@@ -74,7 +74,7 @@ class OntologyConfig(CoreModel):
74
74
  priority=200, # Higher priority = runs first
75
75
  enabled=True,
76
76
  provider_name="openai", # Override default provider
77
- model_name="gpt-4o",
77
+ model_name="gpt-4.1",
78
78
  tenant_id="acme-corp",
79
79
  tags=["legal", "procurement"]
80
80
  )
@@ -111,28 +111,20 @@ To permanently delete, an admin can run:
111
111
 
112
112
  from datetime import datetime
113
113
  from typing import Optional
114
- from uuid import UUID
115
114
 
116
115
  from pydantic import BaseModel, Field
117
116
 
118
- from ...utils.date_utils import utc_now
117
+ from ..core import CoreModel
119
118
 
120
119
 
121
- class SharedSession(BaseModel):
120
+ class SharedSession(CoreModel):
122
121
  """
123
122
  Session sharing record between users.
124
123
 
125
124
  Links a session (identified by session_id from Message records) to a
126
125
  recipient user, enabling collaborative access to conversation history.
127
-
128
- This is NOT a CoreModel - it's a lightweight linking table without
129
- graph edges, metadata, or embeddings.
130
126
  """
131
127
 
132
- id: Optional[UUID] = Field(
133
- default=None,
134
- description="Unique identifier (auto-generated)",
135
- )
136
128
  session_id: str = Field(
137
129
  ...,
138
130
  description="The session being shared (matches Message.session_id)",
@@ -145,24 +137,6 @@ class SharedSession(BaseModel):
145
137
  ...,
146
138
  description="User ID of the recipient (who can now view the session)",
147
139
  )
148
- tenant_id: str = Field(
149
- default="default",
150
- description="Tenant identifier for multi-tenancy isolation",
151
- )
152
- created_at: datetime = Field(
153
- default_factory=utc_now,
154
- description="When the share was created",
155
- )
156
- updated_at: datetime = Field(
157
- default_factory=utc_now,
158
- description="Last modification timestamp",
159
- )
160
- deleted_at: Optional[datetime] = Field(
161
- default=None,
162
- description="Soft delete timestamp (null = active share)",
163
- )
164
-
165
- model_config = {"from_attributes": True}
166
140
 
167
141
 
168
142
  class SharedSessionCreate(BaseModel):
rem/registry.py CHANGED
@@ -123,6 +123,7 @@ class ModelRegistry:
123
123
  return
124
124
 
125
125
  from .models.entities import (
126
+ Feedback,
126
127
  File,
127
128
  ImageResource,
128
129
  Message,
@@ -131,19 +132,24 @@ class ModelRegistry:
131
132
  OntologyConfig,
132
133
  Resource,
133
134
  Schema,
135
+ Session,
136
+ SharedSession,
134
137
  User,
135
138
  )
136
139
 
137
140
  core_models = [
138
- Resource,
141
+ Feedback,
142
+ File,
139
143
  ImageResource,
140
144
  Message,
141
- User,
142
- File,
143
145
  Moment,
144
- Schema,
145
146
  Ontology,
146
147
  OntologyConfig,
148
+ Resource,
149
+ Schema,
150
+ Session,
151
+ SharedSession,
152
+ User,
147
153
  ]
148
154
 
149
155
  for model in core_models:
@@ -308,7 +308,7 @@ json_schema_extra:
308
308
  - provider_name: anthropic
309
309
  model_name: claude-sonnet-4-5-20250929
310
310
  - provider_name: openai
311
- model_name: gpt-4o
311
+ model_name: gpt-4.1
312
312
  embedding_fields:
313
313
  - contract_title
314
314
  - contract_type
@@ -131,4 +131,4 @@ json_schema_extra:
131
131
  - provider_name: anthropic
132
132
  model_name: claude-sonnet-4-5-20250929
133
133
  - provider_name: openai
134
- model_name: gpt-4o
134
+ model_name: gpt-4.1
@@ -255,7 +255,7 @@ json_schema_extra:
255
255
  - provider_name: anthropic
256
256
  model_name: claude-sonnet-4-5-20250929
257
257
  - provider_name: openai
258
- model_name: gpt-4o
258
+ model_name: gpt-4.1
259
259
  embedding_fields:
260
260
  - candidate_name
261
261
  - professional_summary
@@ -370,11 +370,32 @@ class ContentService:
370
370
  file_size = len(file_content)
371
371
  logger.info(f"Read {file_size} bytes from {file_uri} (source: {source_type})")
372
372
 
373
- # Step 2: Write to internal storage (user-scoped)
373
+ # Step 1.5: Early schema detection for YAML/JSON files
374
+ # Skip File entity creation for schemas (agents/evaluators)
375
+ file_suffix = Path(file_name).suffix.lower()
376
+ if file_suffix in ['.yaml', '.yml', '.json']:
377
+ import yaml
378
+ import json
379
+ try:
380
+ content_text = file_content.decode('utf-8') if isinstance(file_content, bytes) else file_content
381
+ data = yaml.safe_load(content_text) if file_suffix in ['.yaml', '.yml'] else json.loads(content_text)
382
+ if isinstance(data, dict):
383
+ json_schema_extra = data.get('json_schema_extra', {})
384
+ kind = json_schema_extra.get('kind', '')
385
+ if kind in ['agent', 'evaluator']:
386
+ # Route directly to schema processing, skip File entity
387
+ logger.info(f"Detected {kind} schema: {file_name}, routing to _process_schema")
388
+ result = self.process_uri(file_uri)
389
+ return await self._process_schema(result, file_uri, user_id)
390
+ except Exception as e:
391
+ logger.debug(f"Early schema detection failed for {file_name}: {e}")
392
+ # Fall through to standard file processing
393
+
394
+ # Step 2: Write to internal storage (public or user-scoped)
374
395
  file_id = str(uuid4())
375
396
  storage_uri, internal_key, content_type, _ = await fs_service.write_to_internal_storage(
376
397
  content=file_content,
377
- tenant_id=user_id, # Using user_id for storage scoping
398
+ tenant_id=user_id or "public", # Storage path: public/ or user_id/
378
399
  file_name=file_name,
379
400
  file_id=file_id,
380
401
  )
@@ -383,7 +404,7 @@ class ContentService:
383
404
  # Step 3: Create File entity
384
405
  file_entity = File(
385
406
  id=file_id,
386
- tenant_id=user_id, # Set tenant_id to user_id (application scoped to user)
407
+ tenant_id=user_id, # None = public/shared
387
408
  user_id=user_id,
388
409
  name=file_name,
389
410
  uri=storage_uri,
@@ -538,7 +559,7 @@ class ContentService:
538
559
  size_bytes=result["metadata"].get("size"),
539
560
  mime_type=result["metadata"].get("content_type"),
540
561
  processing_status="completed",
541
- tenant_id=user_id or "default", # Required field
562
+ tenant_id=user_id, # None = public/shared
542
563
  user_id=user_id,
543
564
  )
544
565
 
@@ -571,7 +592,7 @@ class ContentService:
571
592
  ordinal=i,
572
593
  content=chunk,
573
594
  category="document",
574
- tenant_id=user_id or "default", # Required field
595
+ tenant_id=user_id, # None = public/shared
575
596
  user_id=user_id,
576
597
  )
577
598
  for i, chunk in enumerate(chunks)
@@ -645,9 +666,10 @@ class ContentService:
645
666
  # IMPORTANT: category field distinguishes agents from evaluators
646
667
  # - kind=agent → category="agent" (AI agents with tools/resources)
647
668
  # - kind=evaluator → category="evaluator" (LLM-as-a-Judge evaluators)
669
+ # Schemas (agents/evaluators) default to system tenant for shared access
648
670
  schema_entity = Schema(
649
- tenant_id=user_id or "default",
650
- user_id=user_id,
671
+ tenant_id="system",
672
+ user_id=None,
651
673
  name=name,
652
674
  spec=schema_data,
653
675
  category=kind, # Maps kind → category for database filtering
@@ -717,7 +739,7 @@ class ContentService:
717
739
  processor = EngramProcessor(postgres)
718
740
  result = await processor.process_engram(
719
741
  data=data,
720
- tenant_id=user_id or "default",
742
+ tenant_id=user_id, # None = public/shared
721
743
  user_id=user_id,
722
744
  )
723
745
  logger.info(f"✅ Engram processed: {result.get('resource_id')} with {len(result.get('moment_ids', []))} moments")
@@ -45,7 +45,7 @@ def generate_embedding(
45
45
  return [0.0] * DEFAULT_EMBEDDING_DIMS
46
46
 
47
47
  try:
48
- logger.info(f"Generating OpenAI embedding for text using {model}")
48
+ logger.debug(f"Generating OpenAI embedding for text using {model}")
49
49
 
50
50
  response = requests.post(
51
51
  "https://api.openai.com/v1/embeddings",
@@ -60,7 +60,7 @@ def generate_embedding(
60
60
 
61
61
  data = response.json()
62
62
  embedding = data["data"][0]["embedding"]
63
- logger.info(f"Successfully generated embedding (dimension: {len(embedding)})")
63
+ logger.debug(f"Successfully generated embedding (dimension: {len(embedding)})")
64
64
  return cast(list[float], embedding)
65
65
 
66
66
  except Exception as e:
@@ -97,7 +97,7 @@ async def generate_embedding_async(
97
97
  return [0.0] * DEFAULT_EMBEDDING_DIMS
98
98
 
99
99
  try:
100
- logger.info(f"Generating OpenAI embedding for text using {model}")
100
+ logger.debug(f"Generating OpenAI embedding for text using {model}")
101
101
 
102
102
  async with httpx.AsyncClient() as client:
103
103
  response = await client.post(
@@ -113,7 +113,7 @@ async def generate_embedding_async(
113
113
 
114
114
  data = response.json()
115
115
  embedding = data["data"][0]["embedding"]
116
- logger.info(
116
+ logger.debug(
117
117
  f"Successfully generated embedding (dimension: {len(embedding)})"
118
118
  )
119
119
  return cast(list[float], embedding)
@@ -69,7 +69,7 @@ def get_global_embedding_worker(postgres_service: Any = None) -> "EmbeddingWorke
69
69
  if postgres_service is None:
70
70
  raise RuntimeError("Must provide postgres_service on first call to get_global_embedding_worker")
71
71
  _global_worker = EmbeddingWorker(postgres_service=postgres_service)
72
- logger.info("Created global EmbeddingWorker singleton")
72
+ logger.debug("Created global EmbeddingWorker singleton")
73
73
 
74
74
  return _global_worker
75
75
 
@@ -117,7 +117,7 @@ class EmbeddingWorker:
117
117
  "No OpenAI API key provided - embeddings will use zero vectors"
118
118
  )
119
119
 
120
- logger.info(
120
+ logger.debug(
121
121
  f"Initialized EmbeddingWorker: {num_workers} workers, "
122
122
  f"batch_size={batch_size}, timeout={batch_timeout}s"
123
123
  )
@@ -125,17 +125,17 @@ class EmbeddingWorker:
125
125
  async def start(self) -> None:
126
126
  """Start worker pool."""
127
127
  if self.running:
128
- logger.warning("EmbeddingWorker already running")
128
+ logger.debug("EmbeddingWorker already running")
129
129
  return
130
130
 
131
131
  self.running = True
132
- logger.info(f"Starting {self.num_workers} embedding workers")
132
+ logger.debug(f"Starting {self.num_workers} embedding workers")
133
133
 
134
134
  for i in range(self.num_workers):
135
135
  worker = asyncio.create_task(self._worker_loop(i))
136
136
  self.workers.append(worker)
137
137
 
138
- logger.info("EmbeddingWorker started")
138
+ logger.debug("EmbeddingWorker started")
139
139
 
140
140
  async def stop(self) -> None:
141
141
  """Stop worker pool gracefully - processes remaining queue before stopping."""
@@ -143,7 +143,7 @@ class EmbeddingWorker:
143
143
  return
144
144
 
145
145
  queue_size = self.task_queue.qsize()
146
- logger.info(f"Stopping EmbeddingWorker (processing {queue_size} queued tasks first)")
146
+ logger.debug(f"Stopping EmbeddingWorker (processing {queue_size} queued tasks first)")
147
147
 
148
148
  # Wait for queue to drain (with timeout)
149
149
  max_wait = 30 # 30 seconds max
@@ -171,7 +171,7 @@ class EmbeddingWorker:
171
171
  await asyncio.gather(*self.workers, return_exceptions=True)
172
172
 
173
173
  self.workers.clear()
174
- logger.info("EmbeddingWorker stopped")
174
+ logger.debug("EmbeddingWorker stopped")
175
175
 
176
176
  async def queue_task(self, task: EmbeddingTask) -> None:
177
177
  """
@@ -195,7 +195,7 @@ class EmbeddingWorker:
195
195
  Args:
196
196
  worker_id: Unique worker identifier
197
197
  """
198
- logger.info(f"Worker {worker_id} started")
198
+ logger.debug(f"Worker {worker_id} started")
199
199
 
200
200
  while self.running:
201
201
  try:
@@ -205,7 +205,7 @@ class EmbeddingWorker:
205
205
  if not batch:
206
206
  continue
207
207
 
208
- logger.info(f"Worker {worker_id} processing batch of {len(batch)} tasks")
208
+ logger.debug(f"Worker {worker_id} processing batch of {len(batch)} tasks")
209
209
 
210
210
  # Generate embeddings for batch
211
211
  await self._process_batch(batch)
@@ -213,14 +213,14 @@ class EmbeddingWorker:
213
213
  logger.debug(f"Worker {worker_id} completed batch")
214
214
 
215
215
  except asyncio.CancelledError:
216
- logger.info(f"Worker {worker_id} cancelled")
216
+ logger.debug(f"Worker {worker_id} cancelled")
217
217
  break
218
218
  except Exception as e:
219
219
  logger.error(f"Worker {worker_id} error: {e}", exc_info=True)
220
220
  # Continue processing (don't crash worker on error)
221
221
  await asyncio.sleep(1)
222
222
 
223
- logger.info(f"Worker {worker_id} stopped")
223
+ logger.debug(f"Worker {worker_id} stopped")
224
224
 
225
225
  async def _collect_batch(self) -> list[EmbeddingTask]:
226
226
  """
@@ -284,10 +284,10 @@ class EmbeddingWorker:
284
284
  )
285
285
 
286
286
  # Upsert to database
287
- logger.info(f"Upserting {len(embeddings)} embeddings to database...")
287
+ logger.debug(f"Upserting {len(embeddings)} embeddings to database...")
288
288
  await self._upsert_embeddings(batch, embeddings)
289
289
 
290
- logger.info(
290
+ logger.debug(
291
291
  f"Successfully generated and stored {len(embeddings)} embeddings "
292
292
  f"(provider={provider}, model={model})"
293
293
  )
@@ -315,7 +315,7 @@ class EmbeddingWorker:
315
315
  """
316
316
  if provider == "openai" and self.openai_api_key:
317
317
  try:
318
- logger.info(
318
+ logger.debug(
319
319
  f"Generating OpenAI embeddings for {len(texts)} texts using {model}"
320
320
  )
321
321
 
@@ -336,7 +336,7 @@ class EmbeddingWorker:
336
336
  data = response.json()
337
337
  embeddings = [item["embedding"] for item in data["data"]]
338
338
 
339
- logger.info(
339
+ logger.debug(
340
340
  f"Successfully generated {len(embeddings)} embeddings from OpenAI"
341
341
  )
342
342
  return embeddings
@@ -409,7 +409,7 @@ class EmbeddingWorker:
409
409
  ),
410
410
  )
411
411
 
412
- logger.info(
412
+ logger.debug(
413
413
  f"Upserted embedding: {task.table_name}.{task.entity_id}.{task.field_name}"
414
414
  )
415
415
 
@@ -793,40 +793,72 @@ class PhoenixClient:
793
793
  score: float | None = None,
794
794
  explanation: str | None = None,
795
795
  metadata: dict[str, Any] | None = None,
796
+ trace_id: str | None = None,
796
797
  ) -> str | None:
797
- """Add feedback annotation to a span.
798
+ """Add feedback annotation to a span via Phoenix REST API.
799
+
800
+ Uses direct HTTP POST to /v1/span_annotations for reliability
801
+ (Phoenix Python client API changes frequently).
798
802
 
799
803
  Args:
800
- span_id: Span ID to annotate
804
+ span_id: Span ID to annotate (hex string)
801
805
  annotation_name: Name of the annotation (e.g., "correctness", "user_feedback")
802
806
  annotator_kind: Type of annotator ("HUMAN", "LLM", "CODE")
803
807
  label: Optional label (e.g., "correct", "incorrect", "helpful")
804
808
  score: Optional numeric score (0.0-1.0)
805
809
  explanation: Optional explanation text
806
810
  metadata: Optional additional metadata dict
811
+ trace_id: Optional trace ID (used if span lookup needed)
807
812
 
808
813
  Returns:
809
814
  Annotation ID if successful, None otherwise
810
815
  """
816
+ import httpx
817
+
811
818
  try:
812
- result = self._client.add_span_annotation( # type: ignore[attr-defined]
813
- span_id=span_id,
814
- name=annotation_name,
815
- annotator_kind=annotator_kind,
816
- label=label,
817
- score=score,
818
- explanation=explanation,
819
- metadata=metadata,
820
- )
819
+ # Build annotation payload for Phoenix REST API
820
+ annotation_data = {
821
+ "span_id": span_id,
822
+ "name": annotation_name,
823
+ "annotator_kind": annotator_kind,
824
+ "result": {
825
+ "label": label,
826
+ "score": score,
827
+ "explanation": explanation,
828
+ },
829
+ "metadata": metadata or {},
830
+ }
821
831
 
822
- annotation_id = getattr(result, "id", None) if result else None
823
- logger.info(f"Added {annotator_kind} feedback to span {span_id} -> {annotation_id}")
832
+ # Add trace_id if provided
833
+ if trace_id:
834
+ annotation_data["trace_id"] = trace_id
835
+
836
+ # POST to Phoenix REST API
837
+ annotations_endpoint = f"{self.config.base_url}/v1/span_annotations"
838
+ headers = {}
839
+ if self.config.api_key:
840
+ headers["Authorization"] = f"Bearer {self.config.api_key}"
841
+
842
+ with httpx.Client(timeout=5.0) as client:
843
+ response = client.post(
844
+ annotations_endpoint,
845
+ json={"data": [annotation_data]},
846
+ headers=headers,
847
+ )
848
+ response.raise_for_status()
824
849
 
825
- return annotation_id
850
+ logger.info(f"Added {annotator_kind} feedback to span {span_id}")
851
+ return span_id # Return span_id as annotation reference
826
852
 
853
+ except httpx.HTTPStatusError as e:
854
+ logger.error(
855
+ f"Failed to add span feedback (HTTP {e.response.status_code}): "
856
+ f"{e.response.text if hasattr(e, 'response') else 'N/A'}"
857
+ )
858
+ return None
827
859
  except Exception as e:
828
860
  logger.error(f"Failed to add span feedback: {e}")
829
- raise
861
+ return None
830
862
 
831
863
  def sync_user_feedback(
832
864
  self,
@@ -835,6 +867,7 @@ class PhoenixClient:
835
867
  categories: list[str] | None = None,
836
868
  comment: str | None = None,
837
869
  feedback_id: str | None = None,
870
+ trace_id: str | None = None,
838
871
  ) -> str | None:
839
872
  """Sync user feedback to Phoenix as a span annotation.
840
873
 
@@ -847,6 +880,7 @@ class PhoenixClient:
847
880
  categories: List of feedback categories
848
881
  comment: Free-text comment
849
882
  feedback_id: Optional REM feedback ID for reference
883
+ trace_id: Optional trace ID for the span
850
884
 
851
885
  Returns:
852
886
  Phoenix annotation ID if successful
@@ -860,12 +894,18 @@ class PhoenixClient:
860
894
  ... )
861
895
  """
862
896
  # Convert rating to 0-1 score
897
+ # Rating scheme:
898
+ # -1 = thumbs down → score 0.0
899
+ # 1 = thumbs up → score 1.0
900
+ # 2-5 = star rating → normalized to 0-1 range
863
901
  score = None
864
902
  if rating is not None:
865
903
  if rating == -1:
866
904
  score = 0.0
867
- elif 1 <= rating <= 5:
868
- score = rating / 5.0
905
+ elif rating == 1:
906
+ score = 1.0 # Thumbs up
907
+ elif 2 <= rating <= 5:
908
+ score = (rating - 1) / 4.0 # 2→0.25, 3→0.5, 4→0.75, 5→1.0
869
909
 
870
910
  # Use primary category as label
871
911
  label = categories[0] if categories else None
@@ -880,7 +920,7 @@ class PhoenixClient:
880
920
  explanation = f"Categories: {cats_str}"
881
921
 
882
922
  # Build metadata
883
- metadata = {
923
+ metadata: dict[str, Any] = {
884
924
  "rating": rating,
885
925
  "categories": categories or [],
886
926
  }
@@ -895,6 +935,7 @@ class PhoenixClient:
895
935
  score=score,
896
936
  explanation=explanation,
897
937
  metadata=metadata,
938
+ trace_id=trace_id,
898
939
  )
899
940
 
900
941
  def get_span_annotations(