ws-bom-robot-app 0.0.63__py3-none-any.whl → 0.0.103__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 (51) hide show
  1. ws_bom_robot_app/config.py +30 -8
  2. ws_bom_robot_app/cron_manager.py +13 -12
  3. ws_bom_robot_app/llm/agent_context.py +1 -1
  4. ws_bom_robot_app/llm/agent_handler.py +11 -12
  5. ws_bom_robot_app/llm/agent_lcel.py +80 -18
  6. ws_bom_robot_app/llm/api.py +69 -7
  7. ws_bom_robot_app/llm/evaluator.py +319 -0
  8. ws_bom_robot_app/llm/main.py +51 -28
  9. ws_bom_robot_app/llm/models/api.py +40 -6
  10. ws_bom_robot_app/llm/nebuly_handler.py +18 -15
  11. ws_bom_robot_app/llm/providers/llm_manager.py +233 -75
  12. ws_bom_robot_app/llm/tools/tool_builder.py +4 -1
  13. ws_bom_robot_app/llm/tools/tool_manager.py +48 -22
  14. ws_bom_robot_app/llm/utils/chunker.py +6 -1
  15. ws_bom_robot_app/llm/utils/cleanup.py +81 -0
  16. ws_bom_robot_app/llm/utils/cms.py +60 -14
  17. ws_bom_robot_app/llm/utils/download.py +112 -8
  18. ws_bom_robot_app/llm/vector_store/db/base.py +50 -0
  19. ws_bom_robot_app/llm/vector_store/db/chroma.py +28 -8
  20. ws_bom_robot_app/llm/vector_store/db/faiss.py +35 -8
  21. ws_bom_robot_app/llm/vector_store/db/qdrant.py +29 -14
  22. ws_bom_robot_app/llm/vector_store/integration/api.py +216 -0
  23. ws_bom_robot_app/llm/vector_store/integration/azure.py +1 -1
  24. ws_bom_robot_app/llm/vector_store/integration/base.py +58 -15
  25. ws_bom_robot_app/llm/vector_store/integration/confluence.py +33 -5
  26. ws_bom_robot_app/llm/vector_store/integration/dropbox.py +1 -1
  27. ws_bom_robot_app/llm/vector_store/integration/gcs.py +1 -1
  28. ws_bom_robot_app/llm/vector_store/integration/github.py +22 -22
  29. ws_bom_robot_app/llm/vector_store/integration/googledrive.py +46 -17
  30. ws_bom_robot_app/llm/vector_store/integration/jira.py +93 -60
  31. ws_bom_robot_app/llm/vector_store/integration/manager.py +6 -2
  32. ws_bom_robot_app/llm/vector_store/integration/s3.py +1 -1
  33. ws_bom_robot_app/llm/vector_store/integration/sftp.py +1 -1
  34. ws_bom_robot_app/llm/vector_store/integration/sharepoint.py +7 -14
  35. ws_bom_robot_app/llm/vector_store/integration/shopify.py +143 -0
  36. ws_bom_robot_app/llm/vector_store/integration/sitemap.py +6 -1
  37. ws_bom_robot_app/llm/vector_store/integration/slack.py +3 -2
  38. ws_bom_robot_app/llm/vector_store/integration/thron.py +236 -0
  39. ws_bom_robot_app/llm/vector_store/loader/base.py +52 -8
  40. ws_bom_robot_app/llm/vector_store/loader/docling.py +71 -33
  41. ws_bom_robot_app/main.py +148 -146
  42. ws_bom_robot_app/subprocess_runner.py +106 -0
  43. ws_bom_robot_app/task_manager.py +204 -53
  44. ws_bom_robot_app/util.py +6 -0
  45. {ws_bom_robot_app-0.0.63.dist-info → ws_bom_robot_app-0.0.103.dist-info}/METADATA +158 -75
  46. ws_bom_robot_app-0.0.103.dist-info/RECORD +76 -0
  47. ws_bom_robot_app/llm/settings.py +0 -4
  48. ws_bom_robot_app/llm/utils/kb.py +0 -34
  49. ws_bom_robot_app-0.0.63.dist-info/RECORD +0 -72
  50. {ws_bom_robot_app-0.0.63.dist-info → ws_bom_robot_app-0.0.103.dist-info}/WHEEL +0 -0
  51. {ws_bom_robot_app-0.0.63.dist-info → ws_bom_robot_app-0.0.103.dist-info}/top_level.txt +0 -0
