Skip to content
Open
3 changes: 2 additions & 1 deletion backend/app/agents/devrel/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ def _build_graph(self):
# Phase 2: ReAct Supervisor - Decide what to do next
workflow.add_node("react_supervisor", partial(react_supervisor_node, llm=self.llm))
workflow.add_node("web_search_tool", partial(web_search_tool_node, search_tool=self.search_tool, llm=self.llm))
workflow.add_node("faq_handler_tool", partial(faq_handler_tool_node, faq_tool=self.faq_tool))
workflow.add_node("faq_handler_tool", partial(
faq_handler_tool_node, search_tool=self.search_tool, llm=self.llm))
workflow.add_node("onboarding_tool", onboarding_tool_node)
workflow.add_node("github_toolkit_tool", partial(github_toolkit_tool_node, github_toolkit=self.github_toolkit))

Expand Down
241 changes: 235 additions & 6 deletions backend/app/agents/devrel/nodes/handlers/faq.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,255 @@
import logging
from typing import List, Dict, Any
from langchain_core.messages import HumanMessage
from app.agents.state import AgentState
from app.core.config.settings import settings as app_settings


# Configure logger for this module
logger = logging.getLogger(__name__)

async def handle_faq_node(state: AgentState, faq_tool) -> dict:
"""Handle FAQ requests"""
logger.info(f"Handling FAQ for session {state.session_id}")

# Organization identity and official handles from centralized settings
ORG_NAME = app_settings.org_name
OFFICIAL_HANDLES = [app_settings.org_website, app_settings.org_github, app_settings.org_twitter]


