Files
2026-07-13 13:35:10 +08:00

671 lines
22 KiB
Python

import json
import os
import re
from abc import ABC, abstractmethod
from dataclasses import asdict, dataclass
from datetime import datetime
from enum import Enum
from typing import Any, Dict, Literal, Optional
from openai import OpenAI
@dataclass
class TraceEvent:
"""Single event in the application trace"""
event_type: str # "llm_call", "llm_response", "extraction", "classification", "error", "init"
component: (
str # "openai_api", "deterministic_extractor", "llm_extractor", "support_agent"
)
data: Dict[str, Any]
class ExtractionMode(Enum):
"""Extraction modes available"""
DETERMINISTIC = "deterministic"
LLM = "llm"
class BaseExtractor(ABC):
"""Base class for all extractors"""
@abstractmethod
def extract(self, email_content: str, category: str) -> Dict[str, Optional[str]]:
"""Extract information based on category"""
pass
class DeterministicExtractor(BaseExtractor):
"""Regex and rule-based extraction"""
def extract(self, email_content: str, category: str) -> Dict[str, Optional[str]]:
"""Route to appropriate extraction method"""
extractors = {
"Bug Report": self._extract_bug_info,
"Billing": self._extract_billing_info,
"Feature Request": self._extract_feature_info,
}
extractor = extractors.get(category)
if extractor:
return extractor(email_content)
return {}
def _extract_bug_info(self, email_content: str) -> Dict[str, Optional[str]]:
"""Extract product version and error code from bug reports"""
version_pattern = r"version\s*[:\-]?\s*([0-9]+\.[0-9]+(?:\.[0-9]+)?)"
error_pattern = r"error\s*(?:code\s*)?[:\-]?\s*([A-Z0-9\-_]+)"
version_match = re.search(version_pattern, email_content, re.IGNORECASE)
error_match = re.search(error_pattern, email_content, re.IGNORECASE)
return {
"product_version": version_match.group(1) if version_match else None,
"error_code": error_match.group(1) if error_match else None,
}
def _extract_billing_info(self, email_content: str) -> Dict[str, Optional[str]]:
"""Extract invoice number and amount from billing emails"""
invoice_pattern = r"invoice\s*[#:\-]?\s*([A-Z0-9\-_]+)"
amount_pattern = r"\$([0-9,]+(?:\.[0-9]{2})?)"
invoice_match = re.search(invoice_pattern, email_content, re.IGNORECASE)
amount_match = re.search(amount_pattern, email_content)
# Clean up amount (remove commas)
amount = None
if amount_match:
amount = amount_match.group(1).replace(",", "")
return {
"invoice_number": invoice_match.group(1) if invoice_match else None,
"amount": amount,
}
def _extract_feature_info(self, email_content: str) -> Dict[str, Optional[str]]:
"""Extract feature request details"""
# Urgency detection
urgency_keywords = {
"urgent": ["urgent", "asap", "immediately", "critical", "emergency"],
"high": ["important", "soon", "needed", "priority", "essential"],
"medium": ["would like", "request", "suggest", "consider"],
"low": ["nice to have", "whenever", "eventually", "someday"],
}
urgency_level = "medium" # default
email_lower = email_content.lower()
for level, keywords in urgency_keywords.items():
if any(keyword in email_lower for keyword in keywords):
urgency_level = level
break
# Product area detection
product_areas = [
"dashboard",
"api",
"mobile",
"reports",
"billing",
"user management",
"analytics",
"integration",
"security",
]
mentioned_areas = [area for area in product_areas if area in email_lower]
# Try to extract the main feature request (simple approach)
feature_keywords = [
"add",
"feature",
"ability",
"support",
"implement",
"create",
]
requested_feature = None
for keyword in feature_keywords:
pattern = rf"{keyword}\s+(?:a\s+|an\s+|the\s+)?([^.!?]+)"
match = re.search(pattern, email_content, re.IGNORECASE)
if match:
requested_feature = match.group(1).strip()[:100] # Limit length
break
return {
"requested_feature": requested_feature
or "Feature extraction requires manual review",
"product_area": mentioned_areas[0] if mentioned_areas else "general",
"urgency_level": urgency_level,
}
class LLMExtractor(BaseExtractor):
"""LLM-based extraction"""
def __init__(self, client: OpenAI):
self.client = client
def extract(self, email_content: str, category: str) -> Dict[str, Optional[str]]:
"""Use LLM to extract information"""
extraction_prompts = {
"Bug Report": self._get_bug_extraction_prompt,
"Billing": self._get_billing_extraction_prompt,
"Feature Request": self._get_feature_extraction_prompt,
}
prompt_func = extraction_prompts.get(category)
if not prompt_func:
return {}
prompt = prompt_func(email_content)
try:
response = self.client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": prompt}],
temperature=0,
max_tokens=200,
)
# Parse JSON response
result = json.loads(
response.choices[0].message.content.strip()
if response.choices[0].message.content
else "{}"
)
return result
except Exception:
return {}
def _get_bug_extraction_prompt(self, email_content: str) -> str:
return f"""
Extract the following information from this bug report email:
- product_version: The version number mentioned (e.g., "2.1.4")
- error_code: Any error code mentioned (e.g., "XYZ-123")
Email: {email_content}
Respond with valid JSON only, like:
{{"product_version": "2.1.4", "error_code": "XYZ-123"}}
If a field is not found, use null.
"""
def _get_billing_extraction_prompt(self, email_content: str) -> str:
return f"""
Extract the following information from this billing email:
- invoice_number: The invoice number (e.g., "INV-2024-001")
- amount: The dollar amount mentioned (without $ sign, e.g., "299.99")
Email: {email_content}
Respond with valid JSON only, like:
{{"invoice_number": "INV-2024-001", "amount": "299.99"}}
If a field is not found, use null.
"""
def _get_feature_extraction_prompt(self, email_content: str) -> str:
return f"""
Extract the following information from this feature request email:
- requested_feature: Brief description of the main feature requested (max 100 chars)
- product_area: Which area it relates to (dashboard/api/mobile/reports/billing/user management/analytics/integration/security/general)
- urgency_level: Urgency level (urgent/high/medium/low)
Email: {email_content}
Respond with valid JSON only, like:
{{"requested_feature": "dark mode for dashboard", "product_area": "dashboard", "urgency_level": "high"}}
If a field is not found, use appropriate defaults.
"""
class ConfigurableSupportTriageAgent:
"""Support triage agent with configurable extraction modes"""
def __init__(
self,
api_key: str,
extractor: Optional[BaseExtractor] = None,
logdir: str = "logs",
):
self.client = OpenAI(api_key=api_key)
self.traces = []
self.logdir = logdir
# Create log directory if it doesn't exist
os.makedirs(self.logdir, exist_ok=True)
# If no extractor provided, default to deterministic
if extractor is None:
self.extractor = DeterministicExtractor()
else:
self.extractor = extractor
# Store the extractor type for reference
if isinstance(self.extractor, DeterministicExtractor):
self.extraction_mode = ExtractionMode.DETERMINISTIC
elif isinstance(self.extractor, LLMExtractor):
self.extraction_mode = ExtractionMode.LLM
else:
# Custom extractor
self.extraction_mode = None
print(
f"📧 Initialized Support Triage Agent with {self.extraction_mode.value if self.extraction_mode else 'custom'} extraction mode"
)
self.traces.append(
TraceEvent(
event_type="init",
component="support_agent",
data={
"extraction_mode": (
self.extraction_mode.value if self.extraction_mode else "custom"
)
},
)
)
def set_extractor(self, extractor: BaseExtractor):
"""Change extractor at runtime"""
self.extractor = extractor
# Update extraction mode
if isinstance(self.extractor, DeterministicExtractor):
self.extraction_mode = ExtractionMode.DETERMINISTIC
elif isinstance(self.extractor, LLMExtractor):
self.extraction_mode = ExtractionMode.LLM
else:
self.extraction_mode = None
print(
f"🔄 Switched to {self.extraction_mode.value if self.extraction_mode else 'custom'} extraction mode"
)
self.traces.append(
TraceEvent(
event_type="extractor_change",
component="support_agent",
data={
"new_extractor": type(extractor).__name__,
"extraction_mode": (
self.extraction_mode.value if self.extraction_mode else "custom"
),
},
)
)
def classify_email(self, email_content: str) -> str:
"""Classify email into categories using LLM"""
print("🔍 Step 1: Classifying email category...")
prompt = f"""
Classify the following customer email into exactly one of these categories:
- Billing
- Bug Report
- Feature Request
Email content:
{email_content}
Respond with only the category name, nothing else.
"""
self.traces.append(
TraceEvent(
event_type="llm_call",
component="openai_api",
data={
"operation": "classification",
"model": "gpt-3.5-turbo",
"prompt_length": len(prompt),
"email_length": len(email_content),
},
)
)
try:
response = self.client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": prompt}],
temperature=0,
max_tokens=10,
)
category = (
response.choices[0].message.content.strip()
if response.choices[0].message.content
else "unknown"
)
print(f" ➜ Classified as: {category}")
self.traces.append(
TraceEvent(
event_type="llm_response",
component="openai_api",
data={
"operation": "classification",
"result": category,
"usage": (
response.usage.model_dump() if response.usage else None
),
},
)
)
return category
except Exception as e:
print(" ⚠️ Classification failed, using fallback: Bug Report")
self.traces.append(
TraceEvent(
event_type="error",
component="openai_api",
data={"operation": "classification", "error": str(e)},
)
)
return "Bug Report" # Default fallback
def extract_info(
self, email_content: str, category: str
) -> Dict[str, Optional[str]]:
"""Extract information using configured extractor"""
print(
f"⚙️ Step 2: Extracting {category} details using {self.extraction_mode.value if self.extraction_mode else 'custom'} method..."
)
self.traces.append(
TraceEvent(
event_type="extraction",
component=type(self.extractor).__name__.lower(),
data={
"category": category,
"email_length": len(email_content),
"extraction_mode": (
self.extraction_mode.value if self.extraction_mode else "custom"
),
},
)
)
try:
result = self.extractor.extract(email_content, category)
# Show extracted fields briefly
if result:
extracted_fields = [k for k, v in result.items() if v is not None]
if extracted_fields:
print(f" ➜ Extracted: {', '.join(extracted_fields)}")
else:
print(" ➜ No specific details extracted")
self.traces.append(
TraceEvent(
event_type="extraction_result",
component=type(self.extractor).__name__.lower(),
data={"extracted_fields": list(result.keys()), "result": result},
)
)
return result
except Exception as e:
print(f" ⚠️ Extraction failed: {str(e)}")
self.traces.append(
TraceEvent(
event_type="error",
component=type(self.extractor).__name__.lower(),
data={"operation": "extraction", "error": str(e)},
)
)
return {}
def generate_response(self, category: str, extracted_info: Dict[str, Any]) -> str:
"""Generate response template based on category"""
print("✍️ Step 3: Generating personalized response...")
context = f"Category: {category}\nExtracted info: {json.dumps(extracted_info, indent=2)}"
prompt = f"""
Generate a professional customer support response template for the following:
{context}
The response should:
- Be polite and professional
- Acknowledge the specific issue type
- Include next steps or resolution process
- Reference any extracted information appropriately
Keep it concise but helpful.
"""
self.traces.append(
TraceEvent(
event_type="llm_call",
component="openai_api",
data={
"operation": "response_generation",
"model": "gpt-3.5-turbo",
"category": category,
"extracted_fields": list(extracted_info.keys()),
},
)
)
try:
response = self.client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": prompt}],
temperature=0.3,
max_tokens=300,
)
response_text = (
response.choices[0].message.content.strip()
if response.choices[0].message.content
else ""
)
print(" ➜ Response template generated")
self.traces.append(
TraceEvent(
event_type="llm_response",
component="openai_api",
data={
"operation": "response_generation",
"response_length": len(response_text),
"usage": (
response.usage.model_dump() if response.usage else None
),
},
)
)
return response_text
except Exception as e:
print(" ⚠️ Response generation failed, using fallback")
self.traces.append(
TraceEvent(
event_type="error",
component="openai_api",
data={"operation": "response_generation", "error": str(e)},
)
)
return "Thank you for contacting support. We will review your request and get back to you soon."
def export_traces_to_log(
self, run_id: str, email_content: str, result: Optional[Dict[str, Any]] = None
):
"""Export traces to a log file with run_id"""
timestamp = datetime.now().isoformat()
log_filename = (
f"run_{run_id}_{timestamp.replace(':', '-').replace('.', '-')}.json"
)
log_filepath = os.path.join(self.logdir, log_filename)
log_data = {
"run_id": run_id,
"timestamp": timestamp,
"email_content": email_content,
"result": result,
"extraction_mode": (
self.extraction_mode.value if self.extraction_mode else "custom"
),
"traces": [asdict(trace) for trace in self.traces],
}
with open(log_filepath, "w") as f:
json.dump(log_data, f, indent=2)
return log_filepath
def process_email(
self, email_content: str, run_id: Optional[str] = None
) -> Dict[str, Any]:
"""Main processing function that handles the entire workflow"""
# Generate run_id if not provided
if run_id is None:
run_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{hash(email_content) % 10000:04d}"
print(f"\n🚀 Processing email (Run ID: {run_id})")
print(
f"📄 Email preview: {email_content[:100]}{'...' if len(email_content) > 100 else ''}"
)
# Reset traces for each new email
self.traces = []
self.traces.append(
TraceEvent(
event_type="workflow_start",
component="support_agent",
data={"run_id": run_id, "email_length": len(email_content)},
)
)
try:
# Step 1: Classify email
category = self.classify_email(email_content)
# Step 2: Extract relevant information based on category
extracted_info = self.extract_info(email_content, category)
# Step 3: Generate response template
response_template = self.generate_response(category, extracted_info)
result = {
"category": category,
"extracted_info": extracted_info,
"response_template": response_template,
"extraction_mode": (
self.extraction_mode.value if self.extraction_mode else "custom"
),
}
print("✅ Workflow completed successfully")
print(f"📋 Traces saved to: logs/run_{run_id}_*.json")
self.traces.append(
TraceEvent(
event_type="workflow_complete",
component="support_agent",
data={"run_id": run_id, "success": True},
)
)
# Export traces to log file
self.export_traces_to_log(run_id, email_content, result)
return result
except Exception as e:
print(f"❌ Workflow failed: {str(e)}")
self.traces.append(
TraceEvent(
event_type="error",
component="support_agent",
data={"operation": "process_email", "error": str(e)},
)
)
# Export traces even if processing failed
self.export_traces_to_log(run_id, email_content, {})
# Return minimal result on error
return {
"category": "Bug Report",
"extracted_info": {},
"response_template": "Thank you for contacting support. We will review your request and get back to you soon.",
"extraction_mode": (
self.extraction_mode.value if self.extraction_mode else "custom"
),
}
def default_workflow_client(
extractor_type: Literal["deterministic", "llm"] = "deterministic",
) -> ConfigurableSupportTriageAgent:
"""Create a default workflow client with specified extractor type"""
print(f"🔧 Creating workflow client with {extractor_type} extraction...")
api_key = os.environ.get("OPENAI_API_KEY")
if extractor_type == "deterministic":
extractor = DeterministicExtractor()
elif extractor_type == "llm":
if api_key is None:
raise ValueError(
"OPENAI_API_KEY environment variable is required for LLM extractor"
)
client = OpenAI(api_key=api_key)
extractor = LLMExtractor(client)
else:
raise ValueError(f"Unsupported extractor type: {extractor_type}")
# Use a default API key if none provided and using deterministic extractor
if api_key is None:
api_key = "dummy"
return ConfigurableSupportTriageAgent(
api_key=api_key, extractor=extractor, logdir="logs"
)
# Example usage and testing
def main():
# Initialize the agent with different extractors
api_key = os.environ.get("OPENAI_API_KEY")
if api_key is None:
api_key = "dummy"
# Test emails
test_emails = [
"Hi, I'm getting error code XYZ-123 when using version 2.1.4 of your software. Please help!",
"I need to dispute invoice #INV-2024-001 for 299.99 dollars. The charge seems incorrect.",
]
# Example 1: Using deterministic extractor
print("\n=== Using Deterministic Extractor ===")
deterministic_extractor = DeterministicExtractor()
agent = ConfigurableSupportTriageAgent(
api_key=api_key, extractor=deterministic_extractor, logdir="logs"
)
result = agent.process_email(test_emails[0])
print(f"Result: {result['response_template']}")
if __name__ == "__main__":
main()