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.
- rem/agentic/context.py +28 -24
- rem/agentic/mcp/tool_wrapper.py +29 -3
- rem/agentic/otel/setup.py +92 -4
- rem/agentic/providers/pydantic_ai.py +88 -18
- rem/agentic/schema.py +358 -21
- rem/agentic/tools/rem_tools.py +3 -3
- rem/api/main.py +85 -16
- rem/api/mcp_router/resources.py +1 -1
- rem/api/mcp_router/server.py +18 -4
- rem/api/mcp_router/tools.py +383 -16
- rem/api/routers/admin.py +218 -1
- rem/api/routers/chat/completions.py +30 -3
- rem/api/routers/chat/streaming.py +143 -3
- rem/api/routers/feedback.py +12 -319
- rem/api/routers/query.py +360 -0
- rem/api/routers/shared_sessions.py +13 -13
- rem/cli/commands/README.md +237 -64
- rem/cli/commands/cluster.py +1300 -0
- rem/cli/commands/configure.py +1 -3
- rem/cli/commands/db.py +354 -143
- rem/cli/commands/process.py +14 -8
- rem/cli/commands/schema.py +92 -45
- rem/cli/main.py +27 -6
- rem/models/core/rem_query.py +5 -2
- rem/models/entities/shared_session.py +2 -28
- rem/registry.py +10 -4
- rem/services/content/service.py +30 -8
- rem/services/embeddings/api.py +4 -4
- rem/services/embeddings/worker.py +16 -16
- rem/services/postgres/README.md +151 -26
- rem/services/postgres/__init__.py +2 -1
- rem/services/postgres/diff_service.py +531 -0
- rem/services/postgres/pydantic_to_sqlalchemy.py +427 -129
- rem/services/postgres/schema_generator.py +205 -4
- rem/services/postgres/service.py +6 -6
- rem/services/rem/parser.py +44 -9
- rem/services/rem/service.py +36 -2
- rem/services/session/reload.py +1 -1
- rem/settings.py +56 -7
- rem/sql/background_indexes.sql +19 -24
- rem/sql/migrations/001_install.sql +252 -69
- rem/sql/migrations/002_install_models.sql +2171 -593
- rem/sql/migrations/003_optional_extensions.sql +326 -0
- rem/sql/migrations/004_cache_system.sql +548 -0
- rem/utils/__init__.py +18 -0
- rem/utils/date_utils.py +2 -2
- rem/utils/schema_loader.py +17 -13
- rem/utils/sql_paths.py +146 -0
- rem/workers/__init__.py +2 -1
- rem/workers/unlogged_maintainer.py +463 -0
- {remdb-0.3.103.dist-info → remdb-0.3.118.dist-info}/METADATA +149 -76
- {remdb-0.3.103.dist-info → remdb-0.3.118.dist-info}/RECORD +54 -48
- rem/sql/migrations/003_seed_default_user.sql +0 -48
- {remdb-0.3.103.dist-info → remdb-0.3.118.dist-info}/WHEEL +0 -0
- {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.
|
|
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
|
-
|
|
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)
|