Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
SUPABASE_URL=
SUPABASE_SERVICE_ROLE_KEY=
# API Configuration
# PORT=8000
# CORS_ORIGINS=http://localhost:3000

CORS_ORIGINS=http://localhost:3000
GITHUB_TOKEN=
# SUPABASE_URL=
# SUPABASE_SERVICE_ROLE_KEY=

DISCORD_BOT_TOKEN=
# ENABLE_DISCORD_BOT=true

# EMBEDDING_MODEL=BAAI/bge-small-en-v1.5
# EMBEDDING_MAX_BATCH_SIZE=32
# EMBEDDING_DEVICE=cpu

GEMINI_API_KEY=
TAVILY_API_KEY=

# Langsmith
LANGSMITH_TRACING=
LANGSMITH_ENDPOINT=
LANGSMITH_API_KEY=
LANGSMITH_PROJECT=
10 changes: 10 additions & 0 deletions backend/app/agents/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from .devrel.agent import DevRelAgent
from .shared.base_agent import BaseAgent, AgentState
from .shared.classification_router import ClassificationRouter

__all__ = [
"DevRelAgent",
"BaseAgent",
"AgentState",
"ClassificationRouter"
]
101 changes: 101 additions & 0 deletions backend/app/agents/devrel/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import logging
from typing import Dict, Any
from functools import partial
from langgraph.graph import StateGraph, END
from langchain_google_genai import ChatGoogleGenerativeAI
from ..shared.base_agent import BaseAgent, AgentState
from ..shared.classification_router import MessageCategory
from .tools.search_tool import TavilySearchTool
from .tools.faq_tool import FAQTool
from app.core.config import settings
from .nodes.classify_intent_node import classify_intent_node
from .nodes.gather_context_node import gather_context_node
from .nodes.handle_faq_node import handle_faq_node
from .nodes.handle_web_search_node import handle_web_search_node
from .nodes.handle_technical_support_node import handle_technical_support_node
from .nodes.handle_onboarding_node import handle_onboarding_node
from .nodes.generate_response_node import generate_response_node
from langsmith import traceable

logger = logging.getLogger(__name__)

class DevRelAgent(BaseAgent):
"""DevRel LangGraph Agent for community support and engagement"""

def __init__(self, config: Dict[str, Any] = None):
self.config = config or {}
self.llm = ChatGoogleGenerativeAI(
model=settings.devrel_agent_model,
temperature=0.3,
google_api_key=settings.gemini_api_key
)
self.search_tool = TavilySearchTool()
self.faq_tool = FAQTool()
super().__init__("DevRelAgent", self.config)

def _build_graph(self):
"""Build the DevRel agent workflow graph"""
workflow = StateGraph(AgentState)

# Add nodes
workflow.add_node("classify_intent", partial(classify_intent_node, llm=self.llm))
workflow.add_node("gather_context", gather_context_node)
workflow.add_node("handle_faq", partial(handle_faq_node, faq_tool=self.faq_tool))
workflow.add_node("handle_web_search", partial(
handle_web_search_node, search_tool=self.search_tool, llm=self.llm))
workflow.add_node("handle_technical_support", handle_technical_support_node)
workflow.add_node("handle_onboarding", handle_onboarding_node)
workflow.add_node("generate_response", partial(generate_response_node, llm=self.llm))

