rasa-pro 3.13.1a14__py3-none-any.whl → 3.13.1a16__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of rasa-pro might be problematic. Click here for more details.

rasa/builder/config.py CHANGED
@@ -43,13 +43,18 @@ def get_default_config(assistant_id: str) -> Dict[str, Any]:
43
43
  )
44
44
  )
45
45
  )
46
+
47
+ if not isinstance(base_config, dict):
48
+ raise ValueError("Base config is not a dictionary")
49
+
46
50
  base_config["assistant_id"] = assistant_id
51
+
47
52
  return base_config
48
53
 
49
54
 
50
55
  def get_default_endpoints() -> Dict[str, Any]:
51
56
  """Get default endpoints configuration."""
52
- return read_yaml_file(
57
+ endpoints_config = read_yaml_file(
53
58
  str(
54
59
  importlib_resources.files(PACKAGE_NAME).joinpath(
55
60
  "cli/project_templates/default/endpoints.yml"
@@ -57,6 +62,11 @@ def get_default_endpoints() -> Dict[str, Any]:
57
62
  )
58
63
  )
59
64
 
65
+ if not isinstance(endpoints_config, dict):
66
+ raise ValueError("Endpoints config is not a dictionary")
67
+
68
+ return endpoints_config
69
+
60
70
 
61
71
  def get_default_credentials() -> Dict[str, Any]:
62
72
  """Get default credentials configuration."""
@@ -29,6 +29,12 @@ class LLMGenerationError(PromptToBotError):
29
29
  pass
30
30
 
31
31
 
32
+ class DocumentRetrievalError(PromptToBotError):
33
+ """Raised when document retrieval fails."""
34
+
35
+ pass
36
+
37
+
32
38
  class SchemaValidationError(PromptToBotError):
33
39
  """Raised when schema validation fails."""
34
40
 
@@ -0,0 +1,64 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "type": "object",
4
+ "properties": {
5
+ "content": {
6
+ "type": "array",
7
+ "items": {
8
+ "type": "object",
9
+ "properties": {
10
+ "type": {
11
+ "type": "string"
12
+ },
13
+ "source": {
14
+ "type": "object",
15
+ "properties": {
16
+ "content": {
17
+ "type": "array",
18
+ "items": {
19
+ "type": "object",
20
+ "properties": {
21
+ "type": {
22
+ "type": "string"
23
+ },
24
+ "media_type": {
25
+ "type": "string"
26
+ },
27
+ "text": {
28
+ "type": "string"
29
+ },
30
+ "data": {
31
+ "type": "string"
32
+ }
33
+ }
34
+ }
35
+ },
36
+ "type": {
37
+ "type": "string"
38
+ },
39
+ "media_type": {
40
+ "type": "string"
41
+ },
42
+ "data": {
43
+ "type": "string"
44
+ }
45
+ }
46
+ },
47
+ "title": {
48
+ "type": "string"
49
+ },
50
+ "context": {
51
+ "type": "string"
52
+ },
53
+ "record_type": {
54
+ "type": "string"
55
+ },
56
+ "url": {
57
+ "type": "string"
58
+ }
59
+ }
60
+ }
61
+ }
62
+ },
63
+ "required": ["content"]
64
+ }
@@ -0,0 +1,212 @@
1
+ import asyncio
2
+ import json
3
+ import os
4
+ from contextlib import asynccontextmanager
5
+ from typing import List, Optional
6
+
7
+ import importlib_resources
8
+ import openai
9
+ import structlog
10
+ from openai.types.chat import ChatCompletion
11
+
12
+ from rasa.builder.exceptions import DocumentRetrievalError
13
+ from rasa.builder.models import Document
14
+ from rasa.constants import PACKAGE_NAME
15
+ from rasa.shared.utils.io import read_json_file
16
+
17
+ INKEEP_API_KEY_ENV_VAR = "INKEEP_API_KEY"
18
+ INKEEP_RAG_RESPONSE_SCHEMA_PATH = str(
19
+ importlib_resources.files(PACKAGE_NAME).joinpath(
20
+ "builder/inkeep-rag-response-schema.json"
21
+ )
22
+ )
23
+
24
+ INKEEP_DOCUMENT_RETRIEVAL_MODEL = "inkeep-rag"
25
+ INKEEP_BASE_URL = "https://api.inkeep.com/v1/"
26
+
27
+ structlogger = structlog.get_logger()
28
+
29
+
30
+ class InKeepDocumentRetrieval:
31
+ """Handles the document retrieval from InKeep AI."""
32
+
33
+ def __init__(
34
+ self,
35
+ api_key: Optional[str] = None,
36
+ ):
37
+ self._client: Optional[openai.AsyncOpenAI] = None
38
+ self._rag_schema = read_json_file(INKEEP_RAG_RESPONSE_SCHEMA_PATH)
39
+ self._api_key = api_key or os.getenv(INKEEP_API_KEY_ENV_VAR)
40
+
41
+ async def retrieve_documents(
42
+ self, query: str, temperature: float = 0.0, timeout: float = 30.0
43
+ ) -> List[Document]:
44
+ """Retrieve relevant documents using InKeep AI based on the given query.
45
+
46
+ Args:
47
+ query: The search query
48
+ temperature: Controls randomness in generation (0.0 for deterministic)
49
+ timeout: Timeout for the API call
50
+
51
+ Returns:
52
+ List of Document objects containing retrieved content
53
+
54
+ Raises:
55
+ LLMGenerationError: If the API call fails or returns invalid response
56
+ """
57
+ response = await self._call_inkeep_rag_api(
58
+ query=query,
59
+ temperature=temperature,
60
+ timeout=timeout,
61
+ )
62
+ documents = self._parse_documents_from_response(response)
63
+ return documents
64
+
65
+ async def _call_inkeep_rag_api(
66
+ self, query: str, temperature: float, timeout: float
67
+ ) -> ChatCompletion:
68
+ """Call InKeep AI RAG's API endpoint and return the response content.
69
+
70
+ Args:
71
+ query: The search query to send to InKeep
72
+ temperature: Controls randomness in generation (0.0 for deterministic)
73
+ timeout: Timeout for the API call
74
+
75
+ Returns:
76
+ The response content from InKeep AI. The response is made of the retrieved
77
+ documents.
78
+
79
+ Raises:
80
+ LLMGenerationError: If the API call fails or returns invalid response
81
+ """
82
+ request_params = {
83
+ "model": INKEEP_DOCUMENT_RETRIEVAL_MODEL,
84
+ "messages": [{"role": "user", "content": query}],
85
+ "temperature": temperature,
86
+ "timeout": timeout,
87
+ "response_format": {
88
+ "type": "json_schema",
89
+ "json_schema": self._rag_schema,
90
+ },
91
+ }
92
+ try:
93
+ async with self._get_client() as client:
94
+ response = await client.chat.completions.create(**request_params)
95
+
96
+ if not response.choices[0].message.content:
97
+ structlogger.warning(
98
+ "inkeep_document_retrieval.empty_response",
99
+ event_info="InKeep AI returned an empty response. ",
100
+ request_params=request_params,
101
+ response_content=response.choices[0].message.content,
102
+ )
103
+ raise DocumentRetrievalError(
104
+ "InKeep Document Retrieval: Empty response"
105
+ )
106
+
107
+ return response
108
+
109
+ except openai.OpenAIError as e:
110
+ structlogger.error(
111
+ "inkeep_document_retrieval.api_error",
112
+ event_info="InKeep Document Retrieval: API error",
113
+ request_params=request_params,
114
+ error=e,
115
+ )
116
+ raise DocumentRetrievalError(f"InKeep Document Retrieval: API error: {e}")
117
+ except asyncio.TimeoutError as e:
118
+ structlogger.error(
119
+ "inkeep_document_retrieval.timeout_error",
120
+ event_info="InKeep Document Retrieval: Timeout error",
121
+ request_params=request_params,
122
+ error=e,
123
+ )
124
+ raise DocumentRetrievalError(f"InKeep AI request timed out: {e}")
125
+ except Exception as e:
126
+ structlogger.error(
127
+ "inkeep_document_retrieval.error",
128
+ event_info="InKeep Document Retrieval: Error",
129
+ request_params=request_params,
130
+ error=e,
131
+ )
132
+ raise DocumentRetrievalError(
133
+ f"InKeep Document Retrieval: Unexpected error: {e}"
134
+ )
135
+
136
+ @asynccontextmanager
137
+ async def _get_client(self):
138
+ """Get or create client that handles the API calls to InKeep AI."""
139
+ if self._client is None:
140
+ self._client = openai.AsyncOpenAI(
141
+ api_key=self._api_key,
142
+ base_url=INKEEP_BASE_URL,
143
+ )
144
+
145
+ try:
146
+ yield self._client
147
+ except Exception as e:
148
+ structlogger.error(
149
+ "inkeep_document_retrieval.client_error",
150
+ event_info="InKeep Document Retrieval: Client error",
151
+ error=str(e),
152
+ )
153
+ raise
154
+
155
+ def _parse_documents_from_response(
156
+ self, response: ChatCompletion
157
+ ) -> List[Document]:
158
+ """Parse the InKeep AI response into Document objects.
159
+
160
+ Args:
161
+ response: ChatCompletion response from InKeep AI's RAG model.
162
+
163
+ Returns:
164
+ List of Document objects
165
+ """
166
+ try:
167
+ content = response.choices[0].message.content
168
+ if not content:
169
+ return []
170
+
171
+ response_data = json.loads(content)
172
+ documents = []
173
+
174
+ for item in response_data.get("content", []):
175
+ try:
176
+ document = Document.from_inkeep_rag_response(item)
177
+ documents.append(document)
178
+ except Exception as e:
179
+ structlogger.warning(
180
+ "inkeep_document_retrieval.invalid_document_skipped",
181
+ event_info=(
182
+ "InKeep Document Retrieval: Invalid document structure "
183
+ "skipped. Returning empty list for this item."
184
+ ),
185
+ error=str(e),
186
+ item=item,
187
+ )
188
+ # Continue processing other items, skip this invalid one
189
+ continue
190
+
191
+ return documents
192
+
193
+ except json.JSONDecodeError as e:
194
+ structlogger.warning(
195
+ "inkeep_document_retrieval.parse_response_failed",
196
+ event_info=(
197
+ "InKeep Document Retrieval: Parse response failed. "
198
+ "Returning empty list.",
199
+ ),
200
+ error=str(e),
201
+ )
202
+ return []
203
+ except Exception as e:
204
+ structlogger.error(
205
+ "inkeep_document_retrieval.parse_response_error",
206
+ event_info=(
207
+ "InKeep Document Retrieval: Parse response error. "
208
+ "Returning empty list.",
209
+ ),
210
+ error=str(e),
211
+ )
212
+ return []
@@ -5,17 +5,19 @@ import importlib
5
5
  import json
6
6
  from contextlib import asynccontextmanager
7
7
  from copy import deepcopy
8
- from typing import Any, Dict, List, Optional
8
+ from typing import Any, AsyncGenerator, Dict, List, Optional
9
9
 
10
10
  import importlib_resources
11
11
  import openai
12
12
  import structlog
13
13
  from jinja2 import Template
14
+ from pydantic import ValidationError
14
15
 
15
16
  from rasa.builder import config
16
17
  from rasa.builder.exceptions import LLMGenerationError
18
+ from rasa.builder.inkeep_document_retrieval import InKeepDocumentRetrieval
17
19
  from rasa.builder.llm_context import tracker_as_llm_context
18
- from rasa.builder.models import LLMBuilderContext
20
+ from rasa.builder.models import Document, LLMBuilderContext, LLMHelperResponse
19
21
  from rasa.constants import PACKAGE_NAME
20
22
  from rasa.shared.constants import DOMAIN_SCHEMA_FILE, RESPONSES_SCHEMA_FILE
21
23
  from rasa.shared.core.flows.yaml_flows_io import FLOWS_SCHEMA_FILE
@@ -28,14 +30,14 @@ structlogger = structlog.get_logger()
28
30
  class LLMService:
29
31
  """Handles OpenAI LLM interactions with caching for efficiency."""
30
32
 
31
- def __init__(self):
33
+ def __init__(self) -> None:
32
34
  self._client: Optional[openai.AsyncOpenAI] = None
33
35
  self._domain_schema: Optional[Dict[str, Any]] = None
34
36
  self._flows_schema: Optional[Dict[str, Any]] = None
35
37
  self._helper_schema: Optional[Dict[str, Any]] = None
36
38
 
37
39
  @asynccontextmanager
38
- async def _get_client(self):
40
+ async def _get_client(self) -> AsyncGenerator[openai.AsyncOpenAI, None]:
39
41
  """Get or create OpenAI client with proper resource management."""
40
42
  if self._client is None:
41
43
  self._client = openai.AsyncOpenAI(timeout=config.OPENAI_TIMEOUT)
@@ -46,7 +48,7 @@ class LLMService:
46
48
  structlogger.error("llm.client_error", error=str(e))
47
49
  raise
48
50
 
49
- def _prepare_schemas(self):
51
+ def _prepare_schemas(self) -> None:
50
52
  """Prepare and cache schemas for LLM generation."""
51
53
  if self._domain_schema is None:
52
54
  self._domain_schema = _prepare_domain_schema()
@@ -135,7 +137,7 @@ class LLMService:
135
137
 
136
138
  async def generate_helper_response(
137
139
  self, messages: List[Dict[str, Any]]
138
- ) -> Dict[str, Any]:
140
+ ) -> LLMHelperResponse:
139
141
  """Generate helper response using OpenAI."""
