gnosisllm-knowledge 0.2.0__py3-none-any.whl → 0.4.0__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 (63) hide show
  1. gnosisllm_knowledge/__init__.py +91 -39
  2. gnosisllm_knowledge/api/__init__.py +3 -2
  3. gnosisllm_knowledge/api/knowledge.py +502 -32
  4. gnosisllm_knowledge/api/memory.py +966 -0
  5. gnosisllm_knowledge/backends/__init__.py +14 -5
  6. gnosisllm_knowledge/backends/memory/indexer.py +27 -2
  7. gnosisllm_knowledge/backends/memory/searcher.py +111 -10
  8. gnosisllm_knowledge/backends/opensearch/agentic.py +355 -48
  9. gnosisllm_knowledge/backends/opensearch/config.py +49 -28
  10. gnosisllm_knowledge/backends/opensearch/indexer.py +49 -3
  11. gnosisllm_knowledge/backends/opensearch/mappings.py +14 -5
  12. gnosisllm_knowledge/backends/opensearch/memory/__init__.py +12 -0
  13. gnosisllm_knowledge/backends/opensearch/memory/client.py +1380 -0
  14. gnosisllm_knowledge/backends/opensearch/memory/config.py +127 -0
  15. gnosisllm_knowledge/backends/opensearch/memory/setup.py +322 -0
  16. gnosisllm_knowledge/backends/opensearch/queries.py +33 -33
  17. gnosisllm_knowledge/backends/opensearch/searcher.py +238 -0
  18. gnosisllm_knowledge/backends/opensearch/setup.py +308 -148
  19. gnosisllm_knowledge/cli/app.py +436 -31
  20. gnosisllm_knowledge/cli/commands/agentic.py +26 -9
  21. gnosisllm_knowledge/cli/commands/load.py +169 -19
  22. gnosisllm_knowledge/cli/commands/memory.py +733 -0
  23. gnosisllm_knowledge/cli/commands/search.py +9 -10
  24. gnosisllm_knowledge/cli/commands/setup.py +49 -23
  25. gnosisllm_knowledge/cli/display/service.py +43 -0
  26. gnosisllm_knowledge/cli/utils/config.py +62 -4
  27. gnosisllm_knowledge/core/domain/__init__.py +54 -0
  28. gnosisllm_knowledge/core/domain/discovery.py +166 -0
  29. gnosisllm_knowledge/core/domain/document.py +19 -19
  30. gnosisllm_knowledge/core/domain/memory.py +440 -0
  31. gnosisllm_knowledge/core/domain/result.py +11 -3
  32. gnosisllm_knowledge/core/domain/search.py +12 -25
  33. gnosisllm_knowledge/core/domain/source.py +11 -12
  34. gnosisllm_knowledge/core/events/__init__.py +8 -0
  35. gnosisllm_knowledge/core/events/types.py +198 -5
  36. gnosisllm_knowledge/core/exceptions.py +227 -0
  37. gnosisllm_knowledge/core/interfaces/__init__.py +17 -0
  38. gnosisllm_knowledge/core/interfaces/agentic.py +11 -3
  39. gnosisllm_knowledge/core/interfaces/indexer.py +10 -1
  40. gnosisllm_knowledge/core/interfaces/memory.py +524 -0
  41. gnosisllm_knowledge/core/interfaces/searcher.py +10 -1
  42. gnosisllm_knowledge/core/interfaces/streaming.py +133 -0
  43. gnosisllm_knowledge/core/streaming/__init__.py +36 -0
  44. gnosisllm_knowledge/core/streaming/pipeline.py +228 -0
  45. gnosisllm_knowledge/fetchers/__init__.py +8 -0
  46. gnosisllm_knowledge/fetchers/config.py +27 -0
  47. gnosisllm_knowledge/fetchers/neoreader.py +31 -3
  48. gnosisllm_knowledge/fetchers/neoreader_discovery.py +505 -0
  49. gnosisllm_knowledge/loaders/__init__.py +5 -1
  50. gnosisllm_knowledge/loaders/base.py +3 -4
  51. gnosisllm_knowledge/loaders/discovery.py +338 -0
  52. gnosisllm_knowledge/loaders/discovery_streaming.py +343 -0
  53. gnosisllm_knowledge/loaders/factory.py +46 -0
  54. gnosisllm_knowledge/loaders/sitemap.py +129 -1
  55. gnosisllm_knowledge/loaders/sitemap_streaming.py +258 -0
  56. gnosisllm_knowledge/services/indexing.py +100 -93
  57. gnosisllm_knowledge/services/search.py +84 -31
  58. gnosisllm_knowledge/services/streaming_pipeline.py +334 -0
  59. {gnosisllm_knowledge-0.2.0.dist-info → gnosisllm_knowledge-0.4.0.dist-info}/METADATA +73 -10
  60. gnosisllm_knowledge-0.4.0.dist-info/RECORD +81 -0
  61. gnosisllm_knowledge-0.2.0.dist-info/RECORD +0 -64
  62. {gnosisllm_knowledge-0.2.0.dist-info → gnosisllm_knowledge-0.4.0.dist-info}/WHEEL +0 -0
  63. {gnosisllm_knowledge-0.2.0.dist-info → gnosisllm_knowledge-0.4.0.dist-info}/entry_points.txt +0 -0
