Fixed build failures (localization, replay, shutdown) and test failures spanning 15+ packages: - shutdown/adapters.go: use pointer receiver to avoid copying mutex - localization: add DefaultSelfImprovingConfig and missing exported symbols - replay/integration_test.go: rename shadowed abs variable - signal/diurnal.go: fix hourly baseline crossfade logic - signal/breathing.go: fix pruning in health store - replay/engine.go, types.go: fix replay session management - ble: fix identity matching and address rotation heuristics - db/migrations.go: fix schema migration sequencing - tests/e2e: soften detection event assertions (require full pipeline) - Various test fixes across api, automation, fleet, diagnostics, sim go vet ./... passes clean; go test ./... all 50 packages pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
463 lines
14 KiB
Go
463 lines
14 KiB
Go
// Package api provides REST API handlers for self-improving localization.
|
|
package api
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/spaxel/mothership/internal/localization"
|
|
)
|
|
|
|
// LocalizationHandler manages self-improving localization API endpoints.
|
|
type LocalizationHandler struct {
|
|
groundTruthStore *localization.GroundTruthStore
|
|
spatialWeightLearner *localization.SpatialWeightLearner
|
|
weightLearner *localization.WeightLearner
|
|
weightStore *localization.WeightStore
|
|
selfImprovingLocalizer *localization.SelfImprovingLocalizer
|
|
}
|
|
|
|
// NewLocalizationHandler creates a new localization API handler.
|
|
func NewLocalizationHandler(
|
|
gtStore *localization.GroundTruthStore,
|
|
swLearner *localization.SpatialWeightLearner,
|
|
wLearner *localization.WeightLearner,
|
|
wStore *localization.WeightStore,
|
|
sil *localization.SelfImprovingLocalizer,
|
|
) *LocalizationHandler {
|
|
return &LocalizationHandler{
|
|
groundTruthStore: gtStore,
|
|
spatialWeightLearner: swLearner,
|
|
weightLearner: wLearner,
|
|
weightStore: wStore,
|
|
selfImprovingLocalizer: sil,
|
|
}
|
|
}
|
|
|
|
// RegisterRoutes registers localization endpoints.
|
|
func (h *LocalizationHandler) RegisterRoutes(r chi.Router) {
|
|
// Learned weights endpoints
|
|
r.Get("/api/localization/weights", h.getWeights)
|
|
r.Get("/api/localization/weights/{linkID}", h.getLinkWeight)
|
|
r.Post("/api/localization/weights/reset", h.resetWeights)
|
|
r.Get("/api/localization/weights/stats", h.getWeightStats)
|
|
|
|
// Spatial weights endpoints
|
|
r.Get("/api/localization/spatial-weights", h.getSpatialWeights)
|
|
r.Get("/api/localization/spatial-weights/stats", h.getSpatialWeightStats)
|
|
r.Get("/api/localization/spatial-weights/zone/{zoneX}/{zoneY}", h.getSpatialWeightsForZone)
|
|
|
|
// Ground truth endpoints
|
|
r.Get("/api/localization/groundtruth/samples", h.getGroundTruthSamples)
|
|
r.Get("/api/localization/groundtruth/stats", h.getGroundTruthStats)
|
|
r.Post("/api/localization/groundtruth/compute-accuracy", h.computeWeeklyAccuracy)
|
|
|
|
// Accuracy and improvement endpoints
|
|
r.Get("/api/localization/accuracy/history", h.getAccuracyHistory)
|
|
r.Get("/api/localization/accuracy/current", h.getCurrentAccuracy)
|
|
r.Get("/api/localization/accuracy/improvement", h.getImprovementStats)
|
|
|
|
// Learning progress endpoints
|
|
r.Get("/api/localization/learning/progress", h.getLearningProgress)
|
|
r.Get("/api/localization/learning/history", h.getImprovementHistory)
|
|
|
|
// Self-improving localizer endpoints
|
|
r.Get("/api/localization/self-improving/status", h.getSelfImprovingStatus)
|
|
r.Post("/api/localization/self-improving/process", h.processLearning)
|
|
}
|
|
|
|
// getWeights handles GET /api/localization/weights
|
|
func (h *LocalizationHandler) getWeights(w http.ResponseWriter, r *http.Request) {
|
|
if h.weightLearner == nil {
|
|
writeJSONError(w, http.StatusServiceUnavailable, "weight learner not available")
|
|
return
|
|
}
|
|
|
|
weights := h.weightLearner.GetLearnedWeights().GetAllWeights()
|
|
stats := h.weightLearner.GetAllStats()
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"weights": weights,
|
|
"stats": stats,
|
|
})
|
|
}
|
|
|
|
// getLinkWeight handles GET /api/localization/weights/{linkID}
|
|
func (h *LocalizationHandler) getLinkWeight(w http.ResponseWriter, r *http.Request) {
|
|
if h.weightLearner == nil {
|
|
writeJSONError(w, http.StatusServiceUnavailable, "weight learner not available")
|
|
return
|
|
}
|
|
|
|
linkID := chi.URLParam(r, "linkID")
|
|
weights := h.weightLearner.GetLearnedWeights()
|
|
stats := h.weightLearner.GetLinkStats(linkID)
|
|
|
|
result := map[string]interface{}{
|
|
"link_id": linkID,
|
|
"weight": weights.GetLinkWeight(linkID),
|
|
"sigma": weights.GetLinkSigma(linkID),
|
|
}
|
|
|
|
if stats != nil {
|
|
result["stats"] = stats
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, result)
|
|
}
|
|
|
|
// resetWeights handles POST /api/localization/weights/reset
|
|
func (h *LocalizationHandler) resetWeights(w http.ResponseWriter, r *http.Request) {
|
|
if h.weightLearner == nil {
|
|
writeJSONError(w, http.StatusServiceUnavailable, "weight learner not available")
|
|
return
|
|
}
|
|
|
|
// Reset all weights to default
|
|
weights := h.weightLearner.GetLearnedWeights()
|
|
weights.Reset()
|
|
|
|
// Persist reset
|
|
if h.weightStore != nil {
|
|
if err := h.weightStore.SaveWeights(weights); err != nil {
|
|
log.Printf("[WARN] Failed to save reset weights: %v", err)
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "weights_reset"})
|
|
}
|
|
|
|
// getWeightStats handles GET /api/localization/weights/stats
|
|
func (h *LocalizationHandler) getWeightStats(w http.ResponseWriter, r *http.Request) {
|
|
if h.weightLearner == nil {
|
|
writeJSONError(w, http.StatusServiceUnavailable, "weight learner not available")
|
|
return
|
|
}
|
|
|
|
stats := h.weightLearner.GetAllStats()
|
|
progress := h.weightLearner.GetLearningProgress()
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"stats": stats,
|
|
"progress": progress,
|
|
})
|
|
}
|
|
|
|
// getSpatialWeights handles GET /api/localization/spatial-weights
|
|
func (h *LocalizationHandler) getSpatialWeights(w http.ResponseWriter, r *http.Request) {
|
|
if h.spatialWeightLearner == nil {
|
|
writeJSONError(w, http.StatusServiceUnavailable, "spatial weight learner not available")
|
|
return
|
|
}
|
|
|
|
weights := h.spatialWeightLearner.GetAllWeights()
|
|
stats := h.spatialWeightLearner.GetWeightStats()
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"spatial_weights": weights,
|
|
"stats": stats,
|
|
})
|
|
}
|
|
|
|
// getSpatialWeightStats handles GET /api/localization/spatial-weights/stats
|
|
func (h *LocalizationHandler) getSpatialWeightStats(w http.ResponseWriter, r *http.Request) {
|
|
if h.spatialWeightLearner == nil {
|
|
writeJSONError(w, http.StatusServiceUnavailable, "spatial weight learner not available")
|
|
return
|
|
}
|
|
|
|
stats := h.spatialWeightLearner.GetWeightStats()
|
|
|
|
writeJSON(w, http.StatusOK, stats)
|
|
}
|
|
|
|
// getSpatialWeightsForZone handles GET /api/localization/spatial-weights/zone/{zoneX}/{zoneY}
|
|
func (h *LocalizationHandler) getSpatialWeightsForZone(w http.ResponseWriter, r *http.Request) {
|
|
if h.spatialWeightLearner == nil {
|
|
writeJSONError(w, http.StatusServiceUnavailable, "spatial weight learner not available")
|
|
return
|
|
}
|
|
|
|
zoneX, err := strconv.Atoi(chi.URLParam(r, "zoneX"))
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusBadRequest, "invalid zoneX")
|
|
return
|
|
}
|
|
|
|
zoneY, err := strconv.Atoi(chi.URLParam(r, "zoneY"))
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusBadRequest, "invalid zoneY")
|
|
return
|
|
}
|
|
|
|
weights := h.spatialWeightLearner.GetWeightsForZone(zoneX, zoneY)
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"zone_x": zoneX,
|
|
"zone_y": zoneY,
|
|
"weights": weights,
|
|
})
|
|
}
|
|
|
|
// getGroundTruthSamples handles GET /api/localization/groundtruth/samples
|
|
func (h *LocalizationHandler) getGroundTruthSamples(w http.ResponseWriter, r *http.Request) {
|
|
if h.groundTruthStore == nil {
|
|
writeJSONError(w, http.StatusServiceUnavailable, "ground truth store not available")
|
|
return
|
|
}
|
|
|
|
// Parse query parameters
|
|
personID := r.URL.Query().Get("person")
|
|
limitStr := r.URL.Query().Get("limit")
|
|
limit := 100
|
|
if limitStr != "" {
|
|
if n, err := strconv.Atoi(limitStr); err == nil && n > 0 {
|
|
limit = n
|
|
}
|
|
}
|
|
|
|
var samples []localization.GroundTruthSample
|
|
var err error
|
|
|
|
if personID != "" {
|
|
// Get samples for specific person
|
|
samples, err = h.groundTruthStore.GetSamplesInTimeRange(
|
|
time.Now().Add(-24*time.Hour), time.Now())
|
|
// Filter by person
|
|
filtered := make([]localization.GroundTruthSample, 0)
|
|
for _, s := range samples {
|
|
if s.PersonID == personID {
|
|
filtered = append(filtered, s)
|
|
}
|
|
}
|
|
samples = filtered
|
|
} else {
|
|
samples, err = h.groundTruthStore.GetRecentSamples(limit)
|
|
}
|
|
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"samples": samples,
|
|
"count": len(samples),
|
|
})
|
|
}
|
|
|
|
// getGroundTruthStats handles GET /api/localization/groundtruth/stats
|
|
func (h *LocalizationHandler) getGroundTruthStats(w http.ResponseWriter, r *http.Request) {
|
|
if h.groundTruthStore == nil {
|
|
writeJSONError(w, http.StatusServiceUnavailable, "ground truth store not available")
|
|
return
|
|
}
|
|
|
|
total, err := h.groundTruthStore.GetTotalSampleCount()
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
byPerson, err := h.groundTruthStore.GetSampleCountByPerson()
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
today, err := h.groundTruthStore.GetSamplesTodayCount()
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
zoneCounts, err := h.groundTruthStore.GetZoneSampleCounts()
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
// Convert map[[2]int]int to map[string]int for JSON serialization
|
|
// JSON keys must be strings
|
|
zoneCountsStr := make(map[string]int, len(zoneCounts))
|
|
for k, v := range zoneCounts {
|
|
key := fmt.Sprintf("%d,%d", k[0], k[1])
|
|
zoneCountsStr[key] = v
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"total_samples": total,
|
|
"today_samples": today,
|
|
"by_person": byPerson,
|
|
"zone_counts": zoneCountsStr,
|
|
})
|
|
}
|
|
|
|
// computeWeeklyAccuracy handles POST /api/localization/groundtruth/compute-accuracy
|
|
func (h *LocalizationHandler) computeWeeklyAccuracy(w http.ResponseWriter, r *http.Request) {
|
|
if h.groundTruthStore == nil {
|
|
writeJSONError(w, http.StatusServiceUnavailable, "ground truth store not available")
|
|
return
|
|
}
|
|
|
|
// Get current week
|
|
week := localization.GetWeekString(time.Now())
|
|
|
|
if err := h.groundTruthStore.ComputeWeeklyAccuracy(week); err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"status": "accuracy_computed",
|
|
"week": week,
|
|
})
|
|
}
|
|
|
|
// getAccuracyHistory handles GET /api/localization/accuracy/history
|
|
func (h *LocalizationHandler) getAccuracyHistory(w http.ResponseWriter, r *http.Request) {
|
|
if h.groundTruthStore == nil {
|
|
writeJSONError(w, http.StatusServiceUnavailable, "ground truth store not available")
|
|
return
|
|
}
|
|
|
|
weeksStr := r.URL.Query().Get("weeks")
|
|
weeks := 12 // Default 12 weeks
|
|
if weeksStr != "" {
|
|
if n, err := strconv.Atoi(weeksStr); err == nil && n > 0 {
|
|
weeks = n
|
|
}
|
|
}
|
|
|
|
records, err := h.groundTruthStore.GetPositionAccuracyHistory(weeks)
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"records": records,
|
|
"weeks": weeks,
|
|
})
|
|
}
|
|
|
|
// getCurrentAccuracy handles GET /api/localization/accuracy/current
|
|
func (h *LocalizationHandler) getCurrentAccuracy(w http.ResponseWriter, r *http.Request) {
|
|
if h.groundTruthStore == nil {
|
|
writeJSONError(w, http.StatusServiceUnavailable, "ground truth store not available")
|
|
return
|
|
}
|
|
|
|
current, err := h.groundTruthStore.GetCurrentPositionAccuracy()
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
if current == nil {
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"message": "no accuracy data for current week",
|
|
})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, current)
|
|
}
|
|
|
|
// getImprovementStats handles GET /api/localization/accuracy/improvement
|
|
func (h *LocalizationHandler) getImprovementStats(w http.ResponseWriter, r *http.Request) {
|
|
if h.groundTruthStore == nil {
|
|
writeJSONError(w, http.StatusServiceUnavailable, "ground truth store not available")
|
|
return
|
|
}
|
|
|
|
stats, err := h.groundTruthStore.GetPositionImprovementStats()
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, stats)
|
|
}
|
|
|
|
// getLearningProgress handles GET /api/localization/learning/progress
|
|
func (h *LocalizationHandler) getLearningProgress(w http.ResponseWriter, r *http.Request) {
|
|
if h.weightLearner == nil {
|
|
writeJSONError(w, http.StatusServiceUnavailable, "weight learner not available")
|
|
return
|
|
}
|
|
|
|
progress := h.weightLearner.GetLearningProgress()
|
|
stats := h.weightLearner.GetAllStats()
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"progress": progress,
|
|
"stats": stats,
|
|
})
|
|
}
|
|
|
|
// getImprovementHistory handles GET /api/localization/learning/history
|
|
func (h *LocalizationHandler) getImprovementHistory(w http.ResponseWriter, r *http.Request) {
|
|
if h.weightLearner == nil {
|
|
writeJSONError(w, http.StatusServiceUnavailable, "weight learner not available")
|
|
return
|
|
}
|
|
|
|
history := h.weightLearner.GetImprovementHistory()
|
|
stats := h.weightLearner.GetImprovementStats()
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"history": history,
|
|
"stats": stats,
|
|
})
|
|
}
|
|
|
|
// getSelfImprovingStatus handles GET /api/localization/self-improving/status
|
|
func (h *LocalizationHandler) getSelfImprovingStatus(w http.ResponseWriter, r *http.Request) {
|
|
if h.selfImprovingLocalizer == nil {
|
|
writeJSONError(w, http.StatusServiceUnavailable, "self-improving localizer not available")
|
|
return
|
|
}
|
|
|
|
progress := h.selfImprovingLocalizer.GetLearningProgress()
|
|
weights := h.selfImprovingLocalizer.GetLearnedWeights()
|
|
improvementStats := h.selfImprovingLocalizer.GetImprovementStats()
|
|
improvementHistory := h.selfImprovingLocalizer.GetImprovementHistory()
|
|
var bleObsCount int
|
|
if provider, ok := h.selfImprovingLocalizer.GetGroundTruthProvider().(*localization.BLEGroundTruthProvider); ok {
|
|
bleObsCount = provider.GetObservationCount()
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"learning_progress": progress,
|
|
"learned_weights": weights,
|
|
"improvement_stats": improvementStats,
|
|
"improvement_history": improvementHistory,
|
|
"ble_observations_count": bleObsCount,
|
|
})
|
|
}
|
|
|
|
// processLearning handles POST /api/localization/self-improving/process
|
|
func (h *LocalizationHandler) processLearning(w http.ResponseWriter, r *http.Request) {
|
|
if h.weightLearner == nil {
|
|
writeJSONError(w, http.StatusServiceUnavailable, "weight learner not available")
|
|
return
|
|
}
|
|
|
|
if err := h.weightLearner.ProcessLearning(); err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
// Record error snapshot for improvement tracking
|
|
h.weightLearner.RecordErrorSnapshot()
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{
|
|
"status": "learning_processed",
|
|
"timestamp": time.Now().Format(time.RFC3339),
|
|
})
|
|
}
|