spaxel/mothership/internal/api/feedback.go
jedarden f99dc15a2d feat: complete crowd flow visualization implementation
- Fix Viz3D exports to include flow visualization functions
- Export setFlowLayerVisible, setDwellLayerVisible, setCorridorLayerVisible
- Export setFlowTimeFilter, setFlowData, setDwellData, setCorridorData
- Remove duplicate setDwellLayerVisible function definition

This completes the crowd flow visualization feature that was
already implemented in the backend (flow.go) and frontend
(crowdflow.js, viz3d.js) but had missing exports in the Viz3D module.
2026-04-11 07:27:21 -04:00

284 lines
9.8 KiB
Go

// Package api provides REST API handlers for Spaxel feedback.
package api
import (
"encoding/json"
"log"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/spaxel/mothership/internal/diagnostics"
"github.com/spaxel/mothership/internal/explainability"
)
// FeedbackRequest represents a feedback submission from the timeline.
type FeedbackRequest struct {
Type string `json:"type"` // "correct" or "incorrect"
EventID int64 `json:"event_id"` // Optional: event ID being rated
BlobID int `json:"blob_id"` // Optional: blob ID being rated
Position *struct {
X float64 `json:"x"`
Y float64 `json:"y"`
Z float64 `json:"z"`
} `json:"position,omitempty"` // For "missed" feedback
}
// FeedbackHandler handles simple feedback submissions from the UI.
type FeedbackHandler struct {
eventsHandler *EventsHandler
learningHandler any // Learning handler with ProcessFeedback method
explainabilityHandler *explainability.Handler
diagnosticEngine *diagnostics.DiagnosticEngine
}
// NewFeedbackHandler creates a new feedback handler.
func NewFeedbackHandler(eventsHandler *EventsHandler) *FeedbackHandler {
return &FeedbackHandler{
eventsHandler: eventsHandler,
}
}
// SetLearningHandler sets the learning handler for feedback processing.
func (h *FeedbackHandler) SetLearningHandler(learningHandler any) {
h.learningHandler = learningHandler
}
// SetExplainabilityHandler sets the explainability handler.
func (h *FeedbackHandler) SetExplainabilityHandler(eh *explainability.Handler) {
h.explainabilityHandler = eh
}
// SetDiagnosticEngine sets the diagnostic engine for link health analysis.
func (h *FeedbackHandler) SetDiagnosticEngine(de *diagnostics.DiagnosticEngine) {
h.diagnosticEngine = de
}
// getExplainabilityForBlob retrieves the explainability snapshot for a blob.
// This is a helper method to avoid circular dependencies.
func (h *FeedbackHandler) getExplainabilityForBlob(blobID int, timestamp int64) *explainability.BlobExplanation {
if h.explainabilityHandler == nil {
return nil
}
// The explainability handler has its own mutex, so we can call its method directly
// We need to access the blobHistory map which is not exported, so we'll use a workaround
// by creating a minimal HTTP request to the internal handler
// For now, we'll access the handler's internal state through a public method
// In production, this would be done through a proper interface
return h.explainabilityHandler.GetExplanationForBlob(blobID, timestamp)
}
// RegisterRoutes registers feedback endpoints.
func (h *FeedbackHandler) RegisterRoutes(r chi.Router) {
r.Post("/api/feedback", h.handleSubmitFeedback)
}
// handleSubmitFeedback handles POST /api/feedback
func (h *FeedbackHandler) handleSubmitFeedback(w http.ResponseWriter, r *http.Request) {
var req FeedbackRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid request body")
return
}
// Validate feedback type
if req.Type != "correct" && req.Type != "incorrect" && req.Type != "missed" {
writeJSONError(w, http.StatusBadRequest, "invalid feedback type: must be 'correct', 'incorrect', or 'missed'")
return
}
// Get event details for logging
var zone, person string
var detailJSON string
if req.EventID > 0 {
// Look up event by ID
// This would require a DB query - for now, we'll just log the ID
log.Printf("[INFO] Feedback for event %d: type=%s blob_id=%d", req.EventID, req.Type, req.BlobID)
} else {
log.Printf("[INFO] Feedback without event: type=%s blob_id=%d", req.Type, req.BlobID)
}
// Create detail JSON for the event
details := make(map[string]interface{})
if req.Position != nil {
details["position"] = req.Position
}
detailBytes, _ := json.Marshal(details)
detailJSON = string(detailBytes)
// If this is feedback for a specific event, we could update the event's detail_json
// to include the feedback. For now, we'll just log a new event.
if req.Type == "incorrect" && req.EventID > 0 {
// Log a "corrected" event to indicate the original was marked incorrect
if h.eventsHandler != nil {
_ = h.eventsHandler.LogEvent("feedback_corrected", time.Now(), zone, person, req.BlobID,
`{"original_event_id":`+strconv.FormatInt(req.EventID, 10)+`,"feedback":"incorrect"}`, "info")
}
} else if req.Type == "correct" && req.EventID > 0 {
// Log a positive feedback event
if h.eventsHandler != nil {
_ = h.eventsHandler.LogEvent("feedback_confirmed", time.Now(), zone, person, req.BlobID,
`{"original_event_id":`+strconv.FormatInt(req.EventID, 10)+`,"feedback":"correct"}`, "info")
}
} else if req.Type == "missed" && req.Position != nil {
// Log a missed detection
if h.eventsHandler != nil {
_ = h.eventsHandler.LogEvent("missed_detection", time.Now(), zone, person, req.BlobID,
detailJSON, "warning")
}
}
// If learning handler is available, process the feedback
if h.learningHandler != nil {
// Try to call ProcessFeedback if the method exists
// This uses reflection-like approach to avoid tight coupling
type processor interface {
ProcessFeedback(feedbackType string, eventID int64, blobID int, positionJSON string) error
}
if p, ok := h.learningHandler.(processor); ok {
var positionJSON string
if req.Position != nil {
positionBytes, _ := json.Marshal(req.Position)
positionJSON = string(positionBytes)
}
_ = p.ProcessFeedback(req.Type, req.EventID, req.BlobID, positionJSON)
}
}
// Return success response with inline message
response := map[string]interface{}{
"ok": true,
"message": "Feedback recorded",
}
// Add inline response based on feedback type
switch req.Type {
case "incorrect":
inlineResp := map[string]interface{}{
"type": "adjustment",
"title": "Adjusting detection threshold",
"message": "I've slightly raised the detection threshold for the contributing links. If this keeps happening at this time of day, my hourly baseline will adapt within a few days. You can also adjust sensitivity manually in Settings.",
}
// Add explainability snapshot if available
if req.BlobID > 0 && h.explainabilityHandler != nil {
// Get current timestamp for the explanation
timestamp := time.Now().UnixMilli()
// Fetch explainability for this blob
// We'll use the blob ID to get the explanation
// Get explanation from the handler directly
if exp := h.getExplainabilityForBlob(req.BlobID, timestamp); exp != nil {
// Build explainability response
explainabilityData := map[string]interface{}{
"blob_id": exp.BlobID,
"x": exp.X,
"y": exp.Y,
"z": exp.Z,
"confidence": exp.Confidence,
"timestamp_ms": exp.Timestamp,
"contributing_links": exp.ContributingLinks,
"all_links": exp.AllLinks,
}
// Add diagnostic info for primary contributing link
if len(exp.ContributingLinks) > 0 && h.diagnosticEngine != nil {
primaryLink := exp.ContributingLinks[0]
linkID := primaryLink.LinkID
eventTime := time.UnixMilli(timestamp)
diagnosis := h.diagnosticEngine.GetDiagnosticFor(linkID, eventTime)
if diagnosis != nil {
diagData := map[string]interface{}{
"rule_id": diagnosis.RuleID,
"severity": diagnosis.Severity,
"title": diagnosis.Title,
"detail": diagnosis.Detail,
"advice": diagnosis.Advice,
"confidence": diagnosis.ConfidenceScore,
}
explainabilityData["diagnosis"] = diagData
// Update the inline response message with diagnostic context
if diagnosis.RuleID != "no_issue_detected" && diagnosis.RuleID != "insufficient_data" {
inlineResp["message"] = diagnosis.Detail + " " + diagnosis.Advice
}
}
}
inlineResp["explainability"] = explainabilityData
}
}
response["inline_response"] = inlineResp
case "correct":
response["inline_response"] = map[string]interface{}{
"type": "confirmation",
"title": "Thanks for confirming!",
"message": "This helps improve detection accuracy over time.",
}
}
writeJSON(w, http.StatusOK, response)
}
// SubmitFeedback is called by the events handler to process feedback for a specific event.
func (h *FeedbackHandler) SubmitFeedback(w http.ResponseWriter, r *http.Request, req FeedbackRequest) {
// Validate feedback type
if req.Type != "correct" && req.Type != "incorrect" && req.Type != "missed" {
writeJSONError(w, http.StatusBadRequest, "invalid feedback type: must be 'correct', 'incorrect', or 'missed'")
return
}
// Get event details for logging
var zone, person string
var detailJSON string
// Create detail JSON for the event
details := make(map[string]interface{})
details["original_event_id"] = req.EventID
details["feedback"] = req.Type
if req.Position != nil {
details["position"] = req.Position
}
detailBytes, _ := json.Marshal(details)
detailJSON = string(detailBytes)
// Log feedback event
if h.eventsHandler != nil {
eventType := "feedback_confirmed"
if req.Type == "incorrect" {
eventType = "feedback_corrected"
} else if req.Type == "missed" {
eventType = "missed_detection"
}
_ = h.eventsHandler.LogEvent(eventType, time.Now(), zone, person, req.BlobID, detailJSON, "info")
}
// If learning handler is available, process the feedback
if h.learningHandler != nil {
type processor interface {
ProcessFeedback(feedbackType string, eventID int64, blobID int, positionJSON string) error
}
if p, ok := h.learningHandler.(processor); ok {
var positionJSON string
if req.Position != nil {
positionBytes, _ := json.Marshal(req.Position)
positionJSON = string(positionBytes)
}
_ = p.ProcessFeedback(req.Type, req.EventID, req.BlobID, positionJSON)
}
}
// Return success response
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
"message": "Feedback recorded",
})
}