wildfire_env-pr-132 / src /envs /wildfire_env /server /wildfire_web_interface.py
burtenshaw's picture
burtenshaw HF Staff
Upload folder using huggingface_hub
cbe0084 verified
"""
Custom web interface for Wildfire Environment.
This module provides a wildfire-specific web interface with visual grid display
and wildfire-specific features, without modifying the base web_interface.py.
"""
from typing import Optional
from dataclasses import asdict
from core.env_server.types import EnvironmentMetadata
from ..models import WildfireAction
def get_wildfire_web_interface_html(metadata: Optional[EnvironmentMetadata] = None) -> str:
"""Generate custom HTML for the wildfire environment web interface."""
# Convert markdown to HTML for instructions
instructions_html = ""
if metadata and metadata.readme_content:
instructions_html = _markdown_to_html_simple(metadata.readme_content)
return f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wildfire Environment - Web Interface</title>
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f5f5f5;
height: 100vh;
overflow: hidden;
}}
.container {{
display: flex;
height: 100vh;
}}
.left-pane {{
width: 50%;
background: white;
border-right: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
}}
.right-pane {{
width: 50%;
background: #fafafa;
display: flex;
flex-direction: column;
}}
.pane-header {{
padding: 20px;
border-bottom: 1px solid #e0e0e0;
background: #f8f9fa;
font-weight: 600;
font-size: 16px;
}}
.pane-content {{
flex: 1;
padding: 20px;
overflow-y: auto;
}}
/* Action Form Styles */
.action-form {{
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}}
.form-group {{
margin-bottom: 15px;
}}
.form-group label {{
display: block;
margin-bottom: 5px;
font-weight: 500;
color: #333;
}}
.form-group select, .form-group input {{
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}}
.form-group select:focus, .form-group input:focus {{
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}}
/* Buttons */
.btn {{
background: #007bff;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
margin-right: 10px;
margin-bottom: 10px;
}}
.btn:hover {{
background: #0056b3;
}}
.btn:disabled {{
background: #6c757d;
cursor: not-allowed;
}}
.btn-secondary {{
background: #6c757d;
}}
.btn-secondary:hover {{
background: #545b62;
}}
/* Grid Visualization */
.grid-container {{
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}}
.grid-display {{
display: inline-block;
border: 2px solid #333;
background: #fff;
padding: 5px;
margin: 10px 0;
}}
.grid {{
display: grid;
gap: 1px;
background: #333;
}}
.cell {{
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
cursor: pointer;
position: relative;
}}
.cell.ash {{ background-color: #2f2f2f; }}
.cell.fuel {{ background-color: #228b22; }}
.cell.burning {{ background-color: #ff4500; }}
.cell.firebreak {{ background-color: #8b4513; }}
.cell.watered {{ background-color: #4169e1; }}
.cell:hover {{
opacity: 0.8;
transform: scale(1.1);
z-index: 10;
}}
/* Stats Display */
.stats-display {{
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 15px;
margin-bottom: 20px;
}}
.stats-grid {{
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 15px;
margin-top: 10px;
}}
.stat-item {{
display: flex;
flex-direction: column;
}}
.stat-label {{
font-size: 12px;
color: #666;
margin-bottom: 5px;
}}
.stat-value {{
font-size: 20px;
font-weight: bold;
color: #007bff;
}}
/* Instructions Section */
.instructions-section {{
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}}
.instructions-header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}}
.instructions-title {{
font-size: 18px;
font-weight: 600;
color: #333;
margin: 0;
}}
.instructions-toggle {{
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 5px 10px;
cursor: pointer;
font-size: 12px;
color: #6c757d;
}}
.instructions-toggle:hover {{
background: #e9ecef;
}}
.instructions-content {{
display: none;
max-height: 400px;
overflow-y: auto;
border-top: 1px solid #e0e0e0;
padding-top: 15px;
}}
.instructions-content.expanded {{
display: block;
}}
/* Legend */
.legend {{
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 15px;
margin-bottom: 20px;
}}
.legend-items {{
display: flex;
flex-wrap: wrap;
gap: 15px;
margin-top: 10px;
}}
.legend-item {{
display: flex;
align-items: center;
gap: 8px;
}}
.legend-color {{
width: 20px;
height: 20px;
border: 1px solid #333;
}}
/* Connection Status */
.status-indicator {{
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 8px;
}}
.status-connected {{
background: #28a745;
}}
.status-disconnected {{
background: #dc3545;
}}
/* Action Logs */
.logs-container {{
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 15px;
max-height: 300px;
overflow-y: auto;
}}
.log-entry {{
border-bottom: 1px solid #f0f0f0;
padding: 10px 0;
}}
.log-entry:last-child {{
border-bottom: none;
}}
.log-timestamp {{
font-size: 12px;
color: #666;
margin-bottom: 5px;
}}
.log-action {{
background: #e3f2fd;
padding: 8px;
border-radius: 4px;
margin-bottom: 5px;
font-family: monospace;
font-size: 12px;
}}
.log-reward {{
font-weight: 600;
color: #28a745;
}}
.log-done {{
font-weight: 600;
color: #dc3545;
}}
/* State Display */
.state-display {{
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 15px;
margin-bottom: 20px;
}}
.state-item {{
margin-bottom: 8px;
}}
.state-label {{
font-weight: 500;
color: #666;
}}
.state-value {{
color: #333;
font-family: monospace;
}}
</style>
</head>
<body>
<div class="container">
<!-- Left Pane: Action Interface -->
<div class="left-pane">
<div class="pane-header">
<span class="status-indicator status-disconnected" id="connection-status"></span>
Wildfire Containment Interface
</div>
<div class="pane-content">
<!-- Instructions Section -->
{_generate_instructions_section(instructions_html, metadata)}
<!-- Action Form -->
<div class="action-form">
<h3>Take Action</h3>
<form id="action-form">
<div class="form-group">
<label for="action">Action Type <span style="color: red;">*</span></label>
<select name="action" id="action" required>
<option value="">-- Select Action --</option>
<option value="water">Water (Extinguish Fire)</option>
<option value="break">Break (Create Firebreak)</option>
<option value="wait">Wait (Do Nothing)</option>
</select>
<small style="display: block; margin-top: 5px; color: #666;">
Water: Extinguishes fire at target cell<br>
Break: Creates firebreak to prevent spread<br>
Wait: Fire continues spreading
</small>
</div>
<div class="form-group" id="coordinates-group" style="display: none;">
<label for="x">X Coordinate</label>
<input type="number" name="x" id="x" min="0" placeholder="Enter X coordinate">
<label for="y" style="margin-top: 10px;">Y Coordinate</label>
<input type="number" name="y" id="y" min="0" placeholder="Enter Y coordinate">
<small style="display: block; margin-top: 5px; color: #666;">
Coordinates are required for water and break actions
</small>
</div>
<button type="submit" class="btn" id="step-btn">Execute Action</button>
</form>
</div>
<!-- Control Buttons -->
<div style="margin-bottom: 20px;">
<button class="btn btn-secondary" id="reset-btn">Reset Environment</button>
<button class="btn btn-secondary" id="state-btn">Get State</button>
</div>
<!-- Stats Display -->
<div class="stats-display">
<h3>Environment Stats</h3>
<div class="stats-grid">
<div class="stat-item">
<span class="stat-label">Step Count</span>
<span class="stat-value" id="step-count">0</span>
</div>
<div class="stat-item">
<span class="stat-label">Water Remaining</span>
<span class="stat-value" id="water-remaining">0</span>
</div>
<div class="stat-item">
<span class="stat-label">Breaks Remaining</span>
<span class="stat-value" id="breaks-remaining">0</span>
</div>
<div class="stat-item">
<span class="stat-label">Burning Cells</span>
<span class="stat-value" id="burning-count">0</span>
</div>
</div>
</div>
<!-- Current State Display -->
<div class="state-display">
<h3>Current State</h3>
<div id="current-state">
<div class="state-item">
<span class="state-label">Status:</span>
<span class="state-value" id="env-status">Not initialized</span>
</div>
<div class="state-item">
<span class="state-label">Episode ID:</span>
<span class="state-value" id="episode-id">-</span>
</div>
<div class="state-item">
<span class="state-label">Wind Direction:</span>
<span class="state-value" id="wind-dir">-</span>
</div>
<div class="state-item">
<span class="state-label">Humidity:</span>
<span class="state-value" id="humidity">-</span>
</div>
</div>
</div>
</div>
</div>
<!-- Right Pane: Visual Grid and Logs -->
<div class="right-pane">
<div class="pane-header">
Fire Grid Visualization
</div>
<div class="pane-content">
<!-- Legend -->
<div class="legend">
<h3>Legend</h3>
<div class="legend-items">
<div class="legend-item">
<div class="legend-color" style="background-color: #2f2f2f;"></div>
<span>Ash (Burned)</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #228b22;"></div>
<span>Fuel (Safe)</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #ff4500;"></div>
<span>Burning (Fire)</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #8b4513;"></div>
<span>Firebreak</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #4169e1;"></div>
<span>Watered (Damp)</span>
</div>
</div>
</div>
<!-- Grid Visualization -->
<div class="grid-container">
<h3>Fire Grid</h3>
<div id="grid-status" style="margin-bottom: 10px; font-size: 12px; color: #666;">
Waiting for grid data... (Click "Reset Environment" to initialize)
</div>
<div class="grid-display">
<div id="fire-grid" class="grid">
<!-- Grid will be rendered here -->
</div>
</div>
<p style="margin-top: 10px; font-size: 12px; color: #666;">
Click on a cell to set coordinates for water/break actions
</p>
</div>
<!-- Action Logs -->
<div class="logs-container">
<h3>Action History</h3>
<div id="action-logs">
No actions taken yet
</div>
</div>
</div>
</div>
</div>
<script>
class WildfireWebInterface {{
constructor() {{
this.ws = null;
this.isConnected = false;
this.currentGrid = null;
this.gridWidth = 0;
this.gridHeight = 0;
this.init();
}}
init() {{
this.connectWebSocket();
this.setupEventListeners();
}}
connectWebSocket() {{
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${{protocol}}//${{window.location.host}}/ws`;
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {{
this.isConnected = true;
this.updateConnectionStatus(true);
console.log('WebSocket connected');
// Trigger initial state fetch
this.fetchInitialState();
}};
this.ws.onmessage = (event) => {{
const data = JSON.parse(event.data);
if (data.type === 'state_update') {{
this.updateUI(data.episode_state);
}}
}};
this.ws.onclose = () => {{
this.isConnected = false;
this.updateConnectionStatus(false);
console.log('WebSocket disconnected');
setTimeout(() => this.connectWebSocket(), 3000);
}};
this.ws.onerror = (error) => {{
console.error('WebSocket error:', error);
}};
}}
async fetchInitialState() {{
// Fetch current state on connection to display grid
try {{
// Try to get current observation from state
const stateResponse = await fetch('/web/state');
const state = await stateResponse.json();
// If we have grid data in state, render it
if (state.grid && Array.isArray(state.grid) && state.width && state.height) {{
console.log('Rendering grid from state');
this.renderGrid(state.grid, state.width, state.height);
return;
}}
// If no grid in state, try to get it from the current episode state
// The WebSocket will send the current observation shortly
console.log('No grid in state, waiting for WebSocket update...');
}} catch (error) {{
console.error('Error fetching initial state:', error);
}}
}}
setupEventListeners() {{
// Instructions toggle
const instructionsToggle = document.getElementById('instructions-toggle');
const instructionsContent = document.getElementById('instructions-content');
if (instructionsToggle && instructionsContent) {{
instructionsToggle.addEventListener('click', () => {{
instructionsContent.classList.toggle('expanded');
instructionsToggle.textContent = instructionsContent.classList.contains('expanded')
? 'Hide Instructions' : 'Show Instructions';
}});
}}
// Action type change - show/hide coordinates
document.getElementById('action').addEventListener('change', (e) => {{
const coordsGroup = document.getElementById('coordinates-group');
if (e.target.value === 'water' || e.target.value === 'break') {{
coordsGroup.style.display = 'block';
document.getElementById('x').required = true;
document.getElementById('y').required = true;
}} else {{
coordsGroup.style.display = 'none';
document.getElementById('x').required = false;
document.getElementById('y').required = false;
}}
}});
// Form submission
document.getElementById('action-form').addEventListener('submit', (e) => {{
e.preventDefault();
this.submitAction();
}});
// Reset button
document.getElementById('reset-btn').addEventListener('click', () => {{
this.resetEnvironment();
}});
// State button
document.getElementById('state-btn').addEventListener('click', () => {{
this.getState();
}});
}}
async submitAction() {{
const formData = new FormData(document.getElementById('action-form'));
const action = {{}};
for (const [key, value] of formData.entries()) {{
if (value !== '') {{
if (key === 'x' || key === 'y') {{
action[key] = parseInt(value);
}} else {{
action[key] = value;
}}
}}
}}
// Remove x/y if action is 'wait'
if (action.action === 'wait') {{
delete action.x;
delete action.y;
}}
try {{
const response = await fetch('/web/step', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ action }})
}});
if (!response.ok) {{
throw new Error(`HTTP error! status: ${{response.status}}`);
}}
const result = await response.json();
console.log('Step result:', result);
}} catch (error) {{
console.error('Error submitting action:', error);
alert('Error submitting action: ' + error.message);
}}
}}
async resetEnvironment() {{
try {{
const response = await fetch('/web/reset', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }}
}});
if (!response.ok) {{
throw new Error(`HTTP error! status: ${{response.status}}`);
}}
const result = await response.json();
console.log('Reset result:', result);
console.log('Reset observation:', result.observation);
// Render grid immediately after reset
if (result.observation && result.observation.grid) {{
const obs = result.observation;
console.log('Grid data:', obs.grid);
console.log('Grid dimensions:', obs.width, 'x', obs.height);
if (obs.grid && Array.isArray(obs.grid) && obs.width && obs.height) {{
console.log('Rendering grid from reset...');
this.renderGrid(obs.grid, obs.width, obs.height);
}} else {{
console.warn('Grid data invalid:', {{
gridIsArray: Array.isArray(obs.grid),
width: obs.width,
height: obs.height
}});
}}
}} else {{
console.warn('No grid data in reset result:', result);
}}
}} catch (error) {{
console.error('Error resetting environment:', error);
alert('Error resetting environment: ' + error.message);
}}
}}
async getState() {{
try {{
const response = await fetch('/web/state');
const state = await response.json();
console.log('Current state:', state);
alert('Current state: ' + JSON.stringify(state, null, 2));
}} catch (error) {{
console.error('Error getting state:', error);
alert('Error getting state: ' + error.message);
}}
}}
updateConnectionStatus(connected) {{
const indicator = document.getElementById('connection-status');
if (connected) {{
indicator.className = 'status-indicator status-connected';
}} else {{
indicator.className = 'status-indicator status-disconnected';
}}
}}
updateUI(episodeState) {{
// Update state display
document.getElementById('env-status').textContent =
episodeState.is_reset ? 'Reset' : 'Running';
document.getElementById('episode-id').textContent =
episodeState.episode_id || '-';
document.getElementById('step-count').textContent =
episodeState.step_count.toString();
// Update observation if available
if (episodeState.current_observation) {{
const obs = episodeState.current_observation;
// Update stats
document.getElementById('water-remaining').textContent =
obs.remaining_water !== undefined ? obs.remaining_water : '-';
document.getElementById('breaks-remaining').textContent =
obs.remaining_breaks !== undefined ? obs.remaining_breaks : '-';
document.getElementById('burning-count').textContent =
obs.burning_count !== undefined ? obs.burning_count : '-';
document.getElementById('wind-dir').textContent =
obs.wind_dir || '-';
document.getElementById('humidity').textContent =
obs.humidity !== undefined ? obs.humidity.toFixed(2) : '-';
// Update grid visualization - handle both array and list formats
let gridData = obs.grid;
let gridWidth = obs.width;
let gridHeight = obs.height;
console.log('Updating grid from observation:', {{
hasGrid: !!gridData,
gridType: typeof gridData,
isArray: Array.isArray(gridData),
width: gridWidth,
height: gridHeight
}});
// Convert grid to array if it's not already
if (gridData && !Array.isArray(gridData)) {{
if (typeof gridData === 'string') {{
try {{
gridData = JSON.parse(gridData);
console.log('Parsed grid from string');
}} catch (e) {{
console.error('Error parsing grid data:', e);
gridData = null;
}}
}}
}}
// Ensure we have valid grid data
if (gridData && Array.isArray(gridData) && gridWidth && gridHeight) {{
console.log('Rendering grid from WebSocket update:', gridWidth, 'x', gridHeight, 'cells:', gridData.length);
this.renderGrid(gridData, gridWidth, gridHeight);
}} else {{
console.warn('Invalid grid data in WebSocket update:', {{
grid: gridData,
gridLength: gridData ? (Array.isArray(gridData) ? gridData.length : 'not array') : 'null',
width: gridWidth,
height: gridHeight
}});
}}
}}
// Update action logs
const logsDiv = document.getElementById('action-logs');
if (episodeState.action_logs.length === 0) {{
logsDiv.innerHTML = 'No actions taken yet';
}} else {{
logsDiv.innerHTML = episodeState.action_logs.map(log => `
<div class="log-entry">
<div class="log-timestamp">${{log.timestamp}} (Step ${{log.step_count}})</div>
<div class="log-action">Action: ${{JSON.stringify(log.action, null, 2)}}</div>
<div>
<span class="log-reward">Reward: ${{log.reward !== null ? log.reward.toFixed(2) : 'None'}}</span>
${{log.done ? '<span class="log-done">DONE</span>' : ''}}
</div>
</div>
`).join('');
}}
}}
renderGrid(grid, width, height) {{
this.gridWidth = width;
this.gridHeight = height;
this.currentGrid = grid;
const gridContainer = document.getElementById('fire-grid');
const gridStatus = document.getElementById('grid-status');
if (!gridContainer) {{
console.error('Grid container not found!');
return;
}}
// Validate grid dimensions
if (!width || !height || !grid || !Array.isArray(grid)) {{
console.error('Invalid grid parameters:', {{ width, height, grid }});
if (gridStatus) {{
gridStatus.innerHTML = '<span style="color: red;">Error: Invalid grid data</span>';
}}
gridContainer.innerHTML = '<p style="color: red;">Error: Invalid grid data</p>';
return;
}}
// Calculate grid size once
const gridSize = grid.length;
const expectedSize = width * height;
// Update status
if (gridStatus) {{
gridStatus.innerHTML = `Grid: ${{width}}×${{height}} (${{gridSize}} cells)`;
}}
// Check if grid size matches expected dimensions
if (gridSize !== expectedSize) {{
console.warn(`Grid size mismatch: expected ${{expectedSize}}, got ${{gridSize}}`);
}}
gridContainer.style.gridTemplateColumns = `repeat(${{width}}, 20px)`;
gridContainer.innerHTML = '';
// Grid encoding: 0=ash, 1=fuel, 2=burning, 3=firebreak, 4=watered
const cellClasses = ['ash', 'fuel', 'burning', 'firebreak', 'watered'];
const cellLabels = ['Ash', 'Fuel', 'Burning', 'Firebreak', 'Watered'];
console.log(`Rendering grid: ${{width}}x${{height}}, ${{gridSize}} cells`);
let renderedCells = 0;
for (let y = 0; y < height; y++) {{
for (let x = 0; x < width; x++) {{
const index = y * width + x;
const cellValue = (grid[index] !== undefined && grid[index] !== null) ? grid[index] : 0;
const cellClass = cellClasses[cellValue] || 'ash';
const cellLabel = cellLabels[cellValue] || 'Unknown';
const cell = document.createElement('div');
cell.className = `cell ${{cellClass}}`;
cell.title = `(${{x}}, ${{y}}): ${{cellLabel}} (value: ${{cellValue}})`;
cell.dataset.x = x;
cell.dataset.y = y;
cell.dataset.value = cellValue;
// Click to set coordinates
cell.addEventListener('click', () => {{
const xInput = document.getElementById('x');
const yInput = document.getElementById('y');
if (xInput) xInput.value = x;
if (yInput) yInput.value = y;
}});
gridContainer.appendChild(cell);
renderedCells++;
}}
}}
console.log(`Grid rendered: ${{width}}x${{height}} = ${{renderedCells}} cells`);
// Verify grid is visible
if (gridStatus) {{
gridStatus.innerHTML = `Grid: ${{width}}×${{height}} (${{renderedCells}} cells rendered) ✅`;
gridStatus.style.color = '#28a745';
}}
}}
}}
// Initialize the web interface when the page loads
document.addEventListener('DOMContentLoaded', () => {{
new WildfireWebInterface();
}});
</script>
</body>
</html>
""".replace('{_generate_instructions_section(instructions_html, metadata)}',
_generate_instructions_section(instructions_html, metadata))
def _generate_instructions_section(instructions_html: str, metadata: Optional[EnvironmentMetadata]) -> str:
"""Generate the instructions section."""
if not instructions_html or not metadata:
return ''
return f'''
<!-- Instructions Section -->
<div class="instructions-section">
<div class="instructions-header">
<h3 class="instructions-title">{metadata.name if metadata else "Wildfire Environment"}</h3>
<button class="instructions-toggle" id="instructions-toggle">Show Instructions</button>
</div>
<div class="instructions-content" id="instructions-content">
<div class="instructions-readme">
{instructions_html}
</div>
</div>
</div>
'''
def _markdown_to_html_simple(markdown: str) -> str:
"""Convert basic markdown to HTML."""
import html
import re
# Escape HTML first
html_content = html.escape(markdown)
# Convert headers
html_content = re.sub(r'^# (.*?)$', r'<h1>\1</h1>', html_content, flags=re.MULTILINE)
html_content = re.sub(r'^## (.*?)$', r'<h2>\1</h2>', html_content, flags=re.MULTILINE)
html_content = re.sub(r'^### (.*?)$', r'<h3>\1</h3>', html_content, flags=re.MULTILINE)
# Convert code blocks
html_content = re.sub(r'```(.*?)\n(.*?)\n```', r'<pre><code>\2</code></pre>', html_content, flags=re.DOTALL)
html_content = re.sub(r'`([^`]+)`', r'<code>\1</code>', html_content)
# Convert bold and italic
html_content = re.sub(r'\*\*(.*?)\*\*', r'<strong>\1</strong>', html_content)
html_content = re.sub(r'\*(.*?)\*', r'<em>\1</em>', html_content)
# Convert lists
html_content = re.sub(r'^- (.*?)$', r'<li>\1</li>', html_content, flags=re.MULTILINE)
html_content = re.sub(r'(<li>.*</li>)', r'<ul>\1</ul>', html_content, flags=re.DOTALL)
# Convert line breaks
html_content = html_content.replace('\n', '<br>')
return html_content