# Add edges
workflow.add_edge("classify_intent", "gather_context")
workflow.add_conditional_edges(
"gather_context",
self._route_to_handler,
{
MessageCategory.FAQ: "handle_faq",
MessageCategory.WEB_SEARCH: "handle_web_search",
MessageCategory.ONBOARDING: "handle_onboarding",
MessageCategory.TECHNICAL_SUPPORT: "handle_technical_support",
MessageCategory.COMMUNITY_ENGAGEMENT: "handle_technical_support",
MessageCategory.DOCUMENTATION: "handle_technical_support",
MessageCategory.BUG_REPORT: "handle_technical_support",
MessageCategory.FEATURE_REQUEST: "handle_technical_support",
"default": "handle_technical_support"
}
Comment on lines +52 to +65

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

There appears to be a mismatch in how conditional edges are defined. The _route_to_handler method (lines 84-101) returns string keys (e.g., "faq", "web_search") based on the intent. However, the dictionary provided to add_conditional_edges (lines 55-65) uses MessageCategory enum objects (e.g., MessageCategory.FAQ) as keys for most routes, instead of the strings returned by _route_to_handler.

According to LangGraph documentation, the keys in the mapping dictionary for add_conditional_edges must be the string outputs from the conditional function (_route_to_handler in this case).

This mismatch will likely cause the routing to not work as expected for enum-keyed routes, potentially always falling back to a default or failing if the returned string key isn't found.

Could you update the keys in the add_conditional_edges dictionary to be the string values that _route_to_handler returns (e.g., use "faq" instead of MessageCategory.FAQ)? The "default" key should also match the default string returned by _route_to_handler (which is "technical_support").

        workflow.add_conditional_edges(
            "gather_context",
            self._route_to_handler,
            {
                "faq": "handle_faq",
                "web_search": "handle_web_search",
                "onboarding": "handle_onboarding",
                "technical_support": "handle_technical_support", # This will catch explicit technical_support and defaults
                # Ensure _route_to_handler returns these exact strings for the respective categories
                # For categories that map to the same handler, ensure _route_to_handler returns "technical_support"
                # e.g., COMMUNITY_ENGAGEMENT, DOCUMENTATION, BUG_REPORT, FEATURE_REQUEST should result in "technical_support" from _route_to_handler
            }
        )

Style Guide References

)

# All handlers lead to response generation
for node in ["handle_faq", "handle_web_search", "handle_technical_support", "handle_onboarding"]:
workflow.add_edge(node, "generate_response")

workflow.add_edge("generate_response", END)

# Set entry point
workflow.set_entry_point("classify_intent")

self.graph = workflow.compile()

@traceable(name="devrel_agent_execution", run_type="chain")
async def run(self, initial_state: AgentState) -> AgentState:
"""Execute the agent workflow"""
return await super().run(initial_state)

def _route_to_handler(self, state: AgentState) -> str:
"""Route to the appropriate handler based on intent"""
intent = state.context.get("intent", MessageCategory.TECHNICAL_SUPPORT)
logger.info(f"Routing based on intent: {intent} for session {state.session_id}")

# Mapping from MessageCategory enum to string keys used in add_conditional_edges
route_map = {
MessageCategory.FAQ: "faq",
MessageCategory.WEB_SEARCH: "web_search",
MessageCategory.ONBOARDING: "onboarding",
MessageCategory.TECHNICAL_SUPPORT: "technical_support",
MessageCategory.COMMUNITY_ENGAGEMENT: "technical_support",
MessageCategory.DOCUMENTATION: "technical_support",
MessageCategory.BUG_REPORT: "technical_support",
MessageCategory.FEATURE_REQUEST: "technical_support"
}

return route_map.get(intent, "technical_support")
Empty file.
33 changes: 33 additions & 0 deletions backend/app/agents/devrel/nodes/classify_intent_node.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import logging
from app.agents.shared.state import AgentState
from app.agents.shared.classification_router import ClassificationRouter
from langchain_google_genai import ChatGoogleGenerativeAI
from langsmith import traceable
logger = logging.getLogger(__name__)

@traceable(name="classify_intent_node", run_type="tool")
async def classify_intent_node(state: AgentState, llm: ChatGoogleGenerativeAI) -> AgentState:
"""Classify the user's intent and needs"""
logger.info(f"Classifying intent for session {state.session_id}")

# Get the latest message
latest_message = ""
if state.messages:
latest_message = state.messages[-1].get("content", "")
elif state.context.get("original_message"):
latest_message = state.context["original_message"]

# Use classification router
router = ClassificationRouter(llm)