@@ -1,9 +1,10 @@
1
- import json
2
1
  from typing import Optional
2
+ from urllib.parse import urlparse
3
3
  from langchain_core.embeddings import Embeddings
4
4
  from langchain_core.language_models import BaseChatModel
5
5
  from pydantic import BaseModel, ConfigDict, Field
6
6
  import os
7
+ from ws_bom_robot_app.llm.utils.download import Base64File
7
8
 
8
9
  class LlmConfig(BaseModel):
9
10
  api_url: Optional[str] = None
@@ -36,6 +37,76 @@ class LlmInterface:
36
37
  def get_parser(self):
37
38
  from langchain.agents.output_parsers.openai_tools import OpenAIToolsAgentOutputParser
38
39
  return OpenAIToolsAgentOutputParser()
40
+ async def _format_multimodal_image_message(self, message: dict) -> dict:
41
+ return {
42
+ "type": "image_url",
43
+ "image_url": {
44
+ "url": message.get("url")
45
+ }
46
+ }
47
+ async def _format_multimodal_file_message(self, message: dict, file: Base64File = None) -> dict:
48
+ _file = file or await Base64File.from_url(message.get("url"))
49
+ return {"type": "text", "text": f"Here's a file attachment named `{_file.name}` of type `{_file.mime_type}` in base64: `{_file.base64_content}`"}
50
+ async def format_multimodal_content(self, content: list) -> list:
51
+ _content = []
52
+ for message in content:
53
+ if isinstance(message, dict):
54
+ if message.get("type") == "image" and "url" in message:
55
+ _content.append(await self._format_multimodal_image_message(message))
56
+ elif message.get("type") == "file" and "url" in message:
57
+ _content.append(await self._format_multimodal_file_message(message))
58
+ else:
59
+ # pass through text or other formats unchanged
60
+ _content.append(message)
61
+ else:
62
+ _content.append(message)
63
+ return _content
64
+
65
+ class Anthropic(LlmInterface):
66
+ def get_llm(self):
67
+ from langchain_anthropic import ChatAnthropic
68
+ return ChatAnthropic(
69
+ api_key=self.config.api_key or os.getenv("ANTHROPIC_API_KEY"),
70
+ model=self.config.model,
71
+ temperature=self.config.temperature,
72
+ max_tokens=8192,
73
+ streaming=True,
74
+ #betas=["files-api-2025-04-14"] #https://docs.anthropic.com/en/docs/build-with-claude/files
75
+ )
76
+
77
+ """
78
+ def get_embeddings(self):
79
+ from langchain_voyageai import VoyageAIEmbeddings
80
+ return VoyageAIEmbeddings(
81
+ api_key=self.config.embedding_api_key, #voyage api key
82
+ model="voyage-3")
83
+ """
84
+
85
+ def get_models(self):
86
+ import anthropic
87
+ client = anthropic.Client(api_key=self.config.api_key or os.getenv("ANTHROPIC_API_KEY"))
88
+ response = client.models.list()
89
+ return response.data
90
+
91
+ """
92
+ async def _format_multimodal_image_message(self, message: dict) -> dict:
93
+ file = await Base64File.from_url(message.get("url"))
94
+ return { "type": "image_url", "image_url": { "url": file.base64_url }}
95
+ """
96
+
97
+ #https://python.langchain.com/docs/integrations/chat/anthropic/
98
+ #https://python.langchain.com/docs/how_to/multimodal_inputs/
99
+ async def _format_multimodal_file_message(self, message: dict, file: Base64File = None) -> dict:
100
+ _url = str(message.get("url", ""))
101
+ _url_lower = _url.lower()
102
+ if _url_lower.startswith("http") and any(urlparse(_url_lower).path.endswith(ext) for ext in [".pdf"]):
103
+ return {"type": "file", "source_type": "url", "url": _url}
104
+ else:
105
+ _file = file or await Base64File.from_url(_url)
106
+ if _file.extension in ["pdf"]:
107
+ return {"type": "document", "source": {"type": "base64", "media_type": _file.mime_type, "data": _file.base64_content}}
108
+ else:
109
+ return await super()._format_multimodal_file_message(message, _file)
39
110
 
