remdb 0.3.103__py3-none-any.whl → 0.3.118__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 (55) hide show
  1. rem/agentic/context.py +28 -24
  2. rem/agentic/mcp/tool_wrapper.py +29 -3
  3. rem/agentic/otel/setup.py +92 -4
  4. rem/agentic/providers/pydantic_ai.py +88 -18
  5. rem/agentic/schema.py +358 -21
  6. rem/agentic/tools/rem_tools.py +3 -3
  7. rem/api/main.py +85 -16
  8. rem/api/mcp_router/resources.py +1 -1
  9. rem/api/mcp_router/server.py +18 -4
  10. rem/api/mcp_router/tools.py +383 -16
  11. rem/api/routers/admin.py +218 -1
  12. rem/api/routers/chat/completions.py +30 -3
  13. rem/api/routers/chat/streaming.py +143 -3
  14. rem/api/routers/feedback.py +12 -319
  15. rem/api/routers/query.py +360 -0
  16. rem/api/routers/shared_sessions.py +13 -13
  17. rem/cli/commands/README.md +237 -64
  18. rem/cli/commands/cluster.py +1300 -0
  19. rem/cli/commands/configure.py +1 -3
  20. rem/cli/commands/db.py +354 -143
  21. rem/cli/commands/process.py +14 -8
  22. rem/cli/commands/schema.py +92 -45
  23. rem/cli/main.py +27 -6
  24. rem/models/core/rem_query.py +5 -2
  25. rem/models/entities/shared_session.py +2 -28
  26. rem/registry.py +10 -4
  27. rem/services/content/service.py +30 -8
  28. rem/services/embeddings/api.py +4 -4
  29. rem/services/embeddings/worker.py +16 -16
  30. rem/services/postgres/README.md +151 -26
  31. rem/services/postgres/__init__.py +2 -1
  32. rem/services/postgres/diff_service.py +531 -0
  33. rem/services/postgres/pydantic_to_sqlalchemy.py +427 -129
  34. rem/services/postgres/schema_generator.py +205 -4
  35. rem/services/postgres/service.py +6 -6
  36. rem/services/rem/parser.py +44 -9
  37. rem/services/rem/service.py +36 -2
  38. rem/services/session/reload.py +1 -1
  39. rem/settings.py +56 -7
  40. rem/sql/background_indexes.sql +19 -24
  41. rem/sql/migrations/001_install.sql +252 -69
  42. rem/sql/migrations/002_install_models.sql +2171 -593
  43. rem/sql/migrations/003_optional_extensions.sql +326 -0
  44. rem/sql/migrations/004_cache_system.sql +548 -0
  45. rem/utils/__init__.py +18 -0
  46. rem/utils/date_utils.py +2 -2
  47. rem/utils/schema_loader.py +17 -13
  48. rem/utils/sql_paths.py +146 -0
  49. rem/workers/__init__.py +2 -1
  50. rem/workers/unlogged_maintainer.py +463 -0
  51. {remdb-0.3.103.dist-info → remdb-0.3.118.dist-info}/METADATA +149 -76
  52. {remdb-0.3.103.dist-info → remdb-0.3.118.dist-info}/RECORD +54 -48
  53. rem/sql/migrations/003_seed_default_user.sql +0 -48
  54. {remdb-0.3.103.dist-info → remdb-0.3.118.dist-info}/WHEEL +0 -0
  55. {remdb-0.3.103.dist-info → remdb-0.3.118.dist-info}/entry_points.txt +0 -0
rem/api/routers/admin.py CHANGED
@@ -9,6 +9,9 @@ Endpoints:
9
9
  GET /api/admin/messages - List all messages across users (admin only)
10
10
  GET /api/admin/stats - System statistics (admin only)
11
11
 
12
+ Internal Endpoints (hidden from Swagger, secret-protected):
13
+ POST /api/admin/internal/rebuild-kv - Trigger kv_store rebuild (called by pg_net)
14
+
12
15
  All endpoints require:
13
16
  1. Authentication (valid session)