140
142
  self._prepare_schemas()
141
143
 
@@ -158,9 +160,11 @@ class LLMService:
158
160
  raise LLMGenerationError("Empty response from LLM helper")
159
161
 
160
162
  try:
161
- return json.loads(content)
163
+ return LLMHelperResponse.model_validate_json(json.loads(content))
162
164
  except json.JSONDecodeError as e:
163
165
  raise LLMGenerationError(f"Invalid JSON from LLM helper: {e}")
166
+ except ValidationError as e:
167
+ raise LLMGenerationError(f"Invalid JSON from LLM helper: {e}")
164
168
 
165
169
  except openai.OpenAIError as e:
166
170
  raise LLMGenerationError(f"OpenAI API error in helper: {e}")
@@ -169,30 +173,14 @@ class LLMService:
169
173
 
170
174
  async def search_documentation(
171
175
  self, query: str, max_results: Optional[int] = None
172
- ) -> List[Dict[str, Any]]:
176
+ ) -> List[Document]:
173
177
  """Search documentation using OpenAI vector store."""
174
- if max_results is None:
175
- max_results = config.OPENAI_MAX_VECTOR_RESULTS
176
-
177
- try:
178
- async with self._get_client() as client:
179
- results = await client.vector_stores.search(
180
- vector_store_id=config.OPENAI_VECTOR_STORE_ID,
181
- query=query,
182
- max_num_results=max_results,
183
- rewrite_query=True,
184
- )
185
-
186
- return results.data
187
-
188
- except openai.OpenAIError as e:
189
- structlogger.warning(
190
- "llm.documentation_search_failed", error=str(e), query=query
191
- )
192
- return []
178
+ inkeep_document_retrieval = InKeepDocumentRetrieval()
179
+ documents = await inkeep_document_retrieval.retrieve_documents(query)
180
+ return documents
193
181
 
