- 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>
646 lines
24 KiB
JavaScript
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()">×</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 = '⚠ 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()">×</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 = '👍';
|
|
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 = '👎';
|
|
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();
|
|
}
|
|
})();
|