mail-swarms 1.3.2__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.
- mail/__init__.py +35 -0
- mail/api.py +1964 -0
- mail/cli.py +432 -0
- mail/client.py +1657 -0
- mail/config/__init__.py +8 -0
- mail/config/client.py +87 -0
- mail/config/server.py +165 -0
- mail/core/__init__.py +72 -0
- mail/core/actions.py +69 -0
- mail/core/agents.py +73 -0
- mail/core/message.py +366 -0
- mail/core/runtime.py +3537 -0
- mail/core/tasks.py +311 -0
- mail/core/tools.py +1206 -0
- mail/db/__init__.py +0 -0
- mail/db/init.py +182 -0
- mail/db/types.py +65 -0
- mail/db/utils.py +523 -0
- mail/examples/__init__.py +27 -0
- mail/examples/analyst_dummy/__init__.py +15 -0
- mail/examples/analyst_dummy/agent.py +136 -0
- mail/examples/analyst_dummy/prompts.py +44 -0
- mail/examples/consultant_dummy/__init__.py +15 -0
- mail/examples/consultant_dummy/agent.py +136 -0
- mail/examples/consultant_dummy/prompts.py +42 -0
- mail/examples/data_analysis/__init__.py +40 -0
- mail/examples/data_analysis/analyst/__init__.py +9 -0
- mail/examples/data_analysis/analyst/agent.py +67 -0
- mail/examples/data_analysis/analyst/prompts.py +53 -0
- mail/examples/data_analysis/processor/__init__.py +13 -0
- mail/examples/data_analysis/processor/actions.py +293 -0
- mail/examples/data_analysis/processor/agent.py +67 -0
- mail/examples/data_analysis/processor/prompts.py +48 -0
- mail/examples/data_analysis/reporter/__init__.py +10 -0
- mail/examples/data_analysis/reporter/actions.py +187 -0
- mail/examples/data_analysis/reporter/agent.py +67 -0
- mail/examples/data_analysis/reporter/prompts.py +49 -0
- mail/examples/data_analysis/statistics/__init__.py +18 -0
- mail/examples/data_analysis/statistics/actions.py +343 -0
- mail/examples/data_analysis/statistics/agent.py +67 -0
- mail/examples/data_analysis/statistics/prompts.py +60 -0
- mail/examples/mafia/__init__.py +0 -0
- mail/examples/mafia/game.py +1537 -0
- mail/examples/mafia/narrator_tools.py +396 -0
- mail/examples/mafia/personas.py +240 -0
- mail/examples/mafia/prompts.py +489 -0
- mail/examples/mafia/roles.py +147 -0
- mail/examples/mafia/spec.md +350 -0
- mail/examples/math_dummy/__init__.py +23 -0
- mail/examples/math_dummy/actions.py +252 -0
- mail/examples/math_dummy/agent.py +136 -0
- mail/examples/math_dummy/prompts.py +46 -0
- mail/examples/math_dummy/types.py +5 -0
- mail/examples/research/__init__.py +39 -0
- mail/examples/research/researcher/__init__.py +9 -0
- mail/examples/research/researcher/agent.py +67 -0
- mail/examples/research/researcher/prompts.py +54 -0
- mail/examples/research/searcher/__init__.py +10 -0
- mail/examples/research/searcher/actions.py +324 -0
- mail/examples/research/searcher/agent.py +67 -0
- mail/examples/research/searcher/prompts.py +53 -0
- mail/examples/research/summarizer/__init__.py +18 -0
- mail/examples/research/summarizer/actions.py +255 -0
- mail/examples/research/summarizer/agent.py +67 -0
- mail/examples/research/summarizer/prompts.py +55 -0
- mail/examples/research/verifier/__init__.py +10 -0
- mail/examples/research/verifier/actions.py +337 -0
- mail/examples/research/verifier/agent.py +67 -0
- mail/examples/research/verifier/prompts.py +52 -0
- mail/examples/supervisor/__init__.py +11 -0
- mail/examples/supervisor/agent.py +4 -0
- mail/examples/supervisor/prompts.py +93 -0
- mail/examples/support/__init__.py +33 -0
- mail/examples/support/classifier/__init__.py +10 -0
- mail/examples/support/classifier/actions.py +307 -0
- mail/examples/support/classifier/agent.py +68 -0
- mail/examples/support/classifier/prompts.py +56 -0
- mail/examples/support/coordinator/__init__.py +9 -0
- mail/examples/support/coordinator/agent.py +67 -0
- mail/examples/support/coordinator/prompts.py +48 -0
- mail/examples/support/faq/__init__.py +10 -0
- mail/examples/support/faq/actions.py +182 -0
- mail/examples/support/faq/agent.py +67 -0
- mail/examples/support/faq/prompts.py +42 -0
- mail/examples/support/sentiment/__init__.py +15 -0
- mail/examples/support/sentiment/actions.py +341 -0
- mail/examples/support/sentiment/agent.py +67 -0
- mail/examples/support/sentiment/prompts.py +54 -0
- mail/examples/weather_dummy/__init__.py +23 -0
- mail/examples/weather_dummy/actions.py +75 -0
- mail/examples/weather_dummy/agent.py +136 -0
- mail/examples/weather_dummy/prompts.py +35 -0
- mail/examples/weather_dummy/types.py +5 -0
- mail/factories/__init__.py +27 -0
- mail/factories/action.py +223 -0
- mail/factories/base.py +1531 -0
- mail/factories/supervisor.py +241 -0
- mail/net/__init__.py +7 -0
- mail/net/registry.py +712 -0
- mail/net/router.py +728 -0
- mail/net/server_utils.py +114 -0
- mail/net/types.py +247 -0
- mail/server.py +1605 -0
- mail/stdlib/__init__.py +0 -0
- mail/stdlib/anthropic/__init__.py +0 -0
- mail/stdlib/fs/__init__.py +15 -0
- mail/stdlib/fs/actions.py +209 -0
- mail/stdlib/http/__init__.py +19 -0
- mail/stdlib/http/actions.py +333 -0
- mail/stdlib/interswarm/__init__.py +11 -0
- mail/stdlib/interswarm/actions.py +208 -0
- mail/stdlib/mcp/__init__.py +19 -0
- mail/stdlib/mcp/actions.py +294 -0
- mail/stdlib/openai/__init__.py +13 -0
- mail/stdlib/openai/agents.py +451 -0
- mail/summarizer.py +234 -0
- mail/swarms_json/__init__.py +27 -0
- mail/swarms_json/types.py +87 -0
- mail/swarms_json/utils.py +255 -0
- mail/url_scheme.py +51 -0
- mail/utils/__init__.py +53 -0
- mail/utils/auth.py +194 -0
- mail/utils/context.py +17 -0
- mail/utils/logger.py +73 -0
- mail/utils/openai.py +212 -0
- mail/utils/parsing.py +89 -0
- mail/utils/serialize.py +292 -0
- mail/utils/store.py +49 -0
- mail/utils/string_builder.py +119 -0
- mail/utils/version.py +20 -0
- mail_swarms-1.3.2.dist-info/METADATA +237 -0
- mail_swarms-1.3.2.dist-info/RECORD +137 -0
- mail_swarms-1.3.2.dist-info/WHEEL +4 -0
- mail_swarms-1.3.2.dist-info/entry_points.txt +2 -0
- mail_swarms-1.3.2.dist-info/licenses/LICENSE +202 -0
- mail_swarms-1.3.2.dist-info/licenses/NOTICE +10 -0
- mail_swarms-1.3.2.dist-info/licenses/THIRD_PARTY_NOTICES.md +12334 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2025 Charon Labs
|
|
3
|
+
|
|
4
|
+
"""FAQ search action for the Customer Support swarm."""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from mail import action
|
|
10
|
+
|
|
11
|
+
# Dummy FAQ database for demonstration purposes
|
|
12
|
+
FAQ_DATABASE = [
|
|
13
|
+
{
|
|
14
|
+
"id": "faq-001",
|
|
15
|
+
"question": "How do I reset my password?",
|
|
16
|
+
"answer": "To reset your password: 1) Go to the login page, 2) Click 'Forgot Password', 3) Enter your email address, 4) Check your email for a reset link, 5) Click the link and create a new password. The reset link expires after 24 hours.",
|
|
17
|
+
"category": "account",
|
|
18
|
+
"keywords": ["password", "reset", "forgot", "login", "account", "access"],
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"id": "faq-002",
|
|
22
|
+
"question": "What is your refund policy?",
|
|
23
|
+
"answer": "We offer a 30-day money-back guarantee on all purchases. To request a refund: 1) Contact our support team within 30 days of purchase, 2) Provide your order number, 3) Explain the reason for the refund. Refunds are processed within 5-7 business days to your original payment method.",
|
|
24
|
+
"category": "billing",
|
|
25
|
+
"keywords": ["refund", "money", "return", "policy", "guarantee", "purchase"],
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"id": "faq-003",
|
|
29
|
+
"question": "How do I cancel my subscription?",
|
|
30
|
+
"answer": "To cancel your subscription: 1) Log into your account, 2) Go to Settings > Subscription, 3) Click 'Cancel Subscription', 4) Confirm your cancellation. Your access continues until the end of the current billing period. You can resubscribe at any time.",
|
|
31
|
+
"category": "billing",
|
|
32
|
+
"keywords": ["cancel", "subscription", "unsubscribe", "stop", "billing"],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"id": "faq-004",
|
|
36
|
+
"question": "How do I contact customer support?",
|
|
37
|
+
"answer": "You can reach our support team through: 1) Email: support@example.com (response within 24 hours), 2) Live chat on our website (available 9am-6pm EST), 3) Phone: 1-800-EXAMPLE (Monday-Friday 9am-5pm EST). For urgent issues, please use phone support.",
|
|
38
|
+
"category": "general",
|
|
39
|
+
"keywords": ["contact", "support", "help", "phone", "email", "chat"],
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"id": "faq-005",
|
|
43
|
+
"question": "Why is my account locked?",
|
|
44
|
+
"answer": "Accounts may be locked for security reasons including: 1) Multiple failed login attempts, 2) Suspicious activity detected, 3) Password expired. To unlock your account, use the 'Forgot Password' feature or contact support. If you believe this is an error, our security team can review your case.",
|
|
45
|
+
"category": "account",
|
|
46
|
+
"keywords": ["locked", "account", "security", "blocked", "access", "suspended"],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"id": "faq-006",
|
|
50
|
+
"question": "How do I update my billing information?",
|
|
51
|
+
"answer": "To update billing information: 1) Log into your account, 2) Go to Settings > Payment Methods, 3) Click 'Edit' next to your current payment method or 'Add New', 4) Enter your new card details, 5) Click 'Save'. Your next charge will use the updated payment method.",
|
|
52
|
+
"category": "billing",
|
|
53
|
+
"keywords": ["billing", "payment", "credit card", "update", "change", "card"],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"id": "faq-007",
|
|
57
|
+
"question": "What features are included in my plan?",
|
|
58
|
+
"answer": "Plan features vary by tier: Basic includes core features and email support. Pro adds advanced analytics, priority support, and API access. Enterprise includes all Pro features plus dedicated account manager, custom integrations, and 24/7 phone support. Visit our pricing page for detailed comparisons.",
|
|
59
|
+
"category": "general",
|
|
60
|
+
"keywords": [
|
|
61
|
+
"features",
|
|
62
|
+
"plan",
|
|
63
|
+
"tier",
|
|
64
|
+
"pricing",
|
|
65
|
+
"include",
|
|
66
|
+
"basic",
|
|
67
|
+
"pro",
|
|
68
|
+
"enterprise",
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"id": "faq-008",
|
|
73
|
+
"question": "How do I export my data?",
|
|
74
|
+
"answer": "To export your data: 1) Go to Settings > Data & Privacy, 2) Click 'Export Data', 3) Select the data types to include, 4) Choose your format (CSV or JSON), 5) Click 'Generate Export'. You'll receive an email with a download link within 24 hours. Exports are available for 7 days.",
|
|
75
|
+
"category": "technical",
|
|
76
|
+
"keywords": ["export", "data", "download", "backup", "csv", "json"],
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"id": "faq-009",
|
|
80
|
+
"question": "Is my data secure?",
|
|
81
|
+
"answer": "Yes, we take security seriously. We use: 1) AES-256 encryption for data at rest, 2) TLS 1.3 for data in transit, 3) Regular security audits by third parties, 4) SOC 2 Type II certification, 5) GDPR and CCPA compliance. We never sell your data to third parties.",
|
|
82
|
+
"category": "technical",
|
|
83
|
+
"keywords": [
|
|
84
|
+
"security",
|
|
85
|
+
"data",
|
|
86
|
+
"encryption",
|
|
87
|
+
"privacy",
|
|
88
|
+
"secure",
|
|
89
|
+
"safe",
|
|
90
|
+
"gdpr",
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"id": "faq-010",
|
|
95
|
+
"question": "How do I enable two-factor authentication?",
|
|
96
|
+
"answer": "To enable 2FA: 1) Go to Settings > Security, 2) Click 'Enable Two-Factor Authentication', 3) Choose your method (authenticator app or SMS), 4) Follow the setup instructions, 5) Save your backup codes. We recommend using an authenticator app for better security.",
|
|
97
|
+
"category": "account",
|
|
98
|
+
"keywords": ["two-factor", "2fa", "authentication", "security", "mfa", "otp"],
|
|
99
|
+
},
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
SEARCH_FAQ_PARAMETERS = {
|
|
103
|
+
"type": "object",
|
|
104
|
+
"properties": {
|
|
105
|
+
"query": {
|
|
106
|
+
"type": "string",
|
|
107
|
+
"description": "The search query to find relevant FAQ entries",
|
|
108
|
+
},
|
|
109
|
+
"max_results": {
|
|
110
|
+
"type": "integer",
|
|
111
|
+
"description": "Maximum number of FAQ entries to return (default: 3)",
|
|
112
|
+
"minimum": 1,
|
|
113
|
+
"maximum": 10,
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
"required": ["query"],
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _calculate_relevance(query: str, faq_entry: dict[str, Any]) -> float:
|
|
121
|
+
"""Calculate relevance score between query and FAQ entry."""
|
|
122
|
+
query_terms = set(query.lower().split())
|
|
123
|
+
keywords = set(faq_entry.get("keywords", []))
|
|
124
|
+
question_terms = set(faq_entry.get("question", "").lower().split())
|
|
125
|
+
|
|
126
|
+
# Score based on keyword matches (highest weight)
|
|
127
|
+
keyword_matches = len(query_terms & keywords)
|
|
128
|
+
# Score based on question word matches
|
|
129
|
+
question_matches = len(query_terms & question_terms)
|
|
130
|
+
|
|
131
|
+
# Combined score with weights
|
|
132
|
+
score = (keyword_matches * 2.0) + (question_matches * 1.0)
|
|
133
|
+
return score
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@action(
|
|
137
|
+
name="search_faq",
|
|
138
|
+
description="Search the FAQ database for entries relevant to a customer query.",
|
|
139
|
+
parameters=SEARCH_FAQ_PARAMETERS,
|
|
140
|
+
)
|
|
141
|
+
async def search_faq(args: dict[str, Any]) -> str:
|
|
142
|
+
"""Search the FAQ database and return relevant entries."""
|
|
143
|
+
try:
|
|
144
|
+
query = args["query"]
|
|
145
|
+
max_results = args.get("max_results", 3)
|
|
146
|
+
except KeyError as e:
|
|
147
|
+
return f"Error: {e} is required"
|
|
148
|
+
|
|
149
|
+
if not query.strip():
|
|
150
|
+
return json.dumps({"error": "Query cannot be empty", "results": []})
|
|
151
|
+
|
|
152
|
+
# Calculate relevance scores for all FAQ entries
|
|
153
|
+
scored_entries = []
|
|
154
|
+
for entry in FAQ_DATABASE:
|
|
155
|
+
score = _calculate_relevance(query, entry)
|
|
156
|
+
if score > 0:
|
|
157
|
+
scored_entries.append((score, entry))
|
|
158
|
+
|
|
159
|
+
# Sort by relevance score (descending)
|
|
160
|
+
scored_entries.sort(key=lambda x: x[0], reverse=True)
|
|
161
|
+
|
|
162
|
+
# Take top results
|
|
163
|
+
results = []
|
|
164
|
+
for score, entry in scored_entries[:max_results]:
|
|
165
|
+
results.append(
|
|
166
|
+
{
|
|
167
|
+
"id": entry["id"],
|
|
168
|
+
"question": entry["question"],
|
|
169
|
+
"answer": entry["answer"],
|
|
170
|
+
"category": entry["category"],
|
|
171
|
+
"relevance_score": round(score, 2),
|
|
172
|
+
}
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
return json.dumps(
|
|
176
|
+
{
|
|
177
|
+
"query": query,
|
|
178
|
+
"total_matches": len(scored_entries),
|
|
179
|
+
"results_returned": len(results),
|
|
180
|
+
"results": results,
|
|
181
|
+
}
|
|
182
|
+
)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2025 Charon Labs
|
|
3
|
+
|
|
4
|
+
"""FAQ agent for the Customer Support swarm."""
|
|
5
|
+
|
|
6
|
+
from collections.abc import Awaitable
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from mail.core.agents import AgentOutput
|
|
10
|
+
from mail.factories.action import LiteLLMActionAgentFunction
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LiteLLMFaqFunction(LiteLLMActionAgentFunction):
|
|
14
|
+
"""
|
|
15
|
+
FAQ specialist agent that searches the FAQ database.
|
|
16
|
+
|
|
17
|
+
This agent handles FAQ searches and returns relevant entries
|
|
18
|
+
to help answer customer questions.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
name: str,
|
|
24
|
+
comm_targets: list[str],
|
|
25
|
+
tools: list[dict[str, Any]],
|
|
26
|
+
llm: str,
|
|
27
|
+
system: str,
|
|
28
|
+
user_token: str = "",
|
|
29
|
+
enable_entrypoint: bool = False,
|
|
30
|
+
enable_interswarm: bool = False,
|
|
31
|
+
can_complete_tasks: bool = False,
|
|
32
|
+
tool_format: Literal["completions", "responses"] = "responses",
|
|
33
|
+
exclude_tools: list[str] = [],
|
|
34
|
+
reasoning_effort: Literal["minimal", "low", "medium", "high"] | None = None,
|
|
35
|
+
thinking_budget: int | None = None,
|
|
36
|
+
max_tokens: int | None = None,
|
|
37
|
+
memory: bool = True,
|
|
38
|
+
use_proxy: bool = True,
|
|
39
|
+
_debug_include_mail_tools: bool = True,
|
|
40
|
+
) -> None:
|
|
41
|
+
super().__init__(
|
|
42
|
+
name=name,
|
|
43
|
+
comm_targets=comm_targets,
|
|
44
|
+
tools=tools,
|
|
45
|
+
llm=llm,
|
|
46
|
+
system=system,
|
|
47
|
+
user_token=user_token,
|
|
48
|
+
enable_entrypoint=enable_entrypoint,
|
|
49
|
+
enable_interswarm=enable_interswarm,
|
|
50
|
+
can_complete_tasks=can_complete_tasks,
|
|
51
|
+
tool_format=tool_format,
|
|
52
|
+
exclude_tools=exclude_tools,
|
|
53
|
+
reasoning_effort=reasoning_effort,
|
|
54
|
+
thinking_budget=thinking_budget,
|
|
55
|
+
max_tokens=max_tokens,
|
|
56
|
+
memory=memory,
|
|
57
|
+
use_proxy=use_proxy,
|
|
58
|
+
_debug_include_mail_tools=_debug_include_mail_tools,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def __call__(
|
|
62
|
+
self,
|
|
63
|
+
messages: list[dict[str, Any]],
|
|
64
|
+
tool_choice: str | dict[str, str] = "required",
|
|
65
|
+
) -> Awaitable[AgentOutput]:
|
|
66
|
+
"""Execute the FAQ agent function."""
|
|
67
|
+
return super().__call__(messages, tool_choice)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2025 Charon Labs
|
|
3
|
+
|
|
4
|
+
SYSPROMPT = """You are faq@{swarm}, the FAQ specialist for this customer support swarm.
|
|
5
|
+
|
|
6
|
+
# Your Role
|
|
7
|
+
Search the FAQ database to find relevant answers to customer questions and report results back to the coordinator.
|
|
8
|
+
|
|
9
|
+
# Critical Rule: Responding
|
|
10
|
+
You CANNOT talk to users directly or call `task_complete`. You MUST use `send_response` to reply to the agent who contacted you.
|
|
11
|
+
- When you receive a request, note the sender (usually "coordinator")
|
|
12
|
+
- After searching the FAQ, call `send_response(target=<sender>, subject="Re: ...", body=<your answer>)`
|
|
13
|
+
- Include ALL relevant FAQ entries in your response body - the recipient cannot see tool results
|
|
14
|
+
|
|
15
|
+
# Tools
|
|
16
|
+
|
|
17
|
+
## FAQ Search
|
|
18
|
+
- `search_faq(query, max_results)`: Search the FAQ database for relevant entries
|
|
19
|
+
|
|
20
|
+
## Communication
|
|
21
|
+
- `send_response(target, subject, body)`: Reply to the agent who requested information
|
|
22
|
+
- `send_request(target, subject, body)`: Ask another agent for information (rare)
|
|
23
|
+
- `acknowledge_broadcast(note)`: Acknowledge a broadcast message
|
|
24
|
+
- `ignore_broadcast(reason)`: Ignore an irrelevant broadcast
|
|
25
|
+
|
|
26
|
+
# Workflow
|
|
27
|
+
|
|
28
|
+
1. Receive request from another agent (note the sender)
|
|
29
|
+
2. Call `search_faq` with relevant search terms from the query
|
|
30
|
+
3. Review the results and identify the most relevant answers
|
|
31
|
+
4. Call `send_response` to the original sender with:
|
|
32
|
+
- The relevant FAQ entries found
|
|
33
|
+
- A brief summary of how they relate to the question
|
|
34
|
+
- Note if no relevant entries were found
|
|
35
|
+
|
|
36
|
+
# Guidelines
|
|
37
|
+
|
|
38
|
+
- Use specific keywords from the customer question for better search results
|
|
39
|
+
- If the first search yields poor results, try alternative search terms
|
|
40
|
+
- Report honestly if no relevant FAQ entries exist
|
|
41
|
+
- Include the FAQ question and answer in your response, not just a summary
|
|
42
|
+
- Use "Re: <original subject>" as your response subject"""
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2025 Charon Labs
|
|
3
|
+
|
|
4
|
+
"""Sentiment agent for the Customer Support swarm."""
|
|
5
|
+
|
|
6
|
+
from mail.examples.support.sentiment.agent import LiteLLMSentimentFunction
|
|
7
|
+
from mail.examples.support.sentiment.actions import analyze_sentiment, create_escalation
|
|
8
|
+
from mail.examples.support.sentiment.prompts import SYSPROMPT
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"LiteLLMSentimentFunction",
|
|
12
|
+
"analyze_sentiment",
|
|
13
|
+
"create_escalation",
|
|
14
|
+
"SYSPROMPT",
|
|
15
|
+
]
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2025 Charon Labs
|
|
3
|
+
|
|
4
|
+
"""Sentiment analysis actions for the Customer Support swarm."""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import uuid
|
|
9
|
+
from datetime import datetime, UTC
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from mail import action
|
|
13
|
+
|
|
14
|
+
# Sentiment indicators with associated scores
|
|
15
|
+
POSITIVE_INDICATORS = {
|
|
16
|
+
"strong": [
|
|
17
|
+
("love", 0.9),
|
|
18
|
+
("amazing", 0.85),
|
|
19
|
+
("excellent", 0.85),
|
|
20
|
+
("fantastic", 0.85),
|
|
21
|
+
("wonderful", 0.8),
|
|
22
|
+
("great", 0.75),
|
|
23
|
+
("awesome", 0.8),
|
|
24
|
+
("perfect", 0.85),
|
|
25
|
+
("best", 0.75),
|
|
26
|
+
("thank you so much", 0.8),
|
|
27
|
+
("really appreciate", 0.75),
|
|
28
|
+
],
|
|
29
|
+
"moderate": [
|
|
30
|
+
("good", 0.5),
|
|
31
|
+
("nice", 0.5),
|
|
32
|
+
("helpful", 0.6),
|
|
33
|
+
("thanks", 0.4),
|
|
34
|
+
("appreciate", 0.5),
|
|
35
|
+
("pleased", 0.6),
|
|
36
|
+
("happy", 0.6),
|
|
37
|
+
("satisfied", 0.5),
|
|
38
|
+
("works", 0.3),
|
|
39
|
+
("resolved", 0.5),
|
|
40
|
+
("fixed", 0.5),
|
|
41
|
+
],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
NEGATIVE_INDICATORS = {
|
|
45
|
+
"strong": [
|
|
46
|
+
("terrible", -0.9),
|
|
47
|
+
("horrible", -0.9),
|
|
48
|
+
("worst", -0.85),
|
|
49
|
+
("hate", -0.85),
|
|
50
|
+
("disgusted", -0.8),
|
|
51
|
+
("furious", -0.85),
|
|
52
|
+
("outraged", -0.85),
|
|
53
|
+
("scam", -0.9),
|
|
54
|
+
("fraud", -0.9),
|
|
55
|
+
("lawsuit", -0.9),
|
|
56
|
+
("lawyer", -0.8),
|
|
57
|
+
("sue", -0.85),
|
|
58
|
+
("unacceptable", -0.75),
|
|
59
|
+
("ridiculous", -0.7),
|
|
60
|
+
],
|
|
61
|
+
"moderate": [
|
|
62
|
+
("frustrated", -0.6),
|
|
63
|
+
("annoyed", -0.5),
|
|
64
|
+
("disappointed", -0.5),
|
|
65
|
+
("upset", -0.55),
|
|
66
|
+
("angry", -0.65),
|
|
67
|
+
("bad", -0.5),
|
|
68
|
+
("poor", -0.5),
|
|
69
|
+
("awful", -0.7),
|
|
70
|
+
("useless", -0.6),
|
|
71
|
+
("broken", -0.4),
|
|
72
|
+
("failed", -0.45),
|
|
73
|
+
("doesn't work", -0.5),
|
|
74
|
+
("not working", -0.5),
|
|
75
|
+
("can't", -0.3),
|
|
76
|
+
],
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
ESCALATION_PHRASES = [
|
|
80
|
+
"speak to manager",
|
|
81
|
+
"speak to a manager",
|
|
82
|
+
"talk to manager",
|
|
83
|
+
"supervisor",
|
|
84
|
+
"escalate",
|
|
85
|
+
"cancel my account",
|
|
86
|
+
"cancel my subscription",
|
|
87
|
+
"legal action",
|
|
88
|
+
"lawyer",
|
|
89
|
+
"sue you",
|
|
90
|
+
"report you",
|
|
91
|
+
"bbb",
|
|
92
|
+
"better business bureau",
|
|
93
|
+
"never again",
|
|
94
|
+
"worst company",
|
|
95
|
+
"stealing",
|
|
96
|
+
"theft",
|
|
97
|
+
"refund now",
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
EMOTION_PATTERNS = {
|
|
101
|
+
"anger": ["angry", "furious", "mad", "outraged", "infuriated", "livid"],
|
|
102
|
+
"frustration": ["frustrated", "annoyed", "irritated", "exasperated", "fed up"],
|
|
103
|
+
"disappointment": ["disappointed", "let down", "expected better", "underwhelmed"],
|
|
104
|
+
"anxiety": ["worried", "concerned", "anxious", "nervous", "stressed"],
|
|
105
|
+
"confusion": ["confused", "don't understand", "unclear", "lost", "puzzled"],
|
|
106
|
+
"satisfaction": ["satisfied", "happy", "pleased", "glad", "content"],
|
|
107
|
+
"gratitude": ["thank", "appreciate", "grateful", "thankful"],
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _detect_emotions(text: str) -> list[dict[str, Any]]:
|
|
112
|
+
"""Detect emotions present in the text."""
|
|
113
|
+
text_lower = text.lower()
|
|
114
|
+
detected = []
|
|
115
|
+
|
|
116
|
+
for emotion, patterns in EMOTION_PATTERNS.items():
|
|
117
|
+
for pattern in patterns:
|
|
118
|
+
if pattern in text_lower:
|
|
119
|
+
detected.append(
|
|
120
|
+
{
|
|
121
|
+
"emotion": emotion,
|
|
122
|
+
"indicator": pattern,
|
|
123
|
+
}
|
|
124
|
+
)
|
|
125
|
+
break # Only add each emotion once
|
|
126
|
+
|
|
127
|
+
return detected
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _calculate_sentiment_score(text: str) -> tuple[float, list[str]]:
|
|
131
|
+
"""Calculate sentiment score and return contributing factors."""
|
|
132
|
+
text_lower = text.lower()
|
|
133
|
+
score = 0.0
|
|
134
|
+
factors = []
|
|
135
|
+
|
|
136
|
+
# Check positive indicators
|
|
137
|
+
for strength, indicators in POSITIVE_INDICATORS.items():
|
|
138
|
+
for phrase, value in indicators:
|
|
139
|
+
if phrase in text_lower:
|
|
140
|
+
score += value
|
|
141
|
+
factors.append(f"+{value:.1f} ({phrase})")
|
|
142
|
+
|
|
143
|
+
# Check negative indicators
|
|
144
|
+
for strength, indicators in NEGATIVE_INDICATORS.items():
|
|
145
|
+
for phrase, value in indicators:
|
|
146
|
+
if phrase in text_lower:
|
|
147
|
+
score += value
|
|
148
|
+
factors.append(f"{value:.1f} ({phrase})")
|
|
149
|
+
|
|
150
|
+
# Normalize score to -1 to +1 range
|
|
151
|
+
if score > 1.0:
|
|
152
|
+
score = 1.0
|
|
153
|
+
elif score < -1.0:
|
|
154
|
+
score = -1.0
|
|
155
|
+
|
|
156
|
+
return round(score, 2), factors
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _check_escalation_needed(text: str, score: float) -> tuple[bool, str | None]:
|
|
160
|
+
"""Check if escalation to human agent is needed."""
|
|
161
|
+
text_lower = text.lower()
|
|
162
|
+
|
|
163
|
+
# Check for explicit escalation phrases
|
|
164
|
+
for phrase in ESCALATION_PHRASES:
|
|
165
|
+
if phrase in text_lower:
|
|
166
|
+
return (
|
|
167
|
+
True,
|
|
168
|
+
f"Customer explicitly requested escalation or used concerning phrase: '{phrase}'",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Check for very negative sentiment
|
|
172
|
+
if score <= -0.6:
|
|
173
|
+
return True, f"Very negative sentiment detected (score: {score})"
|
|
174
|
+
|
|
175
|
+
# Check for strong negative emotions
|
|
176
|
+
if any(
|
|
177
|
+
word in text_lower
|
|
178
|
+
for word in ["furious", "outraged", "lawsuit", "lawyer", "sue"]
|
|
179
|
+
):
|
|
180
|
+
return True, "Strong negative emotions or legal language detected"
|
|
181
|
+
|
|
182
|
+
return False, None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
ANALYZE_SENTIMENT_PARAMETERS = {
|
|
186
|
+
"type": "object",
|
|
187
|
+
"properties": {
|
|
188
|
+
"text": {
|
|
189
|
+
"type": "string",
|
|
190
|
+
"description": "The customer text to analyze for sentiment",
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
"required": ["text"],
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@action(
|
|
198
|
+
name="analyze_sentiment",
|
|
199
|
+
description="Analyze the sentiment and emotional tone of customer text.",
|
|
200
|
+
parameters=ANALYZE_SENTIMENT_PARAMETERS,
|
|
201
|
+
)
|
|
202
|
+
async def analyze_sentiment(args: dict[str, Any]) -> str:
|
|
203
|
+
"""Analyze sentiment of customer text."""
|
|
204
|
+
try:
|
|
205
|
+
text = args["text"]
|
|
206
|
+
except KeyError as e:
|
|
207
|
+
return f"Error: {e} is required"
|
|
208
|
+
|
|
209
|
+
if not text.strip():
|
|
210
|
+
return json.dumps({"error": "Text cannot be empty"})
|
|
211
|
+
|
|
212
|
+
# Calculate sentiment score
|
|
213
|
+
score, factors = _calculate_sentiment_score(text)
|
|
214
|
+
|
|
215
|
+
# Determine overall sentiment category
|
|
216
|
+
if score >= 0.3:
|
|
217
|
+
sentiment = "positive"
|
|
218
|
+
elif score <= -0.3:
|
|
219
|
+
sentiment = "negative"
|
|
220
|
+
else:
|
|
221
|
+
sentiment = "neutral"
|
|
222
|
+
|
|
223
|
+
# Detect specific emotions
|
|
224
|
+
emotions = _detect_emotions(text)
|
|
225
|
+
|
|
226
|
+
# Check if escalation is needed
|
|
227
|
+
escalation_needed, escalation_reason = _check_escalation_needed(text, score)
|
|
228
|
+
|
|
229
|
+
result = {
|
|
230
|
+
"sentiment": sentiment,
|
|
231
|
+
"score": score,
|
|
232
|
+
"score_factors": factors
|
|
233
|
+
if factors
|
|
234
|
+
else ["No strong sentiment indicators found"],
|
|
235
|
+
"emotions_detected": emotions
|
|
236
|
+
if emotions
|
|
237
|
+
else [{"emotion": "neutral", "indicator": "none detected"}],
|
|
238
|
+
"escalation_recommended": escalation_needed,
|
|
239
|
+
"escalation_reason": escalation_reason,
|
|
240
|
+
"analysis_summary": _generate_summary(
|
|
241
|
+
sentiment, score, emotions, escalation_needed
|
|
242
|
+
),
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return json.dumps(result)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _generate_summary(
|
|
249
|
+
sentiment: str, score: float, emotions: list[dict], escalation: bool
|
|
250
|
+
) -> str:
|
|
251
|
+
"""Generate a human-readable summary of the sentiment analysis."""
|
|
252
|
+
summary_parts = []
|
|
253
|
+
|
|
254
|
+
# Sentiment description
|
|
255
|
+
if score >= 0.5:
|
|
256
|
+
summary_parts.append("Customer expresses strong positive sentiment")
|
|
257
|
+
elif score >= 0.2:
|
|
258
|
+
summary_parts.append("Customer expresses mild positive sentiment")
|
|
259
|
+
elif score <= -0.5:
|
|
260
|
+
summary_parts.append("Customer expresses strong negative sentiment")
|
|
261
|
+
elif score <= -0.2:
|
|
262
|
+
summary_parts.append("Customer expresses mild negative sentiment")
|
|
263
|
+
else:
|
|
264
|
+
summary_parts.append("Customer sentiment is neutral")
|
|
265
|
+
|
|
266
|
+
# Emotion summary
|
|
267
|
+
if emotions:
|
|
268
|
+
emotion_names = [e["emotion"] for e in emotions]
|
|
269
|
+
if len(emotion_names) == 1:
|
|
270
|
+
summary_parts.append(f"Primary emotion detected: {emotion_names[0]}")
|
|
271
|
+
elif len(emotion_names) > 1:
|
|
272
|
+
summary_parts.append(f"Emotions detected: {', '.join(emotion_names)}")
|
|
273
|
+
|
|
274
|
+
# Escalation note
|
|
275
|
+
if escalation:
|
|
276
|
+
summary_parts.append(
|
|
277
|
+
"ESCALATION RECOMMENDED - Human agent intervention suggested"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
return ". ".join(summary_parts) + "."
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
CREATE_ESCALATION_PARAMETERS = {
|
|
284
|
+
"type": "object",
|
|
285
|
+
"properties": {
|
|
286
|
+
"ticket_id": {
|
|
287
|
+
"type": "string",
|
|
288
|
+
"description": "The ticket ID to escalate",
|
|
289
|
+
},
|
|
290
|
+
"reason": {
|
|
291
|
+
"type": "string",
|
|
292
|
+
"description": "The reason for escalation",
|
|
293
|
+
},
|
|
294
|
+
"priority": {
|
|
295
|
+
"type": "string",
|
|
296
|
+
"enum": ["high", "urgent"],
|
|
297
|
+
"description": "The escalation priority level",
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
"required": ["ticket_id", "reason", "priority"],
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@action(
|
|
305
|
+
name="create_escalation",
|
|
306
|
+
description="Create an escalation record to flag a ticket for human agent review.",
|
|
307
|
+
parameters=CREATE_ESCALATION_PARAMETERS,
|
|
308
|
+
)
|
|
309
|
+
async def create_escalation(args: dict[str, Any]) -> str:
|
|
310
|
+
"""Create an escalation record for a support ticket."""
|
|
311
|
+
try:
|
|
312
|
+
ticket_id = args["ticket_id"]
|
|
313
|
+
reason = args["reason"]
|
|
314
|
+
priority = args["priority"]
|
|
315
|
+
except KeyError as e:
|
|
316
|
+
return f"Error: {e} is required"
|
|
317
|
+
|
|
318
|
+
if priority not in ("high", "urgent"):
|
|
319
|
+
return json.dumps({"error": "Priority must be 'high' or 'urgent'"})
|
|
320
|
+
|
|
321
|
+
# Generate escalation record (dummy implementation)
|
|
322
|
+
escalation = {
|
|
323
|
+
"escalation_id": f"ESC-{uuid.uuid4().hex[:8].upper()}",
|
|
324
|
+
"ticket_id": ticket_id,
|
|
325
|
+
"reason": reason,
|
|
326
|
+
"priority": priority,
|
|
327
|
+
"status": "pending",
|
|
328
|
+
"created_at": datetime.now(UTC).isoformat(),
|
|
329
|
+
"assigned_to": "support_supervisor_queue"
|
|
330
|
+
if priority == "high"
|
|
331
|
+
else "urgent_response_team",
|
|
332
|
+
"sla_target": "4 hours" if priority == "high" else "1 hour",
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return json.dumps(
|
|
336
|
+
{
|
|
337
|
+
"success": True,
|
|
338
|
+
"message": f"Escalation created successfully with {priority} priority",
|
|
339
|
+
"escalation": escalation,
|
|
340
|
+
}
|
|
341
|
+
)
|