spaxel/dashboard/js/feedback.js
jedarden 4af8046acd feat(learning): implement detection feedback loop and accuracy tracking
- Add feedback_store.go with SQLite storage for detection feedback and accuracy metrics
- Add feedback_processor.go for background processing of user feedback
- Add accuracy.go for weekly precision/recall/F1 metric computation
- Add handler.go with REST API routes for feedback submission and accuracy retrieval
- Wire learning package into main_phase6.go with background processing
- Add dashboard/js/feedback.js with thumbs-up/down UI components
- Add dashboard/js/accuracy.js with accuracy panel rendering and sparkline trends
- Add comprehensive tests for feedback storage and accuracy computation

Feedback UI provides:
- Thumbs-up/down buttons for detection events
- Feedback form with false positive/negative options
- Missed detection reporting with position/zone selection
- Motivational counter showing improvement from user corrections

Accuracy panel shows:
- Circular gauge with F1 score
- Week-over-week trend sparkline
- Per-zone breakdown of precision/recall
- Total corrections count and improvement percentage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 14:50:36 -04:00

646 lines
24 KiB
JavaScript

/**
* Feedback UI Components for Detection Accuracy
* Provides thumbs-up/down buttons, feedback forms, and missed detection reporting
*/
(function() {
'use strict';
var Feedback = {
// State
pendingFeedback: null,
feedbackPanelVisible: false,
// Event types
EventTypes: {
BLOB_DETECTION: 'blob_detection',
ZONE_TRANSITION: 'zone_transition',
FALL_ALERT: 'fall_alert',
ANOMALY: 'anomaly'
},
// Feedback types
FeedbackTypes: {
TRUE_POSITIVE: 'TRUE_POSITIVE',
FALSE_POSITIVE: 'FALSE_POSITIVE',
FALSE_NEGATIVE: 'FALSE_NEGATIVE',
WRONG_IDENTITY: 'WRONG_IDENTITY',
WRONG_ZONE: 'WRONG_ZONE'
},
/**
* Initialize the feedback module
*/
init: function() {
this.createFeedbackPanel();
this.createMissedDetectionButton();
console.log('[Feedback] Module initialized');
},
/**
* Create the feedback panel (hidden by default)
*/
createFeedbackPanel: function() {
var panel = document.createElement('div');
panel.id = 'feedback-panel';
panel.className = 'feedback-panel';
panel.style.display = 'none';
panel.innerHTML = '\
<div class="feedback-header">\
<span class="feedback-title">Report Feedback</span>\
<button class="feedback-close" onclick="Feedback.hideFeedbackPanel()">&times;</button>\
</div>\
<div class="feedback-content">\
<div class="feedback-event-info">\
<span class="feedback-event-type"></span>\
<span class="feedback-event-time"></span>\
</div>\
<div class="feedback-form">\
<p class="feedback-question">What was wrong?</p>\
<div class="feedback-options">\
<label class="feedback-option">\
<input type="radio" name="feedback-type" value="FALSE_POSITIVE">\
<span>No one was there (false alarm)</span>\
</label>\
<label class="feedback-option">\
<input type="radio" name="feedback-type" value="FALSE_NEGATIVE">\
<span>Someone was missed at this location</span>\
</label>\
<label class="feedback-option">\
<input type="radio" name="feedback-type" value="WRONG_IDENTITY">\
<span>Wrong person identified</span>\
</label>\
<label class="feedback-option">\
<input type="radio" name="feedback-type" value="WRONG_ZONE">\
<span>Wrong zone/location</span>\
</label>\
</div>\
<div class="feedback-notes">\
<label>Notes (optional)</label>\
<textarea id="feedback-notes" placeholder="Additional details..."></textarea>\
</div>\
<div class="feedback-actions">\
<button class="feedback-btn feedback-btn-cancel" onclick="Feedback.hideFeedbackPanel()">Cancel</button>\
<button class="feedback-btn feedback-btn-submit" onclick="Feedback.submitFeedback()">Submit</button>\
</div>\
</div>\
</div>';
document.body.appendChild(panel);
// Add styles
this.addStyles();
},
/**
* Create the "Report missed detection" button
*/
createMissedDetectionButton: function() {
var btn = document.createElement('button');
btn.id = 'missed-detection-btn';
btn.className = 'missed-detection-btn';
btn.innerHTML = '&#x26A0; Report missed detection';
btn.onclick = function() { Feedback.showMissedDetectionForm(); };
document.body.appendChild(btn);
},
/**
* Show feedback panel for a specific event (thumbs-down clicked)
*/
showFeedbackPanel: function(eventID, eventType, eventTime, details) {
this.pendingFeedback = {
eventID: eventID,
eventType: eventType,
eventTime: eventTime,
details: details || {}
};
var panel = document.getElementById('feedback-panel');
if (!panel) return;
// Update event info
panel.querySelector('.feedback-event-type').textContent = this.formatEventType(eventType);
panel.querySelector('.feedback-event-time').textContent = eventTime ? new Date(eventTime).toLocaleString() : 'Now';
// Reset form
var radios = panel.querySelectorAll('input[name="feedback-type"]');
radios.forEach(function(r) { r.checked = false; });
document.getElementById('feedback-notes').value = '';
// Show panel
panel.style.display = 'block';
this.feedbackPanelVisible = true;
},
/**
* Hide the feedback panel
*/
hideFeedbackPanel: function() {
var panel = document.getElementById('feedback-panel');
if (panel) {
panel.style.display = 'none';
}
this.pendingFeedback = null;
this.feedbackPanelVisible = false;
},
/**
* Submit feedback to the server
*/
submitFeedback: function() {
if (!this.pendingFeedback) return;
var panel = document.getElementById('feedback-panel');
var selected = panel.querySelector('input[name="feedback-type"]:checked');
if (!selected) {
alert('Please select what was wrong with this detection.');
return;
}
var feedbackType = selected.value;
var notes = document.getElementById('feedback-notes').value;
var details = Object.assign({}, this.pendingFeedback.details);
if (notes) {
details.notes = notes;
}
this.sendFeedback(
this.pendingFeedback.eventID,
this.pendingFeedback.eventType,
feedbackType,
details
);
this.hideFeedbackPanel();
},
/**
* Submit thumbs-up (true positive) feedback
*/
submitThumbsUp: function(eventID, eventType, details) {
this.sendFeedback(eventID, eventType, this.FeedbackTypes.TRUE_POSITIVE, details || {});
},
/**
* Send feedback to the API
*/
sendFeedback: function(eventID, eventType, feedbackType, details) {
var data = {
event_id: eventID || '',
event_type: eventType,
feedback_type: feedbackType,
details: details
};
fetch('/api/learning/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
.then(function(res) { return res.json(); })
.then(function(result) {
if (result.success) {
console.log('[Feedback] Submitted:', feedbackType, 'for event', eventID);
if (window.SpaxelApp && window.SpaxelApp.showToast) {
window.SpaxelApp.showToast('Thank you for your feedback!', 'success');
}
}
})
.catch(function(err) {
console.error('[Feedback] Failed to submit:', err);
});
},
/**
* Show missed detection form
*/
showMissedDetectionForm: function() {
var modal = document.createElement('div');
modal.id = 'missed-detection-modal';
modal.className = 'missed-detection-modal';
modal.innerHTML = '\
<div class="missed-detection-card">\
<div class="missed-detection-header">\
<h2>Report Missed Detection</h2>\
<button class="modal-close" onclick="Feedback.closeMissedDetectionModal()">&times;</button>\
</div>\
<div class="missed-detection-form">\
<div class="form-group">\
<label>When did this happen?</label>\
<input type="datetime-local" id="missed-time">\
</div>\
<div class="form-group">\
<label>Where? (Zone)</label>\
<select id="missed-zone">\
<option value="">Select zone...</option>\
</select>\
</div>\
<div class="form-group">\
<label>Position (optional)</label>\
<div class="position-inputs">\
<input type="number" id="missed-x" placeholder="X" step="0.1">\
<input type="number" id="missed-y" placeholder="Y" step="0.1">\
<input type="number" id="missed-z" placeholder="Z" step="0.1">\
</div>\
</div>\
<div class="form-group">\
<label>Notes (optional)</label>\
<textarea id="missed-notes" placeholder="Any additional details..."></textarea>\
</div>\
<div class="form-actions">\
<button class="btn btn-secondary" onclick="Feedback.closeMissedDetectionModal()">Cancel</button>\
<button class="btn btn-primary" onclick="Feedback.submitMissedDetection()">Submit</button>\
</div>\
</div>\
</div>';
document.body.appendChild(modal);
// Set default time to now
var timeInput = document.getElementById('missed-time');
var now = new Date();
timeInput.value = now.toISOString().slice(0, 16);
// Populate zones
this.populateZoneSelector(document.getElementById('missed-zone'));
},
/**
* Close the missed detection modal
*/
closeMissedDetectionModal: function() {
var modal = document.getElementById('missed-detection-modal');
if (modal) {
modal.remove();
}
},
/**
* Submit missed detection report
*/
submitMissedDetection: function() {
var timeStr = document.getElementById('missed-time').value;
var zoneID = document.getElementById('missed-zone').value;
var posX = parseFloat(document.getElementById('missed-x').value) || 0;
var posY = parseFloat(document.getElementById('missed-y').value) || 0;
var posZ = parseFloat(document.getElementById('missed-z').value) || 0;
var notes = document.getElementById('missed-notes').value;
var details = {
zone_id: zoneID,
position_x: posX,
position_y: posY,
position_z: posZ,
user_reported: true
};
if (notes) {
details.notes = notes;
}
this.sendFeedback(
'', // No event ID for missed detections
this.EventTypes.BLOB_DETECTION,
this.FeedbackTypes.FALSE_NEGATIVE,
details
);
this.closeMissedDetectionModal();
},
/**
* Populate zone selector from API
*/
populateZoneSelector: function(select) {
if (!select) return;
fetch('/api/zones')
.then(function(res) { return res.json(); })
.then(function(zones) {
zones = zones || [];
zones.forEach(function(zone) {
var opt = document.createElement('option');
opt.value = zone.id;
opt.textContent = zone.name || zone.id;
select.appendChild(opt);
});
})
.catch(function(err) {
console.error('[Feedback] Failed to load zones:', err);
});
},
/**
* Create thumbs up/down buttons for an event
*/
createFeedbackButtons: function(eventID, eventType, eventTime, details) {
var container = document.createElement('div');
container.className = 'feedback-buttons';
var thumbsUp = document.createElement('button');
thumbsUp.className = 'feedback-btn-icon feedback-thumbs-up';
thumbsUp.innerHTML = '&#x1F44D;';
thumbsUp.title = 'Correct detection';
thumbsUp.onclick = function(e) {
e.stopPropagation();
Feedback.submitThumbsUp(eventID, eventType, details);
};
var thumbsDown = document.createElement('button');
thumbsDown.className = 'feedback-btn-icon feedback-thumbs-down';
thumbsDown.innerHTML = '&#x1F44E;';
thumbsDown.title = 'Incorrect detection';
thumbsDown.onclick = function(e) {
e.stopPropagation();
Feedback.showFeedbackPanel(eventID, eventType, eventTime, details);
};
container.appendChild(thumbsUp);
container.appendChild(thumbsDown);
return container;
},
/**
* Format event type for display
*/
formatEventType: function(eventType) {
var types = {
'blob_detection': 'Detection',
'zone_transition': 'Zone Change',
'fall_alert': 'Fall Alert',
'anomaly': 'Anomaly'
};
return types[eventType] || eventType;
},
/**
* Add CSS styles for feedback UI
*/
addStyles: function() {
if (document.getElementById('feedback-styles')) return;
var style = document.createElement('style');
style.id = 'feedback-styles';
style.textContent = '\
.feedback-panel {\
position: fixed;\
bottom: 100px;\
right: 20px;\
width: 320px;\
background: rgba(0, 0, 0, 0.95);\
border-radius: 8px;\
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);\
z-index: 200;\
font-size: 13px;\
}\
.feedback-header {\
display: flex;\
justify-content: space-between;\
align-items: center;\
padding: 12px 16px;\
border-bottom: 1px solid rgba(255, 255, 255, 0.1);\
}\
.feedback-title {\
font-weight: 600;\
color: #eee;\
}\
.feedback-close {\
background: none;\
border: none;\
color: #888;\
font-size: 20px;\
cursor: pointer;\
}\
.feedback-close:hover { color: #fff; }\
.feedback-content {\
padding: 16px;\
}\
.feedback-event-info {\
display: flex;\
justify-content: space-between;\
margin-bottom: 12px;\
font-size: 11px;\
color: #888;\
}\
.feedback-question {\
font-size: 13px;\
color: #ccc;\
margin-bottom: 10px;\
}\
.feedback-options {\
display: flex;\
flex-direction: column;\
gap: 8px;\
margin-bottom: 16px;\
}\
.feedback-option {\
display: flex;\
align-items: center;\
gap: 8px;\
cursor: pointer;\
padding: 6px 8px;\
border-radius: 4px;\
transition: background 0.2s;\
}\
.feedback-option:hover {\
background: rgba(255, 255, 255, 0.05);\
}\
.feedback-option input {\
margin: 0;\
}\
.feedback-option span {\
color: #bbb;\
font-size: 12px;\
}\
.feedback-notes {\
margin-bottom: 16px;\
}\
.feedback-notes label {\
display: block;\
font-size: 11px;\
color: #888;\
margin-bottom: 4px;\
}\
.feedback-notes textarea {\
width: 100%;\
height: 60px;\
background: rgba(255, 255, 255, 0.08);\
border: 1px solid rgba(255, 255, 255, 0.15);\
border-radius: 4px;\
color: #eee;\
font-size: 12px;\
padding: 8px;\
resize: none;\
box-sizing: border-box;\
}\
.feedback-actions {\
display: flex;\
justify-content: flex-end;\
gap: 8px;\
}\
.feedback-btn {\
padding: 6px 14px;\
border-radius: 4px;\
font-size: 12px;\
cursor: pointer;\
border: none;\
}\
.feedback-btn-cancel {\
background: rgba(255, 255, 255, 0.1);\
color: #ccc;\
}\
.feedback-btn-submit {\
background: #4fc3f7;\
color: #1a1a2e;\
font-weight: 500;\
}\
.feedback-btn-icon {\
background: rgba(255, 255, 255, 0.1);\
border: none;\
width: 28px;\
height: 28px;\
border-radius: 4px;\
cursor: pointer;\
font-size: 14px;\
display: flex;\
align-items: center;\
justify-content: center;\
transition: background 0.2s;\
}\
.feedback-btn-icon:hover {\
background: rgba(255, 255, 255, 0.2);\
}\
.feedback-thumbs-up:hover {\
background: rgba(76, 175, 80, 0.3);\
}\
.feedback-thumbs-down:hover {\
background: rgba(244, 67, 54, 0.3);\
}\
.feedback-buttons {\
display: inline-flex;\
gap: 4px;\
}\
.missed-detection-btn {\
position: fixed;\
bottom: 20px;\
right: 440px;\
background: rgba(255, 167, 38, 0.2);\
border: 1px solid rgba(255, 167, 38, 0.5);\
color: #ffa726;\
padding: 6px 12px;\
border-radius: 4px;\
font-size: 11px;\
cursor: pointer;\
z-index: 100;\
transition: background 0.2s;\
}\
.missed-detection-btn:hover {\
background: rgba(255, 167, 38, 0.3);\
}\
.missed-detection-modal {\
position: fixed;\
top: 0;\
left: 0;\
right: 0;\
bottom: 0;\
background: rgba(0, 0, 0, 0.8);\
display: flex;\
align-items: center;\
justify-content: center;\
z-index: 300;\
}\
.missed-detection-card {\
background: #1e1e3a;\
border-radius: 12px;\
padding: 24px;\
width: 400px;\
max-width: 90%;\
}\
.missed-detection-header {\
display: flex;\
justify-content: space-between;\
align-items: center;\
margin-bottom: 16px;\
}\
.missed-detection-header h2 {\
font-size: 16px;\
color: #eee;\
margin: 0;\
}\
.modal-close {\
background: none;\
border: none;\
color: #888;\
font-size: 24px;\
cursor: pointer;\
}\
.modal-close:hover { color: #fff; }\
.missed-detection-form .form-group {\
margin-bottom: 14px;\
}\
.missed-detection-form label {\
display: block;\
font-size: 12px;\
color: #888;\
margin-bottom: 4px;\
}\
.missed-detection-form input,\
.missed-detection-form select,\
.missed-detection-form textarea {\
width: 100%;\
padding: 8px 10px;\
background: rgba(255, 255, 255, 0.08);\
border: 1px solid rgba(255, 255, 255, 0.15);\
border-radius: 4px;\
color: #eee;\
font-size: 13px;\
box-sizing: border-box;\
}\
.position-inputs {\
display: flex;\
gap: 8px;\
}\
.position-inputs input {\
flex: 1;\
}\
.missed-detection-form textarea {\
height: 60px;\
resize: none;\
}\
.form-actions {\
display: flex;\
justify-content: flex-end;\
gap: 10px;\
margin-top: 20px;\
}\
.btn {\
padding: 8px 16px;\
border-radius: 4px;\
font-size: 13px;\
cursor: pointer;\
border: none;\
}\
.btn-secondary {\
background: rgba(255, 255, 255, 0.1);\
color: #ccc;\
}\
.btn-primary {\
background: #4fc3f7;\
color: #1a1a2e;\
font-weight: 500;\
}';
document.head.appendChild(style);
}
};
// Expose globally
window.Feedback = Feedback;
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() { Feedback.init(); });
} else {
Feedback.init();
}
})();