OpenRAG 0.6.0.dev24__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.
Files changed (97) hide show
  1. agent.py +334 -157
  2. api/chat.py +60 -31
  3. api/connectors.py +292 -279
  4. api/documents.py +2 -52
  5. api/files.py +60 -26
  6. api/health.py +68 -1
  7. api/ingest_preview.py +33 -0
  8. api/langflow_files.py +2 -18
  9. api/models.py +55 -4
  10. api/provider_health.py +6 -6
  11. api/provider_validation.py +702 -158
  12. api/schemas/status.py +75 -0
  13. api/settings/endpoints.py +176 -48
  14. api/settings/helpers.py +28 -10
  15. api/settings/langflow_sync.py +149 -21
  16. api/settings/models.py +34 -0
  17. api/status_utils.py +30 -0
  18. api/upload.py +4 -0
  19. api/v1/files.py +42 -0
  20. api/v1/status.py +36 -0
  21. api/v2/__init__.py +5 -0
  22. api/v2/files.py +79 -0
  23. app/container.py +8 -0
  24. app/factory.py +11 -1
  25. app/lifespan.py +19 -18
  26. app/routes/__init__.py +2 -0
  27. app/routes/internal.py +50 -3
  28. app/routes/public_v1.py +31 -0
  29. app/routes/public_v2.py +22 -0
  30. auth/request_identity.py +44 -64
  31. config/config_manager.py +33 -6
  32. config/embedding_constants.py +30 -1
  33. config/legacy_prompts.py +8 -0
  34. config/model_constants.py +5 -27
  35. config/settings.py +221 -90
  36. connectors/aws_s3/connector.py +1 -0
  37. connectors/base.py +57 -0
  38. connectors/chunk_cleanup.py +110 -0
  39. connectors/service.py +13 -195
  40. dependencies.py +10 -0
  41. mcp_http/server.py +28 -3
  42. models/processors.py +177 -168
  43. models/tasks.py +0 -1
  44. {openrag-0.6.0.dev24.dist-info → openrag-0.7.0.dev1.dist-info}/METADATA +1 -1
  45. {openrag-0.6.0.dev24.dist-info → openrag-0.7.0.dev1.dist-info}/RECORD +91 -81
  46. {openrag-0.6.0.dev24.dist-info → openrag-0.7.0.dev1.dist-info}/WHEEL +1 -1
  47. services/auth_service.py +1 -1
  48. services/chat_service.py +156 -41
  49. services/component_logs.py +164 -0
  50. services/connector_access_service.py +6 -6
  51. services/dls_principal_service.py +15 -12
  52. services/docling_service.py +133 -19
  53. services/file_service.py +20 -15
  54. services/file_service_v2.py +520 -0
  55. services/flows_service.py +166 -133
  56. services/ingest_preview_service.py +132 -2
  57. services/knowledge_filter_service.py +3 -7
  58. services/langflow_file_service.py +142 -41
  59. services/langflow_history_service.py +90 -62
  60. services/models_service.py +224 -99
  61. services/monitor_service.py +2 -2
  62. services/search_service.py +186 -65
  63. services/startup_orchestrator.py +42 -2
  64. services/status_checks.py +275 -0
  65. services/status_diagnostics.py +105 -0
  66. services/status_service.py +99 -0
  67. services/task_service.py +78 -1
  68. services/watsonx_iam.py +69 -0
  69. tui/_assets/docker-compose.yml +6 -3
  70. tui/_assets/flows/ingestion_flow.json +3146 -2569
  71. tui/_assets/flows/openrag_agent.json +1983 -2206
  72. tui/_assets/flows/openrag_nudges.json +1181 -1925
  73. tui/_assets/flows/openrag_url_mcp.json +4003 -3477
  74. tui/cli.py +14 -6
  75. tui/config_fields.py +3 -2
  76. tui/managers/container_manager.py +11 -2
  77. tui/managers/docling_manager.py +9 -2
  78. tui/managers/env_manager.py +17 -22
  79. tui/screens/config.py +50 -91
  80. tui/screens/welcome.py +84 -78
  81. utils/acl_utils.py +4 -3
  82. utils/document_processing.py +28 -0
  83. utils/embeddings.py +4 -3
  84. utils/ingest_preview_flag.py +9 -4
  85. utils/langflow_headers.py +50 -6
  86. utils/langflow_utils.py +135 -1
  87. utils/logging_config.py +47 -0
  88. utils/opensearch_filenames.py +52 -0
  89. tui/_assets/flows/components/ollama_embedding.json +0 -125
  90. tui/_assets/flows/components/ollama_llm.json +0 -638
  91. tui/_assets/flows/components/ollama_llm_text.json +0 -639
  92. tui/_assets/flows/components/watsonx_embedding.json +0 -207
  93. tui/_assets/flows/components/watsonx_llm.json +0 -521
  94. tui/_assets/flows/components/watsonx_llm_text.json +0 -521
  95. {openrag-0.6.0.dev24.dist-info → openrag-0.7.0.dev1.dist-info}/entry_points.txt +0 -0
  96. {openrag-0.6.0.dev24.dist-info → openrag-0.7.0.dev1.dist-info}/licenses/LICENSE +0 -0
  97. {openrag-0.6.0.dev24.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": 'You are the OpenRAG Agent. You answer questions using retrieval, reasoning, and tool use.\nYou have access to several tools. Your job is to determine **which tool to use and when**.\n### Available Tools\n- OpenSearch Retrieval Tool:\n Use this to search the indexed knowledge base. Use when the user asks about product details, internal concepts, processes, architecture, documentation, roadmaps, or anything that may be stored in the index.\n- Conversation History:\n Use this to maintain continuity when the user is referring to previous turns. \n Do not treat history as a factual source.\n- Conversation File Context:\n Use this when the user asks about a document they uploaded or refers directly to its contents.\n **IMPORTANT**: If you receive confirmation that a file was uploaded (e.g., "Confirm that you received this file"), the file content is already available in the conversation context. Do NOT attempt to ingest it as a URL.\n Simply acknowledge the file and answer questions about it directly from the context.\n- URL Ingestion Tool:\n Use this **only** when the user explicitly asks you to read, summarize, or analyze the content of a web URL (http:// or https://).\n **Do NOT use this tool for filenames** (e.g., README.md, document.pdf, data.txt). These are file uploads, not URLs.\n Only use this tool for actual web addresses that the user explicitly provides.\n If unclear → ask a clarifying question.\n- Calculator / Expression Evaluation Tool:\n Use this when the user asks to compare numbers, compute estimates, calculate totals, analyze pricing, or answer any question requiring mathematics or quantitative reasoning.\n If the answer requires arithmetic, call the calculator tool rather than calculating internally.\n### Retrieval Decision Rules\nUse OpenSearch **whenever**:\n1. The question may be answered from internal or indexed data.\n2. The user references team names, product names, release plans, configurations, requirements, or official information.\n3. The user needs a factual, grounded answer.\nDo **not** use retrieval if:\n- The question is purely creative (e.g., storytelling, analogies) or personal preference.\n- The user simply wants text reformatted or rewritten from what is already present in the conversation.\nWhen uncertain → **Retrieve.** Retrieval is low risk and improves grounding.\n### File Upload vs URL Distinction\n**File uploads** (already in context):\n- Filenames like: README.md, document.pdf, notes.txt, data.csv\n- When you see file confirmation messages\n- Use conversation context directly - do NOT call URL tool\n**Web URLs** (need ingestion):\n- Start with http:// or https://\n- Examples: https://example.com, http://docs.site.org\n- User explicitly asks to fetch from web\n### Calculator Usage Rules\nUse the calculator when:\n- Performing arithmetic\n- Estimating totals\n- Comparing values\n- Modeling cost, time, effort, scale, or projections\nDo not perform math internally. **Call the calculator tool instead.**\n### Answer Construction Rules\n1. When asked: "What is OpenRAG", answer the following:\n"OpenRAG is an open-source package for building agentic RAG systems. It supports integration with a wide range of orchestration tools, vector databases, and LLM providers. OpenRAG connects and amplifies three popular, proven open-source projects into one powerful platform:\n**Langflow** – Langflow is a powerful tool to build and deploy AI agents and MCP servers. [Read more](https://www.langflow.org/)\n**OpenSearch** – OpenSearch is an open source, search and observability suite that brings order to unstructured data at scale. [Read more](https://opensearch.org/)\n**Docling** – Docling simplifies document processing with advanced PDF understanding, OCR support, and seamless AI integrations. Parse PDFs, DOCX, PPTX, images & more. [Read more](https://www.docling.ai/)"\n2. Synthesize retrieved or ingested content in your own words.\n3. Support factual claims with citations in the format: (Source: <chunk_id>) placed exactly where the claim occurs (e.g., at the end of the sentence or clause making the claim). If multiple sources support a claim, cite them sequentially like: (Source: chunk_id_1)(Source: chunk_id_2). Use the exact chunk_id or id provided in the retrieved source block.\n4. If no supporting evidence is found:\n Say: "No relevant supporting sources were found for that request."\n5. Never invent facts or hallucinate details.\n6. Be concise, direct, and confident. \n7. Do not reveal internal chain-of-thought.',
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
- delta_text = (
151
- chunk.delta.get("content", "")
152
- or chunk.delta.get("text", "")
153
- or str(chunk.delta)
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
- "results" in chunk_data and isinstance(chunk_data.get("results"), list),
238
- "outputs" in chunk_data and isinstance(chunk_data.get("outputs"), list),
239
- "retrieved_documents" in chunk_data,
240
- "retrieval_results" in chunk_data,
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": chunk_data.get("results")
260
- or chunk_data.get("outputs")
261
- or chunk_data.get("retrieved_documents")
262
- or chunk_data.get("retrieval_results")
263
- or [],
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
- async for chunk in async_stream(
509
- async_client,
510
- prompt,
511
- model,
512
- previous_response_id=previous_response_id,
513
- log_prefix="agent",
514
- ):
515
- # Extract text content to build full response for history
516
- try:
517
- import json
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
- chunk_data = json.loads(chunk.decode("utf-8"))
520
- if "delta" in chunk_data and "content" in chunk_data["delta"]:
521
- full_response += chunk_data["delta"]["content"]
522
- # Extract response_id from chunk
523
- if "id" in chunk_data:
524
- response_id = chunk_data["id"]
525
- elif "response_id" in chunk_data:
526
- response_id = chunk_data["response_id"]
527
- # Capture usage from response.completed event
528
- if chunk_data.get("type") == "response.completed":
529
- response_obj = chunk_data.get("response", {})
530
- usage_data = response_obj.get("usage")
531
- except Exception:
532
- pass
533
- yield chunk
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
- # Add the complete assistant response to message history with response_id and timestamp
536
- assistant_message = {
537
- "role": "assistant",
538
- "content": full_response,
539
- "response_id": response_id,
540
- "timestamp": datetime.now(),
541
- }
542
- # Store usage data if available (from response.completed event)
543
- if usage_data:
544
- assistant_message["response_data"] = {"usage": usage_data}
545
- conversation_state["messages"].append(assistant_message)
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
- # Store the conversation thread with its response_id
548
- if response_id:
549
- conversation_state["last_activity"] = datetime.now()
550
- await store_conversation_thread(user_id, response_id, conversation_state)
551
- logger.debug(
552
- f"Stored conversation thread for user {user_id} with response_id: {response_id}"
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, previous_response_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
- for result in getattr(output_item, "results", None) or []:
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
- if isinstance(implicit_results, list):
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
- # Get the specific conversation thread (or create new one)
773
- conversation_state = get_conversation_thread(user_id, previous_response_id)
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
- user_message = {"role": "user", "content": prompt, "timestamp": datetime.now()}
779
- conversation_state["messages"].append(user_message)
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=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
- # Add the complete assistant response to message history with response_id, timestamp, and function call data
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
- # Store the conversation thread with its response_id
845
- if response_id:
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, response_id, conversation_state)
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, response_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
- error_occurred = True
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
- # Try to store the conversation with error message
875
- # Use a temporary response_id if we don't have one
876
- if not response_id:
877
- response_id = f"error_{user_id}_{int(datetime.now().timestamp())}"
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
- conversation_state["last_activity"] = datetime.now()
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: