Skip to content

Commit 6132ec9

Browse files
Merge pull request #3029 from stbenjam/graphs
chat: support graphing with plotly
2 parents de11e28 + cf87776 commit 6132ec9

File tree

9 files changed

+4835
-262
lines changed

9 files changed

+4835
-262
lines changed

chat/sippy_agent/agent.py

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -263,28 +263,96 @@ def _create_agent_graph(self):
263263
264264
#### Reporting Test Failures
265265
266-
* List up to 5 failing tests explicitly. Summarize extras (e.g., …and 3 more failed).
266+
* List up to 5 failing tests explicitly. Summarize extras (e.g., "…and 3 more failed").
267267
* Explain what those tests validate and why they might fail.
268268
269269
#### Correlating Failures with Changes
270270
271271
* Do **not** analyze changelog until after identifying test failures.
272272
* Match failure keywords (e.g., *networking, storage*) to PR components or repos.
273-
* Only report correlations when theres a clear thematic link.
273+
* Only report correlations when there's a clear thematic link.
274274
275275
#### Correlating Failures with Incidents
276276
277277
* Always use `check_known_incidents` when analyzing payload failures.
278278
* Prefer log evidence, but note correlations if timing and symptoms align.
279279
280+
#### Creating Visualizations
281+
282+
When users request visual representations (e.g., "plot", "graph", "chart", "visualize"), you can create interactive Plotly charts directly in your response.
283+
284+
**How to create a visualization:**
285+
286+
1. After your main text response, include a visualization block using these exact markers:
287+
```
288+
VISUALIZATION_START
289+
{{
290+
"data": [...],
291+
"layout": {{...}},
292+
"config": {{...}}
293+
}}
294+
VISUALIZATION_END
295+
```
296+
297+
2. The JSON must be valid Plotly specification with three fields:
298+
- **data**: Array of trace objects (required)
299+
- **layout**: Layout configuration object (required)
300+
- **config**: Optional config object for controls
301+
302+
**Example - Line chart for test success rates over time:**
303+
```
304+
Here's the trend for the test over the last 7 days:
305+
306+
VISUALIZATION_START
307+
{{
308+
"data": [
309+
{{
310+
"x": ["2025-10-08", "2025-10-09", "2025-10-10", "2025-10-11", "2025-10-12", "2025-10-13", "2025-10-14"],
311+
"y": [85, 82, 90, 88, 91, 89, 92],
312+
"type": "scatter",
313+
"mode": "lines+markers",
314+
"name": "Success Rate",
315+
"line": {{"color": "#4caf50", "width": 3}},
316+
"marker": {{"size": 8}}
317+
}}
318+
],
319+
"layout": {{
320+
"title": {{"text": "Test Success Rate - Last 7 Days"}},
321+
"xaxis": {{"title": "Date"}},
322+
"yaxis": {{"title": "Success Rate (%)", "range": [0, 100]}},
323+
"hovermode": "x unified"
324+
}}
325+
}}
326+
VISUALIZATION_END
327+
```
328+
329+
**Common chart types:**
330+
- **Line charts**: `"type": "scatter", "mode": "lines+markers"` - for trends over time
331+
- **Bar charts**: `"type": "bar"` - for comparisons across categories
332+
- **Scatter plots**: `"type": "scatter", "mode": "markers"` - for correlations
333+
- **Multi-series**: Include multiple objects in the `data` array
334+
335+
**Important:**
336+
- Only create visualizations when the user explicitly requests them or when visual data would significantly enhance understanding
337+
- Always provide text analysis alongside the visualization
338+
- Use colors that work in both light and dark modes
339+
- Keep it simple - don't include excessive styling
340+
341+
**Color Guidelines:**
342+
- **Success/passing data**: Use green shades
343+
- **Failure/error data**: Use red shade
344+
- **Multiple categories**: When showing multiple distinct categories (not success/failure), use colors that make sense for the data
345+
- Ensure colors have sufficient contrast for readability in both light and dark themes
346+
280347
#### Final Answer Composition
281348
282349
Your final answer must be **comprehensive**:
283350
284351
* List failing jobs and tests.
285352
* Explain likely causes.
286353
* Include relevant links (Jobs, PRs, Issues, Incidents).
287-
* Suggest the next logical step (e.g., *“Would you like me to analyze the logs?”*).
354+
* Include visualizations when requested or when they add significant value.
355+
* Suggest the next logical step (e.g., *"Would you like me to analyze the logs?"*).
288356
"""
289357

290358
# Apply persona modification (always prepend if present)

chat/sippy_agent/api_models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,21 @@ class ThinkingStep(BaseModel):
3333
observation: str
3434

3535

36+
class Visualization(BaseModel):
37+
"""A Plotly visualization specification."""
38+
39+
data: List[Dict[str, Any]] # Plotly data traces
40+
layout: Dict[str, Any] # Plotly layout configuration
41+
config: Optional[Dict[str, Any]] = None # Optional Plotly config
42+
43+
3644
class ChatResponse(BaseModel):
3745
"""Response model for chat endpoint."""
3846

3947
response: str
4048
thinking_steps: Optional[List[ThinkingStep]] = None
4149
tools_used: Optional[List[str]] = None
50+
visualizations: Optional[List[Visualization]] = None
4251
error: Optional[str] = None
4352

4453

chat/sippy_agent/web_server.py

Lines changed: 168 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
HealthResponse,
2727
PersonaInfo,
2828
PersonasResponse,
29+
Visualization,
2930
)
3031
from . import metrics
3132
from .metrics_server import start_metrics_server, stop_metrics_server
@@ -192,41 +193,15 @@ async def chat(request: ChatRequest):
192193
request.message, request.chat_history
193194
)
194195

195-
if isinstance(result, dict) and "thinking_steps" in result:
196-
# Convert thinking steps to API format
197-
thinking_steps = []
198-
for i, step in enumerate(result["thinking_steps"], 1):
199-
thinking_steps.append(
200-
ThinkingStep(
201-
step_number=i,
202-
thought=step.get("thought", ""),
203-
action=step.get("action", ""),
204-
action_input=step.get("action_input", ""),
205-
observation=step.get("observation", ""),
206-
)
207-
)
208-
209-
response_text = result["output"]
210-
211-
# Track response size
212-
response_size = len(response_text.encode('utf-8'))
213-
metrics.message_size_bytes.labels(direction="response").observe(response_size)
214-
215-
return ChatResponse(
216-
response=response_text,
217-
thinking_steps=thinking_steps,
218-
tools_used=self._extract_tools_used(
219-
result["thinking_steps"]
220-
),
221-
)
222-
else:
223-
# Track response size
224-
response_size = len(result.encode('utf-8'))
225-
metrics.message_size_bytes.labels(direction="response").observe(response_size)
226-
227-
return ChatResponse(
228-
response=result, thinking_steps=None, tools_used=None
229-
)
196+
# Process the response using common method
197+
processed = self._process_agent_response(result)
198+
199+
return ChatResponse(
200+
response=processed["response_text"],
201+
thinking_steps=processed["thinking_steps"],
202+
tools_used=processed["tools_used"],
203+
visualizations=processed["visualizations"],
204+
)
230205

231206
finally:
232207
# Restore original settings
@@ -367,27 +342,19 @@ async def thinking_callback(
367342
),
368343
)
369344

370-
# Send final response
371-
if isinstance(result, dict) and "output" in result:
372-
response_text = result["output"]
373-
tools_used = self._extract_tools_used(
374-
result.get("thinking_steps", [])
375-
)
376-
else:
377-
response_text = result
378-
tools_used = []
379-
380-
# Track response size
381-
response_size = len(response_text.encode('utf-8'))
382-
metrics.message_size_bytes.labels(direction="response").observe(response_size)
345+
# Process the response using common method
346+
processed = self._process_agent_response(result)
383347

384348
await self.websocket_manager.send_message(
385349
websocket,
386350
StreamMessage(
387351
type="final_response",
388352
data={
389-
"response": response_text,
390-
"tools_used": tools_used,
353+
"response": processed["response_text"],
354+
"tools_used": processed["tools_used"],
355+
"visualizations": [
356+
v.model_dump() for v in processed["visualizations"]
357+
] if processed["visualizations"] else [],
391358
"timestamp": datetime.now().isoformat(),
392359
},
393360
),
@@ -483,6 +450,157 @@ def _extract_tools_used(self, thinking_steps: List[Dict[str, Any]]) -> List[str]
483450
tools.add(action)
484451
return list(tools)
485452

453+
def _extract_visualizations_from_text(self, text: str) -> List[Visualization]:
454+
"""Extract visualization specifications from text content.
455+
456+
Looks for JSON blocks between VISUALIZATION_START and VISUALIZATION_END markers.
457+
"""
458+
visualizations = []
459+
460+
if not text or not isinstance(text, str):
461+
return visualizations
462+
463+
# Find all visualization blocks in the text
464+
start_marker = "VISUALIZATION_START"
465+
end_marker = "VISUALIZATION_END"
466+
467+
current_pos = 0
468+
while True:
469+
start_idx = text.find(start_marker, current_pos)
470+
if start_idx == -1:
471+
break
472+
473+
end_idx = text.find(end_marker, start_idx)
474+
if end_idx == -1:
475+
logger.warning("Found VISUALIZATION_START without matching VISUALIZATION_END")
476+
break
477+
478+
try:
479+
# Extract JSON between markers
480+
viz_start = start_idx + len(start_marker)
481+
viz_json = text[viz_start:end_idx].strip()
482+
483+
# Parse the JSON
484+
viz_data = json.loads(viz_json)
485+
486+
# Get layout and add AI-generated annotation
487+
layout = viz_data.get("layout", {})
488+
489+
# Ensure top margin is sufficient for the title and subtitle
490+
if "margin" not in layout:
491+
layout["margin"] = {}
492+
if "t" not in layout["margin"] or layout["margin"]["t"] < 80:
493+
layout["margin"]["t"] = 80
494+
495+
# Add AI-generated caption as an annotation below the title
496+
if "annotations" not in layout:
497+
layout["annotations"] = []
498+
499+
# Position the caption in the margin area, closer to the title
500+
# y > 1.0 places it in the top margin area
501+
layout["annotations"].append({
502+
"text": "<i>Generated with AI by Sippy Chat</i>",
503+
"xref": "paper",
504+
"yref": "paper",
505+
"x": 0.5,
506+
"y": 1.00, # Just above the plot area in the margin
507+
"xanchor": "center",
508+
"yanchor": "bottom",
509+
"showarrow": False,
510+
"font": {"size": 10, "color": "#666666"}
511+
})
512+
513+
# Create Visualization object
514+
visualization = Visualization(
515+
data=viz_data.get("data", []),
516+
layout=layout,
517+
config=viz_data.get("config"),
518+
)
519+
visualizations.append(visualization)
520+
521+
logger.info(f"Extracted visualization from response text")
522+
except (json.JSONDecodeError, ValueError, KeyError) as e:
523+
logger.warning(f"Failed to parse visualization: {e}")
524+
525+
# Move past this visualization block
526+
current_pos = end_idx + len(end_marker)
527+
528+
return visualizations
529+
530+
def _extract_visualizations(self, response_text: str) -> List[Visualization]:
531+
"""Extract visualizations from response text only (not from tool observations)."""
532+
visualizations = []
533+
534+
# Extract from main response text only
535+
if response_text:
536+
visualizations.extend(self._extract_visualizations_from_text(response_text))
537+
538+
return visualizations
539+
540+
def _strip_visualization_markers(self, text: str) -> str:
541+
"""Remove VISUALIZATION_START...VISUALIZATION_END blocks from text."""
542+
if not text or not isinstance(text, str):
543+
return text
544+
545+
# Remove all visualization blocks (non-greedy match)
546+
cleaned = re.sub(
547+
r'VISUALIZATION_START[\s\S]*?VISUALIZATION_END',
548+
'',
549+
text,
550+
flags=re.MULTILINE
551+
)
552+
return cleaned.strip()
553+
554+
def _process_agent_response(self, result: Any) -> Dict[str, Any]:
555+
"""
556+
Process agent response and extract all components.
557+
558+
Args:
559+
result: The result from agent.achat() - can be dict with thinking_steps or simple string
560+
561+
Returns:
562+
Dict containing: response_text, thinking_steps (API format), tools_used, visualizations
563+
"""
564+
if isinstance(result, dict) and "thinking_steps" in result:
565+
# Response with thinking steps
566+
response_text = result["output"]
567+
thinking_steps = result["thinking_steps"]
568+
tools_used = self._extract_tools_used(thinking_steps)
569+
570+
# Convert thinking steps to API format
571+
api_thinking_steps = []
572+
for i, step in enumerate(thinking_steps, 1):
573+
api_thinking_steps.append(
574+
ThinkingStep(
575+
step_number=i,
576+
thought=step.get("thought", ""),
577+
action=step.get("action", ""),
578+
action_input=step.get("action_input", ""),
579+
observation=step.get("observation", ""),
580+
)
581+
)
582+
thinking_steps = api_thinking_steps
583+
else:
584+
# Simple response without thinking steps
585+
response_text = result
586+
thinking_steps = None
587+
tools_used = []
588+
589+
# Track response size metrics
590+
response_size = len(response_text.encode('utf-8'))
591+
metrics.message_size_bytes.labels(direction="response").observe(response_size)
592+
593+
# Extract visualizations and strip markers from response
594+
visualizations = self._extract_visualizations(response_text)
595+
clean_response = self._strip_visualization_markers(response_text)
596+
597+
return {
598+
"response_text": clean_response,
599+
"thinking_steps": thinking_steps,
600+
"tools_used": tools_used,
601+
"visualizations": visualizations or None,
602+
}
603+
486604
def run(self, host: str = "0.0.0.0", port: int = 8000, reload: bool = False):
487605
"""Run the web server."""
488606
# Start separate metrics server if port is specified

0 commit comments

Comments
 (0)