|
26 | 26 | HealthResponse, |
27 | 27 | PersonaInfo, |
28 | 28 | PersonasResponse, |
| 29 | + Visualization, |
29 | 30 | ) |
30 | 31 | from . import metrics |
31 | 32 | from .metrics_server import start_metrics_server, stop_metrics_server |
@@ -192,41 +193,15 @@ async def chat(request: ChatRequest): |
192 | 193 | request.message, request.chat_history |
193 | 194 | ) |
194 | 195 |
|
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 | + ) |
230 | 205 |
|
231 | 206 | finally: |
232 | 207 | # Restore original settings |
@@ -367,27 +342,19 @@ async def thinking_callback( |
367 | 342 | ), |
368 | 343 | ) |
369 | 344 |
|
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) |
383 | 347 |
|
384 | 348 | await self.websocket_manager.send_message( |
385 | 349 | websocket, |
386 | 350 | StreamMessage( |
387 | 351 | type="final_response", |
388 | 352 | 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 [], |
391 | 358 | "timestamp": datetime.now().isoformat(), |
392 | 359 | }, |
393 | 360 | ), |
@@ -483,6 +450,157 @@ def _extract_tools_used(self, thinking_steps: List[Dict[str, Any]]) -> List[str] |
483 | 450 | tools.add(action) |
484 | 451 | return list(tools) |
485 | 452 |
|
| 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 | + |
486 | 604 | def run(self, host: str = "0.0.0.0", port: int = 8000, reload: bool = False): |
487 | 605 | """Run the web server.""" |
488 | 606 | # Start separate metrics server if port is specified |
|
0 commit comments