async def handle_faq_node(state: AgentState, search_tool: Any, llm: Any) -> dict:
"""
Handle FAQ requests dynamically using web search and AI synthesis.
Pass official handles to search tool if it supports site-restricted queries.
"""
logger.info(f"[FAQ_HANDLER] Handling dynamic 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)
# Early exit if no message
if not latest_message:
logger.warning("[FAQ_HANDLER] Empty latest user message; returning fallback")
return {
"task_result": {
"type": "faq",
"response": _generate_fallback_response(latest_message, ORG_NAME),
"source": "dynamic_web_search"
},
"current_task": "faq_handled"
}

# Append site restrictions to the query if search tool supports it
try:
from urllib.parse import urlparse
domains = []
for u in OFFICIAL_HANDLES:
try:
parsed = urlparse(u)
domain = parsed.netloc or parsed.path # handles bare domains
if domain:
domains.append(domain)
except Exception:
continue
site_filters = " OR ".join([f"site:{d}" for d in domains])
Copy link
Contributor

Choose a reason for hiding this comment

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

Could you please check once why the offical handles aren't being passed properly?

Image

except Exception:
site_filters = ""
logger.info(f"[FAQ_HANDLER] Applying site filters for search: {site_filters or '(none)'}")

faq_response = await _dynamic_faq_process(
latest_message,
search_tool,
llm,
org_name=ORG_NAME,
site_filters=site_filters,
)

return {
"task_result": {
"type": "faq",
"response": faq_response,
"source": "faq_database"
"source": "dynamic_web_search"
},
"current_task": "faq_handled"
}


async def _dynamic_faq_process(
message: str,
search_tool: Any,
llm: Any,
org_name: str = ORG_NAME,
site_filters: str = "",
) -> str:
"""
Dynamic FAQ handler implementing:
1. Intent Detection & Query Refinement
2. Web Search (with site restrictions)
3. AI-Powered Synthesis
4. Generate Final Response
5. Format with Sources
"""
try:
# Step 1: Intent Detection & Query Refinement
logger.info(f"[FAQ_HANDLER] Step 1: Refining FAQ query for org '{org_name}'")
refined_query = await _refine_faq_query(message, llm, org_name)

# Append site filters for restricting to official handles
if site_filters:
refined_query = f"({refined_query}) AND ({site_filters})"
logger.info(f"[FAQ_HANDLER] Refined and filtered query: {refined_query}")

Comment on lines +94 to +101
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Reduce user-content logging to DEBUG and truncate to lower PII exposure and noise

Refined queries and synthesized responses may contain user-provided content. Logging them at INFO can leak PII and increase noise.

Apply:

-        logger.info(f"[FAQ_HANDLER] Step 1: Refining FAQ query for org '{org_name}'")
+        logger.debug(f"[FAQ_HANDLER] Step 1: Refining FAQ query for org '{org_name}'")
@@
-        logger.info(f"[FAQ_HANDLER] Refined and filtered query: {refined_query}")
+        logger.debug(f"[FAQ_HANDLER] Refined and filtered query: {refined_query[:200]}{'...' if len(refined_query) > 200 else ''}")
@@
-    logger.info(f"[FAQ_HANDLER] Synthesized FAQ response: {synthesized_answer[:100]}...")
+    logger.debug(f"[FAQ_HANDLER] Synthesized FAQ response: {synthesized_answer[:100]}...")

If you need INFO-level observability, consider redacting with a sanitizer (emails, phone numbers) before logging.

Also applies to: 196-196

# Step 2: Dynamic Web Search
logger.info(f"[FAQ_HANDLER] Step 2: Searching for: {refined_query}")
try:
search_results = await search_tool.search(refined_query)
except Exception as search_err:
logger.error(f"[FAQ_HANDLER] Search tool error: {search_err}")
return _generate_fallback_response(message, org_name)
Comment on lines +103 to +108
Copy link
Contributor

Choose a reason for hiding this comment

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

πŸ› οΈ Refactor suggestion

Add timeouts around external search calls to prevent hangs

Wrap the search call with asyncio.wait_for using configurable timeouts; otherwise a network stall can block the node.

Apply:

-        try:
-            search_results = await search_tool.search(refined_query)
+        try:
+            search_results = await asyncio.wait_for(
+                search_tool.search(refined_query),
+                timeout=FAQ_SEARCH_TIMEOUT
+            )

Add these near the top of the module (outside the selected range):

import asyncio  # at the imports

# Timeouts with safe defaults; override via settings if available
FAQ_SEARCH_TIMEOUT = getattr(app_settings, "faq_search_timeout", 10.0)
FAQ_LLM_TIMEOUT = getattr(app_settings, "faq_llm_timeout", 15.0)
πŸ€– Prompt for AI Agents
In backend/app/agents/devrel/nodes/handlers/faq.py around lines 103 to 108, the
external search call can hang; add an asyncio timeout: import asyncio at the top
and define FAQ_SEARCH_TIMEOUT = getattr(app_settings, "faq_search_timeout",
10.0) (and FAQ_LLM_TIMEOUT similarly if present), then replace the direct await
search_tool.search(refined_query) with await
asyncio.wait_for(search_tool.search(refined_query), FAQ_SEARCH_TIMEOUT); catch
asyncio.TimeoutError (and still catch general Exception) and log a
timeout-specific error before returning _generate_fallback_response(message,
org_name).


if not search_results:
logger.warning(f"[FAQ_HANDLER] No results found for query: {refined_query}")
return _generate_fallback_response(message, org_name)

# Step 3 & 4: AI-Powered Synthesis & Response Generation
logger.info("[FAQ_HANDLER] Step 3-4: Synthesizing search results into FAQ response")
synthesized_response = await _synthesize_faq_response(message, search_results, llm, org_name)

# Step 5: Format Final Response with Sources
logger.info("[FAQ_HANDLER] Step 5: Formatting final response with sources")
final_response = _format_faq_response(synthesized_response, search_results)

return final_response

except Exception as e:
logger.error(f"[FAQ_HANDLER] Error in dynamic FAQ process: {e}")
return _generate_fallback_response(message, org_name)


async def _refine_faq_query(message: str, llm: Any, org_name: str) -> str:
"""
Step 1: Refine user query for organization-specific FAQ search.
"""
refinement_prompt = f"""
Copy link
Contributor

Choose a reason for hiding this comment

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

could you please migrate the prompts to prompts/faq/ ? as done with the case for the rest of the nodes.

You are helping someone find information about {org_name}.
Transform their question into an effective search query that will find official information about the organization.

User Question: "{message}"

Create a search query that focuses on:
- Official {org_name} information
- The organization's website, blog, or documentation
- Adding terms like "about", "mission", "projects" if relevant

Return only the refined search query, nothing else.

Examples:
- "What does this org do?" β†’ "{org_name} about mission what we do"
- "How do you work?" β†’ "{org_name} how it works process methodology"
- "What projects do you have?" β†’ "{org_name} projects portfolio what we build"
"""
response = await llm.ainvoke([HumanMessage(content=refinement_prompt)])
refined_query = response.content.strip()
logger.info(f"[FAQ_HANDLER] Refined query: {refined_query}")
return refined_query


async def _synthesize_faq_response(
message: str,
search_results: List[Dict[str, Any]],
llm: Any,
org_name: str
) -> str:
"""
Step 3-4: Use LLM to synthesize search results into a comprehensive FAQ answer.
"""
results_context = ""
for i, result in enumerate(search_results[:5]): # Limit to top 5 results
title = result.get('title', 'N/A')
content = result.get('content', 'N/A')
if isinstance(content, str) and len(content) > 500:
content = content[:500] + "..."
url = result.get('url', 'N/A')
results_context += f"\nResult {i+1}:\nTitle: {title}\nContent: {content}\nURL: {url}\n"

synthesis_prompt = f"""
Copy link
Contributor

Choose a reason for hiding this comment

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

could you please migrate the prompts to prompts/faq/ ? as done with the case for the rest of the nodes.

You are an AI assistant representing {org_name}. A user asked: "{message}"

Based on the following search results from official sources, provide a comprehensive, helpful answer about {org_name}.

Search Results:
{results_context}

Instructions:
1. Answer the user's question directly and conversationally
2. Focus on the most relevant and recent information
3. Be informative but concise (2-3 paragraphs max)
4. If the search results don't fully answer the question, acknowledge what you found
5. Sound helpful and knowledgeable about {org_name}
6. Don't mention "search results" in your response - speak as if you know about the organization

Your response:
"""

response = await llm.ainvoke([HumanMessage(content=synthesis_prompt)])
synthesized_answer = response.content.strip()
Comment on lines +194 to +195
Copy link
Contributor

Choose a reason for hiding this comment

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

πŸ› οΈ Refactor suggestion

Apply timeout to LLM synthesis calls

Similar to search operations, LLM calls should have timeouts to prevent hanging.

-    response = await llm.ainvoke([HumanMessage(content=synthesis_prompt)])
+    response = await asyncio.wait_for(
+        llm.ainvoke([HumanMessage(content=synthesis_prompt)]),
+        timeout=FAQ_LLM_TIMEOUT
+    )

Add exception handling:

except asyncio.TimeoutError:
    logger.error(f"[FAQ_HANDLER] LLM timeout after {FAQ_LLM_TIMEOUT}s")
    return _generate_fallback_response(message, org_name)
πŸ€– Prompt for AI Agents
In backend/app/agents/devrel/nodes/handlers/faq.py around lines 194-195, wrap
the LLM synthesis call in asyncio.wait_for using the FAQ_LLM_TIMEOUT constant
and add exception handling: put the await llm.ainvoke(...) inside a try block
with response = await asyncio.wait_for(llm.ainvoke(...), FAQ_LLM_TIMEOUT), catch
asyncio.TimeoutError, log the timeout with logger.error(f"[FAQ_HANDLER] LLM
timeout after {FAQ_LLM_TIMEOUT}s") and return
_generate_fallback_response(message, org_name); ensure asyncio is imported if
not already.

logger.info(f"[FAQ_HANDLER] Synthesized FAQ response: {synthesized_answer[:100]}...")
return synthesized_answer


def _format_faq_response(synthesized_answer: str, search_results: List[Dict[str, Any]]) -> str:
"""
Step 5: Format the final response with sources.
"""
formatted_response = synthesized_answer

if search_results:
formatted_response += "\n\n**πŸ“š Sources:**"
for i, result in enumerate(search_results[:3]): # Show top 3 sources
title = result.get('title', 'Source')
url = result.get('url', '#')
formatted_response += f"\n{i+1}. [{title}]({url})"

return formatted_response


def _generate_fallback_response(message: str, org_name: str) -> str:
"""
Generate a helpful fallback when search fails.
"""
return (
f"I'd be happy to help you learn about {org_name}, but I couldn't find current information to answer your question: \"{message}\"\n\n"
"This might be because:\n"
"- The information isn't publicly available yet\n"
"- The search terms need to be more specific\n"
"- There might be connectivity issues\n\n"
"Try asking a more specific question, or check out our official website and documentation for the most up-to-date information about "
f"{org_name}."
)


# Example usage for testing
if __name__ == "__main__":
Copy link
Contributor

Choose a reason for hiding this comment

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

Please do separate these tests out from the implementation logic. We already have a tests/ directory.

import asyncio
from unittest.mock import AsyncMock

class MockState:
session_id = "test_session"
messages = [{"content": "What projects does your organization have?"}]
context = {}

async def test_faq_handler():
mock_state = MockState()
mock_search_tool = AsyncMock()
mock_search_tool.search.return_value = [
{"title": "Project A", "content": "Details about Project A.", "url": "https://aossie.org/projects/a"},
{"title": "Project B", "content": "Details about Project B.", "url": "https://aossie.org/projects/b"},
]
mock_llm = AsyncMock()
mock_llm.ainvoke.return_value = AsyncMock(content="We have Project A and Project B focusing on AI and Web.")

response = await handle_faq_node(mock_state, mock_search_tool, mock_llm)
print("FAQ Handler response:")
print(response)

asyncio.run(test_faq_handler())
Loading