@@ -2,6 +2,12 @@
2
2
 
3
3
  Uses OpenSearch ML agents for AI-powered search with reasoning capabilities.
4
4
  Supports flow agents (fast RAG) and conversational agents (multi-turn with memory).
5
+
6
+ Note:
7
+ This module is **tenant-agnostic**. Multi-tenancy is achieved through index isolation:
8
+ each tenant's data resides in a separate OpenSearch index. The caller (e.g., gnosisllm-api)
9
+ is responsible for constructing the appropriate index name (e.g., `knowledge-{account_id}`).
10
+ The library operates on the provided index without any tenant-specific filtering logic.
5
11
  """
6
12
 
7
13
  from __future__ import annotations
@@ -9,7 +15,6 @@ from __future__ import annotations
9
15
  import asyncio
10
16
  import json
11
17
  import logging
12
- import uuid
13
18
  from datetime import UTC, datetime
14
19
  from typing import TYPE_CHECKING, Any
15
20
 
@@ -96,11 +101,12 @@ class OpenSearchAgenticSearcher:
96
101
  ) -> AgenticSearchResult:
97
102
  """Execute agentic search with agent orchestration.
98
103
 
99
- The flow:
104
+ The flow with RAGTool:
100
105
  1. Select agent based on query.agent_type
101
106
  2. Build execution request with query and filters
102
107
  3. Execute agent via OpenSearch ML API
103
- 4. Parse response for answer, reasoning, and results
108
+ 4. RAGTool searches the index and generates an AI answer
109
+ 5. Parse response for answer, reasoning, and source documents
104
110
 
105
111
  Args:
106
112
  query: Agentic search query with agent type and context.
@@ -121,7 +127,7 @@ class OpenSearchAgenticSearcher:
121
127
  raise AgenticSearchError(
122
128
  message=f"Agent not configured for type: {query.agent_type.value}",
123
129
  agent_type=query.agent_type.value,
124
- details={"hint": "Run 'gnosisllm-knowledge agentic setup' to configure agents."},
130
+ details={"hint": "Run 'gnosisllm-knowledge agentic setup --force' to configure agents."},
125
131
  )
126
132
 
127
133
  # Build execution request
@@ -133,15 +139,114 @@ class OpenSearchAgenticSearcher:
133
139
  "agent_id": agent_id,
134
140
  "agent_type": query.agent_type.value,
135
141
  "query": query.text[:100],
142
+ "index_name": index_name,
136
143
  },
137
144
  )
138
145
 
139
- # Execute agent
140
- response = await self._execute_agent(agent_id, execute_body)
146
+ # Execute agent - RAGTool handles search AND answer generation
147
+ agent_response = await self._execute_agent(agent_id, execute_body)
141
148
 
