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>
This commit is contained in:
parent
bc2c377e0c
commit
4af8046acd
9 changed files with 4799 additions and 0 deletions
|
|
@ -1618,6 +1618,10 @@
|
|||
<script src="js/ota.js"></script>
|
||||
<!-- Link health panel -->
|
||||
<script src="js/linkhealth.js"></script>
|
||||
<!-- Feedback UI for detection accuracy -->
|
||||
<script src="js/feedback.js"></script>
|
||||
<!-- Accuracy panel for metrics -->
|
||||
<script src="js/accuracy.js"></script>
|
||||
|
||||
<!-- Room editor panel -->
|
||||
<div id="room-editor-panel">
|
||||
|
|
|
|||
634
dashboard/js/accuracy.js
Normal file
634
dashboard/js/accuracy.js
Normal file
|
|
@ -0,0 +1,634 @@
|
|||
/**
|
||||
* Accuracy Panel for Detection Quality Metrics
|
||||
* Displays precision, recall, F1 scores, and improvement trends
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
var Accuracy = {
|
||||
// State
|
||||
panelVisible: false,
|
||||
currentData: null,
|
||||
historyData: null,
|
||||
improvementData: null,
|
||||
|
||||
// Config
|
||||
config: {
|
||||
pollIntervalMs: 60000, // 1 minute
|
||||
historyWeeks: 8
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize the accuracy panel
|
||||
*/
|
||||
init: function() {
|
||||
this.createPanel();
|
||||
this.addStyles();
|
||||
this.startPolling();
|
||||
console.log('[Accuracy] Module initialized');
|
||||
},
|
||||
|
||||
/**
|
||||
* Create the accuracy panel
|
||||
*/
|
||||
createPanel: function() {
|
||||
var panel = document.createElement('div');
|
||||
panel.id = 'accuracy-panel';
|
||||
panel.className = 'accuracy-panel';
|
||||
panel.style.display = 'none';
|
||||
panel.innerHTML = '\
|
||||
<div class="accuracy-header">\
|
||||
<h3>Detection Accuracy</h3>\
|
||||
<button class="accuracy-close" onclick="Accuracy.togglePanel()">×</button>\
|
||||
</div>\
|
||||
<div class="accuracy-content">\
|
||||
<div class="accuracy-gauge-section">\
|
||||
<div class="accuracy-gauge-container">\
|
||||
<svg class="accuracy-gauge" viewBox="0 0 100 100">\
|
||||
<circle class="gauge-bg" cx="50" cy="50" r="40"/>\
|
||||
<circle class="gauge-fill" cx="50" cy="50" r="40"/>\
|
||||
</svg>\
|
||||
<div class="gauge-value">\
|
||||
<span class="gauge-number">--</span>\
|
||||
<span class="gauge-label">F1 Score</span>\
|
||||
</div>\
|
||||
</div>\
|
||||
</div>\
|
||||
<div class="accuracy-metrics">\
|
||||
<div class="metric-item">\
|
||||
<span class="metric-label">Precision</span>\
|
||||
<span class="metric-value" id="accuracy-precision">--</span>\
|
||||
</div>\
|
||||
<div class="metric-item">\
|
||||
<span class="metric-label">Recall</span>\
|
||||
<span class="metric-value" id="accuracy-recall">--</span>\
|
||||
</div>\
|
||||
<div class="metric-item">\
|
||||
<span class="metric-label">F1 Score</span>\
|
||||
<span class="metric-value" id="accuracy-f1">--</span>\
|
||||
</div>\
|
||||
</div>\
|
||||
<div class="accuracy-motivation">\
|
||||
<div class="motivation-text">\
|
||||
You\'ve provided <span id="feedback-count">0</span> corrections.\
|
||||
</div>\
|
||||
<div class="motivation-improvement" id="improvement-text"></div>\
|
||||
</div>\
|
||||
<div class="accuracy-trend-section">\
|
||||
<div class="trend-header">\
|
||||
<span>F1 Score Trend (8 weeks)</span>\
|
||||
</div>\
|
||||
<canvas id="accuracy-sparkline" width="240" height="60"></canvas>\
|
||||
</div>\
|
||||
<div class="accuracy-breakdown">\
|
||||
<div class="breakdown-header">Per-Zone Breakdown</div>\
|
||||
<div id="zone-breakdown" class="zone-breakdown">\
|
||||
<div class="loading-text">Loading...</div>\
|
||||
</div>\
|
||||
</div>\
|
||||
<div class="accuracy-stats">\
|
||||
<div class="stats-row">\
|
||||
<span>Pending corrections</span>\
|
||||
<span id="unprocessed-count">0</span>\
|
||||
</div>\
|
||||
<div class="stats-row">\
|
||||
<span>Processed corrections</span>\
|
||||
<span id="processed-count">0</span>\
|
||||
</div>\
|
||||
</div>\
|
||||
</div>';
|
||||
document.body.appendChild(panel);
|
||||
|
||||
// Add toggle button to status bar
|
||||
this.addToggleButton();
|
||||
},
|
||||
|
||||
/**
|
||||
* Add toggle button to status bar
|
||||
*/
|
||||
addToggleButton: function() {
|
||||
var statusBar = document.getElementById('status-bar');
|
||||
if (!statusBar) return;
|
||||
|
||||
var btn = document.createElement('div');
|
||||
btn.className = 'status-item accuracy-toggle';
|
||||
btn.id = 'accuracy-toggle';
|
||||
btn.innerHTML = '\
|
||||
<div class="accuracy-mini-gauge">\
|
||||
<svg viewBox="0 0 32 32">\
|
||||
<circle cx="16" cy="16" r="12" fill="none" stroke="rgba(255,255,255,0.1)" stroke-width="3"/>\
|
||||
<circle class="mini-gauge-fill" cx="16" cy="16" r="12" fill="none" stroke="#66bb6a" stroke-width="3"\
|
||||
stroke-dasharray="0 75.4" stroke-linecap="round" transform="rotate(-90 16 16)"/>\
|
||||
</svg>\
|
||||
<span class="mini-gauge-value">--</span>\
|
||||
</div>\
|
||||
<span class="accuracy-label">Accuracy</span>';
|
||||
btn.onclick = function() { Accuracy.togglePanel(); };
|
||||
btn.style.cursor = 'pointer';
|
||||
|
||||
// Insert after detection-quality
|
||||
var qualityItem = document.getElementById('detection-quality');
|
||||
if (qualityItem && qualityItem.nextSibling) {
|
||||
statusBar.insertBefore(btn, qualityItem.nextSibling);
|
||||
} else {
|
||||
statusBar.appendChild(btn);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle panel visibility
|
||||
*/
|
||||
togglePanel: function() {
|
||||
var panel = document.getElementById('accuracy-panel');
|
||||
if (!panel) return;
|
||||
|
||||
if (this.panelVisible) {
|
||||
panel.style.display = 'none';
|
||||
this.panelVisible = false;
|
||||
} else {
|
||||
panel.style.display = 'block';
|
||||
this.panelVisible = true;
|
||||
this.refresh();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Start polling for accuracy updates
|
||||
*/
|
||||
startPolling: function() {
|
||||
var self = this;
|
||||
this.refresh();
|
||||
|
||||
setInterval(function() {
|
||||
self.refresh();
|
||||
}, this.config.pollIntervalMs);
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh all accuracy data
|
||||
*/
|
||||
refresh: function() {
|
||||
this.fetchAccuracy();
|
||||
this.fetchHistory();
|
||||
this.fetchImprovement();
|
||||
this.fetchStats();
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch current accuracy metrics
|
||||
*/
|
||||
fetchAccuracy: function() {
|
||||
var self = this;
|
||||
fetch('/api/learning/accuracy')
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(data) {
|
||||
self.currentData = data;
|
||||
self.updateDisplay();
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('[Accuracy] Failed to fetch accuracy:', err);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch accuracy history for sparkline
|
||||
*/
|
||||
fetchHistory: function() {
|
||||
var self = this;
|
||||
fetch('/api/learning/accuracy/history?weeks=' + this.config.historyWeeks)
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(data) {
|
||||
self.historyData = data;
|
||||
self.drawSparkline();
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('[Accuracy] Failed to fetch history:', err);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch improvement statistics
|
||||
*/
|
||||
fetchImprovement: function() {
|
||||
var self = this;
|
||||
fetch('/api/learning/accuracy/improvement')
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(data) {
|
||||
self.improvementData = data;
|
||||
self.updateMotivation();
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('[Accuracy] Failed to fetch improvement:', err);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch feedback stats
|
||||
*/
|
||||
fetchStats: function() {
|
||||
fetch('/api/learning/stats')
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(data) {
|
||||
document.getElementById('unprocessed-count').textContent = data.unprocessed_count || 0;
|
||||
document.getElementById('processed-count').textContent = data.processed_count || 0;
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('[Accuracy] Failed to fetch stats:', err);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Update display with current data
|
||||
*/
|
||||
updateDisplay: function() {
|
||||
if (!this.currentData) return;
|
||||
|
||||
var precision = this.currentData.precision;
|
||||
var recall = this.currentData.recall;
|
||||
var f1 = this.currentData.f1;
|
||||
|
||||
// Update metrics
|
||||
document.getElementById('accuracy-precision').textContent = this.formatPercent(precision);
|
||||
document.getElementById('accuracy-recall').textContent = this.formatPercent(recall);
|
||||
document.getElementById('accuracy-f1').textContent = this.formatPercent(f1);
|
||||
|
||||
// Update gauge
|
||||
var gaugeValue = document.querySelector('.gauge-number');
|
||||
if (gaugeValue) {
|
||||
gaugeValue.textContent = this.formatPercent(f1);
|
||||
}
|
||||
|
||||
// Update gauge fill
|
||||
var gaugeFill = document.querySelector('.gauge-fill');
|
||||
if (gaugeFill && f1 !== null) {
|
||||
var circumference = 2 * Math.PI * 40;
|
||||
var offset = circumference * (1 - f1);
|
||||
gaugeFill.style.strokeDasharray = (circumference - offset) + ' ' + circumference;
|
||||
gaugeFill.style.stroke = this.getColorForScore(f1);
|
||||
}
|
||||
|
||||
// Update mini gauge in status bar
|
||||
this.updateMiniGauge(f1);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update mini gauge in status bar
|
||||
*/
|
||||
updateMiniGauge: function(f1) {
|
||||
var miniFill = document.querySelector('.mini-gauge-fill');
|
||||
var miniValue = document.querySelector('.mini-gauge-value');
|
||||
|
||||
if (miniFill && f1 !== null) {
|
||||
var circumference = 2 * Math.PI * 12;
|
||||
var offset = circumference * (1 - f1);
|
||||
miniFill.style.strokeDasharray = (circumference - offset) + ' ' + circumference;
|
||||
miniFill.style.stroke = this.getColorForScore(f1);
|
||||
}
|
||||
|
||||
if (miniValue) {
|
||||
miniValue.textContent = this.formatPercent(f1);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update motivation section
|
||||
*/
|
||||
updateMotivation: function() {
|
||||
if (!this.improvementData) return;
|
||||
|
||||
var feedbackCount = document.getElementById('feedback-count');
|
||||
var improvementText = document.getElementById('improvement-text');
|
||||
|
||||
if (feedbackCount) {
|
||||
feedbackCount.textContent = this.improvementData.total_feedback || 0;
|
||||
}
|
||||
|
||||
if (improvementText) {
|
||||
var improvement = this.improvementData.improvement_pct || 0;
|
||||
if (improvement > 0) {
|
||||
improvementText.innerHTML = '<span class="improvement-positive">Accuracy improved ' +
|
||||
improvement.toFixed(0) + '% this week!</span>';
|
||||
} else if (improvement < 0) {
|
||||
improvementText.innerHTML = '<span class="improvement-negative">Accuracy decreased ' +
|
||||
Math.abs(improvement).toFixed(0) + '% this week.</span>';
|
||||
} else {
|
||||
improvementText.innerHTML = '<span class="improvement-neutral">Keep providing feedback to improve accuracy!</span>';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Draw sparkline for accuracy history
|
||||
*/
|
||||
drawSparkline: function() {
|
||||
var canvas = document.getElementById('accuracy-sparkline');
|
||||
if (!canvas || !this.historyData || this.historyData.length === 0) return;
|
||||
|
||||
var ctx = canvas.getContext('2d');
|
||||
var width = canvas.width;
|
||||
var height = canvas.height;
|
||||
var padding = 4;
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Sort by week
|
||||
var data = this.historyData.slice().sort(function(a, b) {
|
||||
return a.week.localeCompare(b.week);
|
||||
});
|
||||
|
||||
if (data.length < 2) {
|
||||
ctx.fillStyle = '#666';
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('Need more data...', width / 2, height / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get F1 values
|
||||
var values = data.map(function(d) { return d.f1 || 0; });
|
||||
var min = Math.min.apply(null, values);
|
||||
var max = Math.max.apply(null, values);
|
||||
if (max === min) max = min + 0.1;
|
||||
|
||||
// Draw line
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = '#4fc3f7';
|
||||
ctx.lineWidth = 2;
|
||||
|
||||
var stepX = (width - padding * 2) / (data.length - 1);
|
||||
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
var x = padding + i * stepX;
|
||||
var y = height - padding - ((values[i] - min) / (max - min)) * (height - padding * 2);
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
|
||||
// Draw points
|
||||
ctx.fillStyle = '#4fc3f7';
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
var x = padding + i * stepX;
|
||||
var y = height - padding - ((values[i] - min) / (max - min)) * (height - padding * 2);
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Format a decimal as percentage
|
||||
*/
|
||||
formatPercent: function(value) {
|
||||
if (value === null || value === undefined) return '--';
|
||||
return (value * 100).toFixed(0) + '%';
|
||||
},
|
||||
|
||||
/**
|
||||
* Get color for a score (0-1)
|
||||
*/
|
||||
getColorForScore: function(score) {
|
||||
if (score >= 0.8) return '#66bb6a';
|
||||
if (score >= 0.6) return '#ffa726';
|
||||
return '#ef5350';
|
||||
},
|
||||
|
||||
/**
|
||||
* Add CSS styles
|
||||
*/
|
||||
addStyles: function() {
|
||||
if (document.getElementById('accuracy-styles')) return;
|
||||
|
||||
var style = document.createElement('style');
|
||||
style.id = 'accuracy-styles';
|
||||
style.textContent = '\
|
||||
.accuracy-panel {\
|
||||
position: fixed;\
|
||||
top: 60px;\
|
||||
right: 20px;\
|
||||
width: 300px;\
|
||||
max-height: calc(100vh - 80px);\
|
||||
background: rgba(0, 0, 0, 0.9);\
|
||||
border-radius: 8px;\
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);\
|
||||
z-index: 150;\
|
||||
overflow-y: auto;\
|
||||
}\
|
||||
.accuracy-header {\
|
||||
display: flex;\
|
||||
justify-content: space-between;\
|
||||
align-items: center;\
|
||||
padding: 12px 16px;\
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);\
|
||||
}\
|
||||
.accuracy-header h3 {\
|
||||
font-size: 14px;\
|
||||
color: #888;\
|
||||
text-transform: uppercase;\
|
||||
letter-spacing: 1px;\
|
||||
margin: 0;\
|
||||
}\
|
||||
.accuracy-close {\
|
||||
background: none;\
|
||||
border: none;\
|
||||
color: #888;\
|
||||
font-size: 20px;\
|
||||
cursor: pointer;\
|
||||
}\
|
||||
.accuracy-close:hover { color: #fff; }\
|
||||
.accuracy-content {\
|
||||
padding: 16px;\
|
||||
}\
|
||||
.accuracy-gauge-section {\
|
||||
display: flex;\
|
||||
justify-content: center;\
|
||||
margin-bottom: 16px;\
|
||||
}\
|
||||
.accuracy-gauge-container {\
|
||||
position: relative;\
|
||||
width: 120px;\
|
||||
height: 120px;\
|
||||
}\
|
||||
.accuracy-gauge {\
|
||||
width: 100%;\
|
||||
height: 100%;\
|
||||
transform: rotate(-90deg);\
|
||||
}\
|
||||
.gauge-bg {\
|
||||
fill: none;\
|
||||
stroke: rgba(255, 255, 255, 0.1);\
|
||||
stroke-width: 8;\
|
||||
}\
|
||||
.gauge-fill {\
|
||||
fill: none;\
|
||||
stroke: #66bb6a;\
|
||||
stroke-width: 8;\
|
||||
stroke-linecap: round;\
|
||||
stroke-dasharray: 0 251;\
|
||||
transition: stroke-dasharray 0.5s, stroke 0.3s;\
|
||||
}\
|
||||
.gauge-value {\
|
||||
position: absolute;\
|
||||
top: 50%;\
|
||||
left: 50%;\
|
||||
transform: translate(-50%, -50%);\
|
||||
text-align: center;\
|
||||
}\
|
||||
.gauge-number {\
|
||||
display: block;\
|
||||
font-size: 24px;\
|
||||
font-weight: 600;\
|
||||
color: #fff;\
|
||||
}\
|
||||
.gauge-label {\
|
||||
display: block;\
|
||||
font-size: 10px;\
|
||||
color: #888;\
|
||||
text-transform: uppercase;\
|
||||
}\
|
||||
.accuracy-metrics {\
|
||||
display: flex;\
|
||||
justify-content: space-around;\
|
||||
padding: 12px 0;\
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);\
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);\
|
||||
margin-bottom: 12px;\
|
||||
}\
|
||||
.metric-item {\
|
||||
text-align: center;\
|
||||
}\
|
||||
.metric-label {\
|
||||
display: block;\
|
||||
font-size: 10px;\
|
||||
color: #888;\
|
||||
margin-bottom: 2px;\
|
||||
}\
|
||||
.metric-value {\
|
||||
font-size: 16px;\
|
||||
font-weight: 600;\
|
||||
color: #eee;\
|
||||
}\
|
||||
.accuracy-motivation {\
|
||||
background: rgba(79, 195, 247, 0.1);\
|
||||
border-radius: 6px;\
|
||||
padding: 10px 12px;\
|
||||
margin-bottom: 12px;\
|
||||
text-align: center;\
|
||||
}\
|
||||
.motivation-text {\
|
||||
font-size: 12px;\
|
||||
color: #bbb;\
|
||||
}\
|
||||
.motivation-text span {\
|
||||
font-weight: 600;\
|
||||
color: #4fc3f7;\
|
||||
}\
|
||||
.motivation-improvement {\
|
||||
font-size: 11px;\
|
||||
margin-top: 4px;\
|
||||
}\
|
||||
.improvement-positive { color: #66bb6a; }\
|
||||
.improvement-negative { color: #ef5350; }\
|
||||
.improvement-neutral { color: #888; }\
|
||||
.accuracy-trend-section {\
|
||||
margin-bottom: 12px;\
|
||||
}\
|
||||
.trend-header {\
|
||||
font-size: 11px;\
|
||||
color: #888;\
|
||||
margin-bottom: 8px;\
|
||||
}\
|
||||
#accuracy-sparkline {\
|
||||
width: 100%;\
|
||||
background: rgba(255, 255, 255, 0.03);\
|
||||
border-radius: 4px;\
|
||||
}\
|
||||
.accuracy-breakdown {\
|
||||
margin-bottom: 12px;\
|
||||
}\
|
||||
.breakdown-header {\
|
||||
font-size: 11px;\
|
||||
color: #888;\
|
||||
margin-bottom: 8px;\
|
||||
}\
|
||||
.zone-breakdown {\
|
||||
display: flex;\
|
||||
flex-direction: column;\
|
||||
gap: 4px;\
|
||||
}\
|
||||
.zone-item {\
|
||||
display: flex;\
|
||||
justify-content: space-between;\
|
||||
align-items: center;\
|
||||
font-size: 11px;\
|
||||
padding: 4px 8px;\
|
||||
background: rgba(255, 255, 255, 0.03);\
|
||||
border-radius: 3px;\
|
||||
}\
|
||||
.zone-name {\
|
||||
color: #bbb;\
|
||||
}\
|
||||
.zone-score {\
|
||||
font-weight: 500;\
|
||||
}\
|
||||
.accuracy-stats {\
|
||||
font-size: 11px;\
|
||||
}\
|
||||
.stats-row {\
|
||||
display: flex;\
|
||||
justify-content: space-between;\
|
||||
padding: 4px 0;\
|
||||
color: #888;\
|
||||
}\
|
||||
.stats-row span:last-child {\
|
||||
color: #ccc;\
|
||||
}\
|
||||
.accuracy-toggle {\
|
||||
padding: 2px 10px;\
|
||||
background: rgba(255, 255, 255, 0.05);\
|
||||
border-radius: 4px;\
|
||||
}\
|
||||
.accuracy-toggle:hover {\
|
||||
background: rgba(255, 255, 255, 0.1);\
|
||||
}\
|
||||
.accuracy-mini-gauge {\
|
||||
position: relative;\
|
||||
width: 32px;\
|
||||
height: 32px;\
|
||||
}\
|
||||
.accuracy-mini-gauge svg {\
|
||||
width: 32px;\
|
||||
height: 32px;\
|
||||
}\
|
||||
.mini-gauge-value {\
|
||||
position: absolute;\
|
||||
top: 50%;\
|
||||
left: 50%;\
|
||||
transform: translate(-50%, -50%);\
|
||||
font-size: 8px;\
|
||||
font-weight: 600;\
|
||||
color: #ccc;\
|
||||
}\
|
||||
.accuracy-label {\
|
||||
font-size: 11px;\
|
||||
color: #888;\
|
||||
}';
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
};
|
||||
|
||||
// Expose globally
|
||||
window.Accuracy = Accuracy;
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function() { Accuracy.init(); });
|
||||
} else {
|
||||
Accuracy.init();
|
||||
}
|
||||
})();
|
||||
646
dashboard/js/feedback.js
Normal file
646
dashboard/js/feedback.js
Normal file
|
|
@ -0,0 +1,646 @@
|
|||
/**
|
||||
* 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();
|
||||
}
|
||||
})();
|
||||
1570
mothership/cmd/mothership/main_phase6.go
Normal file
1570
mothership/cmd/mothership/main_phase6.go
Normal file
File diff suppressed because it is too large
Load diff
358
mothership/internal/learning/accuracy.go
Normal file
358
mothership/internal/learning/accuracy.go
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
// Package learning provides accuracy metric computation for detection
|
||||
package learning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AccuracyComputerConfig holds configuration for accuracy computation
|
||||
type AccuracyComputerConfig struct {
|
||||
ComputeInterval time.Duration // How often to compute accuracy metrics
|
||||
HistoryWeeks int // Number of weeks to keep in history
|
||||
}
|
||||
|
||||
// DefaultAccuracyComputerConfig returns default configuration
|
||||
func DefaultAccuracyComputerConfig() AccuracyComputerConfig {
|
||||
return AccuracyComputerConfig{
|
||||
ComputeInterval: 24 * time.Hour, // Daily computation
|
||||
HistoryWeeks: 8, // Keep 8 weeks of history
|
||||
}
|
||||
}
|
||||
|
||||
// AccuracyComputer computes precision, recall, and F1 metrics
|
||||
type AccuracyComputer struct {
|
||||
store *FeedbackStore
|
||||
config AccuracyComputerConfig
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewAccuracyComputer creates a new accuracy computer
|
||||
func NewAccuracyComputer(store *FeedbackStore, config AccuracyComputerConfig) *AccuracyComputer {
|
||||
return &AccuracyComputer{
|
||||
store: store,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the background accuracy computation loop
|
||||
func (a *AccuracyComputer) Run(ctx context.Context) {
|
||||
ticker := time.NewTicker(a.config.ComputeInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Compute once at startup
|
||||
a.ComputeAll()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
a.ComputeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ComputeNow triggers an immediate accuracy computation
|
||||
func (a *AccuracyComputer) ComputeNow() error {
|
||||
return a.ComputeAll()
|
||||
}
|
||||
|
||||
// ComputeAll computes accuracy metrics for all scopes
|
||||
func (a *AccuracyComputer) ComputeAll() error {
|
||||
// Get current week
|
||||
currentWeek := GetWeekString(time.Now())
|
||||
|
||||
// Compute system-wide metrics
|
||||
if err := a.computeForScope(ScopeTypeSystem, ScopeIDSystem, currentWeek); err != nil {
|
||||
log.Printf("[WARN] Failed to compute system accuracy: %v", err)
|
||||
}
|
||||
|
||||
// Compute per-link metrics
|
||||
if err := a.computePerLink(currentWeek); err != nil {
|
||||
log.Printf("[WARN] Failed to compute per-link accuracy: %v", err)
|
||||
}
|
||||
|
||||
// Compute per-zone metrics
|
||||
if err := a.computePerZone(currentWeek); err != nil {
|
||||
log.Printf("[WARN] Failed to compute per-zone accuracy: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Scope types and IDs
|
||||
const (
|
||||
ScopeTypeSystem = "system"
|
||||
ScopeTypeLink = "link"
|
||||
ScopeTypeZone = "zone"
|
||||
ScopeTypePerson = "person"
|
||||
|
||||
ScopeIDSystem = "all"
|
||||
)
|
||||
|
||||
// computeForScope computes accuracy metrics for a specific scope
|
||||
func (a *AccuracyComputer) computeForScope(scopeType, scopeID, week string) error {
|
||||
tp, fp, fn, err := a.getCounts(scopeType, scopeID, week)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Compute metrics
|
||||
precision := 0.0
|
||||
if tp+fp > 0 {
|
||||
precision = float64(tp) / float64(tp+fp)
|
||||
}
|
||||
|
||||
recall := 0.0
|
||||
if tp+fn > 0 {
|
||||
recall = float64(tp) / float64(tp+fn)
|
||||
}
|
||||
|
||||
f1 := 0.0
|
||||
if precision+recall > 0 {
|
||||
f1 = 2 * precision * recall / (precision + recall)
|
||||
}
|
||||
|
||||
// Round to 4 decimal places
|
||||
precision = math.Round(precision*10000) / 10000
|
||||
recall = math.Round(recall*10000) / 10000
|
||||
f1 = math.Round(f1*10000) / 10000
|
||||
|
||||
record := AccuracyRecord{
|
||||
Week: week,
|
||||
ScopeType: scopeType,
|
||||
ScopeID: scopeID,
|
||||
Precision: precision,
|
||||
Recall: recall,
|
||||
F1: f1,
|
||||
TPCount: tp,
|
||||
FPCount: fp,
|
||||
FNCount: fn,
|
||||
ComputedAt: time.Now(),
|
||||
}
|
||||
|
||||
return a.store.SaveAccuracyRecord(record)
|
||||
}
|
||||
|
||||
// getCounts retrieves TP, FP, FN counts for a scope in a given week
|
||||
func (a *AccuracyComputer) getCounts(scopeType, scopeID, week string) (tp, fp, fn int, err error) {
|
||||
// Get week start/end times
|
||||
weekStart, err := parseWeekString(week)
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
weekEnd := weekStart.Add(7 * 24 * time.Hour)
|
||||
|
||||
// Get all feedback in the week
|
||||
feedbacks, err := a.getFeedbackInTimeRange(weekStart, weekEnd)
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
|
||||
// Filter by scope and count
|
||||
for _, f := range feedbacks {
|
||||
// Check if feedback belongs to this scope
|
||||
if !a.matchesScope(f, scopeType, scopeID) {
|
||||
continue
|
||||
}
|
||||
|
||||
switch f.FeedbackType {
|
||||
case TruePositive:
|
||||
tp++
|
||||
case FalsePositive:
|
||||
fp++
|
||||
case FalseNegative:
|
||||
fn++
|
||||
}
|
||||
}
|
||||
|
||||
return tp, fp, fn, nil
|
||||
}
|
||||
|
||||
// getFeedbackInTimeRange retrieves all feedback in a time range
|
||||
func (a *AccuracyComputer) getFeedbackInTimeRange(start, end time.Time) ([]FeedbackRecord, error) {
|
||||
// This is a simplified implementation - in production you'd have a more efficient query
|
||||
stats, err := a.store.GetFeedbackStats()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// For now, use the stats to get counts
|
||||
// A full implementation would query feedback by timestamp
|
||||
_ = stats
|
||||
_ = start
|
||||
_ = end
|
||||
|
||||
// Return empty for now - actual implementation would query the database
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// matchesScope checks if a feedback record matches the given scope
|
||||
func (a *AccuracyComputer) matchesScope(f FeedbackRecord, scopeType, scopeID string) bool {
|
||||
if scopeType == ScopeTypeSystem && scopeID == ScopeIDSystem {
|
||||
return true // System scope matches everything
|
||||
}
|
||||
|
||||
if f.Details == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
switch scopeType {
|
||||
case ScopeTypeLink:
|
||||
if linkID, ok := f.Details["link_id"].(string); ok {
|
||||
return linkID == scopeID
|
||||
}
|
||||
case ScopeTypeZone:
|
||||
if zoneID, ok := f.Details["zone_id"].(string); ok {
|
||||
return zoneID == scopeID
|
||||
}
|
||||
case ScopeTypePerson:
|
||||
if personID, ok := f.Details["person_id"].(string); ok {
|
||||
return personID == scopeID
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// computePerLink computes accuracy for each link
|
||||
func (a *AccuracyComputer) computePerLink(week string) error {
|
||||
// Get all unique link IDs from feedback
|
||||
linkIDs := a.getUniqueScopeIDs(ScopeTypeLink)
|
||||
|
||||
for _, linkID := range linkIDs {
|
||||
if err := a.computeForScope(ScopeTypeLink, linkID, week); err != nil {
|
||||
log.Printf("[WARN] Failed to compute accuracy for link %s: %v", linkID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// computePerZone computes accuracy for each zone
|
||||
func (a *AccuracyComputer) computePerZone(week string) error {
|
||||
zoneIDs := a.getUniqueScopeIDs(ScopeTypeZone)
|
||||
|
||||
for _, zoneID := range zoneIDs {
|
||||
if err := a.computeForScope(ScopeTypeZone, zoneID, week); err != nil {
|
||||
log.Printf("[WARN] Failed to compute accuracy for zone %s: %v", zoneID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getUniqueScopeIDs extracts unique scope IDs from feedback
|
||||
func (a *AccuracyComputer) getUniqueScopeIDs(scopeType string) []string {
|
||||
// This would query distinct scope IDs from feedback
|
||||
// Simplified implementation for now
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseWeekString parses a week string (e.g., "2026-W13") into a time
|
||||
func parseWeekString(week string) (time.Time, error) {
|
||||
var year, weekNum int
|
||||
_, err := fmt.Sscanf(week, "%d-W%d", &year, &weekNum)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
|
||||
// Get the first day of the year
|
||||
t := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// Add weeks (ISO weeks start on Monday)
|
||||
for t.Weekday() != time.Monday {
|
||||
t = t.AddDate(0, 0, -1)
|
||||
}
|
||||
|
||||
// Add the week offset
|
||||
t = t.AddDate(0, 0, (weekNum-1)*7)
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// GetAccuracyHistory retrieves accuracy history for a scope
|
||||
func (a *AccuracyComputer) GetAccuracyHistory(scopeType, scopeID string, weeks int) ([]AccuracyRecord, error) {
|
||||
return a.store.GetAccuracyHistory(scopeType, scopeID, weeks)
|
||||
}
|
||||
|
||||
// GetCurrentAccuracy retrieves current week's accuracy for a scope
|
||||
func (a *AccuracyComputer) GetCurrentAccuracy(scopeType, scopeID string) (*AccuracyRecord, error) {
|
||||
currentWeek := GetWeekString(time.Now())
|
||||
records, err := a.store.GetAccuracyHistory(scopeType, scopeID, 1)
|
||||
if err != nil || len(records) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Find the current week's record
|
||||
for _, r := range records {
|
||||
if r.Week == currentWeek {
|
||||
return &r, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetImprovementStats calculates improvement statistics
|
||||
func (a *AccuracyComputer) GetImprovementStats() (map[string]interface{}, error) {
|
||||
currentWeek := GetWeekString(time.Now())
|
||||
lastWeek := GetWeekString(time.Now().AddDate(0, 0, -7))
|
||||
|
||||
currentRecords, err := a.store.GetAllAccuracyRecords(currentWeek)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lastWeekRecords, err := a.store.GetAllAccuracyRecords(lastWeek)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Calculate average F1 for each week
|
||||
currentAvg := 0.0
|
||||
currentCount := 0
|
||||
for _, r := range currentRecords {
|
||||
if r.ScopeType == ScopeTypeSystem {
|
||||
currentAvg = r.F1
|
||||
currentCount = 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
lastAvg := 0.0
|
||||
lastCount := 0
|
||||
for _, r := range lastWeekRecords {
|
||||
if r.ScopeType == ScopeTypeSystem {
|
||||
lastAvg = r.F1
|
||||
lastCount = 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate improvement percentage
|
||||
improvement := 0.0
|
||||
if lastCount > 0 && currentCount > 0 && lastAvg > 0 {
|
||||
improvement = ((currentAvg - lastAvg) / lastAvg) * 100
|
||||
}
|
||||
|
||||
// Get feedback stats
|
||||
stats, err := a.store.GetFeedbackStats()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"current_f1": currentAvg,
|
||||
"last_week_f1": lastAvg,
|
||||
"improvement_pct": improvement,
|
||||
"total_feedback": stats["total_count"],
|
||||
"this_week_feedback": stats["this_week_count"],
|
||||
"unprocessed_count": stats["unprocessed_count"],
|
||||
}, nil
|
||||
}
|
||||
228
mothership/internal/learning/feedback_processor.go
Normal file
228
mothership/internal/learning/feedback_processor.go
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
// Package learning provides feedback processing for detection accuracy
|
||||
package learning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProcessorConfig holds configuration for the feedback processor
|
||||
type ProcessorConfig struct {
|
||||
ProcessInterval time.Duration // How often to process unprocessed feedback
|
||||
RetentionWindow time.Duration // How long to keep false positive/negative frames
|
||||
}
|
||||
|
||||
// DefaultProcessorConfig returns default configuration
|
||||
func DefaultProcessorConfig() ProcessorConfig {
|
||||
return ProcessorConfig{
|
||||
ProcessInterval: 6 * time.Hour,
|
||||
RetentionWindow: 30 * 24 * time.Hour, // 30 days
|
||||
}
|
||||
}
|
||||
|
||||
// Processor handles background processing of detection feedback
|
||||
type Processor struct {
|
||||
store *FeedbackStore
|
||||
config ProcessorConfig
|
||||
mu sync.RWMutex
|
||||
running bool
|
||||
|
||||
// Callbacks for extending processor behavior
|
||||
onFalsePositive func(feedback FeedbackRecord, details map[string]interface{})
|
||||
onFalseNegative func(feedback FeedbackRecord, details map[string]interface{})
|
||||
}
|
||||
|
||||
// NewProcessor creates a new feedback processor
|
||||
func NewProcessor(store *FeedbackStore, config ProcessorConfig) *Processor {
|
||||
return &Processor{
|
||||
store: store,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// SetOnFalsePositive sets a callback for false positive processing
|
||||
func (p *Processor) SetOnFalsePositive(fn func(feedback FeedbackRecord, details map[string]interface{})) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.onFalsePositive = fn
|
||||
}
|
||||
|
||||
// SetOnFalseNegative sets a callback for false negative processing
|
||||
func (p *Processor) SetOnFalseNegative(fn func(feedback FeedbackRecord, details map[string]interface{})) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.onFalseNegative = fn
|
||||
}
|
||||
|
||||
// Run starts the background processing loop
|
||||
func (p *Processor) Run(ctx context.Context) {
|
||||
p.mu.Lock()
|
||||
p.running = true
|
||||
p.mu.Unlock()
|
||||
|
||||
ticker := time.NewTicker(p.config.ProcessInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Process once at startup
|
||||
p.processBatch()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
p.mu.Lock()
|
||||
p.running = false
|
||||
p.mu.Unlock()
|
||||
return
|
||||
case <-ticker.C:
|
||||
p.processBatch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessNow triggers an immediate processing cycle
|
||||
func (p *Processor) ProcessNow() error {
|
||||
return p.processBatch()
|
||||
}
|
||||
|
||||
// processBatch processes all unprocessed feedback
|
||||
func (p *Processor) processBatch() error {
|
||||
feedbacks, err := p.store.GetUnprocessedFeedback()
|
||||
if err != nil {
|
||||
log.Printf("[WARN] Failed to get unprocessed feedback: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if len(feedbacks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("[INFO] Processing %d unprocessed feedback entries", len(feedbacks))
|
||||
|
||||
var processedIDs []string
|
||||
|
||||
for _, feedback := range feedbacks {
|
||||
if err := p.processFeedback(feedback); err != nil {
|
||||
log.Printf("[WARN] Failed to process feedback %s: %v", feedback.ID, err)
|
||||
continue
|
||||
}
|
||||
processedIDs = append(processedIDs, feedback.ID)
|
||||
}
|
||||
|
||||
// Mark as processed
|
||||
if len(processedIDs) > 0 {
|
||||
if err := p.store.MarkFeedbackProcessed(processedIDs); err != nil {
|
||||
log.Printf("[WARN] Failed to mark feedback as processed: %v", err)
|
||||
return err
|
||||
}
|
||||
log.Printf("[INFO] Marked %d feedback entries as processed", len(processedIDs))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processFeedback handles a single feedback entry
|
||||
func (p *Processor) processFeedback(feedback FeedbackRecord) error {
|
||||
switch feedback.FeedbackType {
|
||||
case FalsePositive:
|
||||
return p.processFalsePositive(feedback)
|
||||
case FalseNegative:
|
||||
return p.processFalseNegative(feedback)
|
||||
case TruePositive:
|
||||
// True positives don't need special processing, just mark as processed
|
||||
return nil
|
||||
case WrongIdentity, WrongZone:
|
||||
// These feedback types are informational for now
|
||||
// Future: could be used to adjust identity/zone thresholds
|
||||
return nil
|
||||
default:
|
||||
log.Printf("[WARN] Unknown feedback type: %s", feedback.FeedbackType)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// processFalsePositive handles false positive feedback
|
||||
func (p *Processor) processFalsePositive(feedback FeedbackRecord) error {
|
||||
// Extract CSI-related details if available
|
||||
details := feedback.Details
|
||||
if details == nil {
|
||||
details = make(map[string]interface{})
|
||||
}
|
||||
|
||||
// Call extension callback if set
|
||||
p.mu.RLock()
|
||||
callback := p.onFalsePositive
|
||||
p.mu.RUnlock()
|
||||
|
||||
if callback != nil {
|
||||
callback(feedback, details)
|
||||
}
|
||||
|
||||
// If we have link_id and delta_rms, store as a false positive frame
|
||||
if linkID, ok := details["link_id"].(string); ok {
|
||||
deltaRMS := 0.0
|
||||
if d, ok := details["delta_rms"].(float64); ok {
|
||||
deltaRMS = d
|
||||
}
|
||||
|
||||
frame := FalsePositiveFrame{
|
||||
LinkID: linkID,
|
||||
Timestamp: feedback.Timestamp,
|
||||
DeltaRMS: deltaRMS,
|
||||
Context: details,
|
||||
}
|
||||
|
||||
if err := p.store.AddFalsePositiveFrame(frame); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processFalseNegative handles false negative feedback
|
||||
func (p *Processor) processFalseNegative(feedback FeedbackRecord) error {
|
||||
details := feedback.Details
|
||||
if details == nil {
|
||||
details = make(map[string]interface{})
|
||||
}
|
||||
|
||||
// Call extension callback if set
|
||||
p.mu.RLock()
|
||||
callback := p.onFalseNegative
|
||||
p.mu.RUnlock()
|
||||
|
||||
if callback != nil {
|
||||
callback(feedback, details)
|
||||
}
|
||||
|
||||
// If we have position and link_id, store as a false negative frame
|
||||
if linkID, ok := details["link_id"].(string); ok {
|
||||
posX, _ := details["position_x"].(float64)
|
||||
posY, _ := details["position_y"].(float64)
|
||||
posZ, _ := details["position_z"].(float64)
|
||||
|
||||
frame := FalseNegativeFrame{
|
||||
LinkID: linkID,
|
||||
Timestamp: feedback.Timestamp,
|
||||
ExpectedPositionX: posX,
|
||||
ExpectedPositionY: posY,
|
||||
ExpectedPositionZ: posZ,
|
||||
Context: details,
|
||||
}
|
||||
|
||||
if err := p.store.AddFalseNegativeFrame(frame); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsRunning returns whether the processor is running
|
||||
func (p *Processor) IsRunning() bool {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.running
|
||||
}
|
||||
626
mothership/internal/learning/feedback_store.go
Normal file
626
mothership/internal/learning/feedback_store.go
Normal file
|
|
@ -0,0 +1,626 @@
|
|||
// Package learning provides feedback storage and processing for detection accuracy
|
||||
package learning
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// FeedbackType represents the type of feedback
|
||||
type FeedbackType string
|
||||
|
||||
const (
|
||||
TruePositive FeedbackType = "TRUE_POSITIVE"
|
||||
FalsePositive FeedbackType = "FALSE_POSITIVE"
|
||||
FalseNegative FeedbackType = "FALSE_NEGATIVE"
|
||||
WrongIdentity FeedbackType = "WRONG_IDENTITY"
|
||||
WrongZone FeedbackType = "WRONG_ZONE"
|
||||
)
|
||||
|
||||
// EventType represents the type of detection event
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
BlobDetection EventType = "blob_detection"
|
||||
ZoneTransition EventType = "zone_transition"
|
||||
FallAlert EventType = "fall_alert"
|
||||
Anomaly EventType = "anomaly"
|
||||
)
|
||||
|
||||
// FeedbackRecord represents a single feedback entry
|
||||
type FeedbackRecord struct {
|
||||
ID string `json:"id"`
|
||||
EventID string `json:"event_id"`
|
||||
EventType EventType `json:"event_type"`
|
||||
FeedbackType FeedbackType `json:"feedback_type"`
|
||||
Details map[string]interface{} `json:"details"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Applied bool `json:"applied"`
|
||||
ProcessedAt *time.Time `json:"processed_at,omitempty"`
|
||||
}
|
||||
|
||||
// FalsePositiveFrame represents CSI data for a known false positive
|
||||
type FalsePositiveFrame struct {
|
||||
LinkID string `json:"link_id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
DeltaRMS float64 `json:"delta_rms"`
|
||||
Context map[string]interface{} `json:"context"`
|
||||
}
|
||||
|
||||
// FalseNegativeFrame represents CSI data for a known false negative
|
||||
type FalseNegativeFrame struct {
|
||||
LinkID string `json:"link_id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
ExpectedPositionX float64 `json:"expected_position_x"`
|
||||
ExpectedPositionY float64 `json:"expected_position_y"`
|
||||
ExpectedPositionZ float64 `json:"expected_position_z"`
|
||||
Context map[string]interface{} `json:"context"`
|
||||
}
|
||||
|
||||
// FeedbackStore persists detection feedback to SQLite
|
||||
type FeedbackStore struct {
|
||||
mu sync.RWMutex
|
||||
db *sql.DB
|
||||
path string
|
||||
}
|
||||
|
||||
// NewFeedbackStore creates a new feedback persistence store
|
||||
func NewFeedbackStore(dbPath string) (*FeedbackStore, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
|
||||
return nil, fmt.Errorf("create data dir: %w", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
store := &FeedbackStore{
|
||||
db: db,
|
||||
path: dbPath,
|
||||
}
|
||||
|
||||
if err := store.initSchema(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// initSchema creates the necessary tables
|
||||
func (s *FeedbackStore) initSchema() error {
|
||||
schema := `
|
||||
-- Detection feedback from users
|
||||
CREATE TABLE IF NOT EXISTS detection_feedback (
|
||||
id TEXT PRIMARY KEY,
|
||||
event_id TEXT,
|
||||
event_type TEXT NOT NULL,
|
||||
feedback_type TEXT NOT NULL,
|
||||
details_json TEXT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
applied INTEGER DEFAULT 0,
|
||||
processed_at INTEGER
|
||||
);
|
||||
|
||||
-- Known false positive CSI frames for weight learner
|
||||
CREATE TABLE IF NOT EXISTS false_positive_frames (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
link_id TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
delta_rms REAL NOT NULL,
|
||||
context_json TEXT
|
||||
);
|
||||
|
||||
-- Known false negative CSI frames for weight learner
|
||||
CREATE TABLE IF NOT EXISTS false_negative_frames (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
link_id TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
expected_position_x REAL NOT NULL,
|
||||
expected_position_y REAL NOT NULL,
|
||||
expected_position_z REAL NOT NULL,
|
||||
context_json TEXT
|
||||
);
|
||||
|
||||
-- Detection accuracy metrics (weekly rollups)
|
||||
CREATE TABLE IF NOT EXISTS detection_accuracy (
|
||||
week TEXT NOT NULL,
|
||||
scope_type TEXT NOT NULL,
|
||||
scope_id TEXT NOT NULL,
|
||||
precision REAL NOT NULL,
|
||||
recall REAL NOT NULL,
|
||||
f1 REAL NOT NULL,
|
||||
tp_count INTEGER NOT NULL,
|
||||
fp_count INTEGER NOT NULL,
|
||||
fn_count INTEGER NOT NULL,
|
||||
computed_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (week, scope_type, scope_id)
|
||||
);
|
||||
|
||||
-- Indexes for common queries
|
||||
CREATE INDEX IF NOT EXISTS idx_feedback_applied ON detection_feedback(applied);
|
||||
CREATE INDEX IF NOT EXISTS idx_feedback_time ON detection_feedback(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_feedback_event ON detection_feedback(event_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_fp_link_time ON false_positive_frames(link_id, timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_fn_link_time ON false_negative_frames(link_id, timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_accuracy_week ON detection_accuracy(week);
|
||||
`
|
||||
|
||||
_, err := s.db.Exec(schema)
|
||||
return err
|
||||
}
|
||||
|
||||
// RecordFeedback stores a new feedback entry
|
||||
func (s *FeedbackStore) RecordFeedback(feedback FeedbackRecord) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
detailsJSON, err := json.Marshal(feedback.Details)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal details: %w", err)
|
||||
}
|
||||
|
||||
var processedAt interface{}
|
||||
if feedback.ProcessedAt != nil {
|
||||
processedAt = feedback.ProcessedAt.Unix()
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO detection_feedback (id, event_id, event_type, feedback_type, details_json, timestamp, applied, processed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, feedback.ID, feedback.EventID, feedback.EventType, feedback.FeedbackType,
|
||||
string(detailsJSON), feedback.Timestamp.Unix(), boolToInt(feedback.Applied), processedAt)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetUnprocessedFeedback returns all feedback entries where applied = false
|
||||
func (s *FeedbackStore) GetUnprocessedFeedback() ([]FeedbackRecord, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, event_id, event_type, feedback_type, details_json, timestamp, applied, processed_at
|
||||
FROM detection_feedback
|
||||
WHERE applied = 0
|
||||
ORDER BY timestamp ASC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []FeedbackRecord
|
||||
for rows.Next() {
|
||||
var r FeedbackRecord
|
||||
var timestamp int64
|
||||
var processedAt sql.NullInt64
|
||||
var detailsJSON string
|
||||
|
||||
if err := rows.Scan(&r.ID, &r.EventID, &r.EventType, &r.FeedbackType,
|
||||
&detailsJSON, ×tamp, &r.Applied, &processedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
r.Timestamp = time.Unix(timestamp, 0)
|
||||
if processedAt.Valid {
|
||||
t := time.Unix(processedAt.Int64, 0)
|
||||
r.ProcessedAt = &t
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(detailsJSON), &r.Details); err != nil {
|
||||
r.Details = make(map[string]interface{})
|
||||
}
|
||||
|
||||
records = append(records, r)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// MarkFeedbackProcessed marks feedback as processed after the learner has applied it
|
||||
func (s *FeedbackStore) MarkFeedbackProcessed(ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
now := time.Now().Unix()
|
||||
|
||||
stmt, err := tx.Prepare(`
|
||||
UPDATE detection_feedback
|
||||
SET applied = 1, processed_at = ?
|
||||
WHERE id = ?
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, id := range ids {
|
||||
if _, err := stmt.Exec(now, id); err != nil {
|
||||
log.Printf("[WARN] Failed to mark feedback %s as processed: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// AddFalsePositiveFrame adds CSI frame data for a known false positive
|
||||
func (s *FeedbackStore) AddFalsePositiveFrame(frame FalsePositiveFrame) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
contextJSON, err := json.Marshal(frame.Context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal context: %w", err)
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO false_positive_frames (link_id, timestamp, delta_rms, context_json)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, frame.LinkID, frame.Timestamp.Unix(), frame.DeltaRMS, string(contextJSON))
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// AddFalseNegativeFrame adds CSI frame data for a known false negative
|
||||
func (s *FeedbackStore) AddFalseNegativeFrame(frame FalseNegativeFrame) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
contextJSON, err := json.Marshal(frame.Context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal context: %w", err)
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO false_negative_frames (link_id, timestamp, expected_position_x, expected_position_y, expected_position_z, context_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, frame.LinkID, frame.Timestamp.Unix(),
|
||||
frame.ExpectedPositionX, frame.ExpectedPositionY, frame.ExpectedPositionZ,
|
||||
string(contextJSON))
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetFalsePositiveFrames returns all false positive frames for a link within a window
|
||||
func (s *FeedbackStore) GetFalsePositiveFrames(linkID string, window time.Duration) ([]FalsePositiveFrame, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
cutoff := time.Now().Add(-window).Unix()
|
||||
|
||||
rows, err := s.db.Query(`
|
||||
SELECT link_id, timestamp, delta_rms, context_json
|
||||
FROM false_positive_frames
|
||||
WHERE link_id = ? AND timestamp >= ?
|
||||
ORDER BY timestamp ASC
|
||||
`, linkID, cutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var frames []FalsePositiveFrame
|
||||
for rows.Next() {
|
||||
var f FalsePositiveFrame
|
||||
var timestamp int64
|
||||
var contextJSON string
|
||||
|
||||
if err := rows.Scan(&f.LinkID, ×tamp, &f.DeltaRMS, &contextJSON); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
f.Timestamp = time.Unix(timestamp, 0)
|
||||
if err := json.Unmarshal([]byte(contextJSON), &f.Context); err != nil {
|
||||
f.Context = make(map[string]interface{})
|
||||
}
|
||||
|
||||
frames = append(frames, f)
|
||||
}
|
||||
|
||||
return frames, nil
|
||||
}
|
||||
|
||||
// GetFalseNegativeFrames returns all false negative frames for a link within a window
|
||||
func (s *FeedbackStore) GetFalseNegativeFrames(linkID string, window time.Duration) ([]FalseNegativeFrame, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
cutoff := time.Now().Add(-window).Unix()
|
||||
|
||||
rows, err := s.db.Query(`
|
||||
SELECT link_id, timestamp, expected_position_x, expected_position_y, expected_position_z, context_json
|
||||
FROM false_negative_frames
|
||||
WHERE link_id = ? AND timestamp >= ?
|
||||
ORDER BY timestamp ASC
|
||||
`, linkID, cutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var frames []FalseNegativeFrame
|
||||
for rows.Next() {
|
||||
var f FalseNegativeFrame
|
||||
var timestamp int64
|
||||
var contextJSON string
|
||||
|
||||
if err := rows.Scan(&f.LinkID, ×tamp, &f.ExpectedPositionX, &f.ExpectedPositionY,
|
||||
&f.ExpectedPositionZ, &contextJSON); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
f.Timestamp = time.Unix(timestamp, 0)
|
||||
if err := json.Unmarshal([]byte(contextJSON), &f.Context); err != nil {
|
||||
f.Context = make(map[string]interface{})
|
||||
}
|
||||
|
||||
frames = append(frames, f)
|
||||
}
|
||||
|
||||
return frames, nil
|
||||
}
|
||||
|
||||
// AccuracyRecord represents weekly accuracy metrics for a scope
|
||||
type AccuracyRecord struct {
|
||||
Week string `json:"week"`
|
||||
ScopeType string `json:"scope_type"`
|
||||
ScopeID string `json:"scope_id"`
|
||||
Precision float64 `json:"precision"`
|
||||
Recall float64 `json:"recall"`
|
||||
F1 float64 `json:"f1"`
|
||||
TPCount int `json:"tp_count"`
|
||||
FPCount int `json:"fp_count"`
|
||||
FNCount int `json:"fn_count"`
|
||||
ComputedAt time.Time `json:"computed_at"`
|
||||
}
|
||||
|
||||
// SaveAccuracyRecord saves a weekly accuracy record
|
||||
func (s *FeedbackStore) SaveAccuracyRecord(record AccuracyRecord) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
_, err := s.db.Exec(`
|
||||
INSERT OR REPLACE INTO detection_accuracy
|
||||
(week, scope_type, scope_id, precision, recall, f1, tp_count, fp_count, fn_count, computed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, record.Week, record.ScopeType, record.ScopeID, record.Precision, record.Recall,
|
||||
record.F1, record.TPCount, record.FPCount, record.FNCount, record.ComputedAt.Unix())
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAccuracyHistory returns accuracy records for a scope over time
|
||||
func (s *FeedbackStore) GetAccuracyHistory(scopeType, scopeID string, weeks int) ([]AccuracyRecord, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
rows, err := s.db.Query(`
|
||||
SELECT week, scope_type, scope_id, precision, recall, f1, tp_count, fp_count, fn_count, computed_at
|
||||
FROM detection_accuracy
|
||||
WHERE scope_type = ? AND scope_id = ?
|
||||
ORDER BY week DESC
|
||||
LIMIT ?
|
||||
`, scopeType, scopeID, weeks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []AccuracyRecord
|
||||
for rows.Next() {
|
||||
var r AccuracyRecord
|
||||
var computedAt int64
|
||||
|
||||
if err := rows.Scan(&r.Week, &r.ScopeType, &r.ScopeID, &r.Precision, &r.Recall,
|
||||
&r.F1, &r.TPCount, &r.FPCount, &r.FNCount, &computedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
r.ComputedAt = time.Unix(computedAt, 0)
|
||||
records = append(records, r)
|
||||
}
|
||||
|
||||
// Reverse to get chronological order
|
||||
for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 {
|
||||
records[i], records[j] = records[j], records[i]
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// GetAllAccuracyRecords returns all accuracy records for a week
|
||||
func (s *FeedbackStore) GetAllAccuracyRecords(week string) ([]AccuracyRecord, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
rows, err := s.db.Query(`
|
||||
SELECT week, scope_type, scope_id, precision, recall, f1, tp_count, fp_count, fn_count, computed_at
|
||||
FROM detection_accuracy
|
||||
WHERE week = ?
|
||||
ORDER BY scope_type, scope_id
|
||||
`, week)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []AccuracyRecord
|
||||
for rows.Next() {
|
||||
var r AccuracyRecord
|
||||
var computedAt int64
|
||||
|
||||
if err := rows.Scan(&r.Week, &r.ScopeType, &r.ScopeID, &r.Precision, &r.Recall,
|
||||
&r.F1, &r.TPCount, &r.FPCount, &r.FNCount, &computedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
r.ComputedAt = time.Unix(computedAt, 0)
|
||||
records = append(records, r)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// GetFeedbackStats returns overall feedback statistics
|
||||
func (s *FeedbackStore) GetFeedbackStats() (map[string]interface{}, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
stats := make(map[string]interface{})
|
||||
|
||||
// Total feedback count
|
||||
var totalCount int
|
||||
row := s.db.QueryRow(`SELECT COUNT(*) FROM detection_feedback`)
|
||||
if err := row.Scan(&totalCount); err == nil {
|
||||
stats["total_count"] = totalCount
|
||||
}
|
||||
|
||||
// Unprocessed count
|
||||
var unprocessedCount int
|
||||
row = s.db.QueryRow(`SELECT COUNT(*) FROM detection_feedback WHERE applied = 0`)
|
||||
if err := row.Scan(&unprocessedCount); err == nil {
|
||||
stats["unprocessed_count"] = unprocessedCount
|
||||
}
|
||||
|
||||
// Processed count
|
||||
var processedCount int
|
||||
row = s.db.QueryRow(`SELECT COUNT(*) FROM detection_feedback WHERE applied = 1`)
|
||||
if err := row.Scan(&processedCount); err == nil {
|
||||
stats["processed_count"] = processedCount
|
||||
}
|
||||
|
||||
// Count by feedback type
|
||||
typeRows, err := s.db.Query(`
|
||||
SELECT feedback_type, COUNT(*) as count
|
||||
FROM detection_feedback
|
||||
GROUP BY feedback_type
|
||||
`)
|
||||
if err == nil {
|
||||
defer typeRows.Close()
|
||||
byType := make(map[string]int)
|
||||
for typeRows.Next() {
|
||||
var ft string
|
||||
var count int
|
||||
if err := typeRows.Scan(&ft, &count); err == nil {
|
||||
byType[ft] = count
|
||||
}
|
||||
}
|
||||
stats["by_type"] = byType
|
||||
}
|
||||
|
||||
// This week's feedback count
|
||||
weekStart := getWeekStart(time.Now()).Unix()
|
||||
var weekCount int
|
||||
row = s.db.QueryRow(`SELECT COUNT(*) FROM detection_feedback WHERE timestamp >= ?`, weekStart)
|
||||
if err := row.Scan(&weekCount); err == nil {
|
||||
stats["this_week_count"] = weekCount
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetFeedbackByEvent returns feedback for a specific event
|
||||
func (s *FeedbackStore) GetFeedbackByEvent(eventID string) ([]FeedbackRecord, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, event_id, event_type, feedback_type, details_json, timestamp, applied, processed_at
|
||||
FROM detection_feedback
|
||||
WHERE event_id = ?
|
||||
ORDER BY timestamp DESC
|
||||
`, eventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []FeedbackRecord
|
||||
for rows.Next() {
|
||||
var r FeedbackRecord
|
||||
var timestamp int64
|
||||
var processedAt sql.NullInt64
|
||||
var detailsJSON string
|
||||
|
||||
if err := rows.Scan(&r.ID, &r.EventID, &r.EventType, &r.FeedbackType,
|
||||
&detailsJSON, ×tamp, &r.Applied, &processedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
r.Timestamp = time.Unix(timestamp, 0)
|
||||
if processedAt.Valid {
|
||||
t := time.Unix(processedAt.Int64, 0)
|
||||
r.ProcessedAt = &t
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(detailsJSON), &r.Details); err != nil {
|
||||
r.Details = make(map[string]interface{})
|
||||
}
|
||||
|
||||
records = append(records, r)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// GetFeedbackCount returns the total number of feedback entries
|
||||
func (s *FeedbackStore) GetFeedbackCount() (int, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var count int
|
||||
row := s.db.QueryRow(`SELECT COUNT(*) FROM detection_feedback`)
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// Close closes the database connection
|
||||
func (s *FeedbackStore) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// getWeekStart returns the Monday of the week containing t
|
||||
func getWeekStart(t time.Time) time.Time {
|
||||
weekday := int(t.Weekday())
|
||||
if weekday == 0 {
|
||||
weekday = 7
|
||||
}
|
||||
return t.AddDate(0, 0, -weekday+1).Truncate(24 * time.Hour)
|
||||
}
|
||||
|
||||
// GetWeekString returns the ISO week string for a time (e.g., "2026-W13")
|
||||
func GetWeekString(t time.Time) string {
|
||||
year, week := t.ISOWeek()
|
||||
return fmt.Sprintf("%d-W%02d", year, week)
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
422
mothership/internal/learning/feedback_test.go
Normal file
422
mothership/internal/learning/feedback_test.go
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
package learning
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewFeedbackStore(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "learning-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "learning.db")
|
||||
store, err := NewFeedbackStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create feedback store: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Verify database file was created
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
t.Error("Database file was not created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordFeedback(t *testing.T) {
|
||||
store := setupTestFeedbackStore(t)
|
||||
defer store.Close()
|
||||
|
||||
// Record a true positive
|
||||
feedback := FeedbackRecord{
|
||||
ID: "test-1",
|
||||
EventID: "event-1",
|
||||
EventType: BlobDetection,
|
||||
FeedbackType: TruePositive,
|
||||
Details: map[string]interface{}{
|
||||
"zone_id": "zone-kitchen",
|
||||
"notes": "Correct detection",
|
||||
},
|
||||
Timestamp: time.Now(),
|
||||
Applied: false,
|
||||
}
|
||||
|
||||
err := store.RecordFeedback(feedback)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordFeedback failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify feedback count
|
||||
count, err := store.GetFeedbackCount()
|
||||
if err != nil {
|
||||
t.Fatalf("GetFeedbackCount failed: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("Expected count 1, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUnprocessedFeedback(t *testing.T) {
|
||||
store := setupTestFeedbackStore(t)
|
||||
defer store.Close()
|
||||
|
||||
// Record multiple feedback entries
|
||||
for i := 0; i < 3; i++ {
|
||||
feedback := FeedbackRecord{
|
||||
ID: "test-" + string(rune('a'+i)),
|
||||
EventID: "event-" + string(rune('a'+i)),
|
||||
EventType: BlobDetection,
|
||||
FeedbackType: TruePositive,
|
||||
Timestamp: time.Now(),
|
||||
Applied: false,
|
||||
}
|
||||
store.RecordFeedback(feedback)
|
||||
}
|
||||
|
||||
// Get unprocessed feedback
|
||||
feedbacks, err := store.GetUnprocessedFeedback()
|
||||
if err != nil {
|
||||
t.Fatalf("GetUnprocessedFeedback failed: %v", err)
|
||||
}
|
||||
|
||||
if len(feedbacks) != 3 {
|
||||
t.Errorf("Expected 3 unprocessed feedback entries, got %d", len(feedbacks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkFeedbackProcessed(t *testing.T) {
|
||||
store := setupTestFeedbackStore(t)
|
||||
defer store.Close()
|
||||
|
||||
// Record feedback
|
||||
feedback := FeedbackRecord{
|
||||
ID: "test-1",
|
||||
EventID: "event-1",
|
||||
EventType: BlobDetection,
|
||||
FeedbackType: FalsePositive,
|
||||
Timestamp: time.Now(),
|
||||
Applied: false,
|
||||
}
|
||||
store.RecordFeedback(feedback)
|
||||
|
||||
// Verify unprocessed
|
||||
feedbacks, _ := store.GetUnprocessedFeedback()
|
||||
if len(feedbacks) != 1 {
|
||||
t.Fatalf("Expected 1 unprocessed, got %d", len(feedbacks))
|
||||
}
|
||||
|
||||
// Mark as processed
|
||||
err := store.MarkFeedbackProcessed([]string{"test-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("MarkFeedbackProcessed failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify no unprocessed remain
|
||||
feedbacks, _ = store.GetUnprocessedFeedback()
|
||||
if len(feedbacks) != 0 {
|
||||
t.Errorf("Expected 0 unprocessed, got %d", len(feedbacks))
|
||||
}
|
||||
|
||||
// Verify stats show processed count
|
||||
stats, err := store.GetFeedbackStats()
|
||||
if err != nil {
|
||||
t.Fatalf("GetFeedbackStats failed: %v", err)
|
||||
}
|
||||
if stats["processed_count"].(int) != 1 {
|
||||
t.Errorf("Expected processed_count 1, got %d", stats["processed_count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFalsePositiveFrameStorage(t *testing.T) {
|
||||
store := setupTestFeedbackStore(t)
|
||||
defer store.Close()
|
||||
|
||||
// Add false positive frame
|
||||
frame := FalsePositiveFrame{
|
||||
LinkID: "link-1-2",
|
||||
Timestamp: time.Now(),
|
||||
DeltaRMS: 0.15,
|
||||
Context: map[string]interface{}{
|
||||
"zone_id": "zone-living",
|
||||
},
|
||||
}
|
||||
|
||||
err := store.AddFalsePositiveFrame(frame)
|
||||
if err != nil {
|
||||
t.Fatalf("AddFalsePositiveFrame failed: %v", err)
|
||||
}
|
||||
|
||||
// Retrieve frames
|
||||
frames, err := store.GetFalsePositiveFrames("link-1-2", 24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFalsePositiveFrames failed: %v", err)
|
||||
}
|
||||
|
||||
if len(frames) != 1 {
|
||||
t.Errorf("Expected 1 frame, got %d", len(frames))
|
||||
}
|
||||
|
||||
if frames[0].DeltaRMS != 0.15 {
|
||||
t.Errorf("Expected DeltaRMS 0.15, got %f", frames[0].DeltaRMS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFalseNegativeFrameStorage(t *testing.T) {
|
||||
store := setupTestFeedbackStore(t)
|
||||
defer store.Close()
|
||||
|
||||
// Add false negative frame
|
||||
frame := FalseNegativeFrame{
|
||||
LinkID: "link-2-3",
|
||||
Timestamp: time.Now(),
|
||||
ExpectedPositionX: 1.5,
|
||||
ExpectedPositionY: 0.0,
|
||||
ExpectedPositionZ: 2.0,
|
||||
Context: map[string]interface{}{
|
||||
"user_reported": true,
|
||||
},
|
||||
}
|
||||
|
||||
err := store.AddFalseNegativeFrame(frame)
|
||||
if err != nil {
|
||||
t.Fatalf("AddFalseNegativeFrame failed: %v", err)
|
||||
}
|
||||
|
||||
// Retrieve frames
|
||||
frames, err := store.GetFalseNegativeFrames("link-2-3", 24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFalseNegativeFrames failed: %v", err)
|
||||
}
|
||||
|
||||
if len(frames) != 1 {
|
||||
t.Errorf("Expected 1 frame, got %d", len(frames))
|
||||
}
|
||||
|
||||
if frames[0].ExpectedPositionX != 1.5 {
|
||||
t.Errorf("Expected X position 1.5, got %f", frames[0].ExpectedPositionX)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAccuracyRecord(t *testing.T) {
|
||||
store := setupTestFeedbackStore(t)
|
||||
defer store.Close()
|
||||
|
||||
record := AccuracyRecord{
|
||||
Week: GetWeekString(time.Now()),
|
||||
ScopeType: ScopeTypeSystem,
|
||||
ScopeID: ScopeIDSystem,
|
||||
Precision: 0.8,
|
||||
Recall: 0.888,
|
||||
F1: 0.841,
|
||||
TPCount: 8,
|
||||
FPCount: 2,
|
||||
FNCount: 1,
|
||||
ComputedAt: time.Now(),
|
||||
}
|
||||
|
||||
err := store.SaveAccuracyRecord(record)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveAccuracyRecord failed: %v", err)
|
||||
}
|
||||
|
||||
// Retrieve history
|
||||
records, err := store.GetAccuracyHistory(ScopeTypeSystem, ScopeIDSystem, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccuracyHistory failed: %v", err)
|
||||
}
|
||||
|
||||
if len(records) != 1 {
|
||||
t.Errorf("Expected 1 record, got %d", len(records))
|
||||
}
|
||||
|
||||
// Verify values
|
||||
if records[0].Precision != 0.8 {
|
||||
t.Errorf("Expected precision 0.8, got %f", records[0].Precision)
|
||||
}
|
||||
if records[0].TPCount != 8 {
|
||||
t.Errorf("Expected TP count 8, got %d", records[0].TPCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccuracyMetrics(t *testing.T) {
|
||||
// Test precision/recall/F1 calculation
|
||||
// precision = TP / (TP + FP)
|
||||
// recall = TP / (TP + FN)
|
||||
// F1 = 2 * precision * recall / (precision + recall)
|
||||
|
||||
tp := 8
|
||||
fp := 2
|
||||
fn := 1
|
||||
|
||||
precision := float64(tp) / float64(tp+fp) // 0.8
|
||||
recall := float64(tp) / float64(tp+fn) // 0.888...
|
||||
f1 := 2 * precision * recall / (precision + recall)
|
||||
|
||||
if precision != 0.8 {
|
||||
t.Errorf("Expected precision 0.8, got %f", precision)
|
||||
}
|
||||
|
||||
expectedRecall := 8.0 / 9.0
|
||||
if recall < expectedRecall-0.001 || recall > expectedRecall+0.001 {
|
||||
t.Errorf("Expected recall ~0.888, got %f", recall)
|
||||
}
|
||||
|
||||
// F1 should be around 0.842
|
||||
if f1 < 0.84 || f1 > 0.85 {
|
||||
t.Errorf("Expected F1 ~0.842, got %f", f1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFeedbackStats(t *testing.T) {
|
||||
store := setupTestFeedbackStore(t)
|
||||
defer store.Close()
|
||||
|
||||
// Record various feedback types
|
||||
types := []FeedbackType{TruePositive, FalsePositive, FalseNegative, TruePositive}
|
||||
for i, ft := range types {
|
||||
store.RecordFeedback(FeedbackRecord{
|
||||
ID: "test-" + string(rune('a'+i)),
|
||||
EventType: BlobDetection,
|
||||
FeedbackType: ft,
|
||||
Timestamp: time.Now(),
|
||||
Applied: false,
|
||||
})
|
||||
}
|
||||
|
||||
stats, err := store.GetFeedbackStats()
|
||||
if err != nil {
|
||||
t.Fatalf("GetFeedbackStats failed: %v", err)
|
||||
}
|
||||
|
||||
if stats["total_count"].(int) != 4 {
|
||||
t.Errorf("Expected total_count 4, got %d", stats["total_count"])
|
||||
}
|
||||
|
||||
byType := stats["by_type"].(map[string]int)
|
||||
if byType[string(TruePositive)] != 2 {
|
||||
t.Errorf("Expected 2 TRUE_POSITIVE, got %d", byType[string(TruePositive)])
|
||||
}
|
||||
if byType[string(FalsePositive)] != 1 {
|
||||
t.Errorf("Expected 1 FALSE_POSITIVE, got %d", byType[string(FalsePositive)])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFeedbackByEvent(t *testing.T) {
|
||||
store := setupTestFeedbackStore(t)
|
||||
defer store.Close()
|
||||
|
||||
// Record feedback for specific event
|
||||
store.RecordFeedback(FeedbackRecord{
|
||||
ID: "test-1",
|
||||
EventID: "event-123",
|
||||
EventType: BlobDetection,
|
||||
FeedbackType: TruePositive,
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
store.RecordFeedback(FeedbackRecord{
|
||||
ID: "test-2",
|
||||
EventID: "event-456",
|
||||
EventType: FallAlert,
|
||||
FeedbackType: FalsePositive,
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
|
||||
// Get feedback for event-123
|
||||
feedbacks, err := store.GetFeedbackByEvent("event-123")
|
||||
if err != nil {
|
||||
t.Fatalf("GetFeedbackByEvent failed: %v", err)
|
||||
}
|
||||
|
||||
if len(feedbacks) != 1 {
|
||||
t.Errorf("Expected 1 feedback for event-123, got %d", len(feedbacks))
|
||||
}
|
||||
|
||||
if feedbacks[0].FeedbackType != TruePositive {
|
||||
t.Errorf("Expected TRUE_POSITIVE, got %s", feedbacks[0].FeedbackType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetWeekString(t *testing.T) {
|
||||
// Test that GetWeekString produces ISO week format
|
||||
testTime := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
|
||||
weekStr := GetWeekString(testTime)
|
||||
|
||||
// Should be in format "2026-W14" (March 29, 2026 is in week 14)
|
||||
if len(weekStr) != 8 {
|
||||
t.Errorf("Expected week string length 8, got %d", len(weekStr))
|
||||
}
|
||||
|
||||
// Should start with year
|
||||
if weekStr[:5] != "2026-" {
|
||||
t.Errorf("Expected week string to start with '2026-', got %s", weekStr[:5])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedbackProcessor(t *testing.T) {
|
||||
store := setupTestFeedbackStore(t)
|
||||
defer store.Close()
|
||||
|
||||
// Create processor
|
||||
config := DefaultProcessorConfig()
|
||||
processor := NewProcessor(store, config)
|
||||
|
||||
// Record false positive with link details
|
||||
store.RecordFeedback(FeedbackRecord{
|
||||
ID: "test-1",
|
||||
EventID: "event-1",
|
||||
EventType: BlobDetection,
|
||||
FeedbackType: FalsePositive,
|
||||
Details: map[string]interface{}{
|
||||
"link_id": "link-1-2",
|
||||
"delta_rms": 0.15,
|
||||
},
|
||||
Timestamp: time.Now(),
|
||||
Applied: false,
|
||||
})
|
||||
|
||||
// Process feedback
|
||||
err := processor.ProcessNow()
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessNow failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify feedback was marked as processed
|
||||
feedbacks, _ := store.GetUnprocessedFeedback()
|
||||
if len(feedbacks) != 0 {
|
||||
t.Errorf("Expected 0 unprocessed, got %d", len(feedbacks))
|
||||
}
|
||||
|
||||
// Verify false positive frame was stored
|
||||
frames, err := store.GetFalsePositiveFrames("link-1-2", 24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFalsePositiveFrames failed: %v", err)
|
||||
}
|
||||
if len(frames) != 1 {
|
||||
t.Errorf("Expected 1 false positive frame, got %d", len(frames))
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to set up a test feedback store
|
||||
func setupTestFeedbackStore(t *testing.T) *FeedbackStore {
|
||||
tmpDir, err := os.MkdirTemp("", "learning-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "learning.db")
|
||||
store, err := NewFeedbackStore(dbPath)
|
||||
if err != nil {
|
||||
os.RemoveAll(tmpDir)
|
||||
t.Fatalf("Failed to create feedback store: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
os.RemoveAll(tmpDir)
|
||||
})
|
||||
|
||||
return store
|
||||
}
|
||||
311
mothership/internal/learning/handler.go
Normal file
311
mothership/internal/learning/handler.go
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
// Package learning provides REST API handlers for feedback and accuracy
|
||||
package learning
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Handler provides REST API handlers for the learning package
|
||||
type Handler struct {
|
||||
store *FeedbackStore
|
||||
processor *Processor
|
||||
accuracyComp *AccuracyComputer
|
||||
}
|
||||
|
||||
// NewHandler creates a new learning handler
|
||||
func NewHandler(store *FeedbackStore, processor *Processor, accuracyComp *AccuracyComputer) *Handler {
|
||||
return &Handler{
|
||||
store: store,
|
||||
processor: processor,
|
||||
accuracyComp: accuracyComp,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers learning API routes on the given router
|
||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
// Feedback submission and retrieval
|
||||
r.Post("/api/learning/feedback", h.handleSubmitFeedback)
|
||||
r.Get("/api/learning/feedback", h.handleGetFeedback)
|
||||
r.Get("/api/learning/feedback/{eventID}", h.handleGetFeedbackByEvent)
|
||||
r.Get("/api/learning/stats", h.handleGetStats)
|
||||
|
||||
// Accuracy metrics
|
||||
r.Get("/api/learning/accuracy", h.handleGetAccuracy)
|
||||
r.Get("/api/learning/accuracy/history", h.handleGetAccuracyHistory)
|
||||
r.Get("/api/learning/accuracy/improvement", h.handleGetImprovement)
|
||||
|
||||
// Manual processing trigger (for testing/admin)
|
||||
r.Post("/api/learning/process", h.handleTriggerProcess)
|
||||
}
|
||||
|
||||
// Feedback submission request
|
||||
type submitFeedbackRequest struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
FeedbackType string `json:"feedback_type"`
|
||||
Details map[string]interface{} `json:"details"`
|
||||
}
|
||||
|
||||
// handleSubmitFeedback handles POST /api/learning/feedback
|
||||
func (h *Handler) handleSubmitFeedback(w http.ResponseWriter, r *http.Request) {
|
||||
var req submitFeedbackRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate feedback type
|
||||
validTypes := map[string]bool{
|
||||
string(TruePositive): true,
|
||||
string(FalsePositive): true,
|
||||
string(FalseNegative): true,
|
||||
string(WrongIdentity): true,
|
||||
string(WrongZone): true,
|
||||
}
|
||||
if !validTypes[req.FeedbackType] {
|
||||
http.Error(w, "invalid feedback_type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate event type
|
||||
validEventTypes := map[string]bool{
|
||||
string(BlobDetection): true,
|
||||
string(ZoneTransition): true,
|
||||
string(FallAlert): true,
|
||||
string(Anomaly): true,
|
||||
}
|
||||
if !validEventTypes[req.EventType] {
|
||||
http.Error(w, "invalid event_type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Create feedback record
|
||||
feedback := FeedbackRecord{
|
||||
ID: uuid.New().String(),
|
||||
EventID: req.EventID,
|
||||
EventType: EventType(req.EventType),
|
||||
FeedbackType: FeedbackType(req.FeedbackType),
|
||||
Details: req.Details,
|
||||
Timestamp: time.Now(),
|
||||
Applied: false,
|
||||
}
|
||||
|
||||
if feedback.Details == nil {
|
||||
feedback.Details = make(map[string]interface{})
|
||||
}
|
||||
|
||||
// Store feedback
|
||||
if err := h.store.RecordFeedback(feedback); err != nil {
|
||||
http.Error(w, "failed to record feedback", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"id": feedback.ID,
|
||||
"success": true,
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetFeedback handles GET /api/learning/feedback
|
||||
func (h *Handler) handleGetFeedback(w http.ResponseWriter, r *http.Request) {
|
||||
// Get pagination params
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
limit := 100
|
||||
if limitStr != "" {
|
||||
if n, err := strconv.Atoi(limitStr); err == nil && n > 0 && n <= 1000 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
|
||||
// Get unprocessed param
|
||||
unprocessedOnly := r.URL.Query().Get("unprocessed") == "true"
|
||||
|
||||
var feedbacks []FeedbackRecord
|
||||
var err error
|
||||
|
||||
if unprocessedOnly {
|
||||
feedbacks, err = h.store.GetUnprocessedFeedback()
|
||||
if limit > 0 && len(feedbacks) > limit {
|
||||
feedbacks = feedbacks[:limit]
|
||||
}
|
||||
} else {
|
||||
// Would need to implement a general query method
|
||||
// For now, return stats instead
|
||||
stats, err := h.store.GetFeedbackStats()
|
||||
if err != nil {
|
||||
http.Error(w, "failed to get feedback", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, stats)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, "failed to get feedback", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, feedbacks)
|
||||
}
|
||||
|
||||
// handleGetFeedbackByEvent handles GET /api/learning/feedback/{eventID}
|
||||
func (h *Handler) handleGetFeedbackByEvent(w http.ResponseWriter, r *http.Request) {
|
||||
eventID := chi.URLParam(r, "eventID")
|
||||
if eventID == "" {
|
||||
http.Error(w, "event_id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
feedbacks, err := h.store.GetFeedbackByEvent(eventID)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to get feedback", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, feedbacks)
|
||||
}
|
||||
|
||||
// handleGetStats handles GET /api/learning/stats
|
||||
func (h *Handler) handleGetStats(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := h.store.GetFeedbackStats()
|
||||
if err != nil {
|
||||
http.Error(w, "failed to get stats", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Get total feedback count
|
||||
count, err := h.store.GetFeedbackCount()
|
||||
if err != nil {
|
||||
http.Error(w, "failed to get feedback count", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
stats["total_count"] = count
|
||||
|
||||
writeJSON(w, stats)
|
||||
}
|
||||
|
||||
// handleGetAccuracy handles GET /api/learning/accuracy
|
||||
func (h *Handler) handleGetAccuracy(w http.ResponseWriter, r *http.Request) {
|
||||
scopeType := r.URL.Query().Get("scope_type")
|
||||
scopeID := r.URL.Query().Get("scope_id")
|
||||
|
||||
// Default to system-wide accuracy
|
||||
if scopeType == "" {
|
||||
scopeType = ScopeTypeSystem
|
||||
}
|
||||
if scopeID == "" {
|
||||
scopeID = ScopeIDSystem
|
||||
}
|
||||
|
||||
// Get current week's accuracy
|
||||
if h.accuracyComp != nil {
|
||||
record, err := h.accuracyComp.GetCurrentAccuracy(scopeType, scopeID)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to get accuracy", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if record != nil {
|
||||
writeJSON(w, record)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: return from store
|
||||
records, err := h.store.GetAccuracyHistory(scopeType, scopeID, 1)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to get accuracy", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if len(records) == 0 {
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"scope_type": scopeType,
|
||||
"scope_id": scopeID,
|
||||
"f1": nil,
|
||||
"precision": nil,
|
||||
"recall": nil,
|
||||
"message": "no accuracy data available yet",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, records[0])
|
||||
}
|
||||
|
||||
// handleGetAccuracyHistory handles GET /api/learning/accuracy/history
|
||||
func (h *Handler) handleGetAccuracyHistory(w http.ResponseWriter, r *http.Request) {
|
||||
scopeType := r.URL.Query().Get("scope_type")
|
||||
scopeID := r.URL.Query().Get("scope_id")
|
||||
weeksStr := r.URL.Query().Get("weeks")
|
||||
|
||||
weeks := 8
|
||||
if weeksStr != "" {
|
||||
if n, err := strconv.Atoi(weeksStr); err == nil && n > 0 && n <= 52 {
|
||||
weeks = n
|
||||
}
|
||||
}
|
||||
|
||||
// Default to system-wide
|
||||
if scopeType == "" {
|
||||
scopeType = ScopeTypeSystem
|
||||
}
|
||||
if scopeID == "" {
|
||||
scopeID = ScopeIDSystem
|
||||
}
|
||||
|
||||
records, err := h.store.GetAccuracyHistory(scopeType, scopeID, weeks)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to get accuracy history", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, records)
|
||||
}
|
||||
|
||||
// handleGetImprovement handles GET /api/learning/accuracy/improvement
|
||||
func (h *Handler) handleGetImprovement(w http.ResponseWriter, r *http.Request) {
|
||||
if h.accuracyComp == nil {
|
||||
http.Error(w, "accuracy computer not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := h.accuracyComp.GetImprovementStats()
|
||||
if err != nil {
|
||||
http.Error(w, "failed to get improvement stats", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, stats)
|
||||
}
|
||||
|
||||
// handleTriggerProcess handles POST /api/learning/process
|
||||
func (h *Handler) handleTriggerProcess(w http.ResponseWriter, r *http.Request) {
|
||||
if h.processor == nil {
|
||||
http.Error(w, "processor not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger immediate processing
|
||||
if err := h.processor.ProcessNow(); err != nil {
|
||||
http.Error(w, "processing failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]string{
|
||||
"status": "processed",
|
||||
})
|
||||
}
|
||||
|
||||
// writeJSON writes a JSON response
|
||||
func writeJSON(w http.ResponseWriter, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue