Spaces:
Sleeping
Sleeping
| """ | |
| Workspace Components for Rubric AI | |
| Contains: workspace_pane, workspace_item, button_group, AssessmentItemPicker | |
| """ | |
| from contextlib import contextmanager | |
| from typing import Callable, List, Dict, Any | |
| from nicegui import ui | |
| from .layout import material_icon | |
| def workspace_pane(title: str, subtitle: str = None, action_button: tuple = None): | |
| """ | |
| Create a workspace pane (left side of the two-column layout). | |
| Usage: | |
| with workspace_pane('Workspace', 'Stage 1: Define Prompt Groups'): | |
| # Your workspace content here | |
| Args: | |
| title: Pane title (e.g., 'Workspace') | |
| subtitle: Pane subtitle (e.g., 'Stage 1: Define Prompt Groups') | |
| action_button: Optional tuple of (label, on_click, icon) for header action button | |
| """ | |
| with ui.element('div').classes('pane'): | |
| # Header | |
| with ui.element('div').classes('pane-header'): | |
| with ui.element('div').classes('flex justify-between items-center w-full'): | |
| with ui.element('div'): | |
| ui.html(f'<h2 class="pane-title">{title}</h2>', sanitize=False) | |
| if subtitle: | |
| ui.html(f'<p class="pane-subtitle">{subtitle}</p>', sanitize=False) | |
| if action_button: | |
| label, on_click, *rest = action_button | |
| icon = rest[0] if rest else 'add' | |
| ui.button(label, on_click=on_click).classes('btn btn-primary').props(f'icon={icon}') | |
| # Content area | |
| with ui.element('div').classes('pane-content') as content: | |
| yield content | |
| class WorkspaceItem: | |
| """ | |
| A draggable workspace list item with edit and delete capabilities. | |
| This creates an item that looks like: | |
| [drag_handle] [editable_text_input] [more_vert_menu] | |
| """ | |
| def __init__( | |
| self, | |
| name: str, | |
| on_delete=None, | |
| on_rename=None, | |
| on_menu_click=None, | |
| container=None | |
| ): | |
| """ | |
| Create a workspace item. | |
| Args: | |
| name: Initial name/text for the item | |
| on_delete: Callback when item is deleted (receives name) | |
| on_rename: Callback when item is renamed (receives old_name, new_name) | |
| on_menu_click: Callback when menu button is clicked | |
| container: Parent container (for removal) | |
| """ | |
| self.name = name | |
| self.on_delete = on_delete | |
| self.on_rename = on_rename | |
| self.on_menu_click = on_menu_click | |
| self.container = container | |
| self.element = None | |
| self.input_element = None | |
| self._build() | |
| def _build(self): | |
| """Build the UI elements.""" | |
| with ui.element('div').classes('workspace-item') as item: | |
| self.element = item | |
| # Drag handle | |
| ui.html('<span class="material-symbols-outlined drag-handle">drag_indicator</span>', sanitize=False) | |
| # Editable input | |
| self.input_element = ui.input(value=self.name).classes( | |
| 'flex-grow bg-transparent border-none outline-none' | |
| ).props('borderless dense') | |
| # Bind rename callback | |
| if self.on_rename: | |
| def handle_rename(e): | |
| new_name = e.value | |
| if new_name != self.name: | |
| self.on_rename(self.name, new_name) | |
| self.name = new_name | |
| self.input_element.on('blur', handle_rename) | |
| # More menu button | |
| with ui.button(icon='more_vert').props('flat round dense').classes('item-actions'): | |
| with ui.menu() as menu: | |
| ui.menu_item('Rename', lambda: self.input_element.run_method('focus')) | |
| ui.menu_item('Delete', self._handle_delete) | |
| def _handle_delete(self): | |
| """Handle deletion of this item.""" | |
| if self.on_delete: | |
| self.on_delete(self.name) | |
| # Remove from UI | |
| if self.element: | |
| self.element.delete() | |
| def delete(self): | |
| """Programmatically delete this item.""" | |
| self._handle_delete() | |
| def workspace_item( | |
| name: str, | |
| on_delete=None, | |
| on_rename=None | |
| ) -> WorkspaceItem: | |
| """ | |
| Factory function to create a workspace item. | |
| Args: | |
| name: Initial name/text for the item | |
| on_delete: Callback when item is deleted (receives name) | |
| on_rename: Callback when item is renamed (receives old_name, new_name) | |
| Returns: | |
| WorkspaceItem instance | |
| """ | |
| return WorkspaceItem(name=name, on_delete=on_delete, on_rename=on_rename) | |
| def workspace_list(): | |
| """ | |
| Create a container for workspace items. | |
| Usage: | |
| with workspace_list() as item_list: | |
| workspace_item('Thesis & Argument', on_delete=handle_delete) | |
| workspace_item('Use of Evidence', on_delete=handle_delete) | |
| """ | |
| with ui.element('div').classes('workspace-list') as container: | |
| yield container | |
| def button_group( | |
| back_label: str = 'Back', | |
| next_label: str = 'Next', | |
| on_back=None, | |
| on_next=None, | |
| next_icon: str = 'arrow_forward', | |
| show_back: bool = True | |
| ): | |
| """ | |
| Create a button group for navigation (Back / Next buttons). | |
| Args: | |
| back_label: Label for back button | |
| next_label: Label for next button | |
| on_back: Callback for back button | |
| on_next: Callback for next button | |
| next_icon: Icon for next button (default: arrow_forward) | |
| show_back: Whether to show the back button | |
| """ | |
| with ui.element('div').classes('button-group'): | |
| if show_back: | |
| ui.button(back_label, on_click=on_back).classes('btn btn-secondary') | |
| else: | |
| # Spacer to push next button to the right | |
| ui.element('div') | |
| with ui.button(on_click=on_next).classes('btn btn-primary'): | |
| ui.label(next_label) | |
| if next_icon: | |
| ui.html(f'<span class="material-symbols-outlined">{next_icon}</span>', sanitize=False) | |
| def add_item_input(placeholder: str = 'Add new item...', on_add=None): | |
| """ | |
| Create an input field with add button for adding new items. | |
| Args: | |
| placeholder: Placeholder text for the input | |
| on_add: Callback when add button is clicked (receives input value) | |
| """ | |
| with ui.element('div').classes('flex items-center gap-2 mt-4 pt-4 border-t border-slate-200'): | |
| input_el = ui.input(placeholder=placeholder).classes('flex-grow').props('dense') | |
| def handle_add(): | |
| if input_el.value and on_add: | |
| on_add(input_el.value) | |
| input_el.set_value('') | |
| ui.button('Add', on_click=handle_add).classes('btn btn-secondary') | |
| input_el.on('keydown.enter', handle_add) | |
| return input_el | |
| class AssessmentItemPicker: | |
| """ | |
| A modal dialog for selecting assessment items from a task's content_stream. | |
| Displays a searchable list of assessment items with checkboxes, | |
| allowing users to select multiple items to add to their prompt groups. | |
| """ | |
| def __init__( | |
| self, | |
| task_data: Dict[str, Any], | |
| on_add_group: Callable[[str, List[Dict[str, Any]]], None] = None, | |
| used_item_ids: List[str] = None | |
| ): | |
| """ | |
| Create an assessment item picker modal. | |
| Args: | |
| task_data: The task_data JSONB from the tasks table | |
| on_add_group: Callback when a group is added (receives group_name and list of selected items) | |
| used_item_ids: List of item IDs already used in other groups (will be greyed out) | |
| """ | |
| self.task_data = task_data | |
| self.on_add_group = on_add_group | |
| self.used_item_ids = set(used_item_ids or []) | |
| self.assessment_items = self._extract_assessment_items() | |
| self.selected_items: Dict[str, bool] = {} # id -> selected | |
| self.group_name = "" | |
| self.dialog = None | |
| self.items_container = None | |
| self.group_name_input = None | |
| self.checkboxes: Dict[str, ui.checkbox] = {} | |
| def _extract_assessment_items(self) -> List[Dict[str, Any]]: | |
| """Extract assessment items from task_data content_stream (no tags).""" | |
| items = [] | |
| content_stream = self.task_data.get('content_stream', []) | |
| for item in content_stream: | |
| if item.get('type') == 'assessment_item': | |
| items.append({ | |
| 'id': item.get('id', str(item.get('sequence_id', ''))), | |
| 'prompt_text': item.get('prompt_text', ''), | |
| 'question_type': item.get('question_type', 'unknown'), | |
| 'is_scorable': item.get('is_scorable', False), | |
| 'sequence_id': item.get('sequence_id', 0), | |
| # Additional fields for table display | |
| 'options': item.get('options', []), | |
| 'correct_answer_text': item.get('correct_answer_text', '') | |
| }) | |
| # Sort by sequence_id | |
| items.sort(key=lambda x: x.get('sequence_id', 0)) | |
| return items | |
| def _truncate_text(self, text: str, max_length: int = 100) -> str: | |
| """Truncate text and add ellipsis if too long.""" | |
| if len(text) <= max_length: | |
| return text | |
| return text[:max_length].rsplit(' ', 1)[0] + '...' | |
| def _format_display_text(self, item: Dict[str, Any]) -> str: | |
| """Format the prompt text for display.""" | |
| prompt = item.get('prompt_text', '') | |
| # Clean up newlines and extra whitespace | |
| prompt = ' '.join(prompt.split()) | |
| return self._truncate_text(prompt, 80) | |
| def _get_question_type_label(self, question_type: str) -> str: | |
| """Get a human-readable label for question type.""" | |
| labels = { | |
| 'drawing': 'Drawing', | |
| 'multiple_choice': 'Multiple Choice', | |
| 'open_response': 'Open Response', | |
| 'table_completion': 'Table' | |
| } | |
| return labels.get(question_type, question_type.title()) | |
| def _is_correct_option(self, option: str, correct_answer_text: str) -> bool: | |
| """Check if an option matches the correct answer text.""" | |
| if not correct_answer_text: | |
| return False | |
| # Options have format like "A. Answer text" or "A) Answer text" | |
| # correct_answer_text is just the answer without the letter prefix | |
| # Strip the letter prefix (e.g., "A. ", "B) ") from option | |
| import re | |
| cleaned_option = re.sub(r'^[A-Z][.\)]\s*', '', option) | |
| return cleaned_option.strip() == correct_answer_text.strip() | |
| def _format_markdown_bold(self, text: str) -> str: | |
| """Convert **text** markdown to HTML bold tags.""" | |
| import re | |
| # Replace **text** with <b>text</b> | |
| return re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text) | |
| def _render_items(self): | |
| """Render the list of assessment items as a table (no tags).""" | |
| self.items_container.clear() | |
| self.checkboxes.clear() | |
| with self.items_container: | |
| if not self.assessment_items: | |
| ui.label('No assessment items found.').classes('text-secondary text-center py-4') | |
| return | |
| # Table header - 3 columns only | |
| with ui.element('div').classes('grid grid-cols-9 gap-2 px-3 py-2 bg-slate-100 border-b font-semibold text-xs text-slate-600 uppercase tracking-wide'): | |
| ui.element('div').classes('col-span-1') # Checkbox column | |
| ui.label('Item').classes('col-span-5') | |
| ui.label('Response Options').classes('col-span-3') | |
| # Table rows | |
| for item in self.assessment_items: | |
| item_id = item['id'] | |
| is_used = item_id in self.used_item_ids | |
| is_selected = self.selected_items.get(item_id, False) | |
| # Determine row classes based on state | |
| row_classes = 'grid grid-cols-9 gap-2 px-3 py-3 border-b border-slate-100 items-start ' | |
| if is_used: | |
| row_classes += 'opacity-50 cursor-not-allowed bg-slate-50' | |
| elif is_selected: | |
| row_classes += 'cursor-pointer hover:bg-slate-50 bg-primary/10' | |
| else: | |
| row_classes += 'cursor-pointer hover:bg-slate-50' | |
| with ui.element('div').classes(row_classes) as row: | |
| # Checkbox column | |
| with ui.element('div').classes('col-span-1 flex items-start pt-1'): | |
| def make_checkbox_handler(iid): | |
| def handler(e): | |
| self._toggle_item(iid, e.value) | |
| return handler | |
| cb = ui.checkbox( | |
| value=is_selected, | |
| on_change=make_checkbox_handler(item_id) if not is_used else None | |
| ).props('dense' + (' disable' if is_used else '')) | |
| self.checkboxes[item_id] = cb | |
| # Make the whole row clickable (but not if used) | |
| if not is_used: | |
| def make_row_handler(iid): | |
| def handler(e): | |
| self._toggle_item_from_row(iid) | |
| return handler | |
| row.on('click', make_row_handler(item_id)) | |
| # ITEM column - full text, wrapped | |
| with ui.element('div').classes('col-span-5'): | |
| # Item number badge | |
| with ui.element('div').classes('flex items-center gap-2 mb-1'): | |
| ui.label(f"Item {item['id']}").classes( | |
| 'text-xs font-semibold ' + ('text-slate-400' if is_used else 'text-primary') | |
| ) | |
| ui.element('span').classes( | |
| 'text-xs px-2 py-0.5 rounded-full bg-slate-200 text-slate-600' | |
| ).text = self._get_question_type_label(item['question_type']) | |
| if is_used: | |
| ui.element('span').classes( | |
| 'text-xs px-2 py-0.5 rounded-full bg-amber-100 text-amber-700' | |
| ).text = 'In use' | |
| # Full prompt text (wrapped, no truncation) | |
| prompt_text = item.get('prompt_text', '') | |
| ui.label(prompt_text).classes( | |
| 'text-sm leading-relaxed break-words ' + ('text-slate-400' if is_used else 'text-slate-700') | |
| ) | |
| # RESPONSE OPTIONS column | |
| with ui.element('div').classes('col-span-3'): | |
| options = item.get('options', []) | |
| correct_answer = item.get('correct_answer_text', '') | |
| if options: | |
| # Multiple choice - show all options with correct highlighted | |
| for option in options: | |
| is_correct = self._is_correct_option(option, correct_answer) | |
| option_classes = 'text-xs leading-relaxed mb-1 ' | |
| if is_used: | |
| option_classes += 'text-slate-400' | |
| elif is_correct: | |
| option_classes += 'text-green-600 font-medium' | |
| else: | |
| option_classes += 'text-slate-600' | |
| ui.label(option).classes(option_classes) | |
| elif correct_answer: | |
| # Non-MC item with correct_answer_text - show with "Ideal Response:" label | |
| # Light blue color (#64b5f6) and handle markdown bold | |
| ui.label('Ideal Response:').classes( | |
| 'text-xs font-semibold mb-1 ' + | |
| ('text-slate-400' if is_used else '') | |
| ).style('' if is_used else 'color: #64b5f6;') | |
| # Format markdown bold and render as HTML | |
| formatted_answer = self._format_markdown_bold(correct_answer) | |
| ui.html( | |
| f'<span class="text-xs leading-relaxed" style="color: {"#94a3b8" if is_used else "#64b5f6"};">{formatted_answer}</span>', | |
| sanitize=False | |
| ) | |
| else: | |
| ui.label('—').classes('text-xs text-slate-400') | |
| # ========== OLD _render_items (COMMENTED OUT) ========== | |
| # def _render_items_old(self): | |
| # """Render the list of assessment items.""" | |
| # self.items_container.clear() | |
| # self.checkboxes.clear() | |
| # | |
| # with self.items_container: | |
| # if not self.assessment_items: | |
| # ui.label('No assessment items found.').classes('text-secondary text-center py-4') | |
| # return | |
| # | |
| # for item in self.assessment_items: | |
| # item_id = item['id'] | |
| # is_used = item_id in self.used_item_ids | |
| # is_selected = self.selected_items.get(item_id, False) | |
| # | |
| # # Determine row classes based on state | |
| # row_classes = 'flex items-start gap-3 p-3 rounded-lg ' | |
| # if is_used: | |
| # row_classes += 'opacity-50 cursor-not-allowed bg-slate-100' | |
| # elif is_selected: | |
| # row_classes += 'cursor-pointer hover:bg-slate-50 bg-primary/10 border border-primary/30' | |
| # else: | |
| # row_classes += 'cursor-pointer hover:bg-slate-50 border border-transparent' | |
| # | |
| # with ui.element('div').classes(row_classes) as row: | |
| # # Checkbox - disabled if item is already used | |
| # def make_checkbox_handler(iid): | |
| # def handler(e): | |
| # self._toggle_item(iid, e.value) | |
| # return handler | |
| # | |
| # cb = ui.checkbox( | |
| # value=is_selected, | |
| # on_change=make_checkbox_handler(item_id) if not is_used else None | |
| # ).props('dense' + (' disable' if is_used else '')) | |
| # self.checkboxes[item_id] = cb | |
| # | |
| # # Make the whole row clickable (but not if used) | |
| # if not is_used: | |
| # def make_row_handler(iid): | |
| # def handler(e): | |
| # self._toggle_item_from_row(iid) | |
| # return handler | |
| # row.on('click', make_row_handler(item_id)) | |
| # | |
| # # Item content | |
| # with ui.element('div').classes('flex-grow min-w-0'): | |
| # # Question type badge and item number | |
| # with ui.element('div').classes('flex items-center gap-2 mb-1'): | |
| # ui.label(f"Item {item['id']}").classes( | |
| # 'text-xs font-semibold ' + ('text-slate-400' if is_used else 'text-primary') | |
| # ) | |
| # ui.element('span').classes( | |
| # 'text-xs px-2 py-0.5 rounded-full bg-slate-200 text-slate-600' | |
| # ).text = self._get_question_type_label(item['question_type']) | |
| # if item.get('is_scorable'): | |
| # ui.element('span').classes( | |
| # 'text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700' | |
| # ).text = 'Scorable' | |
| # # Show "In use" badge for used items | |
| # if is_used: | |
| # ui.element('span').classes( | |
| # 'text-xs px-2 py-0.5 rounded-full bg-amber-100 text-amber-700' | |
| # ).text = 'In use' | |
| # | |
| # # Prompt text | |
| # ui.label(self._format_display_text(item)).classes( | |
| # 'text-sm leading-relaxed ' + ('text-slate-400' if is_used else 'text-slate-700') | |
| # ) | |
| # ========== END OLD _render_items ========== | |
| def _toggle_item(self, item_id: str, value: bool): | |
| """Toggle selection state of an item.""" | |
| print(f"DEBUG _toggle_item: item_id={item_id}, value={value}") | |
| self.selected_items[item_id] = value | |
| print(f"DEBUG selected_items now: {self.selected_items}") | |
| def _toggle_item_from_row(self, item_id: str): | |
| """Toggle item when row is clicked.""" | |
| current = self.selected_items.get(item_id, False) | |
| new_value = not current | |
| print(f"DEBUG _toggle_item_from_row: item_id={item_id}, current={current}, new_value={new_value}") | |
| self.selected_items[item_id] = new_value | |
| print(f"DEBUG selected_items now: {self.selected_items}") | |
| # Update the checkbox visually | |
| if item_id in self.checkboxes: | |
| self.checkboxes[item_id].set_value(new_value) | |
| async def _handle_add_selected(self): | |
| """Handle adding selected items to a prompt group.""" | |
| group_name = self.group_name_input.value.strip() if self.group_name_input else "" | |
| if not group_name: | |
| ui.notify('Please enter a name for the prompt group.', color='warning') | |
| return | |
| selected = [ | |
| item.copy() for item in self.assessment_items | |
| if self.selected_items.get(item['id'], False) | |
| ] | |
| if not selected: | |
| ui.notify('Please select at least one item.', color='warning') | |
| return | |
| if self.on_add_group: | |
| # Handle both sync and async callbacks | |
| import asyncio | |
| result = self.on_add_group(group_name, selected) | |
| if asyncio.iscoroutine(result): | |
| await result | |
| self.dialog.close() | |
| def _get_selected_count(self) -> int: | |
| """Get count of selected items.""" | |
| return sum(1 for v in self.selected_items.values() if v) | |
| def show(self): | |
| """Show the modal dialog.""" | |
| # Use max-w-5xl for wider table layout | |
| with ui.dialog() as self.dialog, ui.card().classes('w-full max-w-5xl'): | |
| # Header | |
| with ui.element('div').classes('flex justify-between items-center mb-4'): | |
| ui.label('Add Prompt Group').classes('text-xl font-bold text-slate-800') | |
| ui.button(icon='close', on_click=self.dialog.close).props('flat round dense') | |
| # Group name input | |
| self.group_name_input = ui.input( | |
| label='Prompt Group Name', | |
| placeholder='e.g., Model Building, Written Explanation...' | |
| ).classes('w-full mb-4').props('outlined') | |
| # Items list label | |
| ui.label('Select assessment items for this group:').classes('text-sm text-slate-600 mb-2') | |
| # Items list (scrollable) - increased height for table view | |
| with ui.element('div').classes('max-h-96 overflow-y-auto border rounded-lg') as container: | |
| self.items_container = container | |
| self._render_items() | |
| # Footer with buttons | |
| with ui.element('div').classes('flex justify-end gap-3 mt-4 pt-4 border-t'): | |
| ui.button('Cancel', on_click=self.dialog.close).classes('btn btn-secondary') | |
| ui.button('Add Selected', on_click=self._handle_add_selected).classes('btn btn-primary') | |
| self.dialog.open() | |
| def close(self): | |
| """Close the modal dialog.""" | |
| if self.dialog: | |
| self.dialog.close() | |