40
111
  class OpenAI(LlmInterface):
41
112
  def __init__(self, config: LlmConfig):
@@ -47,8 +118,9 @@ class OpenAI(LlmInterface):
47
118
  chat = ChatOpenAI(
48
119
  api_key=self.config.api_key or os.getenv("OPENAI_API_KEY"),
49
120
  model=self.config.model,
50
- stream_usage=True)
51
- if not (any(self.config.model.startswith(prefix) for prefix in ["o1", "o3"]) or "search" in self.config.model):
121
+ streaming=True
122
+ )
123
+ if not (any(self.config.model.startswith(prefix) for prefix in ["gpt-5", "o1", "o3"]) or "search" in self.config.model):
52
124
  chat.temperature = self.config.temperature
53
125
  chat.streaming = True
54
126
  return chat
@@ -59,6 +131,13 @@ class OpenAI(LlmInterface):
59
131
  response = openai.models.list()
60
132
  return response.data
61
133
 
134
+ async def _format_multimodal_file_message(self, message: dict, file: Base64File = None) -> dict:
135
+ _file = file or await Base64File.from_url(message.get("url"))
136
+ if _file.extension in ["pdf"]:
137
+ return {"type": "file", "file": { "source_type": "base64", "file_data": _file.base64_url, "mime_type": _file.mime_type, "filename": _file.name}}
138
+ else:
139
+ return await super()._format_multimodal_file_message(message, _file)
140
+
62
141
  class DeepSeek(LlmInterface):
63
142
  def get_llm(self):
64
143
  from langchain_openai import ChatOpenAI
@@ -68,8 +147,7 @@ class DeepSeek(LlmInterface):
68
147
  base_url="https://api.deepseek.com",
69
148
  max_tokens=8192,
70
149
  temperature=self.config.temperature,
71
- streaming=True,
72
- stream_usage=True,
150
+ streaming=True
73
151
  )
74
152
 
75
153
  def get_models(self):
@@ -79,36 +157,50 @@ class DeepSeek(LlmInterface):
79
157
  response = openai.models.list()
80
158
  return response.data
81
159
 
160
+ async def _format_multimodal_image_message(self, message: dict) -> dict:
161
+ print(f"{DeepSeek.__name__} does not support image messages")
162
+ return None
163
+
164
+ async def _format_multimodal_file_message(self, message: dict, file: Base64File = None) -> dict:
165
+ print(f"{DeepSeek.__name__} does not support file messages")
166
+ return None
167
+
82
168
  class Google(LlmInterface):
83
- def get_llm(self):
84
- from langchain_google_genai.chat_models import ChatGoogleGenerativeAI
85
- return ChatGoogleGenerativeAI(
86
- name="chat",
87
- api_key=self.config.api_key or os.getenv("GOOGLE_API_KEY"),
88
- model=self.config.model,
89
- temperature=self.config.temperature,
90
- disable_streaming=False,
91
- )
92
-
93
- def get_embeddings(self):
94
- from langchain_google_genai.embeddings import GoogleGenerativeAIEmbeddings
95
- return GoogleGenerativeAIEmbeddings(
96
- google_api_key=self.config.api_key,
97
- model="models/text-embedding-005")
98
-
99
- def get_models(self):
100
- import google.generativeai as genai
101
- genai.configure(api_key=self.config.api_key or os.getenv("GOOGLE_API_KEY"))
102
- response = genai.list_models()
103
- return [{
104
- "id": model.name,
105
- "name": model.display_name,
106
- "description": model.description,
107
- "input_token_limit": model.input_token_limit,
108
- "output_token_limit": model.output_token_limit
109
- } for model in response if "gemini" in model.name.lower()]
110
-
111
- class Gvertex(LlmInterface):
169
+ def get_llm(self):
170
+ from langchain_google_genai.chat_models import ChatGoogleGenerativeAI
171
+ return ChatGoogleGenerativeAI(
172
+ model=self.config.model,
173
+ google_api_key=self.config.api_key or os.getenv("GOOGLE_API_KEY"),
174
+ temperature=self.config.temperature,
175
+ disable_streaming=False,
176
+ )
177
+
178
+ def get_embeddings(self):
179
+ from langchain_google_genai.embeddings import GoogleGenerativeAIEmbeddings
180
+ return GoogleGenerativeAIEmbeddings(
181
+ google_api_key=self.config.api_key or os.getenv("GOOGLE_API_KEY"),
182
+ model="models/gemini-embedding-001")
183
+
184
+ def get_models(self):
185
+ import google.generativeai as genai
186
+ genai.configure(api_key=self.config.api_key or os.getenv("GOOGLE_API_KEY"))
187
+ response = genai.list_models()
188
+ return [{
189
+ "id": model.name,
190
+ "name": model.display_name,
191
+ "description": model.description,
192
+ "input_token_limit": model.input_token_limit,
193
+ "output_token_limit": model.output_token_limit
194
+ } for model in response if "gemini" in model.name.lower()]
195
+
196
+ async def _format_multimodal_file_message(self, message: dict, file: Base64File = None) -> dict:
197
+ _file = file or await Base64File.from_url(message.get("url"))
198
+ if _file.extension in ["pdf", "csv"]:
199
+ return {"type": "media", "mime_type": _file.mime_type, "data": _file.base64_content }
200
+ else:
201
+ return await super()._format_multimodal_file_message(message, _file)
202
+
203
+ class GoogleVertex(LlmInterface):
112
204
  def get_llm(self):
113
205
  from langchain_google_vertexai import ChatVertexAI
114
206
  return ChatVertexAI(
@@ -116,47 +208,37 @@ class Gvertex(LlmInterface):
116
208
  temperature=self.config.temperature
117
209
  )
118
210
  def get_embeddings(self):
119
- from langchain_google_vertexai import VertexAIEmbeddings
120
- return VertexAIEmbeddings(model_name="text-embedding-005")
211
+ from langchain_google_vertexai.embeddings import VertexAIEmbeddings
212
+ embeddings = VertexAIEmbeddings(model_name="gemini-embedding-001")
213
+ return embeddings
121
214
  def get_models(self):
122
- #from google.cloud import aiplatform
123
- #aiplatform.init()
124
- #models = aiplatform.Model.list()
215
+ _models = [
216
+ {"id":"gemini-2.5-pro"},
217
+ {"id":"gemini-2.5-flash"},
218
+ {"id":"gemini-2.0-flash"},
219
+ {"id":"gemini-2.0-flash-lite"}
220
+ ]
221
+ try:
222
+ from google.cloud import aiplatform
223
+ aiplatform.init()
224
+ _list = aiplatform.Model.list()
225
+ if _list:
226
+ _models = list([{"id": model.name} for model in _list])
125
227
  # removed due issue: https://github.com/langchain-ai/langchain-google/issues/733
126
228
  # Message type "google.cloud.aiplatform.v1beta1.GenerateContentResponse" has no field named "createTime" at "GenerateContentResponse". Available Fields(except extensions): "['candidates', 'modelVersion', 'promptFeedback', 'usageMetadata']"
229
+ except Exception as e:
230
+ print(f"Error fetching models from Gvertex: {e}")
231
+ # fallback to hardcoded models
232
+ #see https://cloud.google.com/vertex-ai/generative-ai/docs/learn/locations#united-states for available models
233
+ finally:
234
+ return _models
127
235
 
128
- #see https://cloud.google.com/vertex-ai/generative-ai/docs/learn/locations#united-states for available models
129
- return [
130
- {"id":"gemini-2.5-pro-preview-05-06"},
131
- {"id":"gemini-2.0-flash"},
132
- {"id":"gemini-2.0-flash-lite"},
133
- {"id":"gemini-1.5-pro-002"}
134
- ]
135
-
136
- class Anthropic(LlmInterface):
137
- def get_llm(self):
138
- from langchain_anthropic import ChatAnthropic
139
- return ChatAnthropic(
140
- api_key=self.config.api_key or os.getenv("ANTHROPIC_API_KEY"),
141
- model=self.config.model,
142
- temperature=self.config.temperature,
143
- streaming=True,
144
- stream_usage=True
145
- )
146
-
147
- """
148
- def get_embeddings(self):
149
- from langchain_voyageai import VoyageAIEmbeddings
150
- return VoyageAIEmbeddings(
151
- api_key=self.config.embedding_api_key, #voyage api key
152
- model="voyage-3")
153
- """
154
-
155
- def get_models(self):
156
- import anthropic
157
- client = anthropic.Client(api_key=self.config.api_key or os.getenv("ANTHROPIC_API_KEY"))
158
- response = client.models.list()
159
- return response.data
236
+ async def _format_multimodal_file_message(self, message: dict, file: Base64File = None) -> dict:
237
+ _file = file or await Base64File.from_url(message.get("url"))
238
+ if _file.extension in ["pdf", "csv"]:
239
+ return {"type": "media", "mime_type": _file.mime_type, "data": _file.base64_content }
240
+ else:
241
+ return await super()._format_multimodal_file_message(message, _file)
160
242
 
