diff --git a/controllers/agentDecisionController.go b/controllers/agentDecisionController.go new file mode 100644 index 0000000..9e3d270 --- /dev/null +++ b/controllers/agentDecisionController.go @@ -0,0 +1,156 @@ +package controllers + +import ( + "encoding/json" + "strconv" + "strings" + "time" + + "doormile/db" + "doormile/models" + "doormile/utils" + + "github.com/gofiber/fiber/v2" +) + +// POST /api/v1/internal/agent-decisions +func CreateAgentDecision(c *fiber.Ctx) error { + type req struct { + DecisionType string `json:"decision_type"` + BookingID *uint64 `json:"booking_id"` + Context map[string]interface{} `json:"context"` + Decision map[string]interface{} `json:"decision"` + Reasoning string `json:"reasoning"` + ContextEmbedding []float32 `json:"context_embedding"` + } + + body := new(req) + if err := c.BodyParser(body); err != nil { + return utils.BadRequest(c, "invalid request body") + } + if body.DecisionType == "" { + return utils.BadRequest(c, "decision_type is required") + } + + contextJSON, err := json.Marshal(body.Context) + if err != nil { + return utils.BadRequest(c, "invalid context JSON") + } + decisionJSON, err := json.Marshal(body.Decision) + if err != nil { + return utils.BadRequest(c, "invalid decision JSON") + } + + record := models.AgentDecision{ + DecisionType: body.DecisionType, + BookingID: body.BookingID, + Context: string(contextJSON), + Decision: string(decisionJSON), + Reasoning: body.Reasoning, + CreatedAt: time.Now(), + } + + if err := db.DB.Create(&record).Error; err != nil { + utils.Error("CreateAgentDecision: insert failed", "error", err) + return utils.Internal(c, "failed to create agent decision") + } + + if len(body.ContextEmbedding) > 0 { + parts := make([]string, len(body.ContextEmbedding)) + for i, v := range body.ContextEmbedding { + parts[i] = strconv.FormatFloat(float64(v), 'f', -1, 32) + } + embeddingStr := "[" + strings.Join(parts, ",") + "]" + if err := db.DB.Exec( + "UPDATE agent_decisions SET context_embedding = ? WHERE id = ?", + embeddingStr, record.ID, + ).Error; err != nil { + utils.Warn("CreateAgentDecision: embedding update failed", "id", record.ID, "error", err) + } + } + + return utils.Created(c, fiber.Map{"id": record.ID}) +} + +// GET /api/v1/internal/agent-decisions/similar +func FindSimilarDecisions(c *fiber.Ctx) error { + decisionType := c.Query("decision_type") + limit, err := strconv.Atoi(c.Query("limit", "5")) + if err != nil || limit < 1 { + limit = 5 + } + + type req struct { + Embedding []float32 `json:"embedding"` + } + body := new(req) + if err := c.BodyParser(body); err != nil || len(body.Embedding) == 0 { + return utils.BadRequest(c, "embedding array is required") + } + + parts := make([]string, len(body.Embedding)) + for i, v := range body.Embedding { + parts[i] = strconv.FormatFloat(float64(v), 'f', -1, 32) + } + embeddingStr := "[" + strings.Join(parts, ",") + "]" + + type row struct { + Decision string `json:"decision"` + Reasoning string `json:"reasoning"` + Outcome *string `json:"outcome"` + Distance float64 `json:"distance"` + } + var results []row + + if err := db.DB.Raw( + `SELECT decision, reasoning, outcome, + context_embedding <=> ? AS distance + FROM agent_decisions + WHERE decision_type = ? AND outcome IS NOT NULL + ORDER BY context_embedding <=> ? + LIMIT ?`, + embeddingStr, decisionType, embeddingStr, limit, + ).Scan(&results).Error; err != nil { + utils.Error("FindSimilarDecisions: query failed", "error", err) + return utils.Internal(c, "failed to query similar decisions") + } + + return utils.List(c, results, int64(len(results))) +} + +// PATCH /api/v1/internal/agent-decisions/:id/outcome +func UpdateDecisionOutcome(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return utils.BadRequest(c, "invalid decision ID") + } + + type req struct { + Outcome string `json:"outcome"` + } + body := new(req) + if err := c.BodyParser(body); err != nil { + return utils.BadRequest(c, "invalid request body") + } + if body.Outcome == "" { + return utils.BadRequest(c, "outcome is required") + } + + now := time.Now() + result := db.DB.Model(&models.AgentDecision{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "outcome": body.Outcome, + "outcome_recorded_at": now, + }) + + if result.Error != nil { + utils.Error("UpdateDecisionOutcome: update failed", "id", id, "error", result.Error) + return utils.Internal(c, "failed to update outcome") + } + if result.RowsAffected == 0 { + return utils.NotFound(c, "agent decision not found") + } + + return utils.OK(c, fiber.Map{"id": id, "outcome": body.Outcome}) +} diff --git a/migrations/migrate.go b/migrations/migrate.go index 594bc27..45d00af 100644 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -40,6 +40,7 @@ func Migrate(db *gorm.DB) error { &models.CompetitorBranch{}, &models.CarrierPricing{}, &models.DoormilePricing{}, + &models.AgentDecision{}, ) if err != nil { @@ -48,5 +49,18 @@ func Migrate(db *gorm.DB) error { } utils.Info("✅ Database migration completed successfully!") + + if res := db.Exec(`ALTER TABLE agent_decisions ADD COLUMN IF NOT EXISTS context_embedding vector(1536)`); res.Error != nil { + utils.Error("❌ Failed to add context_embedding column", "error", res.Error) + } else { + utils.Info("✅ context_embedding vector column ready") + } + + if res := db.Exec(`CREATE INDEX IF NOT EXISTS idx_agent_decisions_embedding ON agent_decisions USING ivfflat (context_embedding vector_cosine_ops) WITH (lists = 100)`); res.Error != nil { + utils.Error("❌ Failed to create ivfflat index on context_embedding", "error", res.Error) + } else { + utils.Info("✅ ivfflat index on context_embedding ready") + } + return nil } diff --git a/models/agentdecision.go b/models/agentdecision.go new file mode 100644 index 0000000..e58728f --- /dev/null +++ b/models/agentdecision.go @@ -0,0 +1,15 @@ +package models + +import "time" + +type AgentDecision struct { + ID uint64 `gorm:"primaryKey"` + DecisionType string `gorm:"size:50;index"` + BookingID *uint64 `gorm:"index"` + Context string `gorm:"type:jsonb"` + Decision string `gorm:"type:jsonb"` + Reasoning string `gorm:"type:text"` + Outcome *string `gorm:"size:30;index"` + OutcomeRecordedAt *time.Time + CreatedAt time.Time +} diff --git a/routes/routes.go b/routes/routes.go index cd464dd..cc109ac 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -274,6 +274,9 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { internal := api.Group("/internal", middlewares.InternalKeyAuth) internal.Post("/notify", controllers.InternalNotify) internal.Post("/bookings/:id/reassign", controllers.InternalReassign) + internal.Post("/agent-decisions", controllers.CreateAgentDecision) + internal.Get("/agent-decisions/similar", controllers.FindSimilarDecisions) + internal.Patch("/agent-decisions/:id/outcome", controllers.UpdateDecisionOutcome) // -------------------- // WEBSOCKET — live miler tracking (no auth, public tracking link)