- 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>
228 lines
5.6 KiB
Go
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
|
|
}
|