veri-agents-external 0.1.2__tar.gz

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.
@@ -0,0 +1,192 @@
1
+ .turbo
2
+
3
+ # go
4
+ vendor
5
+
6
+ # js
7
+ dist
8
+ out-tsc
9
+ node_modules
10
+
11
+ .idea
12
+ *.iml
13
+ .DS_Store
14
+
15
+
16
+ # Byte-compiled / optimized / DLL files
17
+ __pycache__/
18
+ *.py[cod]
19
+ *$py.class
20
+
21
+ # C extensions
22
+ *.so
23
+
24
+ # Distribution / packaging
25
+ .Python
26
+ build/
27
+ develop-eggs/
28
+ dist/
29
+ downloads/
30
+ eggs/
31
+ .eggs/
32
+ lib/
33
+ lib64/
34
+ parts/
35
+ sdist/
36
+ var/
37
+ wheels/
38
+ share/python-wheels/
39
+ *.egg-info/
40
+ .installed.cfg
41
+ *.egg
42
+ MANIFEST
43
+
44
+ # PyInstaller
45
+ # Usually these files are written by a python script from a template
46
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
47
+ *.manifest
48
+ *.spec
49
+
50
+ # Installer logs
51
+ pip-log.txt
52
+ pip-delete-this-directory.txt
53
+
54
+ # Unit test / coverage reports
55
+ htmlcov/
56
+ .tox/
57
+ .nox/
58
+ .coverage
59
+ .coverage.*
60
+ .cache
61
+ nosetests.xml
62
+ coverage.xml
63
+ *.cover
64
+ *.py,cover
65
+ .hypothesis/
66
+ .pytest_cache/
67
+ cover/
68
+
69
+ # Translations
70
+ *.mo
71
+ *.pot
72
+
73
+ # Django stuff:
74
+ *.log
75
+ local_settings.py
76
+ db.sqlite3
77
+ db.sqlite3-journal
78
+
79
+ # Flask stuff:
80
+ instance/
81
+ .webassets-cache
82
+
83
+ # Scrapy stuff:
84
+ .scrapy
85
+
86
+ # Sphinx documentation
87
+ docs/_build/
88
+
89
+ # PyBuilder
90
+ .pybuilder/
91
+ target/
92
+
93
+ # Jupyter Notebook
94
+ .ipynb_checkpoints
95
+
96
+ # IPython
97
+ profile_default/
98
+ ipython_config.py
99
+
100
+ # pyenv
101
+ # For a library or package, you might want to ignore these files since the code is
102
+ # intended to run in multiple environments; otherwise, check them in:
103
+ # .python-version
104
+
105
+ # pipenv
106
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
107
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
108
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
109
+ # install all needed dependencies.
110
+ #Pipfile.lock
111
+
112
+ # UV
113
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
114
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
115
+ # commonly ignored for libraries.
116
+ #uv.lock
117
+
118
+ # poetry
119
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
120
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
121
+ # commonly ignored for libraries.
122
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
123
+ #poetry.lock
124
+
125
+ # pdm
126
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
127
+ #pdm.lock
128
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
129
+ # in version control.
130
+ # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
131
+ .pdm.toml
132
+ .pdm-python
133
+ .pdm-build/
134
+
135
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
136
+ __pypackages__/
137
+
138
+ # Celery stuff
139
+ celerybeat-schedule
140
+ celerybeat.pid
141
+
142
+ # SageMath parsed files
143
+ *.sage.py
144
+
145
+ # Environments
146
+ .env
147
+ .venv
148
+ env/
149
+ venv/
150
+ ENV/
151
+ env.bak/
152
+ venv.bak/
153
+
154
+ # Spyder project settings
155
+ .spyderproject
156
+ .spyproject
157
+
158
+ # Rope project settings
159
+ .ropeproject
160
+
161
+ # mkdocs documentation
162
+ /site
163
+
164
+ # mypy
165
+ .mypy_cache/
166
+ .dmypy.json
167
+ dmypy.json
168
+
169
+ # Pyre type checker
170
+ .pyre/
171
+
172
+ # pytype static type analyzer
173
+ .pytype/
174
+
175
+ # Cython debug symbols
176
+ cython_debug/
177
+
178
+ # PyCharm
179
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
180
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
181
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
182
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
183
+ #.idea/
184
+
185
+ # PyPI configuration file
186
+ .pypirc
187
+
188
+ # generated tools directory
189
+ services/attribute_connection/agent_api/app/tools/
190
+
191
+ # (temporary) cached HTML directory
192
+ services/attribute_connection/mcp_server/html
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: veri-agents-external
3
+ Version: 0.1.2
4
+ Summary: Veritone Agents Toolkit: Connectors to external software
5
+ Author-email: Markus Toman <mtoman@veritone.com>, Teo Boley <tboley@veritone.com>
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: veri-agents-common[langgraph]>=0.1.2
8
+ Requires-Dist: veri-agents-knowledgebase>=0.1.1
9
+ Provides-Extra: all
10
+ Requires-Dist: atlassian-python-api>=4.0.4; extra == 'all'
11
+ Requires-Dist: beautifulsoup4>=4.13.4; extra == 'all'
12
+ Requires-Dist: langchain-community>=0.3.26; extra == 'all'
13
+ Requires-Dist: markdownify>=1.1.0; extra == 'all'
14
+ Requires-Dist: simple-salesforce>=1.12.6; extra == 'all'
15
+ Requires-Dist: slack-bolt<2.0.0,>=1.20.1; extra == 'all'
16
+ Provides-Extra: confluence
17
+ Requires-Dist: atlassian-python-api>=4.0.4; extra == 'confluence'
18
+ Requires-Dist: langchain-community>=0.3.26; extra == 'confluence'
19
+ Requires-Dist: markdownify>=1.1.0; extra == 'confluence'
20
+ Provides-Extra: dev
21
+ Requires-Dist: atlassian-python-api>=4.0.4; extra == 'dev'
22
+ Requires-Dist: beautifulsoup4>=4.13.4; extra == 'dev'
23
+ Requires-Dist: langchain-community>=0.3.26; extra == 'dev'
24
+ Requires-Dist: markdownify>=1.1.0; extra == 'dev'
25
+ Requires-Dist: simple-salesforce>=1.12.6; extra == 'dev'
26
+ Requires-Dist: slack-bolt<2.0.0,>=1.20.1; extra == 'dev'
27
+ Provides-Extra: salesforce
28
+ Requires-Dist: beautifulsoup4>=4.13.4; extra == 'salesforce'
29
+ Requires-Dist: simple-salesforce>=1.12.6; extra == 'salesforce'
30
+ Provides-Extra: slack
31
+ Requires-Dist: slack-bolt<2.0.0,>=1.20.1; extra == 'slack'
@@ -0,0 +1,35 @@
1
+ [project]
2
+ name = "veri-agents-external"
3
+ version = "0.1.2"
4
+ description = "Veritone Agents Toolkit: Connectors to external software"
5
+ authors = [
6
+ {name = "Markus Toman", email = "mtoman@veritone.com"},
7
+ {name = "Teo Boley", email = "tboley@veritone.com"},
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "veri-agents-common[langgraph]>=0.1.2",
12
+ "veri-agents-knowledgebase>=0.1.1",
13
+ ]
14
+
15
+ [project.optional-dependencies]
16
+ confluence = [
17
+ "atlassian-python-api>=4.0.4",
18
+ "langchain-community>=0.3.26",
19
+ "markdownify>=1.1.0",
20
+ ]
21
+ salesforce = [
22
+ "beautifulsoup4>=4.13.4",
23
+ "simple-salesforce>=1.12.6",
24
+ ]
25
+ slack = [
26
+ "slack-bolt<2.0.0,>=1.20.1",
27
+ ]
28
+
29
+ # as optional dep so it can be referenced in workspace pyproject.toml
30
+ all = ["veri-agents-external[confluence,salesforce,slack]==0.1.2"]
31
+ dev = ["veri-agents-external[all]==0.1.1"]
32
+
33
+ [build-system]
34
+ requires = ["hatchling"]
35
+ build-backend = "hatchling.build"
@@ -0,0 +1,80 @@
1
+ import logging
2
+ from datetime import datetime
3
+ from typing import Iterator
4
+
5
+ from langchain_core.documents import Document
6
+ from langchain_community.document_loaders import ConfluenceLoader
7
+ from langchain_text_splitters import RecursiveCharacterTextSplitter
8
+ from veri_agents_knowledgebase.knowledgebase import DataSource, DocumentLoader
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ class ConfluenceDataSource(DataSource):
13
+ username: str
14
+ """ The username for Confluence API access. """
15
+
16
+ api_key: str
17
+ """ The API key for Confluence API access. """
18
+
19
+ space_key: str
20
+ """ The key of the Confluence space to load documents from. """
21
+
22
+
23
+ class ConfluenceDocumentLoader(DocumentLoader):
24
+ def __init__(
25
+ self,
26
+ data_source: ConfluenceDataSource,
27
+ include_attachments: bool = False,
28
+ ):
29
+ super().__init__(data_source)
30
+ self.loader = ConfluenceLoader(
31
+ url=str(data_source.location),
32
+ username=data_source.username,
33
+ api_key=data_source.api_key,
34
+ keep_markdown_format=True,
35
+ include_attachments=include_attachments,
36
+ space_key=data_source.space_key,
37
+ limit=50,
38
+ max_pages=10000,
39
+ )
40
+ self.splitter = RecursiveCharacterTextSplitter(
41
+ chunk_size=500, chunk_overlap=200
42
+ )
43
+
44
+ def _split(self, text: str, metadata: dict) -> list[Document]:
45
+ """Split text into smaller chunks using the text splitter."""
46
+ doc = Document(page_content=text, metadata=metadata)
47
+ new_docs = self.splitter.split_documents([doc])
48
+ return new_docs
49
+
50
+ def _add_content(
51
+ self,
52
+ parent_doc: Document,
53
+ child_docs: list[Document],
54
+ text: str,
55
+ metadata: dict,
56
+ ):
57
+ """Add content to parent document and create child documents."""
58
+ if text.strip():
59
+ parent_doc.page_content += f"{text}\n"
60
+ new_docs = self._split(text, metadata)
61
+ child_docs.extend(new_docs)
62
+
63
+ def load_documents(
64
+ self, **kwargs
65
+ ) -> Iterator[tuple[Document, list[Document] | None]]:
66
+ """Load documents from Confluence and split them into parent and child documents."""
67
+ # Load documents using the lazy_load method
68
+ for doc in self.loader.lazy_load(**kwargs):
69
+ # Create metadata with updated timestamp
70
+ metadata = doc.metadata.copy()
71
+ metadata["last_updated"] = datetime.now().isoformat()
72
+
73
+ # Create parent document
74
+ parent_doc = Document(page_content="", metadata=metadata)
75
+ child_docs: list[Document] = []
76
+
77
+ # Add the full content to both parent and child documents
78
+ self._add_content(parent_doc, child_docs, doc.page_content, metadata)
79
+
80
+ yield parent_doc, child_docs
@@ -0,0 +1,506 @@
1
+ from datetime import datetime
2
+ import os
3
+ import logging
4
+ from typing import List, Optional
5
+
6
+ import requests
7
+ from pydantic import BaseModel, Field
8
+ from simple_salesforce.api import Salesforce
9
+
10
+ log = logging.getLogger(__name__)
11
+
12
+
13
+ class KnowledgeArticle(BaseModel):
14
+ """
15
+ Pydantic model to represent a Salesforce Knowledge Article.
16
+ Uses Field aliases to map from Salesforce API names to Python-friendly names.
17
+ """
18
+
19
+ knowledge_article_id: str = Field(..., alias="KnowledgeArticleId")
20
+ title: Optional[str] = Field(None, alias="Title")
21
+ summary: Optional[str] = Field(None, alias="Summary")
22
+ body: Optional[str] = Field(None, alias="Body__c")
23
+ problem: Optional[str] = Field(None, alias="Problem__c")
24
+ cause: Optional[str] = Field(None, alias="Cause__c")
25
+ solution: Optional[str] = Field(None, alias="Solution__c")
26
+ public_article_link: Optional[str] = Field(None, alias="Public_Article_Link__c")
27
+ product: Optional[str] = Field(None, alias="Product__c")
28
+ marketing_product: Optional[str] = Field(None, alias="Marketing_Product__c")
29
+ question: Optional[str] = Field(None, alias="Question__c")
30
+ answer: Optional[str] = Field(None, alias="Answer__c")
31
+ description: Optional[str] = Field(None, alias="Description__c")
32
+ instructions: Optional[str] = Field(None, alias="Instructions__c")
33
+ release_notes: Optional[str] = Field(None, alias="Release_Notes__c")
34
+
35
+
36
+ class Case(BaseModel):
37
+ """
38
+ Pydantic model to represent a Salesforce Case (Ticket).
39
+ """
40
+
41
+ id: str = Field(..., alias="Id")
42
+ case_number: Optional[str] = Field(None, alias="CaseNumber")
43
+ subject: Optional[str] = Field(None, alias="Subject")
44
+ status: Optional[str] = Field(None, alias="Status")
45
+ priority: Optional[str] = Field(None, alias="Priority")
46
+ origin: Optional[str] = Field(None, alias="Origin")
47
+ type: Optional[str] = Field(None, alias="Type")
48
+ description: Optional[str] = Field(None, alias="Description")
49
+ created_date: str = Field(..., alias="CreatedDate")
50
+ closed_date: Optional[str] = Field(None, alias="ClosedDate")
51
+ is_closed: bool = Field(..., alias="IsClosed")
52
+ is_escalated: bool = Field(..., alias="IsEscalated")
53
+ contact_email: Optional[str] = Field(None, alias="ContactEmail")
54
+ products: Optional[str] = Field(None, alias="Products__c")
55
+
56
+
57
+ class SuggestedArticle(BaseModel):
58
+ Id: str
59
+ Title: Optional[str]
60
+
61
+
62
+ class CaseArticleRecommendation(BaseModel):
63
+ Id: str
64
+ RecommendationAction: Optional[str]
65
+ RecommendationType: Optional[str]
66
+ SuggestedArticleId: Optional[str]
67
+ SuggestedArticle: Optional[SuggestedArticle]
68
+ CreatedDate: datetime
69
+
70
+
71
+ class CaseArticleRecommendationResponse(BaseModel):
72
+ totalSize: int
73
+ done: bool
74
+ records: List[CaseArticleRecommendation]
75
+
76
+
77
+ class SalesforceConnection:
78
+ """
79
+ Handles the connection and authentication with the Salesforce API.
80
+ """
81
+
82
+ def __init__(self, client_id: str, client_secret: str, token_url: str):
83
+ self.client_id = client_id
84
+ self.client_secret = client_secret
85
+ self.token_url = token_url
86
+ self.sf = self._connect()
87
+
88
+ def is_connected(self) -> bool:
89
+ """
90
+ Checks if the Salesforce connection is established.
91
+ Returns:
92
+ bool: True if connected, False otherwise.
93
+ """
94
+ return self.sf is not None
95
+
96
+ def _connect(self):
97
+ """
98
+ Establishes the connection to Salesforce using client credentials.
99
+ """
100
+ log.info("Attempting to connect to Salesforce...")
101
+ try:
102
+ payload = {
103
+ "grant_type": "client_credentials",
104
+ "client_id": self.client_id,
105
+ "client_secret": self.client_secret,
106
+ }
107
+ response = requests.post(self.token_url, data=payload, timeout=10)
108
+ response.raise_for_status()
109
+ response_data = response.json()
110
+ access_token = response_data.get("access_token")
111
+ instance_url = response_data.get("instance_url").rstrip(
112
+ "/"
113
+ ) # Clean trailing slash if present
114
+
115
+ if not access_token or not instance_url:
116
+ raise ValueError(
117
+ "Access token or instance URL not found in the response."
118
+ )
119
+
120
+ log.info("Successfully obtained access token and instance URL.")
121
+ return Salesforce(instance_url=instance_url, session_id=access_token)
122
+
123
+ except requests.exceptions.RequestException as e:
124
+ log.error(f"Error getting access token: {e}")
125
+ return None
126
+ except (ValueError, KeyError) as e:
127
+ log.error(f"Error parsing authentication response: {e}")
128
+ return None
129
+
130
+
131
+ def get_knowledge_articles(sf_connection, limit: int = 200000) -> List[KnowledgeArticle]:
132
+ """
133
+ Retrieves and validates knowledge articles from Salesforce.
134
+
135
+ Returns:
136
+ List[KnowledgeArticle]: A list of validated Pydantic models.
137
+ """
138
+ if not sf_connection.sf:
139
+ log.error(
140
+ "Cannot fetch knowledge articles: Salesforce connection is not available."
141
+ )
142
+ return []
143
+
144
+ query = f"""
145
+ SELECT
146
+ KnowledgeArticleId, Title, Summary, Body__c, Problem__c, Cause__c, Solution__c,
147
+ Public_Article_Link__c, Product__c, Marketing_Product__c, Question__c, Answer__c, Description__c,
148
+ Instructions__c, Release_Notes__c
149
+ FROM Knowledge__kav
150
+ WHERE IsDeleted = false AND PublishStatus = 'Online' AND IsLatestVersion = true AND IsMasterLanguage = true
151
+ LIMIT {limit}
152
+ """
153
+ try:
154
+ log.info("\nQuerying for Knowledge Articles...")
155
+ result = sf_connection.sf.query_all_iter(query)
156
+ validated_articles = [KnowledgeArticle.model_validate(article) for article in result]
157
+ return validated_articles
158
+ except Exception as e:
159
+ log.error(f"An error occurred while getting knowledge articles: {e}")
160
+ return []
161
+
162
+
163
+ def get_cases(
164
+ sf_connection: SalesforceConnection, days_ago: int = 30, limit: int = 100
165
+ ) -> List[Case]:
166
+ """
167
+ Retrieves and validates cases (Tickets) from Salesforce.
168
+
169
+ Returns:
170
+ List[Case]: A list of validated Pydantic models.
171
+ """
172
+ if not sf_connection.sf:
173
+ log.error("Cannot fetch tickets: Salesforce connection is not available.")
174
+ return []
175
+
176
+ query = f"""
177
+ SELECT
178
+ Id, CaseNumber, Subject, Status, Priority, Origin, Type, Description,
179
+ CreatedDate, ClosedDate, IsClosed, IsEscalated, ContactEmail, Products__c
180
+ FROM Case
181
+ WHERE CreatedDate = LAST_N_DAYS:{days_ago}
182
+ ORDER BY CreatedDate DESC
183
+ LIMIT {limit}
184
+ """
185
+ try:
186
+ log.info(f"\nQuerying for {limit} cases from the last {days_ago} days...")
187
+ result = sf_connection.sf.query(query)
188
+ case_data = result.get("records", [])
189
+ log.info(f"Found {len(case_data)} cases. Validating...")
190
+ validated_cases = [Case.model_validate(case) for case in case_data]
191
+ return validated_cases
192
+ except Exception as e:
193
+ log.error(f"An error occurred while getting cases: {e}")
194
+ return []
195
+
196
+
197
+ def get_case_by_id(
198
+ sf_connection: SalesforceConnection, case_id: str
199
+ ) -> Optional[Case]:
200
+ """
201
+ Retrieves and validates a single ticket (Case) from Salesforce by its ID.
202
+
203
+ Args:
204
+ sf_connection (Salesforce): An active simple_salesforce.Salesforce instance.
205
+ ticket_id (str): The ID of the ticket to retrieve.
206
+
207
+ Returns:
208
+ Optional[Case]: A validated Pydantic model of the ticket, or None if not found.
209
+ """
210
+ if not sf_connection.sf:
211
+ log.error("Cannot fetch ticket: Salesforce connection is not available.")
212
+ return None
213
+
214
+ # Sanitize case_id to prevent SOQL injection
215
+ sanitized_case_id = case_id.replace("'", "\\'")
216
+
217
+ query = f"""
218
+ SELECT
219
+ Id, CaseNumber, Subject, Status, Priority, Origin, Type, Description,
220
+ CreatedDate, ClosedDate, IsClosed, IsEscalated, ContactEmail, Products__c
221
+ FROM Case
222
+ WHERE Id = '{sanitized_case_id}'
223
+ LIMIT 1
224
+ """
225
+ try:
226
+ log.info(f"\nQuerying for Case with ID: {case_id}...")
227
+ result = sf_connection.sf.query(query)
228
+ records = result.get("records", [])
229
+ if not records:
230
+ log.info(f"No ticket found with ID: {case_id}")
231
+ return None
232
+
233
+ ticket_data = records[0]
234
+ log.info("Found ticket. Validating...")
235
+ validated_ticket = Case.model_validate(ticket_data)
236
+ return validated_ticket
237
+ except Exception as e:
238
+ log.error(f"An error occurred while getting case by ID: {e}")
239
+ return None
240
+
241
+
242
+ def get_case_by_number(
243
+ sf_connection: SalesforceConnection, case_number: str
244
+ ) -> Optional[Case]:
245
+ """
246
+ Retrieves and validates a single ticket (Case) from Salesforce by its case number.
247
+
248
+ Args:
249
+ sf_connection (Salesforce): An active simple_salesforce.Salesforce instance.
250
+ case_number (str): The case number of the ticket to retrieve.
251
+
252
+ Returns:
253
+ Optional[Case]: A validated Pydantic model of the ticket, or None if not found.
254
+ """
255
+ if not sf_connection.sf:
256
+ log.error("Cannot fetch ticket: Salesforce connection is not available.")
257
+ return None
258
+
259
+ # Sanitize case_number to prevent SOQL injection
260
+ sanitized_case_number = case_number.replace("'", "\\'")
261
+
262
+ query = f"""
263
+ SELECT
264
+ Id, CaseNumber, Subject, Status, Priority, Origin, Type, Description,
265
+ CreatedDate, ClosedDate, IsClosed, IsEscalated, ContactEmail, Products__c
266
+ FROM Case
267
+ WHERE CaseNumber = '{sanitized_case_number}'
268
+ LIMIT 1
269
+ """
270
+ try:
271
+ log.info(f"\nQuerying for Case with Case Number: {case_number}...")
272
+ result = sf_connection.sf.query(query)
273
+ records = result.get("records", [])
274
+ if not records:
275
+ log.info(f"No ticket found with Case Number: {case_number}")
276
+ return None
277
+
278
+ ticket_data = records[0]
279
+ log.info("Found ticket. Validating...")
280
+ validated_ticket = Case.model_validate(ticket_data)
281
+ return validated_ticket
282
+ except Exception as e:
283
+ log.error(f"An error occurred while getting case by number: {e}")
284
+ return None
285
+
286
+
287
+ def get_article_by_id(
288
+ sf_connection: SalesforceConnection, article_id: str
289
+ ) -> Optional[KnowledgeArticle]:
290
+ """
291
+ Retrieves and validates a single knowledge article from Salesforce by its ID.
292
+
293
+ Args:
294
+ sf_connection (Salesforce): An active simple_salesforce.Salesforce instance.
295
+ article_id (str): The ID of the knowledge article to retrieve.
296
+
297
+ Returns:
298
+ Optional[KnowledgeArticle]: A validated Pydantic model of the article, or None if not found.
299
+ """
300
+ if not sf_connection.sf:
301
+ log.error("Cannot fetch article: Salesforce connection is not available.")
302
+ return None
303
+
304
+ # Sanitize article_id to prevent SOQL injection
305
+ sanitized_article_id = article_id.replace("'", "\\'")
306
+
307
+ query = f"""
308
+ SELECT
309
+ KnowledgeArticleId, Title, Summary, Body__c, Problem__c, Cause__c, Solution__c,
310
+ Public_Article_Link__c, Product__c, Question__c, Answer__c, Description__c,
311
+ Instructions__c, Release_Notes__c
312
+ FROM Knowledge__kav
313
+ WHERE KnowledgeArticleId = '{sanitized_article_id}'
314
+ LIMIT 1
315
+ """
316
+ try:
317
+ log.info(f"\nQuerying for Article with ID: {article_id}...")
318
+ result = sf_connection.sf.query(query)
319
+ records = result.get("records", [])
320
+ if not records:
321
+ log.error(f"No article found with ID: {article_id}")
322
+ return None
323
+
324
+ article_data = records[0]
325
+ log.info("Found article. Validating...")
326
+ validated_article = KnowledgeArticle.model_validate(article_data)
327
+ return validated_article
328
+ except Exception as e:
329
+ log.error(f"An error occurred while getting article by ID: {e}")
330
+ return None
331
+
332
+
333
+ def get_article_recommendation(
334
+ sf_connection: SalesforceConnection, ticket_id: str
335
+ ) -> Optional[list[CaseArticleRecommendation]]:
336
+ if not sf_connection.sf:
337
+ log.error("Cannot fetch article: Salesforce connection is not available.")
338
+ return None
339
+
340
+ soql_query = f"""
341
+ SELECT Id,
342
+ RecommendationAction,
343
+ RecommendationType,
344
+ SuggestedArticleId,
345
+ SuggestedArticle.Title,
346
+ CreatedDate
347
+ FROM CaseArticleRecommendation
348
+ WHERE CaseId = '{ticket_id}'
349
+ """
350
+
351
+ try:
352
+ results = sf_connection.sf.query_all(soql_query)
353
+ parsed = CaseArticleRecommendationResponse(**results)
354
+ return parsed.records
355
+ except Exception as e:
356
+ log.error(f"An error occurred while querying for article recommendations: {e}")
357
+ return None
358
+
359
+
360
+ def describe_salesforce_object(sf_connection: SalesforceConnection, object_name: str) -> Optional[dict]:
361
+ """
362
+ Describes a Salesforce object and returns its field information.
363
+
364
+ Args:
365
+ sf_connection: An active SalesforceConnection instance
366
+ object_name: The API name of the Salesforce object (e.g., 'Knowledge__kav', 'Case')
367
+
368
+ Returns:
369
+ Dictionary containing object description or None if error
370
+ """
371
+ if not sf_connection.sf:
372
+ log.error(f"Cannot describe {object_name}: Salesforce connection is not available.")
373
+ return None
374
+
375
+ try:
376
+ # Use getattr to access the object dynamically and call describe()
377
+ obj = getattr(sf_connection.sf, object_name)
378
+ description = obj.describe()
379
+ return description
380
+ except Exception as e:
381
+ log.error(f"An error occurred while describing {object_name}: {e}")
382
+ return None
383
+
384
+
385
+ def print_object_fields(sf_connection: SalesforceConnection, object_name: str):
386
+ """
387
+ Prints all fields of a Salesforce object in a readable format.
388
+
389
+ Args:
390
+ sf_connection: An active SalesforceConnection instance
391
+ object_name: The API name of the Salesforce object
392
+ """
393
+ print(f"\n=== {object_name} Object Fields ===")
394
+ description = describe_salesforce_object(sf_connection, object_name)
395
+
396
+ if not description:
397
+ print(f"Failed to retrieve description for {object_name}")
398
+ return
399
+
400
+ fields = description.get('fields', [])
401
+ print(f"Object Label: {description.get('label', 'N/A')}")
402
+ print(f"Total Fields: {len(fields)}")
403
+ print("\nFields:")
404
+ print("-" * 80)
405
+
406
+ for field in sorted(fields, key=lambda x: x['name']):
407
+ field_name = field.get('name', 'N/A')
408
+ field_label = field.get('label', 'N/A')
409
+ field_type = field.get('type', 'N/A')
410
+ is_custom = field.get('custom', False)
411
+ is_required = not field.get('nillable', True)
412
+
413
+ custom_indicator = " [CUSTOM]" if is_custom else ""
414
+ required_indicator = " [REQUIRED]" if is_required else ""
415
+
416
+ print(f" {field_name:<30} | {field_type:<15} | {field_label}{custom_indicator}{required_indicator}")
417
+
418
+ print("-" * 80)
419
+
420
+
421
+ if __name__ == "__main__":
422
+ CLIENT_ID = os.getenv("SALESFORCE_CLIENT_ID")
423
+ CLIENT_SECRET = os.getenv("SALESFORCE_CLIENT_SECRET")
424
+ TOKEN_URL = os.getenv("SALESFORCE_TOKEN_URL")
425
+ if not CLIENT_ID or not CLIENT_SECRET or not TOKEN_URL:
426
+ raise ValueError(
427
+ "Please set the SALESFORCE_CLIENT_ID and SALESFORCE_CLIENT_SECRET environment variables."
428
+ )
429
+
430
+ sf_conn = SalesforceConnection(CLIENT_ID, CLIENT_SECRET, TOKEN_URL)
431
+
432
+ products = set()
433
+ unknown_products = []
434
+
435
+ if sf_conn.sf:
436
+ # Print all fields for Knowledge__kav and Case objects
437
+ print_object_fields(sf_conn, "Knowledge__kav")
438
+ print_object_fields(sf_conn, "Case")
439
+
440
+ articles = get_knowledge_articles(sf_conn)
441
+ if articles:
442
+ print("\n--- Recently Found Knowledge Articles ---")
443
+ for i, article in enumerate(articles):
444
+ if i <= 3:
445
+ print(f"Title: {article.title}")
446
+ print(f"Summary: {article.summary}")
447
+ print(f"Body: {article.body[:100] if article.body else 'N/A'}...")
448
+ print(f"Problem: {article.problem}")
449
+ print(f"Cause: {article.cause}")
450
+ print(f"Solution: {article.solution}")
451
+ print(f"Public Article Link: {article.public_article_link}")
452
+ print(f"Product: {article.product}")
453
+ print(f"Marketing product: {article.marketing_product}")
454
+ print(f"Question: {article.question}")
455
+ print(f"Answer: {article.answer}")
456
+ print(f"Description: {article.description}")
457
+ print(f"Instructions: {article.instructions}")
458
+ print(f"Release Notes: {article.release_notes}")
459
+ print("-" * 20)
460
+
461
+ if article.marketing_product:
462
+ for p in article.marketing_product.split(";"):
463
+ if p:
464
+ products.add(p.strip())
465
+ else:
466
+ unknown_products.append(article.title)
467
+
468
+ print(f"Found {len(articles)} knowledge articles." )
469
+ tickets = get_cases(sf_conn, days_ago=90, limit=3)
470
+ if tickets:
471
+ print("\n--- Recently Created Cases ---")
472
+ for ticket in tickets:
473
+ # Accessing data via model attributes
474
+ print(f" Case Number: {ticket.case_number}")
475
+ print(f" Subject: {ticket.subject}")
476
+ print(f" Status: {ticket.status}")
477
+ print(f" Products: {ticket.products}")
478
+ if ticket.description:
479
+ print(f" Description: {ticket.description[:100]}")
480
+ print("-" * 20)
481
+
482
+ # --- Example: Retrieve a specific ticket by its ID ---
483
+ first_ticket_id = tickets[0].id
484
+ specific_ticket = get_case_by_id(sf_conn, first_ticket_id)
485
+ if specific_ticket:
486
+ print("\n--- Details for Specific Cases ---")
487
+ print(f" Case Number: {specific_ticket.case_number}")
488
+ if specific_ticket.description:
489
+ print(f" Description: {specific_ticket.description[:100]}")
490
+ # print(specific_ticket)
491
+ print("-" * 20)
492
+
493
+ print(f"Found {len(tickets)} tickets in the last 90 days.")
494
+
495
+ # ---- Example: Retrieve knowledge article recommendations for this specific ticket
496
+ #article_recommendation = get_article_recommendation(
497
+ # sf_conn, first_ticket_id
498
+ #)
499
+ #if article_recommendation:
500
+ # print("\n--- Article Recommendation for Specific Cases ---")
501
+ # print(article_recommendation)
502
+ # print("-" * 20)
503
+
504
+ print(f"Products: {', '.join(products)}")
505
+ print(f"Unknown product for {len(unknown_products)} articles")
506
+ print(f"Unknown products: {', '.join(unknown_products[:30])}")
@@ -0,0 +1,180 @@
1
+ import logging
2
+ import os
3
+ from datetime import datetime
4
+ from typing import Iterator, cast
5
+
6
+ from bs4 import BeautifulSoup
7
+ from langchain_core.documents import Document
8
+ from langchain_text_splitters import RecursiveCharacterTextSplitter
9
+ from veri_agents_knowledgebase.knowledgebase import DataSource, DocumentLoader
10
+
11
+ from .api import SalesforceConnection, get_knowledge_articles
12
+
13
+ log = logging.getLogger(__name__)
14
+
15
+
16
+ class SalesforceDataSource(DataSource):
17
+ client_id: str
18
+ """ The client ID for Salesforce API access. """
19
+
20
+ client_secret: str
21
+ """ The client secret for Salesforce API access. """
22
+
23
+
24
+ class SalesforceSupportDocumentLoader(DocumentLoader):
25
+ def __init__(
26
+ self, data_source: SalesforceDataSource, load_all_products: bool = False
27
+ ):
28
+ super().__init__(data_source)
29
+ self.load_all_products = load_all_products
30
+
31
+ self.product_name_map = {
32
+ "aiWare - aiWare": "aiWare",
33
+ "aiWare - Automate Studio": "Automate",
34
+ "Contact App": "Contact",
35
+ "GLC - Redaction Managed Service (RMS)": "Redact",
36
+ "Relativity (integration)": "Relativity",
37
+ }
38
+ self.splitter = RecursiveCharacterTextSplitter(
39
+ chunk_size=500, chunk_overlap=200
40
+ )
41
+
42
+ def _parse_html(self, text: str | None) -> str:
43
+ if not text:
44
+ return ""
45
+ soup = BeautifulSoup(text, "html.parser")
46
+ return soup.get_text()
47
+
48
+ def _split(self, text: str, metadata: dict):
49
+ doc = Document(page_content=text, metadata=metadata)
50
+ new_docs = self.splitter.split_documents([doc])
51
+ return new_docs
52
+
53
+ def _add(
54
+ self,
55
+ parent_doc: Document,
56
+ docs: list[Document],
57
+ text: str,
58
+ fieldname: str | None,
59
+ metadata: dict,
60
+ ):
61
+ if text:
62
+ if fieldname:
63
+ parent_doc.page_content += f"{fieldname}: {text}\n"
64
+ else:
65
+ parent_doc.page_content += f"{text}\n"
66
+ new_docs = self._split(text, metadata)
67
+ docs.extend(new_docs)
68
+
69
+ def load_documents(
70
+ self, **kwargs
71
+ ) -> Iterator[tuple[Document, list[Document] | None]]:
72
+ products_to_include = []
73
+
74
+ if "products" in kwargs:
75
+ products_to_include = kwargs["products"]
76
+
77
+ data_source = cast(SalesforceDataSource, self.data_source)
78
+ sf = SalesforceConnection(
79
+ token_url=str(data_source.location),
80
+ client_id=data_source.client_id,
81
+ client_secret=data_source.client_secret,
82
+ )
83
+
84
+ if not sf.is_connected():
85
+ raise ValueError(
86
+ "Salesforce connection failed. Please check your credentials."
87
+ )
88
+
89
+ articles = get_knowledge_articles(sf, limit=50000)
90
+
91
+ log.info("Found %d knowledge articles in Salesforce.", len(articles))
92
+
93
+ if not articles:
94
+ raise ValueError("No knowledge articles found in Salesforce.")
95
+
96
+ for article in articles:
97
+ summary = self._parse_html(article.summary)
98
+ content = self._parse_html(article.body)
99
+ problem = self._parse_html(article.problem)
100
+ cause = self._parse_html(article.cause)
101
+ solution = self._parse_html(article.solution)
102
+ question = self._parse_html(article.question)
103
+ answer = self._parse_html(article.answer)
104
+ description = self._parse_html(article.description)
105
+ instructions = self._parse_html(article.instructions)
106
+ release_notes = self._parse_html(article.release_notes)
107
+ title = article.title or "Untitled"
108
+ link = article.public_article_link or "none"
109
+ product = article.marketing_product or ""
110
+
111
+ # TODO: images? docling?
112
+
113
+ # TODO: can we use a metadata filter that can handle this
114
+ product_list = product.split(";")
115
+ for product in product_list:
116
+ if not product:
117
+ continue
118
+ product = self.product_name_map.get(product, product)
119
+ if (
120
+ not self.load_all_products
121
+ and products_to_include
122
+ and product not in products_to_include
123
+ ):
124
+ continue
125
+
126
+ docs: list[Document] = []
127
+ metadata = {
128
+ "source": f"{article.knowledge_article_id}_{product}",
129
+ "title": title,
130
+ "product": product,
131
+ "link": link,
132
+ "last_updated": datetime.now().isoformat(),
133
+ }
134
+ # Title as a separate document because we want to use ParentRetriever
135
+ parent_doc = Document(page_content="", metadata=metadata)
136
+ self._add(parent_doc, docs, title, "Title", metadata)
137
+ self._add(parent_doc, docs, summary, "Summary", metadata)
138
+ self._add(parent_doc, docs, problem, "Problem", metadata)
139
+ self._add(parent_doc, docs, cause, "Cause", metadata)
140
+ self._add(parent_doc, docs, solution, "Solution", metadata)
141
+ self._add(parent_doc, docs, question, "Question", metadata)
142
+ self._add(parent_doc, docs, answer, "Answer", metadata)
143
+ self._add(parent_doc, docs, description, "Description", metadata)
144
+ self._add(parent_doc, docs, instructions, "Instruction", metadata)
145
+ self._add(parent_doc, docs, release_notes, "Release Notes", metadata)
146
+ self._add(parent_doc, docs, content, None, metadata)
147
+ yield parent_doc, docs
148
+
149
+
150
+ def main():
151
+ CLIENT_ID = os.getenv("SALESFORCE_CLIENT_ID")
152
+ CLIENT_SECRET = os.getenv("SALESFORCE_CLIENT_SECRET")
153
+ TOKEN_URL = os.getenv("SALESFORCE_TOKEN_URL")
154
+ if not CLIENT_ID or not CLIENT_SECRET or not TOKEN_URL:
155
+ raise ValueError(
156
+ "Please set the SALESFORCE_CLIENT_ID and SALESFORCE_CLIENT_SECRET environment variables."
157
+ )
158
+
159
+ # Example usage
160
+ ds = SalesforceDataSource(
161
+ name="salesforce_knowledge",
162
+ location=TOKEN_URL,
163
+ client_id=CLIENT_ID,
164
+ client_secret=CLIENT_SECRET,
165
+ )
166
+
167
+ loader = SalesforceSupportDocumentLoader(ds)
168
+ # loader.load_documents(products=["aiWare", "Automate", "Contact", "Redact"])
169
+ for doc, _ in loader.load_documents(
170
+ products=["aiWare", "Automate", "Contact", "Redact"]
171
+ ):
172
+ print(f"Document ID: {doc.metadata['source']}")
173
+ print(f"Title: {doc.metadata['title']}")
174
+ print(f"Product: {doc.metadata['product']}")
175
+ print(f"Content: {doc.page_content[:100]}...") # Print first 100 characters
176
+ print()
177
+
178
+
179
+ if __name__ == "__main__":
180
+ main()
@@ -0,0 +1,163 @@
1
+ import requests
2
+ import logging
3
+ from typing import Optional, Tuple, Type, cast
4
+
5
+ from langchain_core.tools import BaseTool, ToolException, InjectedToolArg
6
+ from typing_extensions import Annotated
7
+ from pydantic import BaseModel, Field
8
+ from datetime import datetime
9
+
10
+ from .api import (
11
+ SalesforceConnection,
12
+ get_cases,
13
+ get_case_by_number,
14
+ # get_knowledge_articles,
15
+ get_article_by_id,
16
+ )
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+
21
+ def filter_none_values(data) -> dict | list:
22
+ if isinstance(data, dict):
23
+ return {k: filter_none_values(v) for k, v in data.items() if v is not None}
24
+ elif isinstance(data, list):
25
+ return [filter_none_values(item) for item in data]
26
+ return data
27
+
28
+
29
+ class SalesforceToolInput(BaseModel):
30
+ """Input for the Salesforce tool."""
31
+
32
+ query: str = Field(
33
+ description="Salesforce SQL query to execute. Use SOQL (Salesforce Object Query Language) syntax."
34
+ )
35
+
36
+
37
+ class SalesforceTool(BaseTool):
38
+ """Generic Salesforce API tool.
39
+
40
+ Typically used as base class for more specialized Salesforce tools.
41
+ """
42
+
43
+ salesforce_client_id: str
44
+ """ The client ID for Salesforce API access. """
45
+
46
+ salesforce_client_secret: str
47
+ """ The client secret for Salesforce API access. """
48
+
49
+ salesforce_token_url: str
50
+ """ The token URL for Salesforce API access. This is typically the OAuth token endpoint. """
51
+
52
+ name: str = "salesforce_tool"
53
+ description: str = "Performs Salesforce queries. Use this tool if you have no other, more specialized Salesforce tool."
54
+ #args_schema = SalesforceToolInput
55
+ response_format: str = "content_and_artifact" # type: ignore
56
+ handle_tool_errors: bool = True
57
+ allow_mutation: bool = False
58
+
59
+ def _connect(self) -> SalesforceConnection:
60
+ sf = SalesforceConnection(
61
+ token_url=self.salesforce_token_url,
62
+ client_id=self.salesforce_client_id,
63
+ client_secret=self.salesforce_client_secret,
64
+ )
65
+
66
+ if not sf.is_connected():
67
+ raise ToolException(
68
+ "Salesforce connection failed. Please check your credentials."
69
+ )
70
+
71
+ return sf
72
+ # articles = get_knowledge_articles(sf, limit=50000)
73
+
74
+ # def _run(
75
+ # self,
76
+ # gql_query: str,
77
+ # # aiware_api_key: Annotated[Optional[str], InjectedToolArg]
78
+ # ) -> Tuple[str, dict]:
79
+ # """Run the aiWARE GraphQL query."""
80
+ # aiware_api_key = None
81
+ # result = self._run_query(gql_query, aiware_api_key)
82
+ # return str(result), {"items": result, "type": "json", "source": "aiware"}
83
+
84
+
85
+ class SalesforceGetRecentCasesInput(BaseModel):
86
+ """Input for the Salesforce Get Recent Tickets tool."""
87
+
88
+ days_ago: int = Field(
89
+ default=90,
90
+ description="Number of days ago to look for cases. For example, if you want to get cases from the last 7 days, set this to 7. Default is 90 days.",
91
+ )
92
+ limit: int = Field(
93
+ default=10, description="Maximum number of cases to return. Default is 10."
94
+ )
95
+
96
+
97
+ class SalesforceGetRecentCases(SalesforceTool):
98
+ """Tool to get recent Salesforce cases."""
99
+
100
+ name: str = "salesforce_recent_cases"
101
+ description: str = "Get recent Salesforce cases. Use this tool to get cases from the last N days. The result is a list of cases with contents."
102
+ args_schema = SalesforceGetRecentCasesInput
103
+
104
+ def _run(self, days_ago: int = 90, limit: int = 10) -> Tuple[str, dict]:
105
+ sf = self._connect()
106
+ result = get_cases(sf, days_ago=days_ago, limit=limit)
107
+ # convert list of tickets to json
108
+ results_json = [result.model_dump() for result in result]
109
+ return str(results_json), {
110
+ "items": results_json,
111
+ "type": "json",
112
+ "source": "salesforce",
113
+ }
114
+
115
+
116
+ class SalesforceGetCaseByNumberInput(BaseModel):
117
+ case_number: str = Field(
118
+ description="Case number of the ticket to retrieve. This is the Salesforce case number of the requested case."
119
+ )
120
+
121
+
122
+ class SalesforceGetCaseByNumber(SalesforceTool):
123
+ """Tool to get a Salesforce ticket by case number."""
124
+
125
+ name: str = "salesforce_case_by_number"
126
+ description: str = "Get a Salesforce case by number. Use this tool to get the contents of a specific case."
127
+ args_schema = SalesforceGetCaseByNumberInput
128
+
129
+ def _run(self, case_number: str) -> Tuple[str, dict]:
130
+ sf = self._connect()
131
+ result = get_case_by_number(sf, case_number)
132
+ if not result:
133
+ raise ToolException(f"Case with ID {case_number} not found.")
134
+ return str(result.model_dump()), {
135
+ "item": result.model_dump(),
136
+ "type": "json",
137
+ "source": "salesforce",
138
+ }
139
+
140
+
141
+ class SalesforceGetArticleByIdInput(BaseModel):
142
+ article_id: str = Field(
143
+ description="ID of the article to retrieve. This is the Salesforce ID of the article."
144
+ )
145
+
146
+
147
+ class SalesforceGetArticleById(SalesforceTool):
148
+ """Tool to get a Salesforce article by ID."""
149
+
150
+ name: str = "salesforce_article_by_id"
151
+ description: str = "Get a Salesforce article by ID. Use this tool to get the contents of a specific article."
152
+ args_schema = SalesforceGetArticleByIdInput
153
+
154
+ def _run(self, article_id: str) -> Tuple[str, dict]:
155
+ sf = self._connect()
156
+ result = get_article_by_id(sf, article_id)
157
+ if not result:
158
+ raise ToolException(f"Article with ID {article_id} not found.")
159
+ return str(result.model_dump()), {
160
+ "item": result.model_dump(),
161
+ "type": "json",
162
+ "source": "salesforce",
163
+ }