classification = await router.classify_message(latest_message, state.context)

# Update state with classification
state.context.update({
"classification": classification,
"intent": classification["category"],
"priority": classification["priority"]
})

state.current_task = "intent_classified"
return state
Empty file.
23 changes: 23 additions & 0 deletions backend/app/agents/devrel/nodes/gather_context_node.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import logging
from app.agents.shared.state import AgentState
from langsmith import traceable

logger = logging.getLogger(__name__)

@traceable(name="gather_context_node", run_type="tool")
async def gather_context_node(state: AgentState) -> AgentState:
"""Gather additional context for the user and their request"""
logger.info(f"Gathering context for session {state.session_id}")

# TODO: Add context gathering from databases
# Currently, context is simple
# In production, query databases for user history, etc.
context_data = {
"user_profile": {"user_id": state.user_id, "platform": state.platform},
"conversation_context": len(state.messages),
"session_info": {"session_id": state.session_id}
}

state.context.update(context_data)
state.current_task = "context_gathered"
return state
80 changes: 80 additions & 0 deletions backend/app/agents/devrel/nodes/generate_response_node.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import logging
from typing import Dict, Any
from app.agents.shared.state import AgentState
from langchain_core.messages import HumanMessage
from ..prompts.base_prompt import GENERAL_LLM_RESPONSE_PROMPT
from langsmith import traceable

logger = logging.getLogger(__name__)

@traceable(name="create_search_response", run_type="tool")
async def _create_search_response(task_result: Dict[str, Any]) -> str:
"""Create a response string from search results."""
query = task_result.get("query")
results = task_result.get("results", [])
if not results:
return f"I couldn't find any information for '{query}'. You might want to try rephrasing your search."

response_parts = [f"Here's what I found for '{query}':"]
for i, result in enumerate(results[:3]):
title = result.get('title', 'N/A')
snippet = result.get('snippet', 'N/A')
url = result.get('url', '#')
result_line = f"{i+1}. {title}: {snippet}"
response_parts.append(result_line)
response_parts.append(f" (Source: {url})")
response_parts.append("You can ask me to search again with a different query if these aren't helpful.")
return "\n".join(response_parts)

@traceable(name="create_llm_response", run_type="tool")
async def _create_llm_response(state: AgentState, task_result: Dict[str, Any], llm) -> str:
"""Generate a response using the LLM based on the current state and task result."""
logger.info(f"Creating LLM response for session {state.session_id}")

latest_message = ""
if state.messages:
latest_message = state.messages[-1].get("content", "")
elif state.context.get("original_message"):
latest_message = state.context["original_message"]

conversation_history_str = "\n".join([
f"{msg.get('type', 'unknown')}: {msg.get('content', '')}"
for msg in state.conversation_history[-5:]
])
current_context_str = str(state.context)
task_type_str = str(task_result.get("type", "N/A"))
task_details_str = str(task_result)

try:
prompt = GENERAL_LLM_RESPONSE_PROMPT.format(
latest_message=latest_message,
conversation_history=conversation_history_str,
current_context=current_context_str,
task_type=task_type_str,
task_details=task_details_str
)
except KeyError as e:
logger.error(f"Missing key in GENERAL_LLM_RESPONSE_PROMPT: {e}")
return "Error: Response template formatting error."

response = await llm.ainvoke([HumanMessage(content=prompt)])
return response.content.strip()

@traceable(name="generate_response_node", run_type="tool")
async def generate_response_node(state: AgentState, llm) -> AgentState:
"""Generate final response to user"""
logger.info(f"Generating response for session {state.session_id}")
task_result = state.task_result or {}

if task_result.get("type") == "faq":
state.final_response = task_result.get("response", "I don't have a specific answer for that question.")
elif task_result.get("type") == "web_search":
response = await _create_search_response(task_result)
state.final_response = response
else:
# Pass the llm instance to _create_llm_response
response = await _create_llm_response(state, task_result, llm)
state.final_response = response