142
149
  duration_ms = (datetime.now(UTC) - start).total_seconds() * 1000
143
150
 
144
- return self._parse_agentic_response(query, response, duration_ms)
151
+ return self._parse_rag_response(query, agent_response, duration_ms)
152
+
153
+ def _parse_rag_response(
154
+ self,
155
+ query: AgenticSearchQuery,
156
+ response: dict[str, Any],
157
+ duration_ms: float,
158
+ ) -> AgenticSearchResult:
159
+ """Parse RAGTool response into AgenticSearchResult.
160
+
161
+ RAGTool returns both an AI-generated answer and source documents.
162
+
163
+ Response format:
164
+ {
165
+ "inference_results": [
166
+ {
167
+ "output": [
168
+ {"name": "knowledge_search", "result": "<LLM answer>"}
169
+ ]
170
+ }
171
+ ]
172
+ }
173
+
174
+ Args:
175
+ query: The original query.
176
+ response: Agent execution response.
177
+ duration_ms: Total execution duration.
178
+
179
+ Returns:
180
+ Parsed AgenticSearchResult with answer and sources.
181
+ """
182
+ answer: str | None = None
183
+ reasoning_steps: list[ReasoningStep] = []
184
+ items: list[SearchResultItem] = []
185
+ conversation_id = response.get("memory_id")
186
+
187
+ # Parse inference results
188
+ inference_results = response.get("inference_results", [])
189
+ if inference_results:
190
+ outputs = inference_results[0].get("output", [])
191
+
192
+ for output in outputs:
193
+ name = output.get("name", "")
194
+ result = output.get("result", "")
195
+
196
+ # Handle dataAsMap structure
197
+ data_as_map = output.get("dataAsMap", {})
198
+ if data_as_map and "response" in data_as_map:
199
+ result = data_as_map.get("response", result)
200
+
201
+ if name == "memory_id":
202
+ conversation_id = str(result) if result else None
203
+ elif name in ("knowledge_search", "RAGTool", "response"):
204
+ # RAGTool returns the LLM-generated answer
205
+ answer = self._extract_answer_from_result(result)
206
+
207
+ if query.include_reasoning:
208
+ reasoning_steps.append(
209
+ ReasoningStep(
210
+ tool="RAGTool",
211
+ action="rag_search",
212
+ input=query.text,
213
+ output=answer[:200] if answer else None,
214
+ duration_ms=duration_ms,
215
+ )
216
+ )
217
+
218
+ # Extract source documents if available in the response
219
+ for output in outputs:
220
+ name = output.get("name", "")
221
+ result = output.get("result", "")
222
+
223
+ # Try to extract source documents from additional_info or similar
224
+ additional_info = output.get("additional_info", {})
225
+ if additional_info:
226
+ hits = additional_info.get("hits", {})
227
+ if hits:
228
+ items.extend(self._parse_opensearch_hits(hits))
229
+
230
+ # If no answer from structured output, try raw response
231
+ if not answer and "response" in response:
232
+ answer = response.get("response")
233
+
234
+ # Preserve the query's conversation_id if agent didn't return one
235
+ final_conversation_id = conversation_id or query.conversation_id
236
+
237
+ return AgenticSearchResult(
238
+ query=query.text,
239
+ mode=SearchMode.AGENTIC,
240
+ items=items,
241
+ total_hits=len(items),
242
+ duration_ms=duration_ms,
243
+ max_score=items[0].score if items else None,
244
+ answer=answer,
245
+ reasoning_steps=reasoning_steps,
246
+ conversation_id=final_conversation_id,
247
+ agent_type=query.agent_type,
248
+ citations=[item.doc_id for item in items[:5]],
249
+ )
145
250
 
