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:
156
controllers/agentDecisionController.go
Normal file
156
controllers/agentDecisionController.go
Normal 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})
|
||||
}
|
||||
@@ -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
15
models/agentdecision.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user