161
243
  class Groq(LlmInterface):
162
244
  def get_llm(self):
@@ -179,10 +261,74 @@ class Groq(LlmInterface):
179
261
  response = requests.get(url, headers=headers)
180
262
  return response.json().get("data", [])
181
263
 
264
+ class IBM(LlmInterface):
265
+ def __init__(self, config: LlmConfig):
266
+ from ibm_watsonx_ai import APIClient,Credentials
267
+ super().__init__(config)
268
+ self.__base_url = self.config.api_url or os.getenv("WATSONX_URL") or "https://us-south.ml.cloud.ibm.com"
269
+ self.__api_key = self.config.api_key or os.getenv("WATSONX_APIKEY")
270
+ self.__client = APIClient(
271
+ credentials=Credentials(url=self.__base_url,api_key=self.__api_key),
272
+ project_id=os.getenv("WATSONX_PROJECTID") or "default"
273
+ )
274
+ def get_llm(self):
275
+ from langchain_ibm import ChatWatsonx
276
+ return ChatWatsonx(
277
+ model_id=self.config.model,
278
+ watsonx_client=self.__client
279
+ )
280
+ def get_models(self):
281
+ import requests
282
+ from datetime import date
283
+ try:
284
+ # https://cloud.ibm.com/apidocs/watsonx-ai#list-foundation-model-specs
285
+ today = date.today().strftime("%Y-%m-%d")
286
+ url = f"{self.__base_url}/ml/v1/foundation_model_specs?version={today}&filters=task_generation,task_summarization:and"
287
+ headers = {
288
+ "Authorization": f"Bearer {self.__api_key}",
289
+ "Content-Type": "application/json"
290
+ }
291
+ response = requests.get(url, headers=headers)
292
+ models = response.json().get("resources", [])
293
+ return [{
294
+ "id": model['model_id'],
295
+ "provider": model['provider'],
296
+ "tasks": model['task_ids'],
297
+ "limits": model.get('model_limits', {}),
298
+ } for model in models]
299
+ except Exception as e:
300
+ print(f"Error fetching models from IBM WatsonX: {e}")
301
+ # https://www.ibm.com/products/watsonx-ai/foundation-models
302
+ return [
303
+ {"id":"ibm/granite-13b-instruct-v2"},
304
+ {"id":"ibm/granite-3-2b-instruct"},
305
+ {"id":"ibm/granite-3-8b-instruct"},
306
+ {"id":"meta-llama/llama-2-13b-chat"},
307
+ {"id":"meta-llama/llama-3-3-70b-instruct"},
308
+ {"id":"meta-llama/llama-4-maverick-17b-128e-instruct-fp8"},
309
+ {"id":"mistralai/mistral-large"},
310
+ {"id":"mistralai/mixtral-8x7b-instruct-v01"},
311
+ {"id":"mistralai/pixtral-12b"}
312
+ ]
313
+
314
+ def get_embeddings(self):
315
+ from langchain_ibm import WatsonxEmbeddings
316
+ from ibm_watsonx_ai.metanames import EmbedTextParamsMetaNames
317
+ # https://dataplatform.cloud.ibm.com/docs/content/wsj/analyze-data/fm-models-embed.html?context=wx&audience=wdp#embed
318
+ embed_params = {
319
+ EmbedTextParamsMetaNames.TRUNCATE_INPUT_TOKENS: 512,
320
+ #EmbedTextParamsMetaNames.RETURN_OPTIONS: {"input_text": True},
321
+ }
322
+ return WatsonxEmbeddings(
323
+ model_id="ibm/granite-embedding-107m-multilingual", #https://www.ibm.com/products/watsonx-ai/foundation-models
324
+ watsonx_client=self.__client,
325
+ params=embed_params
326
+ )
327
+
182
328
  class Ollama(LlmInterface):