14
17
  2. Admin role in user's roles list
@@ -17,11 +20,14 @@ Design Pattern:
17
20
  - Uses require_admin dependency for role enforcement
18
21
  - Cross-tenant queries (no user_id filtering)
19
22
  - Audit logging for admin actions
23
+ - Internal endpoints use X-Internal-Secret header for authentication
20
24
  """
21
25
 
26
+ import asyncio
27
+ import threading
22
28
  from typing import Literal
23
29
 
24
- from fastapi import APIRouter, Depends, HTTPException, Query
30
+ from fastapi import APIRouter, Depends, Header, HTTPException, Query, BackgroundTasks
25
31
  from loguru import logger
26
32
  from pydantic import BaseModel
27
33
 
@@ -32,6 +38,12 @@ from ...settings import settings
32
38
 
33
39
  router = APIRouter(prefix="/api/admin", tags=["admin"])
34
40
 
41
+ # =============================================================================
42
+ # Internal Router (hidden from Swagger)
43
+ # =============================================================================
44
+
45
+ internal_router = APIRouter(prefix="/internal", include_in_schema=False)
46
+
35
47
 
36
48
  # =============================================================================
37
49
  # Response Models
@@ -275,3 +287,208 @@ async def get_system_stats(
275
287
  active_sessions_24h=0, # TODO: implement
276
288
  messages_24h=0, # TODO: implement
277
289
  )
290
+
291
+
292
+ # =============================================================================
293
+ # Internal Endpoints (hidden from Swagger, secret-protected)
294
+ # =============================================================================
295
+
296
+
297
+ class RebuildKVRequest(BaseModel):
298
+ """Request body for kv_store rebuild trigger."""
299
+
300
+ user_id: str | None = None
301
+ triggered_by: str = "api"
302
+ timestamp: str | None = None
303
+
304
+
305
+ class RebuildKVResponse(BaseModel):
306
+ """Response from kv_store rebuild trigger."""
307
+
308
+ status: Literal["submitted", "started", "skipped"]
309
+ message: str
310
+ job_method: str | None = None # "sqs" or "thread"
311
+
312
+
313
+ async def _get_internal_secret() -> str | None:
314
+ """
315
+ Get the internal API secret from cache_system_state table.
316
+
317
+ Returns None if the table doesn't exist or secret not found.
318
+ """
319
+ from ...services.postgres import get_postgres_service
320
+
321
+ db = get_postgres_service()
322
+ if not db:
323
+ return None
324
+
325
+ try:
326
+ await db.connect()
327
+ secret = await db.fetchval("SELECT rem_get_cache_api_secret()")
328
+ return secret
329
+ except Exception as e:
330
+ logger.warning(f"Could not get internal API secret: {e}")
331
+ return None
332
+ finally:
333
+ await db.disconnect()
334
+
335
+
336
+ async def _validate_internal_secret(x_internal_secret: str | None = Header(None)):
337
+ """
338
+ Dependency to validate the X-Internal-Secret header.
339
+
340
+ Raises 401 if secret is missing or invalid.
341
+ """
342
+ if not x_internal_secret:
343
+ logger.warning("Internal endpoint called without X-Internal-Secret header")
344
+ raise HTTPException(status_code=401, detail="Missing X-Internal-Secret header")
345
+
346
+ expected_secret = await _get_internal_secret()
347
+ if not expected_secret:
348
+ logger.error("Could not retrieve internal secret from database")
349
+ raise HTTPException(status_code=503, detail="Internal secret not configured")
350
+
351
+ if x_internal_secret != expected_secret:
352
+ logger.warning("Internal endpoint called with invalid secret")
353
+ raise HTTPException(status_code=401, detail="Invalid X-Internal-Secret")
354
+
355
+ return True
356
+
357
+
358
+ def _run_rebuild_in_thread():
359
+ """
360
+ Run the kv_store rebuild in a background thread.
361
+
362
+ This is the fallback when SQS is not available.
363
+ """
364
+
365
+ def rebuild_task():
366
+ """Thread target function."""
367
+ import asyncio
368
+ from ...workers.unlogged_maintainer import UnloggedMaintainer
369
+
370
+ async def _run():
371
+ maintainer = UnloggedMaintainer()
372
+ if not maintainer.db:
373
+ logger.error("Database not configured, cannot rebuild")
374
+ return
375
+ try:
376
+ await maintainer.db.connect()
377
+ await maintainer.rebuild_with_lock()
378
+ except Exception as e:
379
+ logger.error(f"Background rebuild failed: {e}")
380
+ finally:
381
+ await maintainer.db.disconnect()
382
+
383
+ # Create new event loop for this thread
384
+ loop = asyncio.new_event_loop()
385
+ asyncio.set_event_loop(loop)
386
+ try:
387
+ loop.run_until_complete(_run())
388
+ finally:
389
+ loop.close()
390
+
391
+ thread = threading.Thread(target=rebuild_task, name="kv-rebuild-worker")
392
+ thread.daemon = True
393
+ thread.start()
394
+ logger.info(f"Started background rebuild thread: {thread.name}")
395
+
396
+
397
+ def _submit_sqs_rebuild_job_sync(request: RebuildKVRequest) -> bool:
398
+ """
399
+ Submit rebuild job to SQS queue (synchronous).
400
+
401
+ Returns True if job was submitted, False if SQS unavailable.
402
+ """
403
+ import json
404
+
405
+ import boto3
406
+ from botocore.exceptions import ClientError
407
+
408
+ if not settings.sqs.queue_url:
409
+ logger.debug("SQS queue URL not configured, cannot submit SQS job")
410
+ return False
411
+
412
+ try:
413
+ sqs = boto3.client("sqs", region_name=settings.sqs.region)
414
+
415
+ message_body = {
416
+ "action": "rebuild_kv_store",
417
+ "user_id": request.user_id,
418
+ "triggered_by": request.triggered_by,
419
+ "timestamp": request.timestamp,
420
+ }
421
+
422
+ response = sqs.send_message(
423
+ QueueUrl=settings.sqs.queue_url,
424
+ MessageBody=json.dumps(message_body),
425
+ MessageAttributes={
426
+ "action": {"DataType": "String", "StringValue": "rebuild_kv_store"},
427
+ },
428
+ )
429
+
430
+ message_id = response.get("MessageId")
431
+ logger.info(f"Submitted rebuild job to SQS: {message_id}")
432
+ return True
433
+
434
+ except ClientError as e:
435
+ logger.warning(f"Failed to submit SQS job: {e}")
436
+ return False
437
+ except Exception as e:
438
+ logger.warning(f"SQS submission error: {e}")
439
+ return False
440
+
441
+
442
+ async def _submit_sqs_rebuild_job(request: RebuildKVRequest) -> bool:
443
+ """
444
+ Submit rebuild job to SQS queue (async wrapper).
445
+
446
+ Runs boto3 call in thread pool to avoid blocking event loop.
447
+ """
448
+ import asyncio
449
+
450
+ return await asyncio.to_thread(_submit_sqs_rebuild_job_sync, request)
451
+
452
+
453
+ @internal_router.post("/rebuild-kv", response_model=RebuildKVResponse)
454
+ async def trigger_kv_rebuild(
455
+ request: RebuildKVRequest,
456
+ _: bool = Depends(_validate_internal_secret),
457
+ ) -> RebuildKVResponse:
458
+ """
459
+ Trigger kv_store rebuild (internal endpoint, not shown in Swagger).
460
+
461
+ Called by pg_net from PostgreSQL when self-healing detects empty cache.
462
+ Authentication: X-Internal-Secret header must match secret in cache_system_state.
463
+
464
+ Priority:
465
+ 1. Submit job to SQS (if configured) - scales with KEDA
466
+ 2. Fallback to background thread - runs in same process
467
+
468
+ Note: This endpoint returns immediately. Rebuild happens asynchronously.
469
+ """
470
+ logger.info(
471
+ f"Rebuild kv_store requested by {request.triggered_by} "
472
+ f"(user_id={request.user_id})"
473
+ )
474
+
475
+ # Try SQS first
476
+ if await _submit_sqs_rebuild_job(request):
477
+ return RebuildKVResponse(
478
+ status="submitted",
479
+ message="Rebuild job submitted to SQS queue",
480
+ job_method="sqs",
481
+ )
482
+
483
+ # Fallback to background thread
484
+ _run_rebuild_in_thread()
485
+
486
+ return RebuildKVResponse(
487
+ status="started",
488
+ message="Rebuild started in background thread (SQS unavailable)",
489
+ job_method="thread",
490
+ )
491
+
492
+
493
+ # Include internal router in main router
494
+ router.include_router(internal_router)
@@ -79,7 +79,7 @@ from .models import (
79
79
  ChatCompletionUsage,
80
80
  ChatMessage,
81
81
  )
82
- from .streaming import stream_openai_response, stream_simulator_response
82
+ from .streaming import stream_openai_response, stream_openai_response_with_save, stream_simulator_response
83
83
 
84
84
  router = APIRouter(prefix="/api/v1", tags=["chat"])
85
85
 
@@ -256,7 +256,7 @@ async def chat_completions(body: ChatCompletionRequest, request: Request):
256
256
  detail=f"Agent schema '{schema_name}' not found and default schema unavailable",
257
257
  )
258
258
 
259
- logger.info(f"Using agent schema: {schema_name}, model: {body.model}")
259
+ logger.debug(f"Using agent schema: {schema_name}, model: {body.model}")
260
260
 
261
261
  # Check for audio input
262
262
  is_audio = request.headers.get("x-chat-is-audio", "").lower() == "true"
@@ -317,8 +317,35 @@ async def chat_completions(body: ChatCompletionRequest, request: Request):
317
317
 
318
318
  # Streaming mode
319
319
  if body.stream:
320
+ # Save user message before streaming starts
321
+ if settings.postgres.enabled and context.session_id:
322
+ user_message = {
323
+ "role": "user",
324
+ "content": body.messages[-1].content if body.messages else "",
325
+ "timestamp": datetime.utcnow().isoformat(),
326
+ }
327
+ try:
328
+ store = SessionMessageStore(user_id=context.user_id or settings.test.effective_user_id)
329
+ await store.store_session_messages(
330
+ session_id=context.session_id,
331
+ messages=[user_message],
332
+ user_id=context.user_id,
333
+ compress=False, # User messages are typically short
334
+ )
335
+ logger.debug(f"Saved user message to session {context.session_id}")
336
+ except Exception as e:
337
+ logger.error(f"Failed to save user message: {e}", exc_info=True)
338
+
320
339
  return StreamingResponse(
321
- stream_openai_response(agent, prompt, body.model, request_id),
340
+ stream_openai_response_with_save(
341
+ agent=agent,
342
+ prompt=prompt,
343
+ model=body.model,
344
+ request_id=request_id,
345
+ agent_schema=schema_name,
346
+ session_id=context.session_id,
347
+ user_id=context.user_id,
348
+ ),
322
349
  media_type="text/event-stream",
323
350
  headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
324
351
  )
@@ -71,6 +71,8 @@ async def stream_openai_response(
71
71
  message_id: str | None = None,
72
72
  in_reply_to: str | None = None,
73
73
  session_id: str | None = None,
74
+ # Agent info for metadata
75
+ agent_schema: str | None = None,
74
76
  ) -> AsyncGenerator[str, None]:
75
77
  """
