Add AI agent decision memory layer with pgvector similarity search

- models/agentdecision.go: AgentDecision GORM model (context/decision as jsonb, reasoning as text)
- migrations/migrate.go: AutoMigrate AgentDecision then ALTER TABLE to add vector(1536) column and CREATE ivfflat index via raw SQL
- controllers/agentDecisionController.go: CreateAgentDecision, FindSimilarDecisions (cosine distance), UpdateDecisionOutcome
- routes/routes.go: three routes under /api/v1/internal (InternalKeyAuth applied at group level)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 11:57:53 +05:30
parent 9d409a0d85
commit 7c55b523af
4 changed files with 188 additions and 0 deletions

View File

@@ -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})
}

View File

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

15
models/agentdecision.go Normal file
View File

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

View File

@@ -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)