183
329
  def __init__(self, config: LlmConfig):
184
330
  super().__init__(config)
185
- self.__base_url = self.config.api_url or os.getenv("OLLAMA_API_URL")
331
+ self.__base_url = self.config.api_url or os.getenv("OLLAMA_API_URL") or "http://localhost:11434"
186
332
  def get_llm(self):
187
333
  from langchain_ollama.chat_models import ChatOllama
188
334
  return ChatOllama(
@@ -195,7 +341,7 @@ class Ollama(LlmInterface):
195
341
  from langchain_ollama.embeddings import OllamaEmbeddings
196
342
  return OllamaEmbeddings(
197
343
  base_url=self.__base_url,
198
- model="nomic-embed-text" #mxbai-embed-large
344
+ model="mxbai-embed-large" #nomic-embed-text
199
345
  )
200
346
  def get_models(self):
201
347
  import requests
@@ -212,15 +358,27 @@ class Ollama(LlmInterface):
212
358
  "details": model['details']
213
359
  } for model in models]
214
360
 
361
+ async def _format_multimodal_image_message(self, message: dict) -> dict:
362
+ file = await Base64File.from_url(message.get("url"))
363
+ return { "type": "image_url", "image_url": { "url": file.base64_url }}
364
+
215
365
  class LlmManager:
366
+ """
367
+ Expose the available LLM providers.
368
+ Names are aligned with the LangChain documentation:
369
+ https://python.langchain.com/api_reference/langchain/chat_models/langchain.chat_models.base.init_chat_model.html
370
+ """
216
371
 
217
372
  #class variables (static)
218
373
  _list: dict[str,LlmInterface] = {
219
374
  "anthropic": Anthropic,
220
375
  "deepseek": DeepSeek,
221
- "google": Google,
222
- "gvertex": Gvertex,
376
+ "google": Google, #deprecated
377
+ "google_genai": Google,
378
+ "gvertex": GoogleVertex,#deprecated
379
+ "google_vertexai": GoogleVertex,
223
380
  "groq": Groq,
381
+ "ibm": IBM,
224
382
  "openai": OpenAI,
225
383
  "ollama": Ollama
226
384
  }
@@ -18,7 +18,7 @@ async def __process_proxy_tool(proxy_tool: LlmAppTool) -> LlmAppTool | None:
18
18
  if not app:
19
19
  raise ValueError(f"App with id {app_id} not found.")
20
20
  tool_id = secrets.get("toolId")
21
- tool = next((t for t in app.app_tools if t.id == tool_id), None)
21
+ tool = next((t for t in app.rq.app_tools if app.rq.app_tools and t.id == tool_id), None)
22
22
  if not tool:
23
23
  raise ValueError(f"Tool with function_id {tool_id} not found in app {app.name}.")
24
24
  #override derived tool with proxy tool props
@@ -61,5 +61,8 @@ def get_structured_tools(llm: LlmInterface, tools: list[LlmAppTool], callbacks:l
61
61
  #error_on_invalid_docstring=True
62
62
  )
63
63
  _structured_tool.tags = [tool.function_id if tool.function_id else tool.function_name]
64
+ secrets = tool.secrets_to_dict()
65
+ if secrets and secrets.get("stream") == "true":
66
+ _structured_tool.tags.append("stream")
64
67
  _structured_tools.append(_structured_tool)
65
68
  return _structured_tools
@@ -1,5 +1,5 @@
1
1
  from asyncio import Queue
2
- import aiohttp
2
+ import aiohttp, re
3
3
  from typing import Optional, Type, Callable
4
4
  from ws_bom_robot_app.config import config
5
5
  from ws_bom_robot_app.llm.models.api import LlmApp,LlmAppTool
@@ -128,7 +128,9 @@ class ToolManager:
128
128
  from langchain_core.prompts import ChatPromptTemplate
129
129
  from langchain_core.output_parsers import StrOutputParser, JsonOutputParser
130
130
  from pydantic import create_model