194
182
  @staticmethod
195
- def _format_chat_dump(messages) -> str:
183
+ def _format_chat_dump(messages: List[Dict[str, Any]]) -> str:
196
184
  """Format chat messages for documentation search."""
197
185
  result = ""
198
186
  for message in messages:
@@ -213,16 +201,15 @@ class LLMService:
213
201
  return result
214
202
 
215
203
  @staticmethod
216
- def _format_documentation_results(results) -> str:
204
+ def _format_documentation_results(results: List[Document]) -> str:
217
205
  """Format documentation search results."""
218
206
  if not results:
219
207
  return "<sources>No relevant documentation found.</sources>"
220
208
 
221
209
  formatted_results = ""
222
210
  for result in results:
223
- formatted_result = f"<result url='{result.attributes.get('url', '')}'>"
224
- for part in result.content:
225
- formatted_result += f"<content>{part.text}</content>"
211
+ formatted_result = f"<result url='{result.url}'>"
212
+ formatted_result += f"<content>{result.content}</content>"
226
213
  formatted_results += formatted_result + "</result>"
227
214
 
228
215
  return f"<sources>{formatted_results}</sources>"
@@ -233,6 +220,9 @@ def _prepare_domain_schema() -> Dict[str, Any]:
233
220
  """Prepare domain schema by removing unnecessary parts."""