state.current_task = "response_generated"
return state
28 changes: 28 additions & 0 deletions backend/app/agents/devrel/nodes/handle_faq_node.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import logging
from app.agents.shared.state import AgentState
from langsmith import traceable

logger = logging.getLogger(__name__)

@traceable(name="handle_faq_node", run_type="tool")
async def handle_faq_node(state: AgentState, faq_tool) -> AgentState:
"""Handle FAQ requests"""
logger.info(f"Handling FAQ for session {state.session_id}")

latest_message = ""
if state.messages:
latest_message = state.messages[-1].get("content", "")
elif state.context.get("original_message"):
latest_message = state.context["original_message"]

# faq_tool will be passed from the agent, similar to llm for classify_intent
faq_response = await faq_tool.get_response(latest_message)

state.task_result = {
"type": "faq",
"response": faq_response,
"source": "faq_database"
}

state.current_task = "faq_handled"
return state
19 changes: 19 additions & 0 deletions backend/app/agents/devrel/nodes/handle_onboarding_node.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import logging
from app.agents.shared.state import AgentState
from langsmith import traceable

logger = logging.getLogger(__name__)

@traceable(name="handle_onboarding_node", run_type="tool")
async def handle_onboarding_node(state: AgentState) -> AgentState:
"""Handle onboarding requests"""
logger.info(f"Handling onboarding for session {state.session_id}")

state.task_result = {
"type": "onboarding",
"action": "welcome_and_guide",
"next_steps": ["setup_environment", "first_contribution", "join_community"]
}

state.current_task = "onboarding_handled"
return state
19 changes: 19 additions & 0 deletions backend/app/agents/devrel/nodes/handle_technical_support_node.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import logging
from app.agents.shared.state import AgentState
from langsmith import traceable

logger = logging.getLogger(__name__)

@traceable(name="handle_technical_support_node", run_type="tool")
async def handle_technical_support_node(state: AgentState) -> AgentState:
"""Handle technical support requests"""
logger.info(f"Handling technical support for session {state.session_id}")

state.task_result = {
"type": "technical_support",
"action": "provide_guidance",
"requires_human_review": False
}

state.current_task = "technical_support_handled"
return state
44 changes: 44 additions & 0 deletions backend/app/agents/devrel/nodes/handle_web_search_node.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import logging
from app.agents.shared.state import AgentState
from langchain_core.messages import HumanMessage
from ..prompts.search_prompt import EXTRACT_SEARCH_QUERY_PROMPT
from langsmith import traceable
logger = logging.getLogger(__name__)

@traceable(name="extract_search_query", run_type="tool")
async def _extract_search_query(message: str, llm) -> str:
"""Extract a concise search query from the user's message."""
logger.info(f"Extracting search query from: {message[:100]}")
try:
prompt = EXTRACT_SEARCH_QUERY_PROMPT.format(message=message)
except KeyError as e:
logger.error(f"Missing key in EXTRACT_SEARCH_QUERY_PROMPT: {e}")
return message # Fallback
response = await llm.ainvoke([HumanMessage(content=prompt)])
search_query = response.content.strip()
logger.info(f"Extracted search query: {search_query}")
return search_query

@traceable(name="handle_web_search_node", run_type="tool")
async def handle_web_search_node(state: AgentState, search_tool, llm) -> AgentState:
"""Handle web search requests"""
logger.info(f"Handling web search for session {state.session_id}")

latest_message = ""
if state.messages:
latest_message = state.messages[-1].get("content", "")
elif state.context.get("original_message"):
latest_message = state.context["original_message"]

search_query = await _extract_search_query(latest_message, llm)
search_results = await search_tool.search(search_query)

state.task_result = {
"type": "web_search",
"query": search_query,
"results": search_results,
"source": "tavily_search"
}
state.tools_used.append("tavily_search")
state.current_task = "web_search_handled"
return state
Empty file.
Empty file.
Empty file.
Empty file.
Loading