OpenRAG 0.6.0.dev23__py3-none-any.whl → 0.7.0.dev1__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.
- agent.py +334 -157
- api/chat.py +60 -31
- api/connectors.py +292 -279
- api/documents.py +2 -52
- api/files.py +60 -26
- api/health.py +68 -1
- api/ingest_preview.py +33 -0
- api/langflow_files.py +2 -18
- api/models.py +55 -4
- api/provider_health.py +6 -6
- api/provider_validation.py +702 -158
- api/schemas/status.py +75 -0
- api/settings/endpoints.py +176 -48
- api/settings/helpers.py +28 -10
- api/settings/langflow_sync.py +149 -21
- api/settings/models.py +34 -0
- api/status_utils.py +30 -0
- api/upload.py +4 -0
- api/v1/files.py +42 -0
- api/v1/status.py +36 -0
- api/v2/__init__.py +5 -0
- api/v2/files.py +79 -0
- app/container.py +8 -0
- app/factory.py +11 -1
- app/lifespan.py +19 -18
- app/routes/__init__.py +2 -0
- app/routes/internal.py +50 -3
- app/routes/public_v1.py +31 -0
- app/routes/public_v2.py +22 -0
- auth/request_identity.py +44 -64
- config/config_manager.py +33 -6
- config/embedding_constants.py +30 -1
- config/legacy_prompts.py +8 -0
- config/model_constants.py +5 -27
- config/settings.py +221 -90
- connectors/aws_s3/connector.py +1 -0
- connectors/base.py +57 -0
- connectors/chunk_cleanup.py +110 -0
- connectors/service.py +13 -195
- dependencies.py +10 -0
- mcp_http/server.py +28 -3
- models/processors.py +177 -168
- models/tasks.py +0 -1
- {openrag-0.6.0.dev23.dist-info → openrag-0.7.0.dev1.dist-info}/METADATA +1 -1
- {openrag-0.6.0.dev23.dist-info → openrag-0.7.0.dev1.dist-info}/RECORD +91 -81
- {openrag-0.6.0.dev23.dist-info → openrag-0.7.0.dev1.dist-info}/WHEEL +1 -1
- services/auth_service.py +1 -1
- services/chat_service.py +156 -41
- services/component_logs.py +164 -0
- services/connector_access_service.py +6 -6
- services/dls_principal_service.py +15 -12
- services/docling_service.py +133 -19
- services/file_service.py +20 -15
- services/file_service_v2.py +520 -0
- services/flows_service.py +166 -133
- services/ingest_preview_service.py +132 -2
- services/knowledge_filter_service.py +3 -7
- services/langflow_file_service.py +142 -41
- services/langflow_history_service.py +90 -62
- services/models_service.py +224 -99
- services/monitor_service.py +2 -2
- services/search_service.py +186 -65
- services/startup_orchestrator.py +42 -2
- services/status_checks.py +275 -0
- services/status_diagnostics.py +105 -0
- services/status_service.py +99 -0
- services/task_service.py +78 -1
- services/watsonx_iam.py +69 -0
- tui/_assets/docker-compose.yml +6 -3
- tui/_assets/flows/ingestion_flow.json +3146 -2569
- tui/_assets/flows/openrag_agent.json +1983 -2206
- tui/_assets/flows/openrag_nudges.json +1181 -1925
- tui/_assets/flows/openrag_url_mcp.json +4003 -3477
- tui/cli.py +14 -6
- tui/config_fields.py +3 -2
- tui/managers/container_manager.py +11 -2
- tui/managers/docling_manager.py +9 -2
- tui/managers/env_manager.py +17 -22
- tui/screens/config.py +50 -91
- tui/screens/welcome.py +84 -78
- utils/acl_utils.py +4 -3
- utils/document_processing.py +28 -0
- utils/embeddings.py +4 -3
- utils/ingest_preview_flag.py +9 -4
- utils/langflow_headers.py +50 -6
- utils/langflow_utils.py +135 -1
- utils/logging_config.py +47 -0
- utils/opensearch_filenames.py +52 -0
- tui/_assets/flows/components/ollama_embedding.json +0 -125
- tui/_assets/flows/components/ollama_llm.json +0 -638
- tui/_assets/flows/components/ollama_llm_text.json +0 -639
- tui/_assets/flows/components/watsonx_embedding.json +0 -207
- tui/_assets/flows/components/watsonx_llm.json +0 -521
- tui/_assets/flows/components/watsonx_llm_text.json +0 -521
- {openrag-0.6.0.dev23.dist-info → openrag-0.7.0.dev1.dist-info}/entry_points.txt +0 -0
- {openrag-0.6.0.dev23.dist-info → openrag-0.7.0.dev1.dist-info}/licenses/LICENSE +0 -0
- {openrag-0.6.0.dev23.dist-info → openrag-0.7.0.dev1.dist-info}/top_level.txt +0 -0
agent.py
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import uuid
|
|
1
2
|
from typing import Any
|
|
2
3
|
|
|
4
|
+
from config.config_manager import DEFAULT_SYSTEM_PROMPT
|
|
3
5
|
from services.conversation_persistence_service import conversation_persistence
|
|
6
|
+
from utils.langflow_utils import parse_knowledge_chunks, strip_untrusted_fence_recursive
|
|
4
7
|
from utils.logging_config import get_logger
|
|
5
8
|
|
|
6
9
|
logger = get_logger(__name__)
|
|
@@ -9,6 +12,109 @@ logger = get_logger(__name__)
|
|
|
9
12
|
active_conversations: dict[str, dict[str, Any]] = {}
|
|
10
13
|
|
|
11
14
|
|
|
15
|
+
def _format_provider_error_message(exc: BaseException) -> str:
|
|
16
|
+
"""Return a concise, user-facing provider error string from an exception."""
|
|
17
|
+
from api.provider_validation import sanitize_provider_error_content
|
|
18
|
+
|
|
19
|
+
return sanitize_provider_error_content(str(exc))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _provider_error_display_text(error_text: str, partial_response: str = "") -> str:
|
|
23
|
+
"""Combine any streamed partial answer with the provider error for display/history.
|
|
24
|
+
|
|
25
|
+
If the mid-stream text is itself a provider failure dump (often raw JSON), keep
|
|
26
|
+
only the sanitized error — do not paste the dump above the clean message.
|
|
27
|
+
"""
|
|
28
|
+
from api.provider_validation import looks_like_provider_error_content
|
|
29
|
+
|
|
30
|
+
partial = (partial_response or "").strip()
|
|
31
|
+
if not partial:
|
|
32
|
+
return error_text
|
|
33
|
+
if looks_like_provider_error_content(partial) or "{" in partial:
|
|
34
|
+
return error_text
|
|
35
|
+
if error_text in partial:
|
|
36
|
+
return partial
|
|
37
|
+
return f"{partial}\n\n{error_text}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _conversation_thread_in_memory(user_id: str, response_id: str | None) -> bool:
|
|
41
|
+
"""True when response_id is already keyed in the in-process conversation cache."""
|
|
42
|
+
return bool(
|
|
43
|
+
response_id
|
|
44
|
+
and user_id in active_conversations
|
|
45
|
+
and response_id in active_conversations[user_id]
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _resolve_conversation_store_id(
|
|
50
|
+
response_id: str | None,
|
|
51
|
+
thread_id: str | None,
|
|
52
|
+
*,
|
|
53
|
+
previous_loaded_from_memory: bool,
|
|
54
|
+
mint_if_missing: bool = False,
|
|
55
|
+
) -> str | None:
|
|
56
|
+
"""Resolve the OpenRAG conversation id used for persistence.
|
|
57
|
+
|
|
58
|
+
Prefer an in-memory thread id so follow-ups stay on one sidebar entry.
|
|
59
|
+
Never reuse a cold thread id (would overwrite metadata with empty state).
|
|
60
|
+
Fall back to the stream id, then optionally mint a UUID for failed first turns.
|
|
61
|
+
"""
|
|
62
|
+
if previous_loaded_from_memory and thread_id:
|
|
63
|
+
return thread_id
|
|
64
|
+
if response_id:
|
|
65
|
+
return response_id
|
|
66
|
+
if mint_if_missing:
|
|
67
|
+
return str(uuid.uuid4())
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _conversation_ended_with_error(user_id: str, response_id: str | None) -> bool:
|
|
72
|
+
"""True when the in-memory thread's last assistant message is a provider error."""
|
|
73
|
+
if not response_id or user_id not in active_conversations:
|
|
74
|
+
return False
|
|
75
|
+
messages = active_conversations[user_id].get(response_id, {}).get("messages") or []
|
|
76
|
+
for message in reversed(messages):
|
|
77
|
+
if message.get("role") == "assistant":
|
|
78
|
+
return bool(message.get("error"))
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def _persist_error_conversation(
|
|
83
|
+
user_id: str, store_id: str, conversation_state: dict
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Store a failed turn and claim session ownership.
|
|
86
|
+
|
|
87
|
+
Claiming is required: otherwise the client may retry with this response_id and
|
|
88
|
+
``_assert_owns`` returns 404 (session_not_found) for an unclaimed session.
|
|
89
|
+
"""
|
|
90
|
+
from datetime import datetime
|
|
91
|
+
|
|
92
|
+
from services.session_ownership_service import session_ownership_service
|
|
93
|
+
|
|
94
|
+
conversation_state["last_activity"] = datetime.now()
|
|
95
|
+
await store_conversation_thread(user_id, store_id, conversation_state)
|
|
96
|
+
try:
|
|
97
|
+
await session_ownership_service.claim_session(user_id, store_id)
|
|
98
|
+
logger.debug(f"Claimed session {store_id} for user {user_id} after stream error")
|
|
99
|
+
except Exception as e:
|
|
100
|
+
logger.warning(f"Failed to claim session ownership after stream error: {e}")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _stream_error_chunk(message: str, response_id: str | None = None) -> bytes:
|
|
104
|
+
"""NDJSON error event consumed by the chat streaming client."""
|
|
105
|
+
import json
|
|
106
|
+
|
|
107
|
+
payload: dict[str, Any] = {
|
|
108
|
+
"status": "failed",
|
|
109
|
+
"finish_reason": "error",
|
|
110
|
+
"error": {"message": message},
|
|
111
|
+
}
|
|
112
|
+
if response_id:
|
|
113
|
+
payload["id"] = response_id
|
|
114
|
+
payload["response_id"] = response_id
|
|
115
|
+
return (json.dumps(payload) + "\n").encode("utf-8")
|
|
116
|
+
|
|
117
|
+
|
|
12
118
|
async def get_user_conversations(user_id: str):
|
|
13
119
|
"""Get conversation metadata for a user from persistent storage."""
|
|
14
120
|
return await conversation_persistence.get_user_conversations(user_id)
|
|
@@ -34,7 +140,7 @@ def get_conversation_thread(user_id: str, previous_response_id: str = None):
|
|
|
34
140
|
"messages": [
|
|
35
141
|
{
|
|
36
142
|
"role": "system",
|
|
37
|
-
"content":
|
|
143
|
+
"content": DEFAULT_SYSTEM_PROMPT,
|
|
38
144
|
}
|
|
39
145
|
],
|
|
40
146
|
"previous_response_id": previous_response_id, # Parent response_id for branching
|
|
@@ -60,6 +166,7 @@ async def store_conversation_thread(user_id: str, response_id: str, conversation
|
|
|
60
166
|
content = first_user_msg.get("content", "")
|
|
61
167
|
title = content[:50] + "..." if len(content) > 50 else content
|
|
62
168
|
|
|
169
|
+
user_assistant_messages = [msg for msg in messages if msg.get("role") in ["user", "assistant"]]
|
|
63
170
|
metadata_only = {
|
|
64
171
|
"response_id": response_id,
|
|
65
172
|
"title": title,
|
|
@@ -68,11 +175,19 @@ async def store_conversation_thread(user_id: str, response_id: str, conversation
|
|
|
68
175
|
"last_activity": conversation_state.get("last_activity"),
|
|
69
176
|
"previous_response_id": conversation_state.get("previous_response_id"),
|
|
70
177
|
"filter_id": conversation_state.get("filter_id"),
|
|
71
|
-
"total_messages": len(
|
|
72
|
-
[msg for msg in messages if msg.get("role") in ["user", "assistant"]]
|
|
73
|
-
),
|
|
74
|
-
# Don't store actual messages - Langflow has them
|
|
178
|
+
"total_messages": len(user_assistant_messages),
|
|
75
179
|
}
|
|
180
|
+
# Snapshot error-thread turns: Langflow history is incomplete after fresh-session retries.
|
|
181
|
+
if any(msg.get("error") for msg in user_assistant_messages):
|
|
182
|
+
metadata_only["messages"] = [
|
|
183
|
+
{
|
|
184
|
+
"role": msg.get("role"),
|
|
185
|
+
"content": msg.get("content"),
|
|
186
|
+
"timestamp": msg.get("timestamp"),
|
|
187
|
+
"error": bool(msg.get("error")),
|
|
188
|
+
}
|
|
189
|
+
for msg in user_assistant_messages
|
|
190
|
+
]
|
|
76
191
|
|
|
77
192
|
await conversation_persistence.store_conversation_thread(user_id, response_id, metadata_only)
|
|
78
193
|
|
|
@@ -145,13 +260,16 @@ async def async_response_stream(
|
|
|
145
260
|
if hasattr(chunk, "output_text") and chunk.output_text:
|
|
146
261
|
full_response += chunk.output_text
|
|
147
262
|
elif hasattr(chunk, "delta") and chunk.delta:
|
|
148
|
-
# Handle delta properly - it might be a dict or string
|
|
263
|
+
# Handle delta properly - it might be a dict or string.
|
|
264
|
+
# Do not fall back to str(dict) when content is "" — that pollutes
|
|
265
|
+
# the transcript with "{'content': ''}" prefixes on error chunks.
|
|
149
266
|
if isinstance(chunk.delta, dict):
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
267
|
+
raw_delta = chunk.delta.get("content")
|
|
268
|
+
if raw_delta is None:
|
|
269
|
+
raw_delta = chunk.delta.get("text")
|
|
270
|
+
delta_text = raw_delta if isinstance(raw_delta, str) else ""
|
|
271
|
+
elif chunk.delta is None:
|
|
272
|
+
delta_text = ""
|
|
155
273
|
else:
|
|
156
274
|
delta_text = str(chunk.delta)
|
|
157
275
|
full_response += delta_text
|
|
@@ -190,6 +308,13 @@ async def async_response_stream(
|
|
|
190
308
|
else:
|
|
191
309
|
chunk_data = str(chunk)
|
|
192
310
|
|
|
311
|
+
# VULN-13906: the live chat UI always streams, so this is the only place
|
|
312
|
+
# retrieved/uploaded text (fenced in flows/components/opensearch_multimodal.py
|
|
313
|
+
# and src/agent.py's fence_untrusted_text) reaches the client — strip fence
|
|
314
|
+
# markers here, before anything is yielded. Frontend surfaces (tool-call trace
|
|
315
|
+
# panel, citation click-through popup) read this raw streamed payload directly.
|
|
316
|
+
strip_untrusted_fence_recursive(chunk_data)
|
|
317
|
+
|
|
193
318
|
# Log detailed chunk structure for investigation (especially for Granite 3.3 8b)
|
|
194
319
|
if isinstance(chunk_data, dict):
|
|
195
320
|
# Check for any fields that might indicate tool usage
|
|
@@ -228,16 +353,31 @@ async def async_response_stream(
|
|
|
228
353
|
total_tokens=usage.get("total_tokens"),
|
|
229
354
|
)
|
|
230
355
|
|
|
356
|
+
# Format native tool call results
|
|
357
|
+
if isinstance(chunk_data, dict):
|
|
358
|
+
# Format: {"type": "response.output_item.added", "item": {"type": "tool_call", "results": ...}}
|
|
359
|
+
item = chunk_data.get("item")
|
|
360
|
+
if (
|
|
361
|
+
isinstance(item, dict)
|
|
362
|
+
and item.get("type") == "tool_call"
|
|
363
|
+
and "results" in item
|
|
364
|
+
):
|
|
365
|
+
item["results"] = parse_knowledge_chunks(item["results"])
|
|
366
|
+
|
|
367
|
+
# Also check for direct tool_call chunks
|
|
368
|
+
elif chunk_data.get("type") == "tool_call" and "results" in chunk_data:
|
|
369
|
+
chunk_data["results"] = parse_knowledge_chunks(chunk_data["results"])
|
|
370
|
+
|
|
231
371
|
# Middleware: Detect implicit tool calls and inject standardized events
|
|
232
372
|
# This helps Granite 3.3 8b and other models that don't emit standard markers
|
|
233
373
|
if isinstance(chunk_data, dict) and not detected_tool_call:
|
|
234
374
|
# Check if this chunk contains retrieval results
|
|
235
375
|
has_results = any(
|
|
236
376
|
[
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
"retrieved_documents"
|
|
240
|
-
"retrieval_results"
|
|
377
|
+
bool(chunk_data.get("results")),
|
|
378
|
+
bool(chunk_data.get("outputs")),
|
|
379
|
+
bool(chunk_data.get("retrieved_documents")),
|
|
380
|
+
bool(chunk_data.get("retrieval_results")),
|
|
241
381
|
]
|
|
242
382
|
)
|
|
243
383
|
|
|
@@ -256,11 +396,12 @@ async def async_response_stream(
|
|
|
256
396
|
"tool_name": "Retrieval",
|
|
257
397
|
"status": "completed",
|
|
258
398
|
"inputs": {"implicit": True, "backend_detected": True},
|
|
259
|
-
"results":
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
399
|
+
"results": parse_knowledge_chunks(
|
|
400
|
+
chunk_data.get("results")
|
|
401
|
+
or chunk_data.get("outputs")
|
|
402
|
+
or chunk_data.get("retrieved_documents")
|
|
403
|
+
or chunk_data.get("retrieval_results")
|
|
404
|
+
),
|
|
264
405
|
},
|
|
265
406
|
}
|
|
266
407
|
# Send the synthetic event first
|
|
@@ -489,6 +630,7 @@ async def async_chat_stream(
|
|
|
489
630
|
previous_response_id: str = None,
|
|
490
631
|
filter_id: str = None,
|
|
491
632
|
):
|
|
633
|
+
previous_loaded_from_memory = _conversation_thread_in_memory(user_id, previous_response_id)
|
|
492
634
|
# Get the specific conversation thread (or create new one)
|
|
493
635
|
conversation_state = get_conversation_thread(user_id, previous_response_id)
|
|
494
636
|
|
|
@@ -505,52 +647,78 @@ async def async_chat_stream(
|
|
|
505
647
|
full_response = ""
|
|
506
648
|
response_id = None
|
|
507
649
|
usage_data = None
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
650
|
+
try:
|
|
651
|
+
async for chunk in async_stream(
|
|
652
|
+
async_client,
|
|
653
|
+
prompt,
|
|
654
|
+
model,
|
|
655
|
+
previous_response_id=previous_response_id,
|
|
656
|
+
log_prefix="agent",
|
|
657
|
+
):
|
|
658
|
+
# Extract text content to build full response for history
|
|
659
|
+
try:
|
|
660
|
+
import json
|
|
518
661
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
662
|
+
chunk_data = json.loads(chunk.decode("utf-8"))
|
|
663
|
+
if "delta" in chunk_data and "content" in chunk_data["delta"]:
|
|
664
|
+
full_response += chunk_data["delta"]["content"]
|
|
665
|
+
# Extract response_id from chunk
|
|
666
|
+
if "id" in chunk_data:
|
|
667
|
+
response_id = chunk_data["id"]
|
|
668
|
+
elif "response_id" in chunk_data:
|
|
669
|
+
response_id = chunk_data["response_id"]
|
|
670
|
+
# Capture usage from response.completed event
|
|
671
|
+
if chunk_data.get("type") == "response.completed":
|
|
672
|
+
response_obj = chunk_data.get("response", {})
|
|
673
|
+
usage_data = response_obj.get("usage")
|
|
674
|
+
except Exception:
|
|
675
|
+
pass
|
|
676
|
+
yield chunk
|
|
534
677
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
678
|
+
# Add the complete assistant response to message history with response_id and timestamp
|
|
679
|
+
assistant_message = {
|
|
680
|
+
"role": "assistant",
|
|
681
|
+
"content": full_response,
|
|
682
|
+
"response_id": response_id,
|
|
683
|
+
"timestamp": datetime.now(),
|
|
684
|
+
}
|
|
685
|
+
# Store usage data if available (from response.completed event)
|
|
686
|
+
if usage_data:
|
|
687
|
+
assistant_message["response_data"] = {"usage": usage_data}
|
|
688
|
+
conversation_state["messages"].append(assistant_message)
|
|
546
689
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
690
|
+
# Store the conversation thread with its response_id
|
|
691
|
+
if response_id:
|
|
692
|
+
conversation_state["last_activity"] = datetime.now()
|
|
693
|
+
await store_conversation_thread(user_id, response_id, conversation_state)
|
|
694
|
+
logger.debug(
|
|
695
|
+
f"Stored conversation thread for user {user_id} with response_id: {response_id}"
|
|
696
|
+
)
|
|
697
|
+
except Exception as e:
|
|
698
|
+
logger.error(f"Error in chat stream: {e}", exc_info=True)
|
|
699
|
+
from api.provider_validation import resolve_chat_stream_error_message_async
|
|
700
|
+
|
|
701
|
+
error_text = await resolve_chat_stream_error_message_async(e)
|
|
702
|
+
display_text = _provider_error_display_text(error_text, full_response)
|
|
703
|
+
conversation_state["messages"].append(
|
|
704
|
+
{
|
|
705
|
+
"role": "assistant",
|
|
706
|
+
"content": display_text,
|
|
707
|
+
"timestamp": datetime.now(),
|
|
708
|
+
"error": True,
|
|
709
|
+
}
|
|
553
710
|
)
|
|
711
|
+
store_id = _resolve_conversation_store_id(
|
|
712
|
+
response_id,
|
|
713
|
+
previous_response_id,
|
|
714
|
+
previous_loaded_from_memory=previous_loaded_from_memory,
|
|
715
|
+
mint_if_missing=True,
|
|
716
|
+
)
|
|
717
|
+
try:
|
|
718
|
+
await _persist_error_conversation(user_id, store_id, conversation_state)
|
|
719
|
+
except Exception as store_error:
|
|
720
|
+
logger.error(f"Failed to store error conversation: {store_error}")
|
|
721
|
+
yield _stream_error_chunk(display_text, store_id)
|
|
554
722
|
|
|
555
723
|
|
|
556
724
|
# Async langflow function with conversation storage (non-streaming)
|
|
@@ -561,6 +729,7 @@ async def async_langflow_chat(
|
|
|
561
729
|
user_id: str,
|
|
562
730
|
extra_headers: dict = None,
|
|
563
731
|
previous_response_id: str = None,
|
|
732
|
+
conversation_id: str = None,
|
|
564
733
|
store_conversation: bool = True,
|
|
565
734
|
filter_id: str = None,
|
|
566
735
|
):
|
|
@@ -568,11 +737,13 @@ async def async_langflow_chat(
|
|
|
568
737
|
"async_langflow_chat called",
|
|
569
738
|
user_id=user_id,
|
|
570
739
|
previous_response_id=previous_response_id,
|
|
740
|
+
conversation_id=conversation_id,
|
|
571
741
|
)
|
|
572
742
|
|
|
743
|
+
thread_id = conversation_id or previous_response_id
|
|
573
744
|
if store_conversation:
|
|
574
745
|
# Get the specific conversation thread (or create new one)
|
|
575
|
-
conversation_state = get_conversation_thread(user_id,
|
|
746
|
+
conversation_state = get_conversation_thread(user_id, thread_id)
|
|
576
747
|
logger.debug(
|
|
577
748
|
"Got langflow conversation state",
|
|
578
749
|
message_count=len(conversation_state["messages"]),
|
|
@@ -637,28 +808,7 @@ async def async_langflow_chat(
|
|
|
637
808
|
# regardless of `type` string (Langflow may use different type names).
|
|
638
809
|
if hasattr(response_obj, "output") and response_obj.output:
|
|
639
810
|
for output_item in response_obj.output:
|
|
640
|
-
|
|
641
|
-
rd = (
|
|
642
|
-
result.model_dump()
|
|
643
|
-
if hasattr(result, "model_dump")
|
|
644
|
-
else (result if isinstance(result, dict) else {})
|
|
645
|
-
)
|
|
646
|
-
if "text" in rd:
|
|
647
|
-
sources.append(
|
|
648
|
-
{
|
|
649
|
-
"filename": rd.get("filename", ""),
|
|
650
|
-
"text": rd.get("text", ""),
|
|
651
|
-
"score": rd.get("score", 0),
|
|
652
|
-
"page": rd.get("page"),
|
|
653
|
-
"mimetype": rd.get("mimetype"),
|
|
654
|
-
"chunk_id": rd.get("chunk_id") or rd.get("id") or "",
|
|
655
|
-
"id": rd.get("id") or rd.get("chunk_id") or "",
|
|
656
|
-
"embedding_model": rd.get("embedding_model"),
|
|
657
|
-
"parser": rd.get("parser"),
|
|
658
|
-
"chunk_size": rd.get("chunk_size"),
|
|
659
|
-
"chunk_overlap": rd.get("chunk_overlap"),
|
|
660
|
-
}
|
|
661
|
-
)
|
|
811
|
+
sources.extend(parse_knowledge_chunks(getattr(output_item, "results", None)))
|
|
662
812
|
|
|
663
813
|
# Layer 2: Top-level dict inspection (mirrors streaming middleware in async_response_stream).
|
|
664
814
|
# Langflow may embed retrieval results directly in the response dict rather than
|
|
@@ -674,26 +824,8 @@ async def async_langflow_chat(
|
|
|
674
824
|
or resp_dict.get("outputs")
|
|
675
825
|
or resp_dict.get("retrieved_documents")
|
|
676
826
|
or resp_dict.get("retrieval_results")
|
|
677
|
-
or []
|
|
678
827
|
)
|
|
679
|
-
|
|
680
|
-
for result in implicit_results:
|
|
681
|
-
if isinstance(result, dict) and "text" in result:
|
|
682
|
-
sources.append(
|
|
683
|
-
{
|
|
684
|
-
"filename": result.get("filename", ""),
|
|
685
|
-
"text": result.get("text", ""),
|
|
686
|
-
"score": result.get("score", 0),
|
|
687
|
-
"page": result.get("page"),
|
|
688
|
-
"mimetype": result.get("mimetype"),
|
|
689
|
-
"chunk_id": result.get("chunk_id") or result.get("id") or "",
|
|
690
|
-
"id": result.get("id") or result.get("chunk_id") or "",
|
|
691
|
-
"embedding_model": result.get("embedding_model"),
|
|
692
|
-
"parser": result.get("parser"),
|
|
693
|
-
"chunk_size": result.get("chunk_size"),
|
|
694
|
-
"chunk_overlap": result.get("chunk_overlap"),
|
|
695
|
-
}
|
|
696
|
-
)
|
|
828
|
+
sources.extend(parse_knowledge_chunks(implicit_results))
|
|
697
829
|
|
|
698
830
|
# Layer 3: Citation-text fallback.
|
|
699
831
|
# Parse "(Source: filename)" patterns emitted by the LLM when it cites documents.
|
|
@@ -730,23 +862,11 @@ async def async_langflow_chat(
|
|
|
730
862
|
except Exception as e:
|
|
731
863
|
logger.warning(f"Failed to claim session ownership: {e}")
|
|
732
864
|
|
|
733
|
-
logger.debug(
|
|
734
|
-
f"Stored langflow conversation thread for user {user_id} with response_id: {response_id}"
|
|
735
|
-
)
|
|
736
865
|
logger.debug(
|
|
737
866
|
"Stored langflow conversation thread",
|
|
738
867
|
user_id=user_id,
|
|
739
868
|
response_id=response_id,
|
|
740
869
|
)
|
|
741
|
-
|
|
742
|
-
# Debug: Check what's in user_conversations now
|
|
743
|
-
conversations = await get_user_conversations(user_id)
|
|
744
|
-
logger.debug(
|
|
745
|
-
"User conversations updated",
|
|
746
|
-
user_id=user_id,
|
|
747
|
-
conversation_count=len(conversations),
|
|
748
|
-
conversation_ids=list(conversations.keys()),
|
|
749
|
-
)
|
|
750
870
|
else:
|
|
751
871
|
logger.warning("No response_id received from langflow, conversation not stored")
|
|
752
872
|
|
|
@@ -761,27 +881,35 @@ async def async_langflow_chat_stream(
|
|
|
761
881
|
user_id: str,
|
|
762
882
|
extra_headers: dict = None,
|
|
763
883
|
previous_response_id: str = None,
|
|
884
|
+
conversation_id: str = None,
|
|
764
885
|
filter_id: str = None,
|
|
765
886
|
):
|
|
766
887
|
logger.debug(
|
|
767
888
|
"async_langflow_chat_stream called",
|
|
768
889
|
user_id=user_id,
|
|
769
890
|
previous_response_id=previous_response_id,
|
|
891
|
+
conversation_id=conversation_id,
|
|
770
892
|
)
|
|
771
893
|
|
|
772
|
-
#
|
|
773
|
-
|
|
894
|
+
# conversation_id keeps the OpenRAG sidebar thread; previous_response_id chains
|
|
895
|
+
# the Langflow session. After errors the client omits previous_response_id.
|
|
896
|
+
thread_id = conversation_id or previous_response_id
|
|
897
|
+
previous_loaded_from_memory = _conversation_thread_in_memory(user_id, thread_id)
|
|
898
|
+
conversation_state = get_conversation_thread(user_id, thread_id)
|
|
774
899
|
|
|
775
|
-
# Add user message to conversation with timestamp
|
|
776
900
|
from datetime import datetime
|
|
777
901
|
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
# Store filter_id in conversation state if provided
|
|
902
|
+
conversation_state["messages"].append(
|
|
903
|
+
{"role": "user", "content": prompt, "timestamp": datetime.now()}
|
|
904
|
+
)
|
|
782
905
|
if filter_id:
|
|
783
906
|
conversation_state["filter_id"] = filter_id
|
|
784
907
|
|
|
908
|
+
# Safety net: never continue a Langflow session that already ended in error.
|
|
909
|
+
langflow_previous_response_id = previous_response_id
|
|
910
|
+
if previous_response_id and _conversation_ended_with_error(user_id, thread_id):
|
|
911
|
+
langflow_previous_response_id = None
|
|
912
|
+
|
|
785
913
|
full_response = ""
|
|
786
914
|
response_id = None
|
|
787
915
|
usage_data = None
|
|
@@ -794,7 +922,7 @@ async def async_langflow_chat_stream(
|
|
|
794
922
|
prompt,
|
|
795
923
|
flow_id,
|
|
796
924
|
extra_headers=extra_headers,
|
|
797
|
-
previous_response_id=
|
|
925
|
+
previous_response_id=langflow_previous_response_id,
|
|
798
926
|
log_prefix="langflow",
|
|
799
927
|
):
|
|
800
928
|
# Extract text content to build full response for history
|
|
@@ -812,13 +940,32 @@ async def async_langflow_chat_stream(
|
|
|
812
940
|
elif "response_id" in chunk_data:
|
|
813
941
|
response_id = chunk_data["response_id"]
|
|
814
942
|
|
|
815
|
-
# Check for error status
|
|
943
|
+
# Check for error status. Do not forward bare Langflow error
|
|
944
|
+
# chunks — they often lack a useful message and the client would
|
|
945
|
+
# show a generic failure before our sanitized terminal chunk.
|
|
816
946
|
if (
|
|
817
947
|
chunk_data.get("finish_reason") == "error"
|
|
818
948
|
or chunk_data.get("status") == "failed"
|
|
819
949
|
):
|
|
820
950
|
error_occurred = True
|
|
821
951
|
logger.error("Error detected in Langflow stream chunk")
|
|
952
|
+
err = chunk_data.get("error")
|
|
953
|
+
if isinstance(err, dict):
|
|
954
|
+
err_msg = err.get("message")
|
|
955
|
+
else:
|
|
956
|
+
err_msg = err
|
|
957
|
+
if isinstance(err_msg, str) and err_msg.strip():
|
|
958
|
+
if err_msg not in full_response:
|
|
959
|
+
full_response = (
|
|
960
|
+
f"{full_response}\n\n{err_msg}" if full_response else err_msg
|
|
961
|
+
)
|
|
962
|
+
elif (
|
|
963
|
+
isinstance(chunk_data.get("message"), str) and chunk_data["message"].strip()
|
|
964
|
+
):
|
|
965
|
+
msg = chunk_data["message"]
|
|
966
|
+
if msg not in full_response:
|
|
967
|
+
full_response = f"{full_response}\n\n{msg}" if full_response else msg
|
|
968
|
+
continue
|
|
822
969
|
# Capture usage from response.completed event
|
|
823
970
|
if chunk_data.get("type") == "response.completed":
|
|
824
971
|
response_obj = chunk_data.get("response", {})
|
|
@@ -827,7 +974,39 @@ async def async_langflow_chat_stream(
|
|
|
827
974
|
pass
|
|
828
975
|
yield chunk
|
|
829
976
|
|
|
830
|
-
|
|
977
|
+
from api.provider_validation import (
|
|
978
|
+
looks_like_provider_error_content,
|
|
979
|
+
resolve_chat_stream_error_message_async,
|
|
980
|
+
)
|
|
981
|
+
|
|
982
|
+
# Credential failures are often streamed as plain assistant text with no
|
|
983
|
+
# exception. Sanitize, mark as error, and emit a terminal error chunk so
|
|
984
|
+
# the client shows the error card instead of raw JSON.
|
|
985
|
+
if error_occurred or looks_like_provider_error_content(full_response):
|
|
986
|
+
display_text = await resolve_chat_stream_error_message_async(full_response)
|
|
987
|
+
conversation_state["messages"].append(
|
|
988
|
+
{
|
|
989
|
+
"role": "assistant",
|
|
990
|
+
"content": display_text,
|
|
991
|
+
"response_id": response_id,
|
|
992
|
+
"timestamp": datetime.now(),
|
|
993
|
+
"chunks": collected_chunks,
|
|
994
|
+
"error": True,
|
|
995
|
+
}
|
|
996
|
+
)
|
|
997
|
+
store_id = _resolve_conversation_store_id(
|
|
998
|
+
response_id,
|
|
999
|
+
thread_id,
|
|
1000
|
+
previous_loaded_from_memory=previous_loaded_from_memory,
|
|
1001
|
+
mint_if_missing=True,
|
|
1002
|
+
)
|
|
1003
|
+
try:
|
|
1004
|
+
await _persist_error_conversation(user_id, store_id, conversation_state)
|
|
1005
|
+
except Exception as store_error:
|
|
1006
|
+
logger.error(f"Failed to store error conversation: {store_error}")
|
|
1007
|
+
yield _stream_error_chunk(display_text, store_id)
|
|
1008
|
+
return
|
|
1009
|
+
|
|
831
1010
|
if full_response:
|
|
832
1011
|
assistant_message = {
|
|
833
1012
|
"role": "assistant",
|
|
@@ -841,50 +1020,48 @@ async def async_langflow_chat_stream(
|
|
|
841
1020
|
assistant_message["response_data"] = {"usage": usage_data}
|
|
842
1021
|
conversation_state["messages"].append(assistant_message)
|
|
843
1022
|
|
|
844
|
-
|
|
845
|
-
|
|
1023
|
+
persist_id = _resolve_conversation_store_id(
|
|
1024
|
+
response_id,
|
|
1025
|
+
thread_id,
|
|
1026
|
+
previous_loaded_from_memory=previous_loaded_from_memory,
|
|
1027
|
+
)
|
|
1028
|
+
if persist_id:
|
|
846
1029
|
conversation_state["last_activity"] = datetime.now()
|
|
847
|
-
await store_conversation_thread(user_id,
|
|
848
|
-
|
|
1030
|
+
await store_conversation_thread(user_id, persist_id, conversation_state)
|
|
849
1031
|
try:
|
|
850
1032
|
from services.session_ownership_service import session_ownership_service
|
|
851
1033
|
|
|
852
|
-
await session_ownership_service.claim_session(user_id,
|
|
853
|
-
logger.debug(f"Claimed session {response_id} for user {user_id}")
|
|
1034
|
+
await session_ownership_service.claim_session(user_id, persist_id)
|
|
854
1035
|
except Exception as e:
|
|
855
1036
|
logger.warning(f"Failed to claim session ownership: {e}")
|
|
856
|
-
|
|
857
|
-
logger.debug(
|
|
858
|
-
f"Stored langflow conversation thread for user {user_id} with response_id: {response_id}"
|
|
859
|
-
)
|
|
860
1037
|
except Exception as e:
|
|
861
|
-
# Log the error
|
|
862
1038
|
logger.error(f"Error in langflow chat stream: {e}", exc_info=True)
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
# Store error message in conversation history so it persists
|
|
866
|
-
error_message = {
|
|
867
|
-
"role": "assistant",
|
|
868
|
-
"content": f"Sorry, I encountered an error: {str(e)}",
|
|
869
|
-
"timestamp": datetime.now(),
|
|
870
|
-
"error": True,
|
|
871
|
-
}
|
|
872
|
-
conversation_state["messages"].append(error_message)
|
|
1039
|
+
from api.provider_validation import resolve_chat_stream_error_message_async
|
|
873
1040
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
if
|
|
877
|
-
|
|
1041
|
+
error_text = await resolve_chat_stream_error_message_async(e)
|
|
1042
|
+
display_text = _provider_error_display_text(error_text, full_response)
|
|
1043
|
+
if full_response:
|
|
1044
|
+
display_text = await resolve_chat_stream_error_message_async(display_text)
|
|
878
1045
|
|
|
1046
|
+
conversation_state["messages"].append(
|
|
1047
|
+
{
|
|
1048
|
+
"role": "assistant",
|
|
1049
|
+
"content": display_text,
|
|
1050
|
+
"timestamp": datetime.now(),
|
|
1051
|
+
"error": True,
|
|
1052
|
+
}
|
|
1053
|
+
)
|
|
1054
|
+
store_id = _resolve_conversation_store_id(
|
|
1055
|
+
response_id,
|
|
1056
|
+
thread_id,
|
|
1057
|
+
previous_loaded_from_memory=previous_loaded_from_memory,
|
|
1058
|
+
mint_if_missing=True,
|
|
1059
|
+
)
|
|
879
1060
|
try:
|
|
880
|
-
|
|
881
|
-
await store_conversation_thread(user_id, response_id, conversation_state)
|
|
882
|
-
logger.debug(f"Stored conversation with error for user {user_id}")
|
|
1061
|
+
await _persist_error_conversation(user_id, store_id, conversation_state)
|
|
883
1062
|
except Exception as store_error:
|
|
884
1063
|
logger.error(f"Failed to store error conversation: {store_error}")
|
|
885
|
-
|
|
886
|
-
# Re-raise the exception so it propagates to the API layer
|
|
887
|
-
raise
|
|
1064
|
+
yield _stream_error_chunk(display_text, store_id)
|
|
888
1065
|
|
|
889
1066
|
|
|
890
1067
|
async def delete_user_conversation(user_id: str, response_id: str) -> bool:
|