Skip to content
Open
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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,19 @@ BugZooka can generate a configurable performance summary across metrics for one
* `@PerfScale Jedi inspect 4.22.0-0.nightly-2026-01-05-203335 vs 4.22.0-0.nightly-2026-01-01-123456` - Calls `has_nightly_regressed` tool in [orion-mcp](https://github.com/cloud-bulldozer/orion-mcp) and campares two given nightlies.
* `@PerfScale Jedi inspect 4.22.0-0.nightly-2026-01-05-203335 for config trt-external-payload-node-density.yaml` - Calls `has_nightly_regressed` tool in [orion-mcp](https://github.com/cloud-bulldozer/orion-mcp) for a given nightly checks regression only for a given orion configuration file instead of the [default](https://github.com/cloud-bulldozer/orion-mcp/blob/main/orion_mcp.py#L470).

#### **Free-Form Queries (Agentic)**
The bot handles any natural-language question by passing it to the LLM with all MCP tools available. The LLM autonomously picks which Orion tools to call.

```
@PerfScale Jedi how is cudn-density doing on 5.0?
@PerfScale Jedi show me podReadyLatency_P99 for node-density-cni on 4.22
```

Responses include numeric summaries and chart images uploaded directly to the Slack thread.

#### **Multi-Turn Conversations**
Thread replies to an existing bot conversation are automatically processed without requiring another @mention. Conversation history is kept in-memory per thread with a configurable TTL (default: 2 hours, `CONVERSATION_TTL_SECONDS`).

**Note**: All the triggers that start with a bot mention (.i.e. `@PerfScale Jedi`) run in socket mode. All socket mode features can be used in any slack channel without needing to host your own on premise openshift deployment.

## **Configurables**
Expand Down Expand Up @@ -375,9 +388,11 @@ BugZooka/
│ │ ├── __init__.py
│ │ ├── config.py # Configuration management
│ │ ├── constants.py # Application constants
│ │ ├── conversation.py # Per-thread conversation history manager
│ │ └── utils.py # Shared utility functions
│ ├── integrations/ # External service integrations
│ │ ├── __init__.py
│ │ ├── image_collector.py # Collects images from MCP tool results for Slack upload
│ │ ├── inference_client.py # Inference client re-exports (from py-commons)
│ │ ├── mcp_client.py # MCP protocol client implementation
│ │ ├── rag_client_util.py # RAG vector store utilities
Expand All @@ -389,6 +404,7 @@ BugZooka/
│ ├── failure_keywords.py # Failure pattern detection
│ ├── log_analyzer.py # Main log analysis orchestration
│ ├── log_summarizer.py # Log summarization functionality
│ ├── general_query_analyzer.py # Agentic handler for free-form queries
│ ├── pr_analyzer.py # PR performance analysis with Gemini+MCP
│ ├── prompts.py # AI prompts and templates
│ ├── prow_analyzer.py # Prow-specific CI/CD analysis
Expand All @@ -414,6 +430,8 @@ BugZooka/
│ ├── __init__.py
│ ├── conftest.py # Pytest configuration
│ ├── helpers.py # Test utilities
│ ├── test_conversation.py # Conversation manager tests
│ ├── test_general_query_analyzer.py # General query analyzer tests
│ ├── test_slack_fetcher.py # Slack fetcher tests
│ └── test_slack_socket_listener.py # Socket mode tests
├── Dockerfile # Container image definition
Expand Down
85 changes: 85 additions & 0 deletions bugzooka/analysis/general_query_analyzer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""General-purpose agentic query handler for free-form natural language questions.

Unlike pr_analyzer.py which uses analyze_with_agentic from commons, this module
drives chat_with_tools_async directly so tool calls route through invoke_mcp_tool.
This lets the ImageCollector intercept image content blocks from MCP tools.
"""

import logging

from langchain_core.utils.function_calling import convert_to_openai_tool

from bugzooka.integrations.mcp_client import (
initialize_global_resources_async,
invoke_mcp_tool,
)
from bugzooka.core.utils import make_response
from bugzooka.integrations.inference_client import get_inference_client
from bugzooka.analysis.prompts import GENERAL_QUERY_PROMPT
import bugzooka.integrations.mcp_client as mcp_module

logger = logging.getLogger(__name__)


async def analyze_general_query(
conversation_messages: list[dict],
channel_id: str | None = None,
) -> dict:
"""
Handle a free-form natural language query using the agentic LLM loop.

:param conversation_messages: Full conversation history in OpenAI message format
:param channel_id: Slack channel ID for ES_SERVER routing (optional)
:return: Dictionary with 'success' (bool) and 'message' (str)
"""
if channel_id:
from bugzooka.integrations.mcp_interceptors import current_channel

current_channel.set(channel_id)
logger.debug("Set channel context for general query: %s", channel_id)

await initialize_global_resources_async()

if not mcp_module.mcp_tools:
return make_response(
success=False,
message="No MCP tools available. Please check the MCP server connection.",
)

messages = [
{"role": "system", "content": GENERAL_QUERY_PROMPT["system"]},
]
messages.extend(conversation_messages)

try:
client = get_inference_client()
tools_by_name = {tool.name: tool for tool in mcp_module.mcp_tools}
openai_tools = [convert_to_openai_tool(t) for t in mcp_module.mcp_tools]

async def execute_tool(tool_name, tool_args):
tool = tools_by_name.get(tool_name)
if not tool:
return f"Error: Tool '{tool_name}' not found"
return await invoke_mcp_tool(tool, tool_args)

result = await client.chat_with_tools_async(
messages=messages,
tools=openai_tools,
execute_tool_func=execute_tool,
)

if not result:
logger.warning("LLM returned empty result for general query")
return make_response(
success=False,
message="I couldn't generate a response. Please try rephrasing your question.",
)

return make_response(success=True, message=result)

except Exception as e:
logger.error("Error during general query analysis: %s", e, exc_info=True)
return make_response(
success=False,
message=f"An error occurred while processing your query: {e}",
)
7 changes: 5 additions & 2 deletions bugzooka/analysis/nightly_regression_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ def _parse_nightly_inspect_request(text: str) -> Optional[NightlyInspectRequest]
)


async def analyze_nightly_regression(text: str, channel_id: str = None) -> dict:
async def analyze_nightly_regression(text: str, channel_id: str | None = None) -> dict:
"""
Parse nightly inspection request and call has_nightly_regressed MCP tool directly.

Expand All @@ -108,8 +108,11 @@ async def analyze_nightly_regression(text: str, channel_id: str = None) -> dict:
# Set channel context for ES encryption interceptor
if channel_id:
from bugzooka.integrations.mcp_interceptors import current_channel

current_channel.set(channel_id)
logger.debug("Set channel context for nightly regression analysis: %s", channel_id)
logger.debug(
"Set channel context for nightly regression analysis: %s", channel_id
)
# Parse nightly inspect request from text
parsed = _parse_nightly_inspect_request(text)

Expand Down
3 changes: 2 additions & 1 deletion bugzooka/analysis/perf_summary_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -669,7 +669,7 @@ async def analyze_performance(
versions: Optional[List[str]] = None,
lookback_days: Optional[int] = None,
use_all_configs: bool = False,
channel_id: str = None,
channel_id: str | None = None,
) -> dict:
"""
Analyze performance metrics for the specified config and version.
Expand All @@ -687,6 +687,7 @@ async def analyze_performance(
# Set channel context for ES encryption interceptor
if channel_id:
from bugzooka.integrations.mcp_interceptors import current_channel

current_channel.set(channel_id)
logger.debug("Set channel context for performance summary: %s", channel_id)

Expand Down
15 changes: 11 additions & 4 deletions bugzooka/analysis/pr_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ def _parse_pr_request(text: str) -> Optional[Tuple[str, str, list, str]]:
return org, repo, pr_numbers, version


async def analyze_pr_with_gemini(text: str, channel_id: str = None) -> dict:
async def analyze_pr_with_gemini(text: str, channel_id: str | None = None) -> dict:
"""
Parse PR analysis request and analyze PR performance using Gemini with MCP.

Expand All @@ -102,6 +102,7 @@ async def analyze_pr_with_gemini(text: str, channel_id: str = None) -> dict:
# Set channel context for ES encryption interceptor
if channel_id:
from bugzooka.integrations.mcp_interceptors import current_channel

current_channel.set(channel_id)
logger.debug("Set channel context for PR analysis: %s", channel_id)

Expand All @@ -125,7 +126,10 @@ async def analyze_pr_with_gemini(text: str, channel_id: str = None) -> dict:
pr_display = ", ".join(f"#{pr}" for pr in pr_numbers)
logger.info(
"PR analysis requested for %s/%s %s (OpenShift %s)",
org, repo, pr_display, version,
org,
repo,
pr_display,
version,
)

# Ensure MCP client is initialized
Expand Down Expand Up @@ -153,8 +157,11 @@ async def analyze_pr_with_gemini(text: str, channel_id: str = None) -> dict:

system_prompt = PR_PERFORMANCE_ANALYSIS_PROMPT["system"]
user_prompt = PR_PERFORMANCE_ANALYSIS_PROMPT["user"].format(
org=org, repo=repo, pr_numbers=pr_numbers_str,
pr_urls=pr_urls, version=version,
org=org,
repo=repo,
pr_numbers=pr_numbers_str,
pr_urls=pr_urls,
version=version,
)
assistant_prompt = PR_PERFORMANCE_ANALYSIS_PROMPT["assistant"]

Expand Down
35 changes: 35 additions & 0 deletions bugzooka/analysis/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,41 @@
Beginning analysis now.
""",
}
GENERAL_QUERY_PROMPT = {
"system": """You are PerfScale Jedi, an AI assistant specializing in OpenShift performance analysis.
You help engineers investigate performance metrics, detect regressions, and understand trends.

You have access to Orion MCP tools that can:
- List available test configs: `get_orion_configs`
- List metrics for a config: `get_orion_metrics`, `get_orion_metrics_with_meta`
- Get raw performance data values: `get_orion_performance_data` — returns actual numeric values you can compute stats on (min, max, avg, trend).
- Generate visual charts: `openshift_report_on` — generates chart images that are automatically uploaded to Slack. Use with `options="image"` for charts, `options="json"` for raw data, or `options="both"` for both.
- Check for regressions across all configs: `has_openshift_regressed`
- Check networking-specific regressions: `has_networking_regressed`
- Detect nightly build regressions: `has_nightly_regressed` — compares two nightlies or checks a single nightly for regressions. Use this when the user asks about nightly regressions, what changed between nightlies, or what PRs/changes landed between two nightly builds. Accepts `nightly_version` and optional `previous_nightly` for comparison.
- Analyze PR performance impact: `openshift_report_on_pr`
- Correlate two metrics: `metrics_correlation` — generates a scatter plot image that is automatically uploaded to Slack.
- Get OpenShift release dates: `get_release_date`

IMPORTANT tool guidance:
- ALWAYS call `openshift_report_on` alongside `get_orion_performance_data` when reporting on any metric. The chart image is automatically uploaded to the Slack thread — you do not need to embed it in your text response.
- Use `get_orion_performance_data` for the numeric summary (min, max, avg, trend).
- Use `openshift_report_on` with `options="image"` to generate the visual chart. Call both tools in parallel when possible.
- To find available metrics for a config, call `get_orion_metrics` first.

General instructions:
- When the user mentions a test or config name partially, match it to the right Orion config file.
Examples: "cudn" or "cudn-density" likely means a config like "small-scale-cudn-density-l2-single-ns.yaml".
If ambiguous, call `get_orion_configs` first to find the right one, or ask the user.
- Pass version numbers as-is to tools (e.g., "5.0", "4.22").
- Format responses for Slack: use *bold* for headers, `code` for metric names, and code blocks for tables.
- Be concise and data-driven. When showing metrics, include actual values, averages, and trends.
- Highlight any regressions or notable changes in recent data points.
- If a tool returns a result like "No data found for metric X", that is a SUCCESSFUL tool call — the service is working, but no data exists for that query. Tell the user the specific metric or config was not found and suggest alternatives (e.g., call `get_orion_metrics` to list available metrics). NEVER say you are having trouble communicating with the service when the tool actually returned a response.
- You are in a multi-turn conversation. Use prior context to understand follow-up questions.
""",
}

# Jira tool prompt - used when Jira MCP tools are available
JIRA_TOOL_PROMPT = {
"system": (
Expand Down
89 changes: 89 additions & 0 deletions bugzooka/core/conversation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Thread-safe, in-memory conversation history manager for multi-turn Slack interactions."""

import os
import threading
import time
from dataclasses import dataclass, field

CONVERSATION_TTL_SECONDS = int(os.getenv("CONVERSATION_TTL_SECONDS", "7200"))
MAX_MESSAGES_PER_THREAD = int(os.getenv("MAX_MESSAGES_PER_THREAD", "20"))


@dataclass
class ConversationState:
channel_id: str
thread_ts: str
messages: list[dict] = field(default_factory=list)
last_activity: float = field(default_factory=time.time)


class ConversationManager:
"""Manages per-thread conversation history with TTL-based expiry."""

def __init__(
self,
ttl_seconds: int = CONVERSATION_TTL_SECONDS,
max_messages: int = MAX_MESSAGES_PER_THREAD,
):
self._conversations: dict[str, ConversationState] = {}
self._lock = threading.Lock()
self._ttl = ttl_seconds
self._max_messages = max_messages

def _key(self, channel_id: str, thread_ts: str) -> str:
return f"{channel_id}:{thread_ts}"

def _evict_expired(self) -> None:
now = time.time()
expired = [
k
for k, v in self._conversations.items()
if now - v.last_activity > self._ttl
]
for k in expired:
del self._conversations[k]

def _trim(self, state: ConversationState) -> None:
if len(state.messages) > self._max_messages:
state.messages = state.messages[-self._max_messages :]

def _get_or_create(self, channel_id: str, thread_ts: str) -> ConversationState:
key = self._key(channel_id, thread_ts)
if key not in self._conversations:
self._conversations[key] = ConversationState(
channel_id=channel_id,
thread_ts=thread_ts,
)
state = self._conversations[key]
state.last_activity = time.time()
return state

def append_user_message(self, channel_id: str, thread_ts: str, text: str) -> None:
with self._lock:
self._evict_expired()
state = self._get_or_create(channel_id, thread_ts)
state.messages.append({"role": "user", "content": text})
self._trim(state)

def append_assistant_message(
self, channel_id: str, thread_ts: str, text: str
) -> None:
with self._lock:
self._evict_expired()
state = self._get_or_create(channel_id, thread_ts)
state.messages.append({"role": "assistant", "content": text})
self._trim(state)

def get_messages(self, channel_id: str, thread_ts: str) -> list[dict]:
with self._lock:
self._evict_expired()
key = self._key(channel_id, thread_ts)
state = self._conversations.get(key)
if state is None:
return []
return list(state.messages)

def clear(self, channel_id: str, thread_ts: str) -> None:
with self._lock:
key = self._key(channel_id, thread_ts)
self._conversations.pop(key, None)
Loading
Loading