Files
doormile_backend/controllers/agentDecisionController.go

178 lines
5.4 KiB
Go

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"`
TenantID *uint64 `json:"tenant_id"`
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,
TenantID: body.TenantID,
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
// POST /api/v1/internal/agent-decisions/similar
//
// POST rather than GET because the body carries a 1536-float embedding, and a
// GET body does not survive nginx — which fronts this API today.
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"`
// TenantID scopes the search. Omitted or null means "decisions with no
// tenant" — NOT "every tenant". Precedent must never cross tenants:
// the engine feeds these rows to the model, so one client's history
// would otherwise shape another client's dispatch.
TenantID *uint64 `json:"tenant_id"`
}
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
// outcome IS NOT NULL is the point of the table: an unresolved decision is
// not precedent, it is just a past guess. The outcome sweeper
// (internal/ai/outcomes) is what makes rows eligible here.
//
// The tenant predicate uses IS NOT DISTINCT FROM so a NULL tenant matches
// only NULL — plain `= ?` would match nothing at all for B2C decisions and
// silently return an empty recall forever.
if err := db.DB.Raw(
`SELECT decision, reasoning, outcome,
context_embedding <=> ? AS distance
FROM agent_decisions
WHERE decision_type = ?
AND outcome IS NOT NULL
AND context_embedding IS NOT NULL
AND tenantid IS NOT DISTINCT FROM ?
ORDER BY context_embedding <=> ?
LIMIT ?`,
embeddingStr, decisionType, body.TenantID, 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})
}