remdb 0.3.118__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 (40) hide show
  1. rem/agentic/agents/sse_simulator.py +2 -0
  2. rem/agentic/context.py +23 -3
  3. rem/agentic/mcp/tool_wrapper.py +126 -15
  4. rem/agentic/otel/setup.py +1 -0
  5. rem/agentic/providers/phoenix.py +371 -108
  6. rem/agentic/providers/pydantic_ai.py +122 -43
  7. rem/agentic/schema.py +4 -1
  8. rem/api/mcp_router/tools.py +13 -2
  9. rem/api/routers/chat/completions.py +250 -4
  10. rem/api/routers/chat/models.py +81 -7
  11. rem/api/routers/chat/otel_utils.py +33 -0
  12. rem/api/routers/chat/sse_events.py +17 -1
  13. rem/api/routers/chat/streaming.py +35 -1
  14. rem/api/routers/feedback.py +134 -14
  15. rem/cli/commands/cluster.py +590 -82
  16. rem/cli/commands/configure.py +3 -4
  17. rem/cli/commands/experiments.py +436 -30
  18. rem/cli/commands/session.py +336 -0
  19. rem/cli/dreaming.py +2 -2
  20. rem/cli/main.py +2 -0
  21. rem/config.py +8 -1
  22. rem/models/core/experiment.py +54 -0
  23. rem/models/entities/ontology.py +1 -1
  24. rem/models/entities/ontology_config.py +1 -1
  25. rem/schemas/agents/examples/contract-analyzer.yaml +1 -1
  26. rem/schemas/agents/examples/contract-extractor.yaml +1 -1
  27. rem/schemas/agents/examples/cv-parser.yaml +1 -1
  28. rem/services/phoenix/client.py +59 -18
  29. rem/services/session/compression.py +7 -0
  30. rem/settings.py +236 -13
  31. rem/sql/migrations/002_install_models.sql +91 -91
  32. rem/sql/migrations/004_cache_system.sql +1 -1
  33. rem/utils/schema_loader.py +94 -3
  34. rem/utils/vision.py +1 -1
  35. rem/workers/__init__.py +2 -1
  36. rem/workers/db_listener.py +579 -0
  37. {remdb-0.3.118.dist-info → remdb-0.3.141.dist-info}/METADATA +156 -144
  38. {remdb-0.3.118.dist-info → remdb-0.3.141.dist-info}/RECORD +40 -37
  39. {remdb-0.3.118.dist-info → remdb-0.3.141.dist-info}/WHEEL +0 -0
  40. {remdb-0.3.118.dist-info → remdb-0.3.141.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,336 @@
1
+ """
2
+ CLI command for viewing and simulating session conversations.
3
+
4
+ Usage:
5
+ rem session show <user_id> [--session-id] [--role user|assistant|system]
6
+ rem session show <user_id> --simulate-next [--save] [--custom-sim-prompt "..."]
7
+
8
+ Examples:
9
+ # Show all messages for a user
10
+ rem session show 11111111-1111-1111-1111-111111111001
11
+
12
+ # Show only user messages
13
+ rem session show 11111111-1111-1111-1111-111111111001 --role user
14
+
15
+ # Simulate next user message
16
+ rem session show 11111111-1111-1111-1111-111111111001 --simulate-next
17
+
18
+ # Simulate with custom prompt and save
19
+ rem session show 11111111-1111-1111-1111-111111111001 --simulate-next --save \
20
+ --custom-sim-prompt "Respond as an anxious patient"
21
+ """
22
+
23
+ import asyncio
24
+ from pathlib import Path
25
+ from typing import Literal
26
+
27
+ import click
28
+ import yaml
29
+ from loguru import logger
30
+
31
+ from ...models.entities.user import User
32
+ from ...models.entities.message import Message
33
+ from ...services.postgres import get_postgres_service
34
+ from ...services.postgres.repository import Repository
35
+ from ...settings import settings
36
+
37
+
38
+ SIMULATOR_PROMPT = """You are simulating a patient in a mental health conversation.
39
+
40
+ ## Context
41
+ You are continuing a conversation with a clinical evaluation agent. Based on the
42
+ user profile and conversation history below, generate the next realistic patient message.
43
+
44
+ ## User Profile
45
+ {user_profile}
46
+
47
+ ## Conversation History
48
+ {conversation_history}
49
+
50
+ ## Instructions
51
+ - Stay in character as the patient described in the profile
52
+ - Your response should be natural, conversational, and consistent with the patient's presentation
53
+ - Consider the patient's risk level, symptoms, and communication style
54
+ - Do NOT include any metadata or role labels - just the raw message content
55
+ - Keep responses concise (1-3 sentences typical for conversation)
56
+
57
+ Generate the next patient message:"""
58
+
59
+
60
+ async def _load_user_and_messages(
61
+ user_id: str,
62
+ session_id: str | None = None,
63
+ role_filter: str | None = None,
64
+ limit: int = 100,
65
+ ) -> tuple[User | None, list[Message]]:
66
+ """Load user profile and messages from database."""
67
+ pg = get_postgres_service()
68
+ if not pg:
69
+ logger.error("PostgreSQL not available")
70
+ return None, []
71
+
72
+ await pg.connect()
73
+
74
+ try:
75
+ # Load user
76
+ user_repo = Repository(User, "users", db=pg)
77
+ user = await user_repo.get_by_id(user_id, tenant_id="default")
78
+
79
+ # Load messages
80
+ message_repo = Repository(Message, "messages", db=pg)
81
+ filters = {"user_id": user_id}
82
+ if session_id:
83
+ filters["session_id"] = session_id
84
+
85
+ messages = await message_repo.find(
86
+ filters=filters,
87
+ order_by="created_at ASC",
88
+ limit=limit,
89
+ )
90
+
91
+ # Filter by role if specified
92
+ if role_filter:
93
+ messages = [m for m in messages if m.message_type == role_filter]
94
+
95
+ return user, messages
96
+
97
+ finally:
98
+ await pg.disconnect()
99
+
100
+
101
+ def _format_user_yaml(user: User | None) -> str:
102
+ """Format user profile as YAML."""
103
+ if not user:
104
+ return "# No user found"
105
+
106
+ data = {
107
+ "id": str(user.id),
108
+ "name": user.name,
109
+ "summary": user.summary,
110
+ "interests": user.interests,
111
+ "preferred_topics": user.preferred_topics,
112
+ "metadata": user.metadata,
113
+ }
114
+ return yaml.dump(data, default_flow_style=False, allow_unicode=True)
115
+
116
+
117
+ def _format_messages_yaml(messages: list[Message]) -> str:
118
+ """Format messages as YAML."""
119
+ if not messages:
120
+ return "# No messages found"
121
+
122
+ data = []
123
+ for msg in messages:
124
+ data.append({
125
+ "role": msg.message_type or "unknown",
126
+ "content": msg.content,
127
+ "session_id": msg.session_id,
128
+ "created_at": msg.created_at.isoformat() if msg.created_at else None,
129
+ })
130
+ return yaml.dump(data, default_flow_style=False, allow_unicode=True)
131
+
132
+
133
+ def _format_conversation_for_llm(messages: list[Message]) -> str:
134
+ """Format conversation history for LLM context."""
135
+ lines = []
136
+ for msg in messages:
137
+ role = msg.message_type or "unknown"
138
+ lines.append(f"[{role.upper()}]: {msg.content}")
139
+ return "\n\n".join(lines) if lines else "(No previous messages)"
140
+
141
+
142
+ async def _simulate_next_message(
143
+ user: User | None,
144
+ messages: list[Message],
145
+ custom_prompt: str | None = None,
146
+ ) -> str:
147
+ """Use LLM to simulate the next patient message."""
148
+ from pydantic_ai import Agent
149
+
150
+ # Build context
151
+ user_profile = _format_user_yaml(user) if user else "Unknown patient"
152
+ conversation_history = _format_conversation_for_llm(messages)
153
+
154
+ # Use custom prompt or default
155
+ if custom_prompt:
156
+ # Check if it's a file path
157
+ if Path(custom_prompt).exists():
158
+ prompt_template = Path(custom_prompt).read_text()
159
+ else:
160
+ prompt_template = custom_prompt
161
+ # Simple variable substitution
162
+ prompt = prompt_template.replace("{user_profile}", user_profile)
163
+ prompt = prompt.replace("{conversation_history}", conversation_history)
164
+ else:
165
+ prompt = SIMULATOR_PROMPT.format(
166
+ user_profile=user_profile,
167
+ conversation_history=conversation_history,
168
+ )
169
+
170
+ # Create simple agent for simulation
171
+ agent = Agent(
172
+ model=settings.llm.default_model,
173
+ system_prompt="You are a patient simulator. Generate realistic patient responses.",
174
+ )
175
+
176
+ result = await agent.run(prompt)
177
+ return result.output
178
+
179
+
180
+ async def _save_message(
181
+ user_id: str,
182
+ session_id: str | None,
183
+ content: str,
184
+ role: str = "user",
185
+ ) -> Message:
186
+ """Save a simulated message to the database."""
187
+ from uuid import uuid4
188
+
189
+ pg = get_postgres_service()
190
+ if not pg:
191
+ raise RuntimeError("PostgreSQL not available")
192
+
193
+ await pg.connect()
194
+
195
+ try:
196
+ message_repo = Repository(Message, "messages", db=pg)
197
+
198
+ message = Message(
199
+ id=uuid4(),
200
+ user_id=user_id,
201
+ tenant_id="default",
202
+ session_id=session_id or str(uuid4()),
203
+ content=content,
204
+ message_type=role,
205
+ )
206
+
207
+ await message_repo.upsert(message)
208
+ return message
209
+
210
+ finally:
211
+ await pg.disconnect()
212
+
213
+
214
+ @click.group()
215
+ def session():
216
+ """Session viewing and simulation commands."""
217
+ pass
218
+
219
+
220
+ @session.command("show")
221
+ @click.argument("user_id")
222
+ @click.option("--session-id", "-s", help="Filter by session ID")
223
+ @click.option(
224
+ "--role", "-r",
225
+ type=click.Choice(["user", "assistant", "system", "tool"]),
226
+ help="Filter messages by role",
227
+ )
228
+ @click.option("--limit", "-l", default=100, help="Max messages to load")
229
+ @click.option("--simulate-next", is_flag=True, help="Simulate the next patient message")
230
+ @click.option("--save", is_flag=True, help="Save simulated message to database")
231
+ @click.option(
232
+ "--custom-sim-prompt", "-p",
233
+ help="Custom simulation prompt (text or file path)",
234
+ )
235
+ def show(
236
+ user_id: str,
237
+ session_id: str | None,
238
+ role: str | None,
239
+ limit: int,
240
+ simulate_next: bool,
241
+ save: bool,
242
+ custom_sim_prompt: str | None,
243
+ ):
244
+ """
245
+ Show user profile and session messages.
246
+
247
+ USER_ID: The user identifier to load.
248
+
249
+ Examples:
250
+
251
+ # Show user and all messages
252
+ rem session show 11111111-1111-1111-1111-111111111001
253
+
254
+ # Show only assistant responses
255
+ rem session show 11111111-1111-1111-1111-111111111001 --role assistant
256
+
257
+ # Simulate next patient message
258
+ rem session show 11111111-1111-1111-1111-111111111001 --simulate-next
259
+
260
+ # Simulate and save to database
261
+ rem session show 11111111-1111-1111-1111-111111111001 --simulate-next --save
262
+ """
263
+ asyncio.run(_show_async(
264
+ user_id=user_id,
265
+ session_id=session_id,
266
+ role_filter=role,
267
+ limit=limit,
268
+ simulate_next=simulate_next,
269
+ save=save,
270
+ custom_sim_prompt=custom_sim_prompt,
271
+ ))
272
+
273
+
274
+ async def _show_async(
275
+ user_id: str,
276
+ session_id: str | None,
277
+ role_filter: str | None,
278
+ limit: int,
279
+ simulate_next: bool,
280
+ save: bool,
281
+ custom_sim_prompt: str | None,
282
+ ):
283
+ """Async implementation of show command."""
284
+ # Load data
285
+ user, messages = await _load_user_and_messages(
286
+ user_id=user_id,
287
+ session_id=session_id,
288
+ role_filter=role_filter if not simulate_next else None, # Need all messages for simulation
289
+ limit=limit,
290
+ )
291
+
292
+ # Display user profile
293
+ click.echo("\n# User Profile")
294
+ click.echo("---")
295
+ click.echo(_format_user_yaml(user))
296
+
297
+ # Display messages (apply filter for display if simulating)
298
+ display_messages = messages
299
+ if simulate_next and role_filter:
300
+ display_messages = [m for m in messages if m.message_type == role_filter]
301
+
302
+ click.echo("\n# Messages")
303
+ click.echo("---")
304
+ click.echo(_format_messages_yaml(display_messages))
305
+
306
+ # Simulate next message if requested
307
+ if simulate_next:
308
+ click.echo("\n# Simulated Next Message")
309
+ click.echo("---")
310
+
311
+ try:
312
+ simulated = await _simulate_next_message(
313
+ user=user,
314
+ messages=messages,
315
+ custom_prompt=custom_sim_prompt,
316
+ )
317
+ click.echo(f"role: user")
318
+ click.echo(f"content: |\n {simulated}")
319
+
320
+ if save:
321
+ saved_msg = await _save_message(
322
+ user_id=user_id,
323
+ session_id=session_id,
324
+ content=simulated,
325
+ role="user",
326
+ )
327
+ logger.success(f"Saved message: {saved_msg.id}")
328
+
329
+ except Exception as e:
330
+ logger.error(f"Simulation failed: {e}")
331
+ raise
332
+
333
+
334
+ def register_command(cli_group):
335
+ """Register the session command group."""
336
+ cli_group.add_command(session)
rem/cli/dreaming.py CHANGED
@@ -43,7 +43,7 @@ rem-dreaming full --user-id=user-123 --rem-api-url=http://localhost:8000
43
43
  Environment Variables:
44
44
  - REM_API_URL: REM API endpoint (default: http://rem-api:8000)
45
45
  - REM_EMBEDDING_PROVIDER: Embedding provider (default: text-embedding-3-small)
46
- - REM_DEFAULT_MODEL: LLM model (default: gpt-4o)
46
+ - REM_DEFAULT_MODEL: LLM model (default: gpt-4.1)
47
47
  - REM_LOOKBACK_HOURS: Default lookback window (default: 24)
48
48
  - OPENAI_API_KEY: OpenAI API key
49
49
 
@@ -83,7 +83,7 @@ def get_worker() -> DreamingWorker:
83
83
  embedding_provider=os.getenv(
84
84
  "REM_EMBEDDING_PROVIDER", "text-embedding-3-small"
85
85
  ),
86
- default_model=os.getenv("REM_DEFAULT_MODEL", "gpt-4o"),
86
+ default_model=os.getenv("REM_DEFAULT_MODEL", "gpt-4.1"),
87
87
  lookback_hours=int(os.getenv("REM_LOOKBACK_HOURS", "24")),
88
88
  )
89
89
 
rem/cli/main.py CHANGED
@@ -96,6 +96,7 @@ from .commands.serve import register_command as register_serve_command
96
96
  from .commands.mcp import register_command as register_mcp_command
97
97
  from .commands.scaffold import scaffold as scaffold_command
98
98
  from .commands.cluster import register_commands as register_cluster_commands
99
+ from .commands.session import register_command as register_session_command
99
100
 
100
101
  register_schema_commands(schema)
101
102
  register_db_commands(db)
@@ -108,6 +109,7 @@ register_serve_command(cli)
108
109
  register_mcp_command(cli)
109
110
  cli.add_command(experiments_group)
110
111
  cli.add_command(scaffold_command)
112
+ register_session_command(cli)
111
113
 
112
114
 
113
115
  def main():
rem/config.py CHANGED
@@ -95,9 +95,16 @@ def load_config() -> dict[str, Any]:
95
95
  """
96
96
  Load configuration from ~/.rem/config.yaml.
97
97
 
98
+ Set REM_SKIP_CONFIG=1 to skip loading the config file (useful when using .env files).
99
+
98
100
  Returns:
99
- Configuration dictionary (empty if file doesn't exist)
101
+ Configuration dictionary (empty if file doesn't exist or skipped)
100
102
  """
103
+ # Allow skipping config file via environment variable
104
+ if os.environ.get("REM_SKIP_CONFIG", "").lower() in ("1", "true", "yes"):
105
+ logger.debug("Skipping config file (REM_SKIP_CONFIG is set)")
106
+ return {}
107
+
101
108
  config_path = get_config_path()
102
109
 
103
110
  if not config_path.exists():
@@ -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
@@ -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
  )
@@ -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
@@ -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(