234
221
  domain_schema = deepcopy(read_schema_file(DOMAIN_SCHEMA_FILE, PACKAGE_NAME, False))
235
222
 
223
+ if not isinstance(domain_schema, dict):
224
+ raise ValueError("Domain schema is not a dictionary")
225
+
236
226
  # Remove parts not needed for CALM bots
237
227
  unnecessary_keys = ["intents", "entities", "forms", "config", "session_config"]
238
228
 
rasa/builder/main.py CHANGED
@@ -5,13 +5,23 @@ import logging
5
5
  import sys
6
6
  from typing import Optional
7
7
 
8
+ import structlog
9
+ from sanic import HTTPResponse, Sanic
10
+ from sanic.request import Request
11
+ from sanic_openapi import openapi3_blueprint
12
+
8
13
  import rasa.core.utils
14
+ from rasa.builder import config
9
15
  from rasa.builder.logging_utils import collecting_logs_processor
10
- from rasa.builder.service import BotBuilderService
16
+ from rasa.builder.service import bp, setup_project_generator
17
+ from rasa.core.channels.studio_chat import StudioChatInput
18
+ from rasa.server import configure_cors
11
19
  from rasa.utils.common import configure_logging_and_warnings
12
20
  from rasa.utils.log_utils import configure_structlog
