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.
- veri_agents_external-0.1.2/.gitignore +192 -0
- veri_agents_external-0.1.2/PKG-INFO +31 -0
- veri_agents_external-0.1.2/pyproject.toml +35 -0
- veri_agents_external-0.1.2/src/veri_agents_external/__init__.py +0 -0
- veri_agents_external-0.1.2/src/veri_agents_external/confluence/loader.py +80 -0
- veri_agents_external-0.1.2/src/veri_agents_external/salesforce/api.py +506 -0
- veri_agents_external-0.1.2/src/veri_agents_external/salesforce/loader.py +180 -0
- veri_agents_external-0.1.2/src/veri_agents_external/salesforce/tools.py +163 -0
|
@@ -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"
|
|
File without changes
|
|
@@ -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
|
+
}
|