131
- system_message = self.app_tool.llm_chain_settings.prompt
131
+ system_message = self.app_tool.llm_chain_settings.prompt.format(
132
+ thread_id = self.app_tool.thread_id if self.app_tool.thread_id else "no-thread-id",
133
+ )
132
134
  context = []
133
135
  if self.app_tool.data_source == "knowledgebase":
134
136
  context = await self.__extract_documents(input, self.app_tool)
@@ -166,6 +168,7 @@ class ToolManager:
166
168
  return result
167
169
 
168
170
  async def proxy_app_chat(self, query: str) -> str | None:
171
+ from ws_bom_robot_app.llm.models.api import LlmMessage
169
172
  secrets = self.app_tool.secrets_to_dict()
170
173
  app_id = secrets.get("appId")
171
174
  if not app_id:
@@ -173,26 +176,49 @@ class ToolManager:
173
176
  app: CmsApp = await get_app_by_id(app_id)
174
177
  if not app:
175
178
  raise ValueError(f"App with id {app_id} not found.")
176
- url = f"{config.robot_cms_host}/api/llm/message?locale=en&raw=true"
177
- auth = config.robot_cms_auth
178
- headers = {"Authorization": auth} if auth else {}
179
- async with aiohttp.ClientSession() as session:
180
- data = {
181
- "appKey": app.credentials.app_key,
182
- "apiKey": app.credentials.api_key,
183
- "messages": [
184
- {
185
- "role": "user",
186
- "content": query
187
- }
188
- ]
189
- }
190
- async with session.post(url, json=data, headers=headers) as response:
191
- if response.status == 200:
192
- return await response.text()
193
- else:
194
- raise ValueError(f"Error fetching chat response: {response.status}")
195
- return None
179
+ # message
180
+ app.rq.messages.append(LlmMessage(role="user", content=query))
181
+ # tracing
182
+ if str(secrets.get("disable_tracing", False)).lower() in ['1','true','yes']:
183
+ app.rq.lang_chain_tracing = False
184
+ app.rq.lang_chain_project = ''
185
+ app.rq.secrets['nebulyApiKey'] = ''
186
+ # http: for debugging purposes
187
+ if str(secrets.get("use_http", False)).lower() in ['1','true','yes']:
188
+ import base64
189
+ url = f"http://localhost:{config.runtime_options().tcp_port}/api/llm/stream/raw"
190
+ auth = f"Basic {base64.b64encode((config.robot_user + ':' + config.robot_password).encode('utf-8')).decode('utf-8')}"
191
+ headers = {"Authorization": auth} if auth else {}
192
+ async with aiohttp.ClientSession() as session:
193
+ _data = app.rq.model_dump(mode='json',by_alias=True,exclude_unset=True,exclude_none=True, exclude_defaults=True)
194
+ async with session.post(url, json=_data, headers=headers) as response:
195
+ if response.status == 200:
196
+ return await response.text()
197
+ else:
198
+ raise ValueError(f"Error fetching chat response: {response.status}")
199
+ return None
200
+ else: # default
201
+ try:
202
+ from ws_bom_robot_app.llm.main import stream
203
+ import json
204
+ chunks = []
205
+ async for chunk in stream(rq=app.rq, ctx=None, formatted=False):
206
+ chunks.append(chunk)
207
+ rs = ''.join(chunks) if chunks else None
208
+
209
+ # if the app has output_structure, parse the JSON and return dict
210
+ if rs and app.rq.output_structure:
211
+ try:
212
+ cleaned_rs = re.sub(r'^```(?:json)?\s*\n?', '', rs.strip())
213
+ cleaned_rs = re.sub(r'\n?```\s*$', '', cleaned_rs)
214
+ return json.loads(cleaned_rs)
215
+ except json.JSONDecodeError:
216
+ print(f"[!] Failed to parse JSON output from proxy_app_chat: {rs}")
217
+ return rs
218
+ return rs
219
+ except Exception as e:
220
+ print(f"[!] Error in proxy_app_chat: {e}")
221
+ return None
196
222
 
197
223
  async def proxy_app_tool(self) -> None:
198
224
  return None
@@ -1,12 +1,17 @@
1
1
  from langchain_core.documents import Document
2
2
  from langchain_text_splitters import CharacterTextSplitter
3
+ import logging
3
4
 
4
5
  class DocumentChunker:
