Enhance Robot Cache Implementation and Documentation

- Marked the Cache Implementation as complete in TODO.md, confirming all tasks are finished with comprehensive integration tests.
- Updated cache.go to provide a thread-safe in-memory cache for Robot instances, improving performance and reliability.
- Enhanced the Load method to include pagination and configurable model name, ensuring efficient data handling.
- Added detailed comments and structured code for better readability and maintainability across the cache implementation.
- Improved validation and error handling in various utility functions to ensure robustness in data processing.
This commit is contained in:
Max 2026-01-14 18:54:18 +08:00
parent 435cb8b934
commit f330a92a2b
15 changed files with 1391 additions and 616 deletions

File diff suppressed because it is too large Load diff

View file

@ -201,12 +201,17 @@ This phase delivers a fully working scheduling pipeline:
Trigger → Manager → Cache → Dedup → Pool → Worker → Executor(stub) → Job
```
### 3.1 Cache Implementation
### 3.1 Cache Implementation (COMPLETE)
- [ ] `cache/cache.go` - Cache struct with thread-safe map
- [ ] `cache/load.go` - load robots from `__yao.member` where `member_type='robot'` and `autonomous_mode=true`
- [ ] `cache/refresh.go` - refresh single robot, periodic full refresh (every hour)
- [ ] Test: load/refresh with real DB
- [x] `cache/cache.go` - Cache struct with thread-safe map
- [x] `cache/load.go` - load robots from `__yao.member` where `member_type='robot'` and `autonomous_mode=true`
- [x] Implemented pagination (100 robots per page)
- [x] Configurable model name via `SetMemberModel()`
- [x] `cache/refresh.go` - refresh single robot, periodic full refresh (every hour)
- [x] Test: load/refresh with real DB
- [x] Created comprehensive integration tests with real database
- [x] Tests cover Load, LoadByID, Refresh, ListByTeam, GetByStatus
- [x] All tests passing with proper cleanup
### 3.2 Pool Implementation

View file

@ -7,7 +7,7 @@ import (
)
// Cache implements types.Cache interface
// This is a stub implementation for Phase 2
// Thread-safe in-memory cache for Robot instances
type Cache struct {
robots map[string]*types.Robot // memberID -> Robot
byTeam map[string][]string // teamID -> memberIDs
@ -22,12 +22,6 @@ func New() *Cache {
}
}
// Load loads all active robots from database
// Stub: returns nil (will be implemented in Phase 3)
func (c *Cache) Load(ctx *types.Context) error {
return nil
}
// Get returns a robot by member ID
// Stub: returns nil (will be implemented in Phase 3)
func (c *Cache) Get(memberID string) *types.Robot {
@ -37,7 +31,6 @@ func (c *Cache) Get(memberID string) *types.Robot {
}
// List returns all robots for a team
// Stub: returns empty slice (will be implemented in Phase 3)
func (c *Cache) List(teamID string) []*types.Robot {
c.mu.RLock()
defer c.mu.RUnlock()
@ -52,14 +45,14 @@ func (c *Cache) List(teamID string) []*types.Robot {
return robots
}
// Refresh refreshes a single robot's config from database
// Stub: returns nil (will be implemented in Phase 3)
func (c *Cache) Refresh(ctx *types.Context, memberID string) error {
return nil
}
// Note: Refresh is implemented in refresh.go
// Add adds or updates a robot in cache
func (c *Cache) Add(robot *types.Robot) {
if robot == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()

345
agent/robot/cache/cache_test.go vendored Normal file
View file

@ -0,0 +1,345 @@
package cache_test
import (
"context"
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/gou/model"
"github.com/yaoapp/xun/capsule"
"github.com/yaoapp/yao/agent/robot/cache"
"github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/agent/testutils"
)
// TestCacheLoad tests loading all active robots from database
func TestCacheLoad(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
// Clean up any existing test data first
cleanupTestRobots(t)
// Create test robots in database
setupTestRobots(t)
defer cleanupTestRobots(t)
c := cache.New()
ctx := types.NewContext(context.Background(), nil)
// Load all robots
err := c.Load(ctx)
assert.NoError(t, err)
// Count should be at least 2 (may have other robots in DB)
count := c.Count()
assert.GreaterOrEqual(t, count, 2, "Should load at least 2 active autonomous robots")
// Verify first robot
robot1 := c.Get("robot_test_sales_001")
assert.NotNil(t, robot1, "Sales bot should be loaded")
if robot1 == nil {
t.Fatal("robot_test_sales_001 not found in cache")
}
assert.Equal(t, "robot_test_sales_001", robot1.MemberID)
assert.Equal(t, "team_test_cache_001", robot1.TeamID)
assert.Equal(t, "Test Sales Bot", robot1.DisplayName)
assert.Equal(t, types.RobotIdle, robot1.Status)
assert.True(t, robot1.AutonomousMode)
assert.NotNil(t, robot1.Config, "Robot config should be parsed")
assert.NotNil(t, robot1.Config.Identity, "Identity should be parsed")
assert.Equal(t, "Sales Manager", robot1.Config.Identity.Role)
assert.Equal(t, 3, robot1.Config.Quota.GetMax())
// Verify second robot
robot2 := c.Get("robot_test_support_002")
assert.NotNil(t, robot2, "Support bot should be loaded")
assert.Equal(t, "robot_test_support_002", robot2.MemberID)
assert.Equal(t, "Test Support Bot", robot2.DisplayName)
assert.NotNil(t, robot2.Config)
assert.Equal(t, "Customer Support", robot2.Config.Identity.Role)
assert.Equal(t, 2, robot2.Config.Quota.GetMax())
// Verify inactive robot is not loaded
robot3 := c.Get("robot_test_inactive_003")
assert.Nil(t, robot3, "Inactive robot should not be loaded")
}
// TestCacheLoadByID tests loading a single robot by member ID
func TestCacheLoadByID(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
cleanupTestRobots(t)
setupTestRobots(t)
defer cleanupTestRobots(t)
c := cache.New()
ctx := types.NewContext(context.Background(), nil)
t.Run("load existing robot", func(t *testing.T) {
robot, err := c.LoadByID(ctx, "robot_test_sales_001")
assert.NoError(t, err)
assert.NotNil(t, robot)
assert.Equal(t, "robot_test_sales_001", robot.MemberID)
assert.Equal(t, "Test Sales Bot", robot.DisplayName)
assert.NotNil(t, robot.Config)
})
t.Run("load non-existent robot", func(t *testing.T) {
robot, err := c.LoadByID(ctx, "robot_nonexistent")
assert.Error(t, err)
assert.Equal(t, types.ErrRobotNotFound, err)
assert.Nil(t, robot)
})
t.Run("load inactive robot by ID", func(t *testing.T) {
// LoadByID doesn't filter by status, so it should load
robot, err := c.LoadByID(ctx, "robot_test_inactive_003")
assert.NoError(t, err)
assert.NotNil(t, robot)
assert.Equal(t, "robot_test_inactive_003", robot.MemberID)
})
}
// TestCacheRefresh tests refreshing a single robot from database
func TestCacheRefresh(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
cleanupTestRobots(t)
setupTestRobots(t)
defer cleanupTestRobots(t)
c := cache.New()
ctx := types.NewContext(context.Background(), nil)
// Load initial data
err := c.Load(ctx)
assert.NoError(t, err)
t.Run("refresh existing robot", func(t *testing.T) {
err := c.Refresh(ctx, "robot_test_sales_001")
assert.NoError(t, err)
// Robot should still be in cache
robot := c.Get("robot_test_sales_001")
assert.NotNil(t, robot)
})
t.Run("refresh removes non-existent robot", func(t *testing.T) {
// Add a fake robot to cache
c.Add(&types.Robot{MemberID: "robot_test_fake", TeamID: "team_test_cache_001"})
assert.NotNil(t, c.Get("robot_test_fake"))
// Refresh should remove it
err := c.Refresh(ctx, "robot_test_fake")
assert.NoError(t, err)
assert.Nil(t, c.Get("robot_test_fake"), "Non-existent robot should be removed")
})
}
// TestCacheListByTeam tests listing robots by team
func TestCacheListByTeam(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
cleanupTestRobots(t)
setupTestRobots(t)
defer cleanupTestRobots(t)
c := cache.New()
ctx := types.NewContext(context.Background(), nil)
// Load all robots
err := c.Load(ctx)
assert.NoError(t, err)
// List robots by team
robots := c.List("team_test_cache_001")
assert.Len(t, robots, 2, "Should have 2 robots in team_test_cache_001")
// List robots for non-existent team
robots = c.List("team_nonexistent")
assert.Len(t, robots, 0, "Non-existent team should have no robots")
}
// TestCacheGetByStatus tests getting robots by status
func TestCacheGetByStatus(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
cleanupTestRobots(t)
setupTestRobots(t)
defer cleanupTestRobots(t)
c := cache.New()
ctx := types.NewContext(context.Background(), nil)
// Load all robots
err := c.Load(ctx)
assert.NoError(t, err)
// Get idle robots (may have others in DB)
idle := c.GetIdle()
assert.GreaterOrEqual(t, len(idle), 2, "Should have at least 2 idle robots")
// Verify our test robots are not working
testRobot1 := c.Get("robot_test_sales_001")
testRobot2 := c.Get("robot_test_support_002")
assert.Equal(t, types.RobotIdle, testRobot1.Status, "Test robot 1 should be idle")
assert.Equal(t, types.RobotIdle, testRobot2.Status, "Test robot 2 should be idle")
}
// setupTestRobots creates 3 test robot records in database
func setupTestRobots(t *testing.T) {
// Get the actual table name from model
m := model.Select("__yao.member")
tableName := m.MetaData.Table.Name
qb := capsule.Query()
// Robot 1: Sales Bot (active, autonomous)
robotConfig1 := map[string]interface{}{
"identity": map[string]interface{}{
"role": "Sales Manager",
"duties": []string{"Manage leads", "Follow up customers"},
"rules": []string{"Be professional", "Reply within 24h"},
},
"quota": map[string]interface{}{
"max": 3,
"queue": 15,
"priority": 7,
},
"clock": map[string]interface{}{
"mode": "times",
"times": []string{"09:00", "14:00"},
"tz": "Asia/Shanghai",
},
}
config1JSON, _ := json.Marshal(robotConfig1)
err := qb.Table(tableName).Insert([]map[string]interface{}{
{
"member_id": "robot_test_sales_001",
"team_id": "team_test_cache_001",
"member_type": "robot",
"display_name": "Test Sales Bot",
"system_prompt": "You are a professional sales manager assistant.",
"status": "active",
"role_id": "member", // required field
"autonomous_mode": true,
"robot_status": "idle",
"robot_config": string(config1JSON),
},
})
if err != nil {
t.Fatalf("Failed to insert robot_test_sales_001: %v", err)
}
// Robot 2: Support Bot (active, autonomous)
robotConfig2 := map[string]interface{}{
"identity": map[string]interface{}{
"role": "Customer Support",
"duties": []string{"Answer questions", "Resolve issues"},
},
"quota": map[string]interface{}{
"max": 2,
"queue": 10,
"priority": 5,
},
"clock": map[string]interface{}{
"mode": "interval",
"every": "1h",
},
}
config2JSON, _ := json.Marshal(robotConfig2)
err = qb.Table(tableName).Insert([]map[string]interface{}{
{
"member_id": "robot_test_support_002",
"team_id": "team_test_cache_001",
"member_type": "robot",
"display_name": "Test Support Bot",
"system_prompt": "You are a helpful customer support assistant.",
"status": "active",
"role_id": "member", // required field
"autonomous_mode": true,
"robot_status": "idle",
"robot_config": string(config2JSON),
},
})
if err != nil {
t.Fatalf("Failed to insert robot_test_support_002: %v", err)
}
// Robot 3: Inactive robot (should not be loaded by Load())
err = qb.Table(tableName).Insert([]map[string]interface{}{
{
"member_id": "robot_test_inactive_003",
"team_id": "team_test_cache_001",
"member_type": "robot",
"display_name": "Test Inactive Bot",
"status": "inactive",
"role_id": "member", // required field
"autonomous_mode": true,
"robot_status": "paused",
},
})
if err != nil {
t.Fatalf("Failed to insert robot_test_inactive_003: %v", err)
}
}
// cleanupTestRobots removes test robot records
func cleanupTestRobots(t *testing.T) {
qb := capsule.Query()
// Use the member model to perform soft delete
m := model.Select("__yao.member")
// Delete test robots
m.DeleteWhere(model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "member_id", Value: "robot_test_sales_001"},
},
})
m.DeleteWhere(model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "member_id", Value: "robot_test_support_002"},
},
})
m.DeleteWhere(model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "member_id", Value: "robot_test_inactive_003"},
},
})
// Hard delete from database (cleanup for next test run)
m2 := model.Select("__yao.member")
tableName2 := m2.MetaData.Table.Name
qb.Table(tableName2).Where("member_id", "robot_test_sales_001").Delete()
qb.Table(tableName2).Where("member_id", "robot_test_support_002").Delete()
qb.Table(tableName2).Where("member_id", "robot_test_inactive_003").Delete()
}

115
agent/robot/cache/load.go vendored Normal file
View file

@ -0,0 +1,115 @@
package cache
import (
"fmt"
"github.com/yaoapp/gou/model"
"github.com/yaoapp/kun/maps"
"github.com/yaoapp/yao/agent/robot/types"
)
// memberModel is the model name for member table
// Can be changed via SetMemberModel() during system initialization
var memberModel = "__yao.member"
// memberFields are the fields to select when loading robots
var memberFields = []interface{}{
"id",
"member_id",
"team_id",
"display_name",
"system_prompt",
"robot_status",
"autonomous_mode",
"robot_config",
}
// SetMemberModel sets the member model name
// Call this during system initialization to override the default
func SetMemberModel(model string) {
if model != "" {
memberModel = model
}
}
// Load loads all active robots from database with pagination
// Query: member_type='robot' AND autonomous_mode=true AND status='active'
func (c *Cache) Load(ctx *types.Context) error {
m := model.Select(memberModel)
// Clear existing cache first
c.mu.Lock()
c.robots = make(map[string]*types.Robot)
c.byTeam = make(map[string][]string)
c.mu.Unlock()
// Paginate to handle large number of robots
page := 1
pageSize := 100 // load 100 robots per page
totalLoaded := 0
for {
// Query with pagination
result, err := m.Paginate(model.QueryParam{
Select: memberFields,
Wheres: []model.QueryWhere{
{Column: "member_type", Value: "robot"},
{Column: "autonomous_mode", Value: true},
{Column: "status", Value: "active"},
},
}, page, pageSize)
if err != nil {
return fmt.Errorf("failed to load robots (page %d): %w", page, err)
}
// Extract records from pagination result
data, ok := result.Get("data").([]maps.MapStr)
if !ok || len(data) == 0 {
break
}
// Parse and add each robot
for _, record := range data {
robot, err := types.NewRobotFromMap(map[string]interface{}(record))
if err != nil {
// Log error but continue loading other robots
continue
}
c.Add(robot)
totalLoaded++
}
// Check if there are more pages
total, _ := result.Get("total").(int)
if totalLoaded >= total {
break
}
page++
}
return nil
}
// LoadByID loads a single robot from database by member ID
func (c *Cache) LoadByID(ctx *types.Context, memberID string) (*types.Robot, error) {
m := model.Select(memberModel)
records, err := m.Get(model.QueryParam{
Select: memberFields,
Wheres: []model.QueryWhere{
{Column: "member_id", Value: memberID},
{Column: "member_type", Value: "robot"},
},
Limit: 1,
})
if err != nil {
return nil, fmt.Errorf("failed to load robot %s: %w", memberID, err)
}
if len(records) == 0 {
return nil, types.ErrRobotNotFound
}
return types.NewRobotFromMap(map[string]interface{}(records[0]))
}

142
agent/robot/cache/refresh.go vendored Normal file
View file

@ -0,0 +1,142 @@
package cache
import (
"sync"
"time"
"github.com/yaoapp/yao/agent/robot/types"
)
// RefreshConfig holds refresh configuration
type RefreshConfig struct {
Interval time.Duration // full refresh interval (default: 1 hour)
}
// DefaultRefreshConfig returns default refresh configuration
func DefaultRefreshConfig() *RefreshConfig {
return &RefreshConfig{
Interval: time.Hour,
}
}
// refreshState holds the refresh goroutine state
type refreshState struct {
ticker *time.Ticker
done chan struct{}
mu sync.Mutex
}
var refresher = &refreshState{}
// Refresh refreshes a single robot's config from database
func (c *Cache) Refresh(ctx *types.Context, memberID string) error {
robot, err := c.LoadByID(ctx, memberID)
if err != nil {
// If robot not found or no longer autonomous, remove from cache
if err == types.ErrRobotNotFound {
c.Remove(memberID)
return nil
}
return err
}
// Check if robot is still active and autonomous
if !robot.AutonomousMode {
c.Remove(memberID)
return nil
}
// Update cache
c.Add(robot)
return nil
}
// StartAutoRefresh starts periodic full refresh
func (c *Cache) StartAutoRefresh(ctx *types.Context, config *RefreshConfig) {
if config == nil {
config = DefaultRefreshConfig()
}
refresher.mu.Lock()
defer refresher.mu.Unlock()
// Stop existing refresher if any
if refresher.done != nil {
close(refresher.done)
}
refresher.ticker = time.NewTicker(config.Interval)
refresher.done = make(chan struct{})
go func() {
for {
select {
case <-refresher.done:
refresher.ticker.Stop()
return
case <-refresher.ticker.C:
// Perform full refresh
_ = c.Load(ctx)
}
}
}()
}
// StopAutoRefresh stops the periodic refresh
func (c *Cache) StopAutoRefresh() {
refresher.mu.Lock()
defer refresher.mu.Unlock()
if refresher.done != nil {
close(refresher.done)
refresher.done = nil
}
}
// RefreshAll reloads all robots from database
func (c *Cache) RefreshAll(ctx *types.Context) error {
return c.Load(ctx)
}
// Count returns the number of cached robots
func (c *Cache) Count() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.robots)
}
// ListAll returns all cached robots (across all teams)
func (c *Cache) ListAll() []*types.Robot {
c.mu.RLock()
defer c.mu.RUnlock()
robots := make([]*types.Robot, 0, len(c.robots))
for _, robot := range c.robots {
robots = append(robots, robot)
}
return robots
}
// GetByStatus returns robots with the specified status
func (c *Cache) GetByStatus(status types.RobotStatus) []*types.Robot {
c.mu.RLock()
defer c.mu.RUnlock()
var robots []*types.Robot
for _, robot := range c.robots {
if robot.Status == status {
robots = append(robots, robot)
}
}
return robots
}
// GetIdle returns all idle robots ready to execute
func (c *Cache) GetIdle() []*types.Robot {
return c.GetByStatus(types.RobotIdle)
}
// GetWorking returns all currently working robots
func (c *Cache) GetWorking() []*types.Robot {
return c.GetByStatus(types.RobotWorking)
}

View file

@ -5,11 +5,11 @@ import "time"
// ClockContext - time context for P0 inspiration
type ClockContext struct {
Now time.Time `json:"now"`
Hour int `json:"hour"` // 0-23
DayOfWeek string `json:"day_of_week"` // Monday, Tuesday...
DayOfMonth int `json:"day_of_month"` // 1-31
WeekOfYear int `json:"week_of_year"` // 1-52
Month int `json:"month"` // 1-12
Hour int `json:"hour"` // 0-23
DayOfWeek string `json:"day_of_week"` // Monday, Tuesday...
DayOfMonth int `json:"day_of_month"` // 1-31
WeekOfYear int `json:"week_of_year"` // 1-52
Month int `json:"month"` // 1-12
Year int `json:"year"`
IsWeekend bool `json:"is_weekend"`
IsMonthStart bool `json:"is_month_start"` // 1st-3rd

View file

@ -1,6 +1,9 @@
package types
import "time"
import (
"encoding/json"
"time"
)
// Config - robot_config in __yao.member
type Config struct {
@ -8,9 +11,9 @@ type Config struct {
Clock *Clock `json:"clock,omitempty"`
Identity *Identity `json:"identity"`
Quota *Quota `json:"quota,omitempty"`
KB *KB `json:"kb,omitempty"` // shared knowledge base (same as assistant)
DB *DB `json:"db,omitempty"` // shared database (same as assistant)
Learn *Learn `json:"learn,omitempty"` // learning config for private KB
KB *KB `json:"kb,omitempty"` // shared knowledge base (same as assistant)
DB *DB `json:"db,omitempty"` // shared database (same as assistant)
Learn *Learn `json:"learn,omitempty"` // learning config for private KB
Resources *Resources `json:"resources,omitempty"`
Delivery *Delivery `json:"delivery,omitempty"`
Events []Event `json:"events,omitempty"`
@ -206,3 +209,44 @@ type Event struct {
Source string `json:"source"` // webhook path or table name
Filter map[string]interface{} `json:"filter,omitempty"`
}
// ParseConfig parses robot_config from various formats (string, []byte, map)
func ParseConfig(data interface{}) (*Config, error) {
if data == nil {
return nil, nil
}
var configBytes []byte
switch v := data.(type) {
case string:
if v == "" {
return nil, nil
}
configBytes = []byte(v)
case []byte:
if len(v) == 0 {
return nil, nil
}
configBytes = v
case map[string]interface{}:
var err error
configBytes, err = json.Marshal(v)
if err != nil {
return nil, err
}
default:
var err error
configBytes, err = json.Marshal(v)
if err != nil {
return nil, err
}
}
var config Config
if err := json.Unmarshal(configBytes, &config); err != nil {
return nil, err
}
return &config, nil
}

View file

@ -8,7 +8,7 @@ import (
// Context - robot execution context (lightweight)
type Context struct {
context.Context // embed standard context
context.Context // embed standard context
Auth *types.AuthorizedInfo `json:"auth,omitempty"` // reuse oauth AuthorizedInfo
MemberID string `json:"member_id,omitempty"` // current robot member ID
RequestID string `json:"request_id,omitempty"` // request trace ID

View file

@ -36,8 +36,8 @@ type RobotState struct {
TeamID string `json:"team_id"`
DisplayName string `json:"display_name"`
Status RobotStatus `json:"status"`
Running int `json:"running"` // current running execution count
MaxRunning int `json:"max_running"` // max concurrent allowed
Running int `json:"running"` // current running execution count
MaxRunning int `json:"max_running"` // max concurrent allowed
LastRun *time.Time `json:"last_run,omitempty"`
NextRun *time.Time `json:"next_run,omitempty"`
RunningIDs []string `json:"running_ids,omitempty"` // list of running execution IDs

View file

@ -2,6 +2,7 @@ package types
import (
"context"
"fmt"
"sync"
"time"
@ -89,8 +90,8 @@ func (r *Robot) GetExecutions() []*Execution {
// Each trigger creates a new Execution, mapped to a job.Job for monitoring
// Relationship: 1 Execution = 1 job.Job
type Execution struct {
ID string `json:"id"` // unique execution ID
MemberID string `json:"member_id"` // robot member ID
ID string `json:"id"` // unique execution ID
MemberID string `json:"member_id"` // robot member ID
TeamID string `json:"team_id"`
TriggerType TriggerType `json:"trigger_type"` // clock | human | event
StartTime time.Time `json:"start_time"`
@ -148,11 +149,13 @@ type CurrentState struct {
// Example:
// ## Goals
// 1. [High] Analyze sales data and identify trends
// - Reason: Sales up 50%, need to understand why
// - Reason: Sales up 50%, need to understand why
//
// 2. [Normal] Prepare weekly report for manager
// - Reason: Friday 5pm, weekly report due
// - Reason: Friday 5pm, weekly report due
//
// 3. [Low] Update CRM with new leads
// - Reason: 3 pending leads from yesterday
// - Reason: 3 pending leads from yesterday
type Goals struct {
Content string `json:"content"` // markdown text
}
@ -201,3 +204,76 @@ type LearningEntry struct {
Tags []string `json:"tags,omitempty"`
Meta interface{} `json:"meta,omitempty"`
}
// NewRobotFromMap creates a Robot from a map (typically from DB record)
func NewRobotFromMap(m map[string]interface{}) (*Robot, error) {
memberID := getString(m, "member_id")
teamID := getString(m, "team_id")
// Validate required fields
if memberID == "" || teamID == "" {
return nil, fmt.Errorf("missing required fields: member_id or team_id")
}
robot := &Robot{
MemberID: memberID,
TeamID: teamID,
DisplayName: getString(m, "display_name"),
SystemPrompt: getString(m, "system_prompt"),
AutonomousMode: getBool(m, "autonomous_mode"),
}
// Parse robot_status
if status := getString(m, "robot_status"); status != "" {
robot.Status = RobotStatus(status)
} else {
robot.Status = RobotIdle
}
// Parse robot_config JSON
if configData, ok := m["robot_config"]; ok && configData != nil {
config, err := ParseConfig(configData)
if err != nil {
return nil, fmt.Errorf("failed to parse robot_config: %w", err)
}
robot.Config = config
}
return robot, nil
}
// getString safely gets a string value from map
func getString(m map[string]interface{}, key string) string {
if m == nil {
return ""
}
if v, ok := m[key]; ok && v != nil {
if s, ok := v.(string); ok {
return s
}
return fmt.Sprintf("%v", v)
}
return ""
}
// getBool safely gets a bool value from map
func getBool(m map[string]interface{}, key string) bool {
if m == nil {
return false
}
if v, ok := m[key]; ok && v != nil {
switch b := v.(type) {
case bool:
return b
case int:
return b != 0
case int64:
return b != 0
case float64:
return b != 0
case string:
return b == "true" || b == "1"
}
}
return false
}

View file

@ -89,3 +89,58 @@ func CloneMap(m map[string]interface{}) map[string]interface{} {
}
return result
}
// GetString safely gets a string value from map
func GetString(m map[string]interface{}, key string) string {
if m == nil {
return ""
}
if v, ok := m[key]; ok && v != nil {
return ToString(v)
}
return ""
}
// GetBool safely gets a bool value from map
func GetBool(m map[string]interface{}, key string) bool {
if m == nil {
return false
}
if v, ok := m[key]; ok && v != nil {
switch b := v.(type) {
case bool:
return b
case int:
return b != 0
case int64:
return b != 0
case float64:
return b != 0
case string:
return b == "true" || b == "1"
}
}
return false
}
// GetInt safely gets an int value from map
func GetInt(m map[string]interface{}, key string) int {
if m == nil {
return 0
}
if v, ok := m[key]; ok && v != nil {
switch n := v.(type) {
case int:
return n
case int64:
return int(n)
case float64:
return int(n)
case string:
var i int
fmt.Sscanf(n, "%d", &i)
return i
}
}
return 0
}

View file

@ -92,7 +92,7 @@ func NextScheduledTime(now time.Time, timeStr string, days []string, loc *time.L
}
nowInLoc := now.In(loc)
// Start from today at the specified time
next := time.Date(nowInLoc.Year(), nowInLoc.Month(), nowInLoc.Day(), hour, minute, 0, 0, loc)

View file

@ -145,10 +145,10 @@ func TestToJSON(t *testing.T) {
func TestFromJSON(t *testing.T) {
jsonStr := `{"name":"test","age":30}`
var result map[string]interface{}
err := utils.FromJSON(jsonStr, &result)
assert.NoError(t, err)
assert.Equal(t, "test", result["name"])
assert.Equal(t, float64(30), result["age"]) // JSON numbers are float64

View file

@ -8,7 +8,7 @@ import (
var (
// Email regex pattern
emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
// Time pattern (HH:MM)
timeRegex = regexp.MustCompile(`^([01]?[0-9]|2[0-3]):[0-5][0-9]$`)
)
@ -33,7 +33,7 @@ func ValidateRequired(fieldName string, value interface{}) error {
if value == nil {
return fmt.Errorf("%s is required", fieldName)
}
switch v := value.(type) {
case string:
if IsEmpty(v) {
@ -48,7 +48,7 @@ func ValidateRequired(fieldName string, value interface{}) error {
return fmt.Errorf("%s is required", fieldName)
}
}
return nil
}