13
21
  from rasa.utils.sanic_error_handler import register_custom_sanic_error_handler
14
22
 
23
+ structlogger = structlog.get_logger()
24
+
15
25
 
16
26
  def setup_logging() -> None:
17
27
  """Setup logging configuration."""
@@ -31,22 +41,98 @@ def setup_logging() -> None:
31
41
  )
32
42
 
33
43
 
44
+ def setup_input_channel() -> StudioChatInput:
45
+ """Setup the input channel for chat interactions."""
46
+ studio_chat_credentials = config.get_default_credentials().get(
47
+ StudioChatInput.name()
48
+ )
49
+ return StudioChatInput.from_credentials(credentials=studio_chat_credentials)
50
+
51
+
52
+ def setup_middleware(app: Sanic) -> None:
53
+ """Setup middleware for request/response processing."""
54
+
55
+ @app.middleware("request")
56
+ async def log_request(request: Request) -> None:
57
+ structlogger.info(
58
+ "request.received",
59
+ method=request.method,
60
+ path=request.path,
61
+ remote_addr=request.remote_addr or "unknown",
62
+ )
63
+
64
+ @app.middleware("response")
65
+ async def log_response(request: Request, response: HTTPResponse) -> None:
66
+ structlogger.info(
67
+ "request.completed",
68
+ method=request.method,
69
+ path=request.path,
70
+ status=response.status,
71
+ )
72
+
73
+
74
+ def create_app(project_folder: Optional[str] = None) -> Sanic:
75
+ """Create and configure the Sanic app."""
76
+ app = Sanic("BotBuilderService")
77
+
78
+ # Basic app configuration
79
+ app.config.REQUEST_TIMEOUT = 60 # 1 minute timeout
80
+ app.ctx.agent = None
81
+
82
+ # Set up project generator and store in app context
83
+ app.ctx.project_generator = setup_project_generator(project_folder)
84
+
85
+ # Set up input channel and store in app context
86
+ app.ctx.input_channel = setup_input_channel()
87
+
88
+ # Register the blueprint
89
+ app.blueprint(bp)
90
+
91
+ # OpenAPI docs
92
+ app.blueprint(openapi3_blueprint)
93
+ app.config.API_TITLE = "Bot Builder API"
94
+ app.config.API_VERSION = rasa.__version__
95
+ app.config.API_DESCRIPTION = (
96
+ "API for building conversational AI bots from prompts and templates. "
97
+ "The API allows to change the assistant and retrain it with new data."
98
+ )
99
+
100
+ # Setup middleware
101
+ setup_middleware(app)
102
+
103
+ configure_cors(app, cors_origins=config.CORS_ORIGINS)
104
+
105
+ # Register input channel webhooks
106
+ from rasa.core import channels
107
+
108
+ channels.channel.register([app.ctx.input_channel], app, route="/webhooks/")
109
+
110
+ return app
111
+
112
+
34
113
  def main(project_folder: Optional[str] = None) -> None:
35
114
  """Main entry point."""
36
115
  try:
37
116
  # Setup logging
38
117
  setup_logging()
39
118
 
40
- # Create and configure service
41
-
42
- service = BotBuilderService(project_folder)
43
- register_custom_sanic_error_handler(service.app)
44
-
45
- # Log available routes
46
- rasa.core.utils.list_routes(service.app)
119
+ # Create and configure app
120
+ app = create_app(project_folder)
121
+ register_custom_sanic_error_handler(app)
47
122
 
