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

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

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

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

228 lines
5.6 KiB
Go

// 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
}