146
251
  async def get_conversation(
147
252
  self,
@@ -197,13 +302,15 @@ class OpenSearchAgenticSearcher:
197
302
 
198
303
  async def list_conversations(
199
304
  self,
200
- account_id: str | None = None,
201
305
  limit: int = 100,
202
306
  ) -> list[dict[str, Any]]:
203
307
  """List active conversations.
204
308
 
309
+ Note:
310
+ This library is tenant-agnostic. Multi-tenancy is achieved through
311
+ index isolation (separate index per account).
312
+
205
313
  Args:
206
- account_id: Filter by account (multi-tenant).
207
314
  limit: Maximum number of conversations.
208
315
 
209
316
  Returns:
@@ -211,8 +318,6 @@ class OpenSearchAgenticSearcher:
211
318
  """
212
319
  try:
213
320
  body: dict[str, Any] = {"size": limit}
214
- if account_id:
215
- body["query"] = {"term": {"account_id": account_id}}
216
321
 
217
322
  response = await self._client.transport.perform_request(
218
323
  "POST",
@@ -265,16 +370,18 @@ class OpenSearchAgenticSearcher:
265
370
  async def create_conversation(
266
371
  self,
267
372
  name: str | None = None,
268
- account_id: str | None = None,
269
373
  ) -> str | None:
270
374
  """Create a new conversation memory.
271
375
 
272
376
  Uses the OpenSearch Memory API to create a conversation memory.
273
377
  The endpoint is POST /_plugins/_ml/memory (introduced in 2.12).
274
378
 
379
+ Note:
380
+ This library is tenant-agnostic. Multi-tenancy is achieved through
381
+ index isolation (separate index per account).
382
+
275
383
  Args:
276
384
  name: Optional name for the conversation.
277
- account_id: Optional account ID for multi-tenancy.
278
385
 
279
386
  Returns:
280
387
  The new conversation/memory ID, or None if creation fails.
@@ -282,8 +389,6 @@ class OpenSearchAgenticSearcher:
282
389
  body: dict[str, Any] = {}
283
390
  if name:
284
391
  body["name"] = name
285
- if account_id:
286
- body["account_id"] = account_id
287
392
 
288
393
  try:
289
394
  # POST /_plugins/_ml/memory creates a new memory (OpenSearch 2.12+)
@@ -317,17 +422,18 @@ class OpenSearchAgenticSearcher:
317
422
  ) -> dict[str, Any]:
318
423
  """Build agent execution request.
319
424
 
320
- Only includes parameters that the agent actually uses:
425
+ RAGTool requires:
321
426
  - question: The user's query (required)
322
- - memory_id: For conversation continuity (conversational agents)
323
- - message_history_limit: Number of historical messages to include
324
427
 
325
- Note: VectorDBTool's index and model_id are configured in the agent,
326
- not passed at runtime. Extra parameters cause IllegalArgumentException.
428
+ The index is configured at agent creation time, not at execution time.
429
+ RAGTool searches the configured index and generates an AI answer.
430
+
431
+ Conversational agents also support:
432
+ - memory_id: For conversation continuity
327
433
 
328
434
  Args:
329
435
  query: The agentic search query.
330
- index_name: Target index name (not used - agent has hardcoded index).
436
+ index_name: Target index name (for logging, not used by RAGTool).
331
437
 
332
438
  Returns:
333
439
  Request body for agent execution.
@@ -339,7 +445,6 @@ class OpenSearchAgenticSearcher:
339
445
  }
340
446
 
341
447
  # Add conversation context for conversational agents
342
- # OpenSearch handles memory injection automatically with app_type=rag
343
448
  if query.agent_type == AgentType.CONVERSATIONAL and query.conversation_id:
344
449
  request["parameters"]["memory_id"] = query.conversation_id
345
450
 
@@ -386,32 +491,155 @@ class OpenSearchAgenticSearcher:
386
491
  cause=e,
387
492
  )
388
493
 
494
+ def _extract_dsl_from_agent_response(
495
+ self,
496
+ response: dict[str, Any],
497
+ ) -> dict[str, Any] | None:
498
+ """Extract generated DSL query from agent response.
499
+
500
+ The flow agent with QueryPlanningTool returns the DSL in the output.
501
+ Format: {"inference_results": [{"output": [{"name": "response", "result": "<DSL JSON>"}]}]}
502
+
503
+ Args:
504
+ response: Agent execution response.
505
+
506
+ Returns:
507
+ Parsed DSL query dict, or None if not found.
508
+ """
509
+ try:
510
+ inference_results = response.get("inference_results", [])
511
+ if not inference_results:
512
+ return None
513
+
514
+ outputs = inference_results[0].get("output", [])
515
+ for output in outputs:
516
+ name = output.get("name", "")
517
+ result = output.get("result", "")
518
+
519
+ # QueryPlanningTool outputs come as "response" or "query_planner"
520
+ if name in ("response", "query_planner", "QueryPlanningTool"):
521
+ if isinstance(result, dict):
522
+ return result
523
+ if isinstance(result, str) and result.strip():
524
+ # Try to parse as JSON
525
+ return self._parse_dsl_string(result)
526
+
527
+ return None
528
+ except Exception as e:
529
+ self._logger.warning(f"Failed to extract DSL from agent response: {e}")
530
+ return None
531
+
532
+ def _parse_dsl_string(self, dsl_string: str) -> dict[str, Any] | None:
533
+ """Parse a DSL query string into a dictionary.
534
+
535
+ Handles various formats:
536
+ - Raw JSON
537
+ - Markdown code blocks
538
+ - JSON with surrounding text
539
+
540
+ Args:
541
+ dsl_string: The DSL query as a string.
542
+
543
+ Returns:
544
+ Parsed DSL query dict, or None if parsing fails.
545
+ """
546
+ dsl_string = dsl_string.strip()
547
+
548
+ # Remove markdown code blocks if present
549
+ if dsl_string.startswith("```"):
550
+ lines = dsl_string.split("\n")
551
+ # Remove first line (```json or ```)
552
+ lines = lines[1:] if lines else []
553
+ # Remove last line (```)
554
+ if lines and lines[-1].strip() == "```":
555
+ lines = lines[:-1]
556
+ dsl_string = "\n".join(lines).strip()
557
+
558
+ # Try to find and parse JSON
559
+ try:
560
+ # Find the first { and last }
561
+ start = dsl_string.find("{")
562
+ end = dsl_string.rfind("}") + 1
563
+ if start >= 0 and end > start:
564
+ json_str = dsl_string[start:end]
565
+ return json.loads(json_str)
566
+ except json.JSONDecodeError as e:
567
+ self._logger.debug(f"Failed to parse DSL JSON: {e}")
568
+
569
+ # Try parsing the whole string
570
+ try:
571
+ return json.loads(dsl_string)
572
+ except json.JSONDecodeError:
573
+ pass
574
+
575
+ return None
576
+
577
+ async def _execute_dsl_query(
578
+ self,
579
+ index_name: str,
580
+ dsl_query: dict[str, Any],
581
+ ) -> dict[str, Any]:
582
+ """Execute a DSL query against the index.
583
+
584
+ Args:
585
+ index_name: Target index name.
586
+ dsl_query: OpenSearch DSL query to execute.
587
+
588
+ Returns:
589
+ Search response with hits.
590
+
591
+ Raises:
592
+ AgenticSearchError: If query execution fails.
593
+ """
594
+ try:
595
+ response = await self._client.search(
596
+ index=index_name,
597
+ body=dsl_query,
598
+ )
599
+ return response
600
+ except Exception as e:
601
+ self._logger.error(f"DSL query execution failed: {e}")
602
+ raise AgenticSearchError(
603
+ message=f"Failed to execute generated DSL query: {e}",
604
+ details={"index_name": index_name, "query": str(dsl_query)[:200]},
605
+ cause=e,
606
+ )
607
+
389
608
  def _parse_agentic_response(
390
609
  self,
391
610
  query: AgenticSearchQuery,
392
- response: dict[str, Any],
611
+ agent_response: dict[str, Any],
393
612
  duration_ms: float,
613
+ search_response: dict[str, Any] | None = None,
614
+ generated_dsl: dict[str, Any] | None = None,
394
615
  ) -> AgenticSearchResult:
395
616
  """Parse agent response into AgenticSearchResult.
396
617
 
397
- The response structure from OpenSearch ML agents:
398
- {
399
- "inference_results": [
400
- {
401
- "output": [
402
- {"name": "response", "result": "The answer..."},
403
- {"name": "knowledge_search", "result": {...}}
404
- ]
405
- }
406
- ],
407
- "memory_id": "...",
408
- ...
409
- }
618
+ Supports two response formats:
619
+
620
+ 1. QueryPlanningTool (OpenSearch 3.2+):
621
+ The agent generates DSL queries which we then execute.
622
+ Agent response: {"inference_results": [{"output": [{"name": "response", "result": "<DSL JSON>"}]}]}
623
+ Search response: Standard OpenSearch search response with hits.
624
+
625
+ 2. Legacy VectorDBTool + MLModelTool:
626
+ {
627
+ "inference_results": [
628
+ {
629
+ "output": [
630
+ {"name": "knowledge_search", "result": {...}},
631
+ {"name": "answer_generator", "result": "..."}
632
+ ]
633
+ }
634
+ ]
635
+ }
410
636
 
411
637
  Args:
412
638
  query: The original query.
413
- response: Agent execution response.
639
+ agent_response: Agent execution response.
414
640
  duration_ms: Total execution duration.
641
+ search_response: Search results from executing the generated DSL (optional).
642
+ generated_dsl: The DSL query generated by the agent (optional).
415
643
 
416
644
  Returns:
417
645
  Parsed AgenticSearchResult.
@@ -419,13 +647,40 @@ class OpenSearchAgenticSearcher:
419
647
  answer: str | None = None
420
648
  reasoning_steps: list[ReasoningStep] = []
421
649
  items: list[SearchResultItem] = []
422
- conversation_id = response.get("memory_id")
650
+ conversation_id = agent_response.get("memory_id")
651
+ dsl_string: str | None = None
423
652
  total_tokens = 0
424
653
  prompt_tokens = 0
425
654
  completion_tokens = 0
426
655
 
427
- # Parse inference results
428
- inference_results = response.get("inference_results", [])
656
+ # Parse search results from executed DSL query first (QueryPlanningTool flow)
657
+ if search_response:
658
+ hits_data = search_response.get("hits", {})
659
+ items.extend(self._parse_opensearch_hits(hits_data))
660
+
661
+ if query.include_reasoning:
662
+ dsl_string = json.dumps(generated_dsl) if generated_dsl else None
663
+ reasoning_steps.append(
664
+ ReasoningStep(
665
+ tool="QueryPlanningTool",
666
+ action="query_generation",
667
+ input=query.text,
668
+ output=dsl_string[:200] if dsl_string else None,
669
+ duration_ms=0,
670
+ )
671
+ )
672
+ reasoning_steps.append(
673
+ ReasoningStep(
674
+ tool="QueryPlanningTool",
675
+ action="search_execution",
676
+ input=dsl_string[:100] if dsl_string else query.text,
677
+ output=f"Found {len(items)} documents",
678
+ duration_ms=0,
679
+ )
680
+ )
681
+
682
+ # Parse inference results for additional outputs (legacy or conversational)
683
+ inference_results = agent_response.get("inference_results", [])
429
684
  if inference_results:
430
685
  outputs = inference_results[0].get("output", [])
431
686
 
@@ -443,8 +698,8 @@ class OpenSearchAgenticSearcher:
443
698
  elif name == "parent_message_id":
444
699
  # Track parent message ID for conversation threading
445
700
  pass # Could store for future use
446
- elif name in ("response", "answer_generator", "MLModelTool"):
447
- # Parse answer from output
701
+ elif name in ("answer_generator", "MLModelTool"):
702
+ # Parse answer from output (legacy format)
448
703
  answer = self._extract_answer_from_result(result)
449
704
 
450
705
  # Add reasoning step for answer generation
@@ -459,7 +714,7 @@ class OpenSearchAgenticSearcher:
459
714
  )
460
715
  )
461
716
  elif name in ("knowledge_search", "VectorDBTool"):
462
- # Parse search results from tool output
717
+ # Parse search results from legacy VectorDBTool output
463
718
  items.extend(self._parse_tool_search_results(result))
464
719
 
465
720
  # Add reasoning step
@@ -470,9 +725,10 @@ class OpenSearchAgenticSearcher:
470
725
  action="search",
471
726
  input=query.text,
472
727
  output=f"Found {len(items)} documents",
473
- duration_ms=0, # Not tracked per-step
728
+ duration_ms=0,
474
729
  )
475
730
  )
731
+ # Skip "response" and "query_planner" here - they're handled via generated_dsl parameter
476
732
 
477
733
  # Parse token usage if available
478
734
  usage = inference_results[0].get("usage", {})
@@ -480,8 +736,8 @@ class OpenSearchAgenticSearcher:
480
736
  prompt_tokens = usage.get("prompt_tokens", 0)
481
737
  completion_tokens = usage.get("completion_tokens", 0)
482
738
 
483
- # Parse agentic context for reasoning traces
484
- agentic_context = response.get("agentic_context", {})
739
+ # Parse agentic context for reasoning traces (if present)
740
+ agentic_context = agent_response.get("agentic_context", {})
485
741
  traces = agentic_context.get("traces", [])
486
742
  for trace in traces:
487
743
  if query.include_reasoning:
@@ -497,8 +753,8 @@ class OpenSearchAgenticSearcher:
497
753
  )
498
754
 
499
755
  # If no answer from structured output, try to get from raw response
500
- if not answer and "response" in response:
501
- answer = response.get("response")
756
+ if not answer and "response" in agent_response:
757
+ answer = agent_response.get("response")
502
758
 
503
759
  # Preserve the query's conversation_id if agent didn't return one
504
760
  # This allows multi-turn conversations when memory was created beforehand
@@ -519,6 +775,7 @@ class OpenSearchAgenticSearcher:
519
775
  total_tokens=total_tokens,
520
776
  prompt_tokens=prompt_tokens,
521
777
  completion_tokens=completion_tokens,
778
+ generated_query=dsl_string, # Include the generated DSL for debugging
522
779
  )
523
780
 
524
781
  def _extract_answer_from_result(
@@ -622,6 +879,56 @@ class OpenSearchAgenticSearcher:
622
879
 
623
880
  return items
624
881
 
882
+ def _parse_opensearch_hits(
883
+ self,
884
+ hits_data: dict[str, Any],
885
+ ) -> list[SearchResultItem]:
886
+ """Parse OpenSearch hits structure into SearchResultItems.
887
+
888
+ Standard OpenSearch response format:
889
+ {
890
+ "total": {"value": 10},
891
+ "max_score": 1.0,
892
+ "hits": [
893
+ {"_id": "...", "_score": 0.9, "_source": {...}}
894
+ ]
895
+ }
896
+
897
+ Args:
898
+ hits_data: OpenSearch hits object.
899
+
900
+ Returns:
901
+ List of SearchResultItem.
902
+ """
903
+ items: list[SearchResultItem] = []
904
+
905
+ hits = hits_data.get("hits", [])
906
+ for hit in hits:
907
+ if not isinstance(hit, dict):
908
+ continue
909
+
910
+ source = hit.get("_source", {})
911
+ if not source:
912
+ continue
913
+
914
+ items.append(
915
+ SearchResultItem(
916
+ doc_id=hit.get("_id", ""),
917
+ content=source.get("content", ""),
918
+ score=hit.get("_score", 0.0),
919
+ title=source.get("title"),
920
+ url=source.get("url"),
921
+ source=source.get("source"),
922
+ collection_id=source.get("collection_id"),
923
+ source_id=source.get("source_id"),
924
+ chunk_index=source.get("chunk_index"),
925
+ total_chunks=source.get("total_chunks"),
926
+ metadata=source.get("metadata"),
927
+ )
928
+ )
929
+
930
+ return items
931
+
625
932
 
626
933
  class AgenticSearchFallback:
627
934
  """Fallback handler for when agentic search fails.