48
123
  # Run the service
49
- service.run()
124
+ structlogger.info(
125
+ "service.starting",
126
+ host=config.BUILDER_SERVER_HOST,
127
+ port=config.BUILDER_SERVER_PORT,
128
+ )
129
+
130
+ app.run(
131
+ host=config.BUILDER_SERVER_HOST,
132
+ port=config.BUILDER_SERVER_PORT,
133
+ legacy=True,
134
+ motd=False,
135
+ )
50
136
 
51
137
  except KeyboardInterrupt:
52
138
  print("\nService stopped by user")
rasa/builder/models.py CHANGED
@@ -19,7 +19,7 @@ class PromptRequest(BaseModel):
19
19
  )
20
20
 
21
21
  @validator("prompt")
22
- def validate_prompt(cls, v):
22
+ def validate_prompt(cls, v: str) -> str:
23
23
  if not v.strip():
24
24
  raise ValueError("Prompt cannot be empty or whitespace only")
25
25
  return v.strip()
@@ -39,7 +39,7 @@ class TemplateRequest(BaseModel):
39
39
  )
40
40
 
41
41
  @validator("template_name")
42
- def validate_template_name(cls, v):
42
+ def validate_template_name(cls, v: Any) -> Any:
43
43
  if v not in ProjectTemplateName:
44
44
  raise ValueError(
45
45
  f"Template name must be one of {ProjectTemplateName.supported_values()}"
@@ -129,14 +129,6 @@ class LLMHelperResponse(BaseModel):
129
129
  content_blocks: List[Union[TextBlock, CodeBlock, FileBlock, LinkBlock]] = Field(...)
130
130
 
131
131
 
132
- class ApiResponse(BaseModel):
133
- """Standard API response model."""
134
-
135
- status: str = Field(...)
136
- message: Optional[str] = Field(None)
137
- data: Optional[Dict[str, Any]] = Field(None)
138
-
139
-
140
132
  class ApiErrorResponse(BaseModel):
141
133
  """API error response model."""
142
134
 
@@ -172,3 +164,62 @@ class TrainingResult(BaseModel):
172
164
  success: bool = Field(...)
173
165
  model_path: Optional[str] = Field(None)
174
166
  error: Optional[str] = Field(None)
167
+
168
+
169
+ BotFiles = Dict[str, Optional[str]]
170
+
171
+
172
+ class Document(BaseModel):
173
+ """Model for document retrieval results."""
174
+
175
+ content: str = Field(...)
176
+ url: Optional[str] = Field(None)
177
+ title: Optional[str] = Field(None)
178
+ metadata: Optional[Dict[str, Any]] = Field(None)
179
+
180
+ @classmethod
181
+ def from_inkeep_rag_response(cls, rag_item: Dict[str, Any]) -> "Document":
182
+ """Create a Document object from a single InKeep RAG response item.
183
+
184
+ Args:
185
+ rag_item: Single item from InKeep RAG response
186
+
187
+ Returns:
188
+ Document object with extracted content and metadata
189
+ """
190
+ source = rag_item.get("source", {})
191
+ text_content = cls._extract_text_from_source(source)
192
+
193
+ return cls(
194
+ content=text_content.strip() if text_content else "",
195
+ url=rag_item.get("url"),
196
+ title=rag_item.get("title"),
197
+ metadata={
198
+ "type": rag_item.get("type"),
199
+ "record_type": rag_item.get("record_type"),
200
+ "context": rag_item.get("context"),
201
+ "media_type": source.get("media_type"),
202
+ },
203
+ )
204
+
205
+ @staticmethod
206
+ def _extract_text_from_source(source: Dict[str, Any]) -> str:
207
+ """Extract text content from InKeep source object.
208
+
209
+ Args:
210
+ source: Source object from InKeep RAG response
211
+
212
+ Returns:
213
+ Extracted text content
214
+ """
215
+ # Try to extract from content array first
216
+ if "content" in source:
217
+ text_parts = []
218
+ for content_item in source["content"]:
219
+ if content_item.get("type") == "text" and content_item.get("text"):
220
+ text_parts.append(content_item["text"])
221
+ if text_parts:
222
+ return "\n".join(text_parts)
223
+
224
+ # Fallback to source data
225
+ return source.get("data", "")