76
78
  Stream Pydantic AI agent responses with rich SSE events.
@@ -258,8 +260,6 @@ async def stream_openai_response(
258
260
  # Queue for completion matching (FIFO)
259
261
  pending_tool_completions.append((tool_name, tool_id))
260
262
 
261
- logger.info(f"🔧 {tool_name}")
262
-
263
263
  # Emit tool_call SSE event (started)
264
264
  # Try to get arguments as dict
265
265
  args_dict = None
@@ -269,6 +269,18 @@ async def stream_openai_response(
269
269
  elif isinstance(event.part.args, dict):
270
270
  args_dict = event.part.args
271
271
 
272
+ # Log tool call with key parameters
273
+ if args_dict and tool_name == "search_rem":
274
+ query_type = args_dict.get("query_type", "?")
275
+ limit = args_dict.get("limit", 20)
276
+ table = args_dict.get("table", "")
277
+ query_text = args_dict.get("query_text", args_dict.get("entity_key", ""))
278
+ if query_text and len(query_text) > 50:
279
+ query_text = query_text[:50] + "..."
280
+ logger.info(f"🔧 {tool_name} {query_type.upper()} '{query_text}' table={table} limit={limit}")
281
+ else:
282
+ logger.info(f"🔧 {tool_name}")
283
+
272
284
  yield format_sse_event(ToolCallEvent(
273
285
  tool_name=tool_name,
274
286
  tool_id=tool_id,
@@ -354,21 +366,43 @@ async def stream_openai_response(
354
366
  registered_sources = result_content.get("sources")
355
367
  registered_references = result_content.get("references")
356
368
  registered_flags = result_content.get("flags")
369
+ # Risk assessment fields
370
+ registered_risk_level = result_content.get("risk_level")
371
+ registered_risk_score = result_content.get("risk_score")
372
+ registered_risk_reasoning = result_content.get("risk_reasoning")
373
+ registered_recommended_action = result_content.get("recommended_action")
374
+ # Extra fields
375
+ registered_extra = result_content.get("extra")
357
376
 
358
377
  logger.info(
359
378
  f"📊 Metadata registered: confidence={registered_confidence}, "
360
- f"sources={registered_sources}"
379
+ f"risk_level={registered_risk_level}, sources={registered_sources}"
361
380
  )
362
381
 
382
+ # Build extra dict with risk fields and any custom extras
383
+ extra_data = {}
384
+ if registered_risk_level is not None:
385
+ extra_data["risk_level"] = registered_risk_level
386
+ if registered_risk_score is not None:
387
+ extra_data["risk_score"] = registered_risk_score
388
+ if registered_risk_reasoning is not None:
389
+ extra_data["risk_reasoning"] = registered_risk_reasoning
390
+ if registered_recommended_action is not None:
391
+ extra_data["recommended_action"] = registered_recommended_action
392
+ if registered_extra:
393
+ extra_data.update(registered_extra)
394
+
363
395
  # Emit metadata event immediately
364
396
  yield format_sse_event(MetadataEvent(
365
397
  message_id=message_id,
366
398
  in_reply_to=in_reply_to,
367
399
  session_id=session_id,
400
+ agent_schema=agent_schema,
368
401
  confidence=registered_confidence,
369
402
  sources=registered_sources,
370
403
  model_version=model,
371
404
  flags=registered_flags,
405
+ extra=extra_data if extra_data else None,
372
406
  hidden=False,
373
407
  ))
374
408
 
@@ -377,6 +411,31 @@ async def stream_openai_response(
377
411
  result_str = str(result_content)
378
412
  result_summary = result_str[:200] + "..." if len(result_str) > 200 else result_str
379
413
 
414
+ # Log result count for search_rem
415
+ if tool_name == "search_rem" and isinstance(result_content, dict):
416
+ results = result_content.get("results", {})
417
+ # Handle nested result structure: results may be a dict with 'results' list and 'count'
418
+ if isinstance(results, dict):
419
+ count = results.get("count", len(results.get("results", [])))
420
+ query_type = results.get("query_type", "?")
421
+ query_text = results.get("query_text", results.get("key", ""))
422
+ table = results.get("table_name", "")
423
+ elif isinstance(results, list):
424
+ count = len(results)
425
+ query_type = "?"
426
+ query_text = ""
427
+ table = ""
428
+ else:
429
+ count = "?"
430
+ query_type = "?"
431
+ query_text = ""
432
+ table = ""
433
+ status = result_content.get("status", "unknown")
434
+ # Truncate query text for logging
435
+ if query_text and len(str(query_text)) > 40:
436
+ query_text = str(query_text)[:40] + "..."
437
+ logger.info(f" ↳ {tool_name} {query_type} '{query_text}' table={table} → {count} results")
438
+
380
439
  yield format_sse_event(ToolCallEvent(
381
440
  tool_name=tool_name,
382
441
  tool_id=tool_id,
@@ -464,6 +523,7 @@ async def stream_openai_response(
464
523
  message_id=message_id,
465
524
  in_reply_to=in_reply_to,
466
525
  session_id=session_id,
526
+ agent_schema=agent_schema,
467
527
  confidence=1.0, # Default to 100% confidence
468
528
  model_version=model,
469
529
  latency_ms=latency_ms,
@@ -606,3 +666,83 @@ async def stream_minimal_simulator(
606
666
  # Simulator now yields SSE-formatted strings directly (OpenAI-compatible)
607
667
  async for sse_string in stream_minimal_demo(content=content, delay_ms=delay_ms):
608
668
  yield sse_string
669
+
670
+
671
+ async def stream_openai_response_with_save(
672
+ agent: Agent,
673
+ prompt: str,
674
+ model: str,
675
+ request_id: str | None = None,
676
+ agent_schema: str | None = None,
677
+ session_id: str | None = None,
678
+ user_id: str | None = None,
679
+ ) -> AsyncGenerator[str, None]:
680
+ """
681
+ Wrapper around stream_openai_response that saves the assistant response after streaming.
682
+
683
+ This accumulates all text content during streaming and saves it to the database
684
+ after the stream completes.
685
+
686
+ Args:
687
+ agent: Pydantic AI agent instance
688
+ prompt: User prompt
689
+ model: Model name
690
+ request_id: Optional request ID
691
+ agent_schema: Agent schema name
692
+ session_id: Session ID for message storage
693
+ user_id: User ID for message storage
694
+
695
+ Yields:
696
+ SSE-formatted strings
697
+ """
698
+ from ....utils.date_utils import utc_now, to_iso
699
+ from ....services.session import SessionMessageStore
700
+ from ....settings import settings
701
+
702
+ # Accumulate content during streaming
703
+ accumulated_content = []
704
+
705
+ async for chunk in stream_openai_response(
706
+ agent=agent,
707
+ prompt=prompt,
708
+ model=model,
709
+ request_id=request_id,
710
+ agent_schema=agent_schema,
711
+ session_id=session_id,
712
+ ):
713
+ yield chunk
714
+
715
+ # Extract text content from OpenAI-format chunks
716
+ # Format: data: {"choices": [{"delta": {"content": "..."}}]}
717
+ if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
718
+ try:
719
+ data_str = chunk[6:].strip() # Remove "data: " prefix
720
+ if data_str:
721
+ data = json.loads(data_str)
722
+ if "choices" in data and data["choices"]:
723
+ delta = data["choices"][0].get("delta", {})
724
+ content = delta.get("content")
725
+ if content:
726
+ accumulated_content.append(content)
727
+ except (json.JSONDecodeError, KeyError, IndexError):
728
+ pass # Skip non-JSON or malformed chunks
729
+
730
+ # After streaming completes, save the assistant response
731
+ if settings.postgres.enabled and session_id and accumulated_content:
732
+ full_content = "".join(accumulated_content)
733
+ assistant_message = {
734
+ "role": "assistant",
735
+ "content": full_content,
736
+ "timestamp": to_iso(utc_now()),
737
+ }
738
+ try:
739
+ store = SessionMessageStore(user_id=user_id or settings.test.effective_user_id)
740
+ await store.store_session_messages(
741
+ session_id=session_id,
742
+ messages=[assistant_message],
743
+ user_id=user_id,
744
+ compress=True, # Compress long assistant responses
745
+ )
746
+ logger.debug(f"Saved assistant response to session {session_id} ({len(full_content)} chars)")
747
+ except Exception as e:
748
+ logger.error(f"Failed to save assistant response: {e}", exc_info=True)