""" Interview Presenter Abstract presentation layer for different UI modes. Handles: - Formatting questions for display - Parsing user responses - Showing summaries and warnings """ from abc import ABC, abstractmethod from dataclasses import dataclass from typing import Any, Optional, List, Dict import re from .question_engine import Question, QuestionOption @dataclass class PresentedQuestion: """A question formatted for presentation.""" question_id: str formatted_text: str question_number: int total_questions: int category_name: str class InterviewPresenter(ABC): """ Abstract base for interview presentation. Different presenters handle UI-specific rendering: - ClaudePresenter: Markdown for Claude conversation - DashboardPresenter: WebSocket events for React UI (future) - CLIPresenter: Interactive terminal prompts (future) """ @abstractmethod def present_question( self, question: Question, question_number: int, total_questions: int, category_name: str, dynamic_content: Optional[str] = None ) -> str: """ Format a question for display. Args: question: Question to present question_number: Current question number total_questions: Estimated total questions category_name: Name of the question category dynamic_content: Dynamic content to inject (e.g., extractor summary) Returns: Formatted question string """ pass @abstractmethod def parse_response(self, response: str, question: Question) -> Any: """ Parse user's response into structured value. Args: response: Raw user response question: Question being answered Returns: Parsed answer value """ pass @abstractmethod def show_summary(self, blueprint: "StudyBlueprint") -> str: """ Format interview summary/blueprint for display. Args: blueprint: Generated study blueprint Returns: Formatted summary string """ pass @abstractmethod def show_warning(self, warning: str, severity: str = "warning") -> str: """ Format a warning message for display. Args: warning: Warning message severity: "error", "warning", or "info" Returns: Formatted warning string """ pass @abstractmethod def show_progress(self, current: int, total: int, phase: str) -> str: """ Format progress indicator. Args: current: Current question number total: Estimated total questions phase: Current phase name Returns: Formatted progress string """ pass class ClaudePresenter(InterviewPresenter): """ Presenter for Claude conversation mode (VS Code, Web). Formats questions and responses as markdown for natural conversation flow with Claude. """ def present_question( self, question: Question, question_number: int, total_questions: int, category_name: str, dynamic_content: Optional[str] = None ) -> str: """Format question as markdown for Claude to present.""" lines = [] # Header with progress lines.append(f"### Question {question_number} of ~{total_questions}: {category_name}") lines.append("") # Main question text lines.append(question.text) lines.append("") # Dynamic content if provided if dynamic_content: lines.append(dynamic_content) lines.append("") # Options for choice questions if question.options and question.question_type in ["choice", "multi_choice"]: for i, opt in enumerate(question.options, 1): desc = f" - {opt.description}" if opt.description else "" lines.append(f"{i}. **{opt.label}**{desc}") lines.append("") # Help text if question.help_text: lines.append(f"> {question.help_text}") lines.append("") # Engineering guidance if question.engineering_guidance: lines.append(f"> **Tip**: {question.engineering_guidance}") lines.append("") # Default value hint if question.default is not None and question.default != []: if isinstance(question.default, list): default_str = ", ".join(str(d) for d in question.default) else: default_str = str(question.default) lines.append(f"*Default: {default_str}*") lines.append("") # Input prompt based on type if question.question_type == "text": lines.append("Please describe:") elif question.question_type == "numeric": units = question.validation.units if question.validation else "" lines.append(f"Enter value{f' ({units})' if units else ''}:") elif question.question_type == "choice": lines.append("Type your choice (number or description):") elif question.question_type == "multi_choice": lines.append("Type your choices (numbers or descriptions, comma-separated):") elif question.question_type == "confirm": lines.append("Type **yes** or **no**:") elif question.question_type == "parameter_select": lines.append("Type parameter names (comma-separated) or select by number:") elif question.question_type == "bounds": lines.append("Enter bounds (e.g., '2 to 10' or 'min 2, max 10'):") return "\n".join(lines) def parse_response(self, response: str, question: Question) -> Any: """Parse natural language response into structured answer.""" response = response.strip() if question.question_type == "text": return response elif question.question_type == "numeric": return self._parse_numeric(response, question) elif question.question_type == "confirm": return self._parse_confirm(response) elif question.question_type == "choice": return self._parse_choice(response, question) elif question.question_type == "multi_choice": return self._parse_multi_choice(response, question) elif question.question_type == "parameter_select": return self._parse_parameter_select(response, question) elif question.question_type == "bounds": return self._parse_bounds(response) return response def _parse_numeric(self, response: str, question: Question) -> Optional[float]: """Parse numeric response with unit handling.""" # Remove common unit suffixes cleaned = re.sub(r'\s*(mm|cm|m|kg|g|MPa|Pa|GPa|Hz|kHz|MHz|°|deg)s?\s*$', '', response, flags=re.I) # Extract number match = re.search(r'[-+]?\d*\.?\d+', cleaned) if match: return float(match.group()) return None def _parse_confirm(self, response: str) -> Optional[bool]: """Parse yes/no confirmation.""" lower = response.lower().strip() # Positive responses if lower in ["yes", "y", "true", "1", "ok", "sure", "yep", "yeah", "correct", "confirmed", "confirm", "affirmative"]: return True # Negative responses if lower in ["no", "n", "false", "0", "nope", "nah", "cancel", "incorrect", "negative"]: return False # Try to detect intent from natural language if "yes" in lower or "ok" in lower or "correct" in lower: return True if "no" in lower or "don't" in lower or "not" in lower: return False return None def _parse_choice(self, response: str, question: Question) -> Any: """Parse single choice response.""" if not question.options: return response # Try by number if response.isdigit(): idx = int(response) - 1 if 0 <= idx < len(question.options): return question.options[idx].value # Try by value (exact match) for opt in question.options: if response.lower() == str(opt.value).lower(): return opt.value # Try by label (exact match) for opt in question.options: if response.lower() == opt.label.lower(): return opt.value # Try fuzzy match on label for opt in question.options: if response.lower() in opt.label.lower(): return opt.value # Return as-is for custom values return response def _parse_multi_choice(self, response: str, question: Question) -> List[Any]: """Parse multiple choice response.""" # Split by comma, 'and', or numbers parts = re.split(r'[,&]|\band\b|\s+', response) parts = [p.strip() for p in parts if p.strip()] values = [] for part in parts: if not part: continue # Try by number if part.isdigit() and question.options: idx = int(part) - 1 if 0 <= idx < len(question.options): value = question.options[idx].value if value not in values: values.append(value) continue # Try by value/label if question.options: found = False for opt in question.options: if part.lower() == str(opt.value).lower() or part.lower() == opt.label.lower(): if opt.value not in values: values.append(opt.value) found = True break if part.lower() in opt.label.lower(): if opt.value not in values: values.append(opt.value) found = True break if found: continue # Add as custom value if part not in values: values.append(part) return values def _parse_parameter_select(self, response: str, question: Question) -> List[str]: """Parse parameter selection response.""" # Split by comma, 'and', or numbers parts = re.split(r'[,&]|\band\b', response) parameters = [] for part in parts: part = part.strip() if not part: continue # Try by number if we have options if part.isdigit() and question.options: idx = int(part) - 1 if 0 <= idx < len(question.options): parameters.append(question.options[idx].value) continue # Add as parameter name parameters.append(part) return parameters def _parse_bounds(self, response: str) -> Optional[Dict[str, float]]: """Parse bounds specification.""" bounds = {} # Try "min to max" format match = re.search(r'(\d+\.?\d*)\s*(?:to|-)\s*(\d+\.?\d*)', response) if match: bounds["min"] = float(match.group(1)) bounds["max"] = float(match.group(2)) return bounds # Try "min: X, max: Y" format min_match = re.search(r'min[:\s]+(\d+\.?\d*)', response, re.I) max_match = re.search(r'max[:\s]+(\d+\.?\d*)', response, re.I) if min_match: bounds["min"] = float(min_match.group(1)) if max_match: bounds["max"] = float(max_match.group(1)) return bounds if bounds else None def show_summary(self, blueprint: "StudyBlueprint") -> str: """Format interview summary/blueprint for display.""" lines = [] lines.append(f"## Study Blueprint: {blueprint.study_name}") lines.append("") # Description if blueprint.study_description: lines.append(f"**Description**: {blueprint.study_description}") lines.append("") # Design Variables lines.append(f"### Design Variables ({len(blueprint.design_variables)})") lines.append("") lines.append("| Parameter | Current | Min | Max | Units |") lines.append("|-----------|---------|-----|-----|-------|") for dv in blueprint.design_variables: lines.append(f"| {dv.parameter} | {dv.current_value} | {dv.min_value} | {dv.max_value} | {dv.units or '-'} |") lines.append("") # Objectives lines.append(f"### Objectives ({len(blueprint.objectives)})") lines.append("") lines.append("| Goal | Extractor | Parameters |") lines.append("|------|-----------|------------|") for obj in blueprint.objectives: params = ", ".join(f"{k}={v}" for k, v in (obj.extractor_params or {}).items()) or "-" lines.append(f"| {obj.goal} | {obj.extractor} | {params} |") lines.append("") # Constraints if blueprint.constraints: lines.append(f"### Constraints ({len(blueprint.constraints)})") lines.append("") lines.append("| Type | Threshold | Extractor |") lines.append("|------|-----------|-----------|") for con in blueprint.constraints: op = "<=" if con.constraint_type == "max" else ">=" lines.append(f"| {con.name} | {op} {con.threshold} | {con.extractor} |") lines.append("") # Settings lines.append("### Settings") lines.append("") lines.append(f"- **Protocol**: {blueprint.protocol}") lines.append(f"- **Trials**: {blueprint.n_trials}") lines.append(f"- **Sampler**: {blueprint.sampler}") lines.append("") # Warnings if blueprint.warnings_acknowledged: lines.append("### Acknowledged Warnings") lines.append("") for warning in blueprint.warnings_acknowledged: lines.append(f"- {warning}") lines.append("") lines.append("---") lines.append("") lines.append("Does this look correct? Reply **yes** to generate the study, or describe what to change.") return "\n".join(lines) def show_warning(self, warning: str, severity: str = "warning") -> str: """Format a warning message for display.""" icons = { "error": "X", "warning": "!", "info": "i" } icon = icons.get(severity, "!") if severity == "error": return f"\n**[{icon}] ERROR**: {warning}\n" elif severity == "warning": return f"\n**[{icon}] Warning**: {warning}\n" else: return f"\n*[{icon}] Note*: {warning}\n" def show_progress(self, current: int, total: int, phase: str) -> str: """Format progress indicator.""" percentage = int((current / total) * 100) if total > 0 else 0 bar_length = 20 filled = int(bar_length * current / total) if total > 0 else 0 bar = "=" * filled + "-" * (bar_length - filled) return f"**Progress**: [{bar}] {percentage}% - {phase}" class DashboardPresenter(InterviewPresenter): """ Presenter for dashboard UI mode (future). Emits WebSocket events for React UI to render. """ def present_question( self, question: Question, question_number: int, total_questions: int, category_name: str, dynamic_content: Optional[str] = None ) -> str: """Emit WebSocket event for dashboard to render.""" # This would emit an event to the dashboard # For now, return JSON representation import json return json.dumps({ "type": "question", "data": { "question_id": question.id, "question_number": question_number, "total_questions": total_questions, "category": category_name, "text": question.text, "question_type": question.question_type, "options": [{"value": o.value, "label": o.label} for o in (question.options or [])], "help_text": question.help_text, "default": question.default, "dynamic_content": dynamic_content, } }) def parse_response(self, response: str, question: Question) -> Any: """Parse JSON response from dashboard.""" import json try: data = json.loads(response) return data.get("value", response) except json.JSONDecodeError: # Fall back to Claude parser claude = ClaudePresenter() return claude.parse_response(response, question) def show_summary(self, blueprint: "StudyBlueprint") -> str: """Emit summary event for dashboard.""" import json return json.dumps({ "type": "summary", "data": blueprint.to_dict() if hasattr(blueprint, 'to_dict') else str(blueprint) }) def show_warning(self, warning: str, severity: str = "warning") -> str: """Emit warning event for dashboard.""" import json return json.dumps({ "type": "warning", "data": {"message": warning, "severity": severity} }) def show_progress(self, current: int, total: int, phase: str) -> str: """Emit progress event for dashboard.""" import json return json.dumps({ "type": "progress", "data": {"current": current, "total": total, "phase": phase} }) class CLIPresenter(InterviewPresenter): """ Presenter for CLI wizard mode (future). Interactive terminal prompts using Rich/Questionary. """ def present_question( self, question: Question, question_number: int, total_questions: int, category_name: str, dynamic_content: Optional[str] = None ) -> str: """Format for CLI display.""" # Simple text format for CLI lines = [] lines.append(f"\n[{question_number}/{total_questions}] {category_name}") lines.append("-" * 50) lines.append(question.text) if question.options: for i, opt in enumerate(question.options, 1): lines.append(f" {i}. {opt.label}") if question.help_text: lines.append(f"\nHint: {question.help_text}") lines.append("") return "\n".join(lines) def parse_response(self, response: str, question: Question) -> Any: """Parse CLI response (delegate to Claude parser).""" claude = ClaudePresenter() return claude.parse_response(response, question) def show_summary(self, blueprint: "StudyBlueprint") -> str: """Format summary for CLI.""" claude = ClaudePresenter() return claude.show_summary(blueprint) def show_warning(self, warning: str, severity: str = "warning") -> str: """Format warning for CLI.""" icons = {"error": "[ERROR]", "warning": "[WARN]", "info": "[INFO]"} return f"\n{icons.get(severity, '[WARN]')} {warning}\n" def show_progress(self, current: int, total: int, phase: str) -> str: """Format progress for CLI.""" return f"Progress: {current}/{total} ({phase})" # Import for type hints from typing import TYPE_CHECKING if TYPE_CHECKING: from .study_blueprint import StudyBlueprint