6
+ _MAX_CHUNK_SIZE = 10_000
5
7
  @staticmethod
6
8
  def chunk(documents: list[Document]) -> list[Document]:
7
- text_splitter = CharacterTextSplitter(chunk_size=10_000, chunk_overlap=500)
9
+ text_splitter = CharacterTextSplitter(chunk_size=DocumentChunker._MAX_CHUNK_SIZE, chunk_overlap=int(DocumentChunker._MAX_CHUNK_SIZE * 0.02))
8
10
  chunked_documents = []
9
11
  for doc in documents:
12
+ if len(doc.page_content) <= DocumentChunker._MAX_CHUNK_SIZE:
13
+ chunked_documents.append(doc)
14
+ continue
10
15
  chunks = text_splitter.split_text(doc.page_content)
11
16
  for chunk in chunks:
12
17
  chunked_documents.append(
@@ -0,0 +1,81 @@
1
+ import os, logging
2
+ from ws_bom_robot_app.config import config
3
+ from datetime import datetime, timedelta
4
+
5
+ def _cleanup_data_file(folders: list[str], retention: float) -> dict:
6
+ """
7
+ clean up old data files in the specified folder
8
+
9
+ Returns:
10
+ - Dictionary with cleanup statistics
11
+ """
12
+ _deleted_files = []
13
+ _deleted_dirs = []
14
+ _freed_space = 0
15
+
16
+ for folder in folders:
17
+ if not os.path.exists(folder):
18
+ logging.warning(f"Folder does not exist: {folder}")
19
+ continue
20
+
21
+ # delete old files
22
+ for root, dirs, files in os.walk(folder, topdown=False):
23
+ for file in files:
24
+ file_path = os.path.join(root, file)
25
+ try:
26
+ file_stat = os.stat(file_path)
27
+ file_creation_time = datetime.fromtimestamp(file_stat.st_mtime)
28
+ if file_creation_time < datetime.now() - timedelta(days=retention):
29
+ _freed_space += file_stat.st_size
30
+ os.remove(file_path)
31
+ _deleted_files.append(file_path)
32
+ except (OSError, IOError) as e:
33
+ logging.error(f"Error deleting file {file_path}: {e}")
34
+
35
+ # clean up empty directories (bottom-up)
36
+ for root, dirs, files in os.walk(folder, topdown=False):
37
+ # skip the root folder itself
38
+ if root == folder:
39
+ continue
40
+ try:
41
+ # check if directory is empty
42
+ if not os.listdir(root):
43
+ os.rmdir(root)
44
+ _deleted_dirs.append(root)
45
+ except OSError as e:
46
+ logging.debug(f"Could not remove directory {root}: {e}")
47
+ logging.info(f"Deleted {len(_deleted_files)} files; Freed space: {_freed_space / (1024 * 1024):.2f} MB")
48
+
49
+ return {
50
+ "deleted_files_count": len(_deleted_files),
51
+ "deleted_dirs_count": len(_deleted_dirs),
52
+ "freed_space_mb": _freed_space / (1024 * 1024)
53
+ }
54
+
55
+ def kb_cleanup_data_file() -> dict:
56
+ """
57
+ clean up vector db data files
58
+ """
59
+
60
+ folders = [
61
+ os.path.join(config.robot_data_folder, config.robot_data_db_folder, config.robot_data_db_folder_out),
62
+ os.path.join(config.robot_data_folder, config.robot_data_db_folder, config.robot_data_db_folder_store),
63
+ os.path.join(config.robot_data_folder, config.robot_data_db_folder, config.robot_data_db_folder_src)
64
+ ]
65
+ return _cleanup_data_file(folders, config.robot_data_db_retention_days)
66
+
67
+ def chat_cleanup_attachment() -> dict:
68
+ """
69
+ clean up chat attachment files
70
+ """
71
+ folders = [
72
+ os.path.join(config.robot_data_folder, config.robot_data_attachment_folder)
73
+ ]
74
+ return _cleanup_data_file(folders, config.robot_data_attachment_retention_days)
75
+
76
+ def task_cleanup_history() -> None:
77
+ """
78
+ clean up task queue
79
+ """
80
+ from ws_bom_robot_app.task_manager import task_manager
81
+ task_manager.cleanup_task()