diff --git a/cyberneuro_multi-agent-neuroimaging-analysis/requirements.txt b/cyberneuro_multi-agent-neuroimaging-analysis/requirements.txt new file mode 100644 index 0000000..02ef72a --- /dev/null +++ b/cyberneuro_multi-agent-neuroimaging-analysis/requirements.txt @@ -0,0 +1,2 @@ +beautifulsoup4>=4.12.3 +ollama>=0.3.3 diff --git a/cyberneuro_multi-agent-neuroimaging-analysis/visualizer_agent.py b/cyberneuro_multi-agent-neuroimaging-analysis/visualizer_agent.py index e9dc5e9..68fe304 100644 --- a/cyberneuro_multi-agent-neuroimaging-analysis/visualizer_agent.py +++ b/cyberneuro_multi-agent-neuroimaging-analysis/visualizer_agent.py @@ -1,7 +1,13 @@ -import re import asyncio -from typing import Optional, List -from dataclasses import dataclass +import json +import logging +import re +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Callable, Dict, List, Optional, Tuple + +from bs4 import BeautifulSoup, Tag + # ============================================================ # CONFIGURATION @@ -14,261 +20,1033 @@ # Default model - change this to your preferred model DEFAULT_MODEL = "llama3.1:8b" +# Token budgets +MAX_PATCH_TOKENS = 800 +MAX_FULL_TOKENS = 4096 + +logger = logging.getLogger(__name__) + + # ============================================================ # DATA CLASSES # ============================================================ + @dataclass class ValidationResult: """Result of HTML validation.""" + valid: bool error: Optional[str] = None warnings: Optional[List[str]] = None + @dataclass -class EditResult: - """Result of visualization edit operation.""" +class VisualizerResponse: + """External response shape kept for MCP and FastAPI compatibility.""" + status: str # "success" or "error" html: Optional[str] = None message: Optional[str] = None warnings: Optional[List[str]] = None + +class EditIntent(Enum): + TITLE_CHANGE = "title_change" + SUBTITLE_CHANGE = "subtitle_change" + COLOR_CHANGE = "color_change" + AXIS_LABEL = "axis_label" + LEGEND_TOGGLE = "legend_toggle" + DATA_UPDATE = "data_update" + FONT_SIZE = "font_size" + ADD_ELEMENT = "add_element" + REMOVE_ELEMENT = "remove_element" + LAYOUT_CHANGE = "layout_change" + STYLE_CHANGE = "style_change" + COMPLEX = "complex" + + +@dataclass +class ClassifiedEdit: + intent: EditIntent + target_selector: Optional[str] = None + new_value: Optional[str] = None + parameters: dict = field(default_factory=dict) + confidence: float = 1.0 + + +@dataclass +class EditResult: + success: bool + html: str + method: str # "programmatic", "scoped_llm", "full_rewrite", "none" + changes_description: str + tokens_used: int = 0 + + +# ============================================================ +# PROMPTS +# ============================================================ + +INTENT_CLASSIFIER_SYSTEM_PROMPT = """You are an edit intent classifier. Given a user's request to modify an HTML visualization, return a JSON object with: +- \"intent\": one of [title_change, subtitle_change, color_change, axis_label, legend_toggle, data_update, font_size, add_element, remove_element, layout_change, style_change, complex] +- \"target_css\": CSS selector for the element to edit, or null +- \"new_value\": the new value if it's a simple replacement, or null +- \"confidence\": 0.0-1.0 + +Return ONLY the JSON object, nothing else.""" + + +SCOPED_PATCH_SYSTEM_PROMPT = """You are an HTML visualization editor. You will receive: +1. A user's edit request +2. A FRAGMENT of HTML - the specific section that needs editing + +Return ONLY the modified HTML fragment. Do not add explanations, markdown fences, or any text outside the HTML. The fragment must be valid HTML and must preserve the same root element tag and class structure. + +Rules: +- Make ONLY the change the user requested +- Preserve all existing classes, ids, and data attributes +- Preserve the dark theme (use CSS variables, not hardcoded light colors) +- Do not add external scripts or stylesheets +- If the requested change is unclear, make your best interpretation""" + + +SYSTEM_PROMPT = """You are an HTML visualization editor. Return ONLY the complete modified HTML card. Rules: +- Start with a div with class \"visualizationCard\" +- Do not use external scripts or stylesheets +- Preserve the dark theme using CSS variables +- Use the vc-header, vc-title, vc-subtitle, vc-body, vc-footer, vc-insight structure +- If data is missing, show a warning block instead of inventing values +- Make ONLY the change the user requested - do not alter anything else""" + + +# ============================================================ +# CLASSIFICATION CONSTANTS +# ============================================================ + +COLOR_NORMALIZATION_MAP: Dict[str, str] = { + "red": "#E24B4A", + "blue": "#378ADD", + "green": "#639922", + "yellow": "#EF9F27", + "orange": "#D85A30", + "purple": "#7F77DD", + "pink": "#D4537E", + "gray": "#888780", + "grey": "#888780", + "teal": "#1D9E75", + "coral": "#D85A30", + "amber": "#BA7517", + "black": "#2C2C2A", + "white": "#F1EFE8", +} + +COLOR_WORD_PATTERN = "|".join(sorted(COLOR_NORMALIZATION_MAP.keys(), key=len, reverse=True)) + +ELEMENT_SELECTOR_MAP: Dict[str, str] = { + "title": ".vc-title, h1, h2, .title, [class*='title']", + "subtitle": ".vc-subtitle, .subtitle, [class*='subtitle'], .description", + "description": ".vc-subtitle, .subtitle, [class*='subtitle'], .description", + "legend": ".vc-legend, [class*='legend']", + "grid": "[class*='grid'], .grid", + "tooltip": "[class*='tooltip'], .tooltip", + "label": "[class*='label'], .label", + "annotation": ".vc-insight, [class*='annotation']", + "footer": ".vc-footer, footer", + "header": ".vc-header, header", + "insight": ".vc-insight", +} + +INTENT_PATTERNS: Dict[EditIntent, List[re.Pattern[str]]] = { + EditIntent.TITLE_CHANGE: [ + re.compile(r"\b(?:change|update|set|make|rename|edit)\b[^\n]{0,120}\btitle\b", re.IGNORECASE), + re.compile(r"\btitle\b[^\n]{0,120}\b(?:to|should\s+be|as|say|be)\b", re.IGNORECASE), + ], + EditIntent.SUBTITLE_CHANGE: [ + re.compile(r"\b(?:change|update|set|make|rename|edit)\b[^\n]{0,120}\b(?:subtitle|description)\b", re.IGNORECASE), + re.compile(r"\b(?:subtitle|description)\b[^\n]{0,120}\b(?:to|should\s+be|as|say|be)\b", re.IGNORECASE), + ], + EditIntent.COLOR_CHANGE: [ + re.compile( + rf"\b(?:change|make|set|turn|color)\b[^\n]{{0,120}}\b(?:bars?|lines?|area|pie|slices?|background|fill|stroke|chart)\b[^\n]{{0,80}}(?:{COLOR_WORD_PATTERN}|#[0-9a-fA-F]{{3,8}})\b", + re.IGNORECASE, + ), + re.compile( + rf"(?:{COLOR_WORD_PATTERN}|#[0-9a-fA-F]{{3,8}})\b[^\n]{{0,80}}\b(?:bars?|lines?|area|pie|slices?|background|fill|stroke)\b", + re.IGNORECASE, + ), + ], + EditIntent.AXIS_LABEL: [ + re.compile(r"\b(?:change|update|set|rename|label)\b[^\n]{0,120}\b(?:x|y|horizontal|vertical)\s*-?\s*axis\b", re.IGNORECASE), + re.compile(r"\b(?:x|y|horizontal|vertical)\s*-?\s*axis\b[^\n]{0,120}\b(?:label|title|name)\b", re.IGNORECASE), + ], + EditIntent.LEGEND_TOGGLE: [ + re.compile(r"\b(?:show|hide|toggle|add|remove)\b[^\n]{0,60}\blegend\b", re.IGNORECASE), + ], + EditIntent.FONT_SIZE: [ + re.compile(r"\b(?:font|text)\s*size\b", re.IGNORECASE), + re.compile(r"\b(?:bigger|smaller|larger)\b[^\n]{0,40}\b(?:text|font)\b", re.IGNORECASE), + re.compile(r"\b\d+\s*px\b", re.IGNORECASE), + ], + EditIntent.ADD_ELEMENT: [ + re.compile(r"\b(?:add|include|insert)\b[^\n]{0,120}\b(?:title|subtitle|legend|grid|tooltip|label|annotation|footer|header|insight)\b", re.IGNORECASE), + ], + EditIntent.REMOVE_ELEMENT: [ + re.compile(r"\b(?:remove|delete|hide|drop)\b[^\n]{0,120}\b(?:title|subtitle|legend|grid|tooltip|label|annotation|footer|header|insight)\b", re.IGNORECASE), + ], + EditIntent.DATA_UPDATE: [ + re.compile(r"\b(?:update|change|replace)\b[^\n]{0,120}\b(?:data|values|numbers|dataset|series)\b", re.IGNORECASE), + ], + EditIntent.LAYOUT_CHANGE: [ + re.compile(r"\b(?:layout|arrange|alignment|position|move|resize|spacing)\b", re.IGNORECASE), + ], + EditIntent.STYLE_CHANGE: [ + re.compile(r"\b(?:style|theme|professional|cleaner|modern)\b", re.IGNORECASE), + ], +} + +INTENT_PRIORITY: List[EditIntent] = [ + EditIntent.TITLE_CHANGE, + EditIntent.SUBTITLE_CHANGE, + EditIntent.COLOR_CHANGE, + EditIntent.AXIS_LABEL, + EditIntent.REMOVE_ELEMENT, + EditIntent.ADD_ELEMENT, + EditIntent.LEGEND_TOGGLE, + EditIntent.FONT_SIZE, + EditIntent.DATA_UPDATE, + EditIntent.LAYOUT_CHANGE, + EditIntent.STYLE_CHANGE, +] + + # ============================================================ # VALIDATION FUNCTIONS # ============================================================ + def validate_visualization_html(html: str, allow_external_images: bool = False) -> ValidationResult: """ Validate HTML content meets visualization card requirements. """ warnings: List[str] = [] - + if not html or not isinstance(html, str): return ValidationResult(valid=False, error="HTML content is required") - + if len(html) > MAX_HTML_SIZE: return ValidationResult( - valid=False, - error=f"HTML content exceeds maximum size limit ({MAX_HTML_SIZE} characters)" + valid=False, + error=f"HTML content exceeds maximum size limit ({MAX_HTML_SIZE} characters)", ) - + if REQUIRED_ROOT_CLASS not in html: return ValidationResult( valid=False, - error=f"Missing required .{REQUIRED_ROOT_CLASS} root element" + error=f"Missing required .{REQUIRED_ROOT_CLASS} root element", ) - + # Block external script sources - external_script_pattern = re.compile(r'' ) assert not result.valid, "Test 3 failed: Should reject external scripts" - print("✓ Test 3: Rejects external script sources") - + print("OK Test 3: Rejects external script sources") + # Test 4: Inline script allowed result = validate_visualization_html( '
' ) assert result.valid, f"Test 4 failed: {result.error}" - print("✓ Test 4: Allows inline scripts") - + print("OK Test 4: Allows inline scripts") + # Test 5: External stylesheet result = validate_visualization_html( '' ) assert not result.valid, "Test 5 failed: Should reject external stylesheets" - print("✓ Test 5: Rejects external stylesheets") - + print("OK Test 5: Rejects external stylesheets") + # Test 6: Clean LLM output messy_output = "```html\n