remdb 0.3.114__py3-none-any.whl → 0.3.172__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.
- rem/agentic/agents/__init__.py +16 -0
- rem/agentic/agents/agent_manager.py +311 -0
- rem/agentic/agents/sse_simulator.py +2 -0
- rem/agentic/context.py +103 -5
- rem/agentic/context_builder.py +36 -9
- rem/agentic/mcp/tool_wrapper.py +161 -18
- rem/agentic/otel/setup.py +1 -0
- rem/agentic/providers/phoenix.py +371 -108
- rem/agentic/providers/pydantic_ai.py +172 -30
- rem/agentic/schema.py +8 -4
- rem/api/deps.py +3 -5
- rem/api/main.py +26 -4
- rem/api/mcp_router/resources.py +15 -10
- rem/api/mcp_router/server.py +11 -3
- rem/api/mcp_router/tools.py +418 -4
- rem/api/middleware/tracking.py +5 -5
- rem/api/routers/admin.py +218 -1
- rem/api/routers/auth.py +349 -6
- rem/api/routers/chat/completions.py +255 -7
- rem/api/routers/chat/models.py +81 -7
- rem/api/routers/chat/otel_utils.py +33 -0
- rem/api/routers/chat/sse_events.py +17 -1
- rem/api/routers/chat/streaming.py +126 -19
- rem/api/routers/feedback.py +134 -14
- rem/api/routers/messages.py +24 -15
- rem/api/routers/query.py +6 -3
- rem/auth/__init__.py +13 -3
- rem/auth/jwt.py +352 -0
- rem/auth/middleware.py +115 -10
- rem/auth/providers/__init__.py +4 -1
- rem/auth/providers/email.py +215 -0
- rem/cli/commands/README.md +42 -0
- rem/cli/commands/cluster.py +617 -168
- rem/cli/commands/configure.py +4 -7
- rem/cli/commands/db.py +66 -22
- rem/cli/commands/experiments.py +468 -76
- rem/cli/commands/schema.py +6 -5
- rem/cli/commands/session.py +336 -0
- rem/cli/dreaming.py +2 -2
- rem/cli/main.py +2 -0
- rem/config.py +8 -1
- rem/models/core/experiment.py +58 -14
- rem/models/entities/__init__.py +4 -0
- rem/models/entities/ontology.py +1 -1
- rem/models/entities/ontology_config.py +1 -1
- rem/models/entities/subscriber.py +175 -0
- rem/models/entities/user.py +1 -0
- rem/schemas/agents/core/agent-builder.yaml +235 -0
- rem/schemas/agents/examples/contract-analyzer.yaml +1 -1
- rem/schemas/agents/examples/contract-extractor.yaml +1 -1
- rem/schemas/agents/examples/cv-parser.yaml +1 -1
- rem/services/__init__.py +3 -1
- rem/services/content/service.py +4 -3
- rem/services/email/__init__.py +10 -0
- rem/services/email/service.py +513 -0
- rem/services/email/templates.py +360 -0
- rem/services/phoenix/client.py +59 -18
- rem/services/postgres/README.md +38 -0
- rem/services/postgres/diff_service.py +127 -6
- rem/services/postgres/pydantic_to_sqlalchemy.py +45 -13
- rem/services/postgres/repository.py +5 -4
- rem/services/postgres/schema_generator.py +205 -4
- rem/services/session/compression.py +120 -50
- rem/services/session/reload.py +14 -7
- rem/services/user_service.py +41 -9
- rem/settings.py +442 -23
- rem/sql/migrations/001_install.sql +156 -0
- rem/sql/migrations/002_install_models.sql +1951 -88
- rem/sql/migrations/004_cache_system.sql +548 -0
- rem/sql/migrations/005_schema_update.sql +145 -0
- rem/utils/README.md +45 -0
- rem/utils/__init__.py +18 -0
- rem/utils/files.py +157 -1
- rem/utils/schema_loader.py +139 -10
- rem/utils/sql_paths.py +146 -0
- rem/utils/vision.py +1 -1
- rem/workers/__init__.py +3 -1
- rem/workers/db_listener.py +579 -0
- rem/workers/unlogged_maintainer.py +463 -0
- {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/METADATA +218 -180
- {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/RECORD +83 -68
- {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/WHEEL +0 -0
- {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/entry_points.txt +0 -0
|
@@ -47,6 +47,7 @@ from pydantic_ai.messages import (
|
|
|
47
47
|
ToolCallPart,
|
|
48
48
|
)
|
|
49
49
|
|
|
50
|
+
from .otel_utils import get_current_trace_context, get_tracer
|
|
50
51
|
from .models import (
|
|
51
52
|
ChatCompletionMessageDelta,
|
|
52
53
|
ChatCompletionStreamChoice,
|
|
@@ -73,6 +74,11 @@ async def stream_openai_response(
|
|
|
73
74
|
session_id: str | None = None,
|
|
74
75
|
# Agent info for metadata
|
|
75
76
|
agent_schema: str | None = None,
|
|
77
|
+
# Mutable container to capture trace context (deterministic, not AI-dependent)
|
|
78
|
+
trace_context_out: dict | None = None,
|
|
79
|
+
# Mutable container to capture tool calls for persistence
|
|
80
|
+
# Format: list of {"tool_name": str, "tool_id": str, "arguments": dict, "result": any}
|
|
81
|
+
tool_calls_out: list | None = None,
|
|
76
82
|
) -> AsyncGenerator[str, None]:
|
|
77
83
|
"""
|
|
78
84
|
Stream Pydantic AI agent responses with rich SSE events.
|
|
@@ -143,6 +149,9 @@ async def stream_openai_response(
|
|
|
143
149
|
pending_tool_completions: list[tuple[str, str]] = []
|
|
144
150
|
# Track if metadata was registered via register_metadata tool
|
|
145
151
|
metadata_registered = False
|
|
152
|
+
# Track pending tool calls with full data for persistence
|
|
153
|
+
# Maps tool_id -> {"tool_name": str, "tool_id": str, "arguments": dict}
|
|
154
|
+
pending_tool_data: dict[str, dict] = {}
|
|
146
155
|
|
|
147
156
|
try:
|
|
148
157
|
# Emit initial progress event
|
|
@@ -156,6 +165,14 @@ async def stream_openai_response(
|
|
|
156
165
|
|
|
157
166
|
# Use agent.iter() to get complete execution with tool calls
|
|
158
167
|
async with agent.iter(prompt) as agent_run:
|
|
168
|
+
# Capture trace context IMMEDIATELY inside agent execution
|
|
169
|
+
# This is deterministic - it's the OTEL context from Pydantic AI instrumentation
|
|
170
|
+
# NOT dependent on any AI-generated content
|
|
171
|
+
captured_trace_id, captured_span_id = get_current_trace_context()
|
|
172
|
+
if trace_context_out is not None:
|
|
173
|
+
trace_context_out["trace_id"] = captured_trace_id
|
|
174
|
+
trace_context_out["span_id"] = captured_span_id
|
|
175
|
+
|
|
159
176
|
async for node in agent_run:
|
|
160
177
|
# Check if this is a model request node (includes tool calls)
|
|
161
178
|
if Agent.is_model_request_node(node):
|
|
@@ -288,6 +305,13 @@ async def stream_openai_response(
|
|
|
288
305
|
arguments=args_dict
|
|
289
306
|
))
|
|
290
307
|
|
|
308
|
+
# Track tool call data for persistence (especially register_metadata)
|
|
309
|
+
pending_tool_data[tool_id] = {
|
|
310
|
+
"tool_name": tool_name,
|
|
311
|
+
"tool_id": tool_id,
|
|
312
|
+
"arguments": args_dict,
|
|
313
|
+
}
|
|
314
|
+
|
|
291
315
|
# Update progress
|
|
292
316
|
current_step = 2
|
|
293
317
|
total_steps = 4 # Added tool execution step
|
|
@@ -366,6 +390,8 @@ async def stream_openai_response(
|
|
|
366
390
|
registered_sources = result_content.get("sources")
|
|
367
391
|
registered_references = result_content.get("references")
|
|
368
392
|
registered_flags = result_content.get("flags")
|
|
393
|
+
# Session naming
|
|
394
|
+
registered_session_name = result_content.get("session_name")
|
|
369
395
|
# Risk assessment fields
|
|
370
396
|
registered_risk_level = result_content.get("risk_level")
|
|
371
397
|
registered_risk_score = result_content.get("risk_score")
|
|
@@ -376,6 +402,7 @@ async def stream_openai_response(
|
|
|
376
402
|
|
|
377
403
|
logger.info(
|
|
378
404
|
f"📊 Metadata registered: confidence={registered_confidence}, "
|
|
405
|
+
f"session_name={registered_session_name}, "
|
|
379
406
|
f"risk_level={registered_risk_level}, sources={registered_sources}"
|
|
380
407
|
)
|
|
381
408
|
|
|
@@ -398,6 +425,7 @@ async def stream_openai_response(
|
|
|
398
425
|
in_reply_to=in_reply_to,
|
|
399
426
|
session_id=session_id,
|
|
400
427
|
agent_schema=agent_schema,
|
|
428
|
+
session_name=registered_session_name,
|
|
401
429
|
confidence=registered_confidence,
|
|
402
430
|
sources=registered_sources,
|
|
403
431
|
model_version=model,
|
|
@@ -406,6 +434,15 @@ async def stream_openai_response(
|
|
|
406
434
|
hidden=False,
|
|
407
435
|
))
|
|
408
436
|
|
|
437
|
+
# Capture tool call with result for persistence
|
|
438
|
+
# Special handling for register_metadata - always capture full data
|
|
439
|
+
if tool_calls_out is not None and tool_id in pending_tool_data:
|
|
440
|
+
tool_data = pending_tool_data[tool_id]
|
|
441
|
+
tool_data["result"] = result_content
|
|
442
|
+
tool_data["is_metadata"] = is_metadata_event
|
|
443
|
+
tool_calls_out.append(tool_data)
|
|
444
|
+
del pending_tool_data[tool_id]
|
|
445
|
+
|
|
409
446
|
if not is_metadata_event:
|
|
410
447
|
# Normal tool completion - emit ToolCallEvent
|
|
411
448
|
result_str = str(result_content)
|
|
@@ -528,6 +565,9 @@ async def stream_openai_response(
|
|
|
528
565
|
model_version=model,
|
|
529
566
|
latency_ms=latency_ms,
|
|
530
567
|
token_count=token_count,
|
|
568
|
+
# Include deterministic trace context captured from OTEL
|
|
569
|
+
trace_id=captured_trace_id,
|
|
570
|
+
span_id=captured_span_id,
|
|
531
571
|
))
|
|
532
572
|
|
|
533
573
|
# Mark all progress complete
|
|
@@ -699,9 +739,20 @@ async def stream_openai_response_with_save(
|
|
|
699
739
|
from ....services.session import SessionMessageStore
|
|
700
740
|
from ....settings import settings
|
|
701
741
|
|
|
742
|
+
# Pre-generate message_id so it can be sent in metadata event
|
|
743
|
+
# This allows frontend to use it for feedback before DB persistence
|
|
744
|
+
message_id = str(uuid.uuid4())
|
|
745
|
+
|
|
746
|
+
# Mutable container for capturing trace context from inside agent execution
|
|
747
|
+
# This is deterministic - captured from OTEL instrumentation, not AI-generated
|
|
748
|
+
trace_context: dict = {}
|
|
749
|
+
|
|
702
750
|
# Accumulate content during streaming
|
|
703
751
|
accumulated_content = []
|
|
704
752
|
|
|
753
|
+
# Capture tool calls for persistence (especially register_metadata)
|
|
754
|
+
tool_calls: list = []
|
|
755
|
+
|
|
705
756
|
async for chunk in stream_openai_response(
|
|
706
757
|
agent=agent,
|
|
707
758
|
prompt=prompt,
|
|
@@ -709,6 +760,9 @@ async def stream_openai_response_with_save(
|
|
|
709
760
|
request_id=request_id,
|
|
710
761
|
agent_schema=agent_schema,
|
|
711
762
|
session_id=session_id,
|
|
763
|
+
message_id=message_id,
|
|
764
|
+
trace_context_out=trace_context, # Pass container to capture trace IDs
|
|
765
|
+
tool_calls_out=tool_calls, # Capture tool calls for persistence
|
|
712
766
|
):
|
|
713
767
|
yield chunk
|
|
714
768
|
|
|
@@ -727,22 +781,75 @@ async def stream_openai_response_with_save(
|
|
|
727
781
|
except (json.JSONDecodeError, KeyError, IndexError):
|
|
728
782
|
pass # Skip non-JSON or malformed chunks
|
|
729
783
|
|
|
730
|
-
# After streaming completes, save
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
784
|
+
# After streaming completes, save tool calls and assistant response
|
|
785
|
+
# Note: All messages stored UNCOMPRESSED. Compression happens on reload.
|
|
786
|
+
if settings.postgres.enabled and session_id:
|
|
787
|
+
# Get captured trace context from container (deterministically captured inside agent execution)
|
|
788
|
+
captured_trace_id = trace_context.get("trace_id")
|
|
789
|
+
captured_span_id = trace_context.get("span_id")
|
|
790
|
+
timestamp = to_iso(utc_now())
|
|
791
|
+
|
|
792
|
+
messages_to_store = []
|
|
793
|
+
|
|
794
|
+
# First, store tool call messages (message_type: "tool")
|
|
795
|
+
for tool_call in tool_calls:
|
|
796
|
+
tool_message = {
|
|
797
|
+
"role": "tool",
|
|
798
|
+
"content": json.dumps(tool_call.get("result", {}), default=str),
|
|
799
|
+
"timestamp": timestamp,
|
|
800
|
+
"trace_id": captured_trace_id,
|
|
801
|
+
"span_id": captured_span_id,
|
|
802
|
+
# Store tool call details in a way that can be reconstructed
|
|
803
|
+
"tool_call_id": tool_call.get("tool_id"),
|
|
804
|
+
"tool_name": tool_call.get("tool_name"),
|
|
805
|
+
"tool_arguments": tool_call.get("arguments"),
|
|
806
|
+
}
|
|
807
|
+
messages_to_store.append(tool_message)
|
|
808
|
+
|
|
809
|
+
# Then store assistant text response (if any)
|
|
810
|
+
if accumulated_content:
|
|
811
|
+
full_content = "".join(accumulated_content)
|
|
812
|
+
assistant_message = {
|
|
813
|
+
"id": message_id, # Use pre-generated ID for consistency with metadata event
|
|
814
|
+
"role": "assistant",
|
|
815
|
+
"content": full_content,
|
|
816
|
+
"timestamp": timestamp,
|
|
817
|
+
"trace_id": captured_trace_id,
|
|
818
|
+
"span_id": captured_span_id,
|
|
819
|
+
}
|
|
820
|
+
messages_to_store.append(assistant_message)
|
|
821
|
+
|
|
822
|
+
if messages_to_store:
|
|
823
|
+
try:
|
|
824
|
+
store = SessionMessageStore(user_id=user_id or settings.test.effective_user_id)
|
|
825
|
+
await store.store_session_messages(
|
|
826
|
+
session_id=session_id,
|
|
827
|
+
messages=messages_to_store,
|
|
828
|
+
user_id=user_id,
|
|
829
|
+
compress=False, # Store uncompressed; compression happens on reload
|
|
830
|
+
)
|
|
831
|
+
logger.debug(
|
|
832
|
+
f"Saved {len(tool_calls)} tool calls and "
|
|
833
|
+
f"{'assistant response' if accumulated_content else 'no text'} "
|
|
834
|
+
f"to session {session_id}"
|
|
835
|
+
)
|
|
836
|
+
except Exception as e:
|
|
837
|
+
logger.error(f"Failed to save session messages: {e}", exc_info=True)
|
|
838
|
+
|
|
839
|
+
# Update session description with session_name (non-blocking, after all yields)
|
|
840
|
+
for tool_call in tool_calls:
|
|
841
|
+
if tool_call.get("tool_name") == "register_metadata" and tool_call.get("is_metadata"):
|
|
842
|
+
session_name = tool_call.get("arguments", {}).get("session_name")
|
|
843
|
+
if session_name:
|
|
844
|
+
try:
|
|
845
|
+
from ....models.entities import Session
|
|
846
|
+
from ....services.postgres import Repository
|
|
847
|
+
repo = Repository(Session, table_name="sessions")
|
|
848
|
+
session = await repo.get_by_id(session_id)
|
|
849
|
+
if session and session.description != session_name:
|
|
850
|
+
session.description = session_name
|
|
851
|
+
await repo.update(session)
|
|
852
|
+
logger.debug(f"Updated session {session_id} description to '{session_name}'")
|
|
853
|
+
except Exception as e:
|
|
854
|
+
logger.warning(f"Failed to update session description: {e}")
|
|
855
|
+
break
|
rem/api/routers/feedback.py
CHANGED
|
@@ -7,16 +7,64 @@ Endpoints:
|
|
|
7
7
|
POST /api/v1/messages/feedback - Submit feedback on a message
|
|
8
8
|
|
|
9
9
|
Trace Integration:
|
|
10
|
-
- Feedback
|
|
11
|
-
- 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)
|
|
12
60
|
"""
|
|
13
61
|
|
|
14
|
-
from fastapi import APIRouter, Header, HTTPException, Request
|
|
62
|
+
from fastapi import APIRouter, Header, HTTPException, Request, Response
|
|
15
63
|
from loguru import logger
|
|
16
64
|
from pydantic import BaseModel, Field
|
|
17
65
|
|
|
18
66
|
from ..deps import get_user_id_from_request
|
|
19
|
-
from ...models.entities import Feedback
|
|
67
|
+
from ...models.entities import Feedback
|
|
20
68
|
from ...services.postgres import Repository
|
|
21
69
|
from ...settings import settings
|
|
22
70
|
|
|
@@ -73,9 +121,10 @@ class FeedbackResponse(BaseModel):
|
|
|
73
121
|
# =============================================================================
|
|
74
122
|
|
|
75
123
|
|
|
76
|
-
@router.post("/messages/feedback", response_model=FeedbackResponse
|
|
124
|
+
@router.post("/messages/feedback", response_model=FeedbackResponse)
|
|
77
125
|
async def submit_feedback(
|
|
78
126
|
request: Request,
|
|
127
|
+
response: Response,
|
|
79
128
|
request_body: FeedbackCreateRequest,
|
|
80
129
|
x_tenant_id: str = Header(alias="X-Tenant-Id", default="default"),
|
|
81
130
|
) -> FeedbackResponse:
|
|
@@ -89,8 +138,12 @@ async def submit_feedback(
|
|
|
89
138
|
- Provided explicitly in the request
|
|
90
139
|
- Auto-resolved from the message if message_id is provided
|
|
91
140
|
|
|
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)
|
|
144
|
+
|
|
92
145
|
Returns:
|
|
93
|
-
Created feedback object
|
|
146
|
+
Created feedback object with phoenix_synced indicating sync status
|
|
94
147
|
"""
|
|
95
148
|
if not settings.postgres.enabled:
|
|
96
149
|
raise HTTPException(status_code=503, detail="Database not enabled")
|
|
@@ -102,11 +155,44 @@ async def submit_feedback(
|
|
|
102
155
|
span_id = request_body.span_id
|
|
103
156
|
|
|
104
157
|
if request_body.message_id and (not trace_id or not span_id):
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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")
|
|
110
196
|
|
|
111
197
|
feedback = Feedback(
|
|
112
198
|
session_id=request_body.session_id,
|
|
@@ -130,9 +216,43 @@ async def submit_feedback(
|
|
|
130
216
|
f"message={request_body.message_id}, rating={request_body.rating}"
|
|
131
217
|
)
|
|
132
218
|
|
|
133
|
-
#
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
136
256
|
|
|
137
257
|
return FeedbackResponse(
|
|
138
258
|
id=str(result.id),
|
rem/api/routers/messages.py
CHANGED
|
@@ -134,7 +134,6 @@ async def list_messages(
|
|
|
134
134
|
),
|
|
135
135
|
limit: int = Query(default=50, ge=1, le=100, description="Max results to return"),
|
|
136
136
|
offset: int = Query(default=0, ge=0, description="Offset for pagination"),
|
|
137
|
-
x_tenant_id: str = Header(alias="X-Tenant-Id", default="default"),
|
|
138
137
|
) -> MessageListResponse:
|
|
139
138
|
"""
|
|
140
139
|
List messages with optional filters.
|
|
@@ -158,15 +157,18 @@ async def list_messages(
|
|
|
158
157
|
|
|
159
158
|
repo = Repository(Message, table_name="messages")
|
|
160
159
|
|
|
160
|
+
# Get current user for logging
|
|
161
|
+
current_user = get_current_user(request)
|
|
162
|
+
jwt_user_id = current_user.get("id") if current_user else None
|
|
163
|
+
|
|
161
164
|
# If mine=true, force filter to current user's ID from JWT
|
|
162
165
|
effective_user_id = user_id
|
|
163
166
|
if mine:
|
|
164
|
-
current_user = get_current_user(request)
|
|
165
167
|
if current_user:
|
|
166
168
|
effective_user_id = current_user.get("id")
|
|
167
169
|
|
|
168
170
|
# Build user-scoped filters (admin can see all, regular users see only their own)
|
|
169
|
-
filters = await get_user_filter(request, x_user_id=effective_user_id
|
|
171
|
+
filters = await get_user_filter(request, x_user_id=effective_user_id)
|
|
170
172
|
|
|
171
173
|
# Apply optional filters
|
|
172
174
|
if session_id:
|
|
@@ -174,6 +176,13 @@ async def list_messages(
|
|
|
174
176
|
if message_type:
|
|
175
177
|
filters["message_type"] = message_type
|
|
176
178
|
|
|
179
|
+
# Log the query parameters for debugging
|
|
180
|
+
logger.debug(
|
|
181
|
+
f"[messages] Query: session_id={session_id} | "
|
|
182
|
+
f"jwt_user_id={jwt_user_id} | "
|
|
183
|
+
f"filters={filters}"
|
|
184
|
+
)
|
|
185
|
+
|
|
177
186
|
# For date filtering, we need custom SQL (not supported by basic Repository)
|
|
178
187
|
# For now, fetch all matching base filters and filter in Python
|
|
179
188
|
# TODO: Extend Repository to support date range filters
|
|
@@ -206,6 +215,12 @@ async def list_messages(
|
|
|
206
215
|
# Get total count for pagination info
|
|
207
216
|
total = await repo.count(filters)
|
|
208
217
|
|
|
218
|
+
# Log result count
|
|
219
|
+
logger.debug(
|
|
220
|
+
f"[messages] Result: returned={len(messages)} | total={total} | "
|
|
221
|
+
f"session_id={session_id}"
|
|
222
|
+
)
|
|
223
|
+
|
|
209
224
|
return MessageListResponse(data=messages, total=total, has_more=has_more)
|
|
210
225
|
|
|
211
226
|
|
|
@@ -213,7 +228,6 @@ async def list_messages(
|
|
|
213
228
|
async def get_message(
|
|
214
229
|
request: Request,
|
|
215
230
|
message_id: str,
|
|
216
|
-
x_tenant_id: str = Header(alias="X-Tenant-Id", default="default"),
|
|
217
231
|
) -> Message:
|
|
218
232
|
"""
|
|
219
233
|
Get a specific message by ID.
|
|
@@ -236,7 +250,7 @@ async def get_message(
|
|
|
236
250
|
raise HTTPException(status_code=503, detail="Database not enabled")
|
|
237
251
|
|
|
238
252
|
repo = Repository(Message, table_name="messages")
|
|
239
|
-
message = await repo.get_by_id(message_id
|
|
253
|
+
message = await repo.get_by_id(message_id)
|
|
240
254
|
|
|
241
255
|
if not message:
|
|
242
256
|
raise HTTPException(status_code=404, detail=f"Message '{message_id}' not found")
|
|
@@ -263,7 +277,6 @@ async def list_sessions(
|
|
|
263
277
|
mode: SessionMode | None = Query(default=None, description="Filter by session mode"),
|
|
264
278
|
page: int = Query(default=1, ge=1, description="Page number (1-indexed)"),
|
|
265
279
|
page_size: int = Query(default=50, ge=1, le=100, description="Number of results per page"),
|
|
266
|
-
x_tenant_id: str = Header(alias="X-Tenant-Id", default="default"),
|
|
267
280
|
) -> SessionsQueryResponse:
|
|
268
281
|
"""
|
|
269
282
|
List sessions with optional filters and page-based pagination.
|
|
@@ -288,7 +301,7 @@ async def list_sessions(
|
|
|
288
301
|
repo = Repository(Session, table_name="sessions")
|
|
289
302
|
|
|
290
303
|
# Build user-scoped filters (admin can see all, regular users see only their own)
|
|
291
|
-
filters = await get_user_filter(request, x_user_id=user_id
|
|
304
|
+
filters = await get_user_filter(request, x_user_id=user_id)
|
|
292
305
|
if mode:
|
|
293
306
|
filters["mode"] = mode.value
|
|
294
307
|
|
|
@@ -319,7 +332,6 @@ async def create_session(
|
|
|
319
332
|
request_body: SessionCreateRequest,
|
|
320
333
|
user: dict = Depends(require_admin),
|
|
321
334
|
x_user_id: str = Header(alias="X-User-Id", default="default"),
|
|
322
|
-
x_tenant_id: str = Header(alias="X-Tenant-Id", default="default"),
|
|
323
335
|
) -> Session:
|
|
324
336
|
"""
|
|
325
337
|
Create a new session.
|
|
@@ -334,7 +346,6 @@ async def create_session(
|
|
|
334
346
|
|
|
335
347
|
Headers:
|
|
336
348
|
- X-User-Id: User identifier (owner of the session)
|
|
337
|
-
- X-Tenant-Id: Tenant identifier
|
|
338
349
|
|
|
339
350
|
Returns:
|
|
340
351
|
Created session object
|
|
@@ -354,7 +365,7 @@ async def create_session(
|
|
|
354
365
|
prompt=request_body.prompt,
|
|
355
366
|
agent_schema_uri=request_body.agent_schema_uri,
|
|
356
367
|
user_id=effective_user_id,
|
|
357
|
-
tenant_id=
|
|
368
|
+
tenant_id="default", # tenant_id not used for filtering, set to default
|
|
358
369
|
)
|
|
359
370
|
|
|
360
371
|
repo = Repository(Session, table_name="sessions")
|
|
@@ -372,7 +383,6 @@ async def create_session(
|
|
|
372
383
|
async def get_session(
|
|
373
384
|
request: Request,
|
|
374
385
|
session_id: str,
|
|
375
|
-
x_tenant_id: str = Header(alias="X-Tenant-Id", default="default"),
|
|
376
386
|
) -> Session:
|
|
377
387
|
"""
|
|
378
388
|
Get a specific session by ID.
|
|
@@ -395,11 +405,11 @@ async def get_session(
|
|
|
395
405
|
raise HTTPException(status_code=503, detail="Database not enabled")
|
|
396
406
|
|
|
397
407
|
repo = Repository(Session, table_name="sessions")
|
|
398
|
-
session = await repo.get_by_id(session_id
|
|
408
|
+
session = await repo.get_by_id(session_id)
|
|
399
409
|
|
|
400
410
|
if not session:
|
|
401
411
|
# Try finding by name
|
|
402
|
-
sessions = await repo.find({"name": session_id
|
|
412
|
+
sessions = await repo.find({"name": session_id}, limit=1)
|
|
403
413
|
if sessions:
|
|
404
414
|
session = sessions[0]
|
|
405
415
|
else:
|
|
@@ -420,7 +430,6 @@ async def update_session(
|
|
|
420
430
|
request: Request,
|
|
421
431
|
session_id: str,
|
|
422
432
|
request_body: SessionUpdateRequest,
|
|
423
|
-
x_tenant_id: str = Header(alias="X-Tenant-Id", default="default"),
|
|
424
433
|
) -> Session:
|
|
425
434
|
"""
|
|
426
435
|
Update an existing session.
|
|
@@ -450,7 +459,7 @@ async def update_session(
|
|
|
450
459
|
raise HTTPException(status_code=503, detail="Database not enabled")
|
|
451
460
|
|
|
452
461
|
repo = Repository(Session, table_name="sessions")
|
|
453
|
-
session = await repo.get_by_id(session_id
|
|
462
|
+
session = await repo.get_by_id(session_id)
|
|
454
463
|
|
|
455
464
|
if not session:
|
|
456
465
|
raise HTTPException(status_code=404, detail=f"Session '{session_id}' not found")
|
rem/api/routers/query.py
CHANGED
|
@@ -216,7 +216,7 @@ class QueryResponse(BaseModel):
|
|
|
216
216
|
@router.post("/query", response_model=QueryResponse)
|
|
217
217
|
async def execute_query(
|
|
218
218
|
request: QueryRequest,
|
|
219
|
-
x_user_id: str = Header(
|
|
219
|
+
x_user_id: str | None = Header(default=None, description="User ID for query isolation (optional, uses default if not provided)"),
|
|
220
220
|
) -> QueryResponse:
|
|
221
221
|
"""
|
|
222
222
|
Execute a REM query.
|
|
@@ -265,6 +265,9 @@ async def execute_query(
|
|
|
265
265
|
|
|
266
266
|
rem_service = RemService(db)
|
|
267
267
|
|
|
268
|
+
# Use effective_user_id from settings if not provided
|
|
269
|
+
effective_user_id = x_user_id or settings.test.effective_user_id
|
|
270
|
+
|
|
268
271
|
if request.mode == QueryMode.STAGED_PLAN:
|
|
269
272
|
# Staged plan mode - execute multi-stage query plan
|
|
270
273
|
# TODO: Implementation pending in RemService.execute_staged_plan()
|
|
@@ -295,7 +298,7 @@ async def execute_query(
|
|
|
295
298
|
|
|
296
299
|
result = await rem_service.ask_rem(
|
|
297
300
|
natural_query=request.query,
|
|
298
|
-
tenant_id=
|
|
301
|
+
tenant_id=effective_user_id,
|
|
299
302
|
llm_model=request.model,
|
|
300
303
|
plan_mode=request.plan_only,
|
|
301
304
|
)
|
|
@@ -333,7 +336,7 @@ async def execute_query(
|
|
|
333
336
|
rem_query = RemQuery.model_validate({
|
|
334
337
|
"query_type": query_type,
|
|
335
338
|
"parameters": parameters,
|
|
336
|
-
"user_id":
|
|
339
|
+
"user_id": effective_user_id,
|
|
337
340
|
})
|
|
338
341
|
|
|
339
342
|
result = await rem_service.execute_query(rem_query)
|
rem/auth/__init__.py
CHANGED
|
@@ -1,26 +1,36 @@
|
|
|
1
1
|
"""
|
|
2
2
|
REM Authentication Module.
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
Authentication with support for:
|
|
5
|
+
- Email passwordless login (verification codes)
|
|
5
6
|
- Google OAuth
|
|
6
7
|
- Microsoft Entra ID (Azure AD) OIDC
|
|
7
8
|
- Custom OIDC providers
|
|
8
9
|
|
|
9
10
|
Design Pattern:
|
|
10
11
|
- Provider-agnostic base classes
|
|
11
|
-
- PKCE (Proof Key for Code Exchange) for
|
|
12
|
+
- PKCE (Proof Key for Code Exchange) for OAuth flows
|
|
12
13
|
- State parameter for CSRF protection
|
|
13
14
|
- Nonce for ID token replay protection
|
|
14
15
|
- Token validation with JWKS
|
|
15
|
-
- Clean separation: providers/ for
|
|
16
|
+
- Clean separation: providers/ for auth logic, middleware.py for FastAPI integration
|
|
17
|
+
|
|
18
|
+
Email Auth Flow:
|
|
19
|
+
1. POST /api/auth/email/send-code with {email}
|
|
20
|
+
2. User receives code via email
|
|
21
|
+
3. POST /api/auth/email/verify with {email, code}
|
|
22
|
+
4. Session created, user authenticated
|
|
16
23
|
"""
|
|
17
24
|
|
|
18
25
|
from .providers.base import OAuthProvider
|
|
26
|
+
from .providers.email import EmailAuthProvider, EmailAuthResult
|
|
19
27
|
from .providers.google import GoogleOAuthProvider
|
|
20
28
|
from .providers.microsoft import MicrosoftOAuthProvider
|
|
21
29
|
|
|
22
30
|
__all__ = [
|
|
23
31
|
"OAuthProvider",
|
|
32
|
+
"EmailAuthProvider",
|
|
33
|
+
"EmailAuthResult",
|
|
24
34
|
"GoogleOAuthProvider",
|
|
25
35
|
"MicrosoftOAuthProvider",
|
|
26
36
|
]
|