tâches planifiées : prompts cron persistés, outil scheduler, conversations liées aux tâches, rattrapage des exécutions manquées au redémarrage (go-cron MissedRunOnce)
This commit is contained in:
1 parent
bedb6c98e7
commit
058370830f
71 files changed
+1627
-100
No files matched your search
@@ -17,6 +17,7 @@ type Database interface {
|
||||
AgentRepository() domain.AgentRepository
|
||||
ChatRepository() domain.ChatRepository
|
||||
ToolRepository() domain.ToolRepository
|
||||
TaskRepository() domain.TaskRepository
|
||||
Migrate() error
|
||||
Close()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package turso
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
@@ -30,7 +31,7 @@ func NewTursoChatRepository(db *sql.DB) *TursoChatRepository {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *TursoChatRepository) ListChats(userID string, teamID string, page int, query string) ([]*domain.Chat, error) {
|
||||
func (r *TursoChatRepository) ListChats(userID string, teamID string, page int, query string, taskID string) ([]*domain.Chat, error) {
|
||||
where := "team_id=$1 and team_id in (select team_id from user_teams where user_id=$2)"
|
||||
args := []any{teamID, userID}
|
||||
if query != "" {
|
||||
@@ -41,6 +42,10 @@ func (r *TursoChatRepository) ListChats(userID string, teamID string, page int,
|
||||
where += " and title like $3 escape '\\'"
|
||||
args = append(args, "%"+escaped+"%")
|
||||
}
|
||||
if taskID != "" {
|
||||
where += fmt.Sprintf(" and task_id=$%d", len(args)+1)
|
||||
args = append(args, taskID)
|
||||
}
|
||||
return r.chatTable.Select(
|
||||
orm.WithWhere(where, args...),
|
||||
orm.WithOrder("_date_created desc"),
|
||||
|
||||
@@ -87,3 +87,7 @@ func (db *TursoDB) ChatRepository() domain.ChatRepository {
|
||||
func (db *TursoDB) ToolRepository() domain.ToolRepository {
|
||||
return NewTursoToolRepository(db.DB)
|
||||
}
|
||||
|
||||
func (db *TursoDB) TaskRepository() domain.TaskRepository {
|
||||
return NewTursoTaskRepository(db.DB)
|
||||
}
|
||||
@@ -25,7 +25,7 @@ func TestListChatsFilter(t *testing.T) {
|
||||
create table user_teams (user_id text, team_id text);
|
||||
insert into chats (id, team_id, user_id, agent_id, task_id, title, fresh_title, _date_created, _version)
|
||||
values ('c1','t1','u1','','','Problème de timeout',0,'2026-01-01 10:00:00','v'),
|
||||
('c2','t1','u1','','','Discussion navigateur',0,'2026-01-02 10:00:00','v'),
|
||||
('c2','t1','u1','','task-9','Discussion navigateur',0,'2026-01-02 10:00:00','v'),
|
||||
('c3','t1','u1','','','Recette de cuisine 100%',0,'2026-01-03 10:00:00','v');
|
||||
insert into user_teams (user_id, team_id) values ('u1','t1');
|
||||
insert into chats (id, team_id, user_id, agent_id, task_id, title, fresh_title, _date_created, _version)
|
||||
@@ -40,14 +40,14 @@ func TestListChatsFilter(t *testing.T) {
|
||||
repo := NewTursoChatRepository(db)
|
||||
|
||||
// sans filtre, page 0 : 20 conversations (28 en base)
|
||||
p0, err := repo.ListChats("u1", "t1", 0, "")
|
||||
p0, err := repo.ListChats("u1", "t1", 0, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(p0) != 20 {
|
||||
t.Fatalf("page 0: expected 20, got %d", len(p0))
|
||||
}
|
||||
p1, err := repo.ListChats("u1", "t1", 1, "")
|
||||
p1, err := repo.ListChats("u1", "t1", 1, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -61,7 +61,7 @@ func TestListChatsFilter(t *testing.T) {
|
||||
}
|
||||
seen[c.ID] = true
|
||||
}
|
||||
p2, err := repo.ListChats("u1", "t1", 2, "")
|
||||
p2, err := repo.ListChats("u1", "t1", 2, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -70,7 +70,7 @@ func TestListChatsFilter(t *testing.T) {
|
||||
}
|
||||
|
||||
// filtre simple
|
||||
res, err := repo.ListChats("u1", "t1", 0, "timeout")
|
||||
res, err := repo.ListChats("u1", "t1", 0, "timeout", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -79,7 +79,7 @@ func TestListChatsFilter(t *testing.T) {
|
||||
}
|
||||
|
||||
// jokers LIKE neutralisés : '100%' ne doit rien matcher de spécial
|
||||
res, err = repo.ListChats("u1", "t1", 0, "100%")
|
||||
res, err = repo.ListChats("u1", "t1", 0, "100%", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func TestListChatsFilter(t *testing.T) {
|
||||
}
|
||||
|
||||
// pas de résultat
|
||||
res, err = repo.ListChats("u1", "t1", 0, "inexistant")
|
||||
res, err = repo.ListChats("u1", "t1", 0, "inexistant", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -96,5 +96,14 @@ func TestListChatsFilter(t *testing.T) {
|
||||
t.Fatalf("expected 0, got %d", len(res))
|
||||
}
|
||||
|
||||
// filtre par tâche
|
||||
taskRes, err := repo.ListChats("u1", "t1", 0, "", "task-9")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(taskRes) != 1 || taskRes[0].ID != "c2" {
|
||||
t.Fatalf("filter task-9: expected [c2], got %v", taskRes)
|
||||
}
|
||||
|
||||
_ = domain.Chat{}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
alter table tasks drop column repeat;
|
||||
@@ -0,0 +1 @@
|
||||
alter table tasks add column repeat integer not null default 1;
|
||||
+1
@@ -0,0 +1 @@
|
||||
alter table tasks drop column last_datetime;
|
||||
+1
@@ -0,0 +1 @@
|
||||
alter table tasks add column last_datetime numeric;
|
||||
@@ -0,0 +1,85 @@
|
||||
package turso
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
|
||||
"trankilou.fr/lassistanoque/backend/internal/adapter/database/orm"
|
||||
"trankilou.fr/lassistanoque/backend/internal/domain"
|
||||
)
|
||||
|
||||
type TursoTaskRepository struct {
|
||||
db *sql.DB
|
||||
taskTable orm.Table[domain.Task]
|
||||
}
|
||||
|
||||
func NewTursoTaskRepository(db *sql.DB) *TursoTaskRepository {
|
||||
dialect := orm.NewSqliteDialect()
|
||||
taskTable, err := orm.NewTable[domain.Task](db, dialect, "tasks")
|
||||
if err != nil {
|
||||
log.Fatalf("error creating task table")
|
||||
}
|
||||
|
||||
return &TursoTaskRepository{
|
||||
db: db,
|
||||
taskTable: taskTable,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *TursoTaskRepository) ListTasks(userID string, teamID string) ([]*domain.Task, error) {
|
||||
return r.taskTable.Select(
|
||||
orm.WithWhere(
|
||||
"team_id=$1 and team_id in (select team_id from user_teams where user_id=$2)",
|
||||
teamID,
|
||||
userID,
|
||||
),
|
||||
orm.WithOrder("_date_created desc"),
|
||||
)
|
||||
}
|
||||
|
||||
func (r *TursoTaskRepository) ListActiveTasks() ([]*domain.Task, error) {
|
||||
return r.taskTable.Select(
|
||||
orm.WithWhere("status=$1", domain.TaskStatusActive),
|
||||
)
|
||||
}
|
||||
|
||||
func (r *TursoTaskRepository) GetTaskByID(userID string, teamID string, id string) (*domain.Task, error) {
|
||||
return r.taskTable.SelectOne(
|
||||
"id=$1 and team_id=$2 and team_id in (select team_id from user_teams where user_id=$3)",
|
||||
id,
|
||||
teamID,
|
||||
userID,
|
||||
)
|
||||
}
|
||||
|
||||
func (r *TursoTaskRepository) CreateTask(task *domain.Task) (*domain.Task, error) {
|
||||
return r.taskTable.Insert(task)
|
||||
}
|
||||
|
||||
func (r *TursoTaskRepository) UpdateTask(task *domain.Task) (*domain.Task, error) {
|
||||
return r.taskTable.Update(task)
|
||||
}
|
||||
|
||||
func (r *TursoTaskRepository) DeleteTask(userID string, teamID string, id string) error {
|
||||
return r.taskTable.DeleteWhere(
|
||||
"id=$1 and team_id=$2 and team_id in (select team_id from user_teams where user_id=$3)",
|
||||
id,
|
||||
teamID,
|
||||
userID,
|
||||
)
|
||||
}
|
||||
|
||||
// FirstUserOfTeam retourne le premier utilisateur d'une équipe. Les tâches
|
||||
// planifiées ne portent pas d'utilisateur : c'est lui qui porte la conversation
|
||||
// générée lors de l'exécution.
|
||||
func (r *TursoTaskRepository) FirstUserOfTeam(teamID string) (string, error) {
|
||||
var userID string
|
||||
err := r.db.QueryRow(
|
||||
"select user_id from user_teams where team_id=$1 order by _date_created asc limit 1",
|
||||
teamID,
|
||||
).Scan(&userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"trankilou.fr/lassistanoque/backend/internal/domain"
|
||||
"trankilou.fr/lassistanoque/backend/internal/service/task"
|
||||
)
|
||||
|
||||
// SchedulerTool permet à l'agent de planifier des tâches pour l'utilisateur :
|
||||
// exécution d'un prompt selon une spécification cron, avec ou sans répétition.
|
||||
type SchedulerTool struct {
|
||||
taskService *task.Service
|
||||
}
|
||||
|
||||
func New(taskService *task.Service) *SchedulerTool {
|
||||
return &SchedulerTool{taskService: taskService}
|
||||
}
|
||||
|
||||
func (t *SchedulerTool) Name() string {
|
||||
return "scheduler"
|
||||
}
|
||||
|
||||
func (t *SchedulerTool) Description() string {
|
||||
return "Schedule tasks that run automatically at specified times, with or without repetition. " +
|
||||
"Use this tool whenever the user asks to do something later or on a recurring basis " +
|
||||
"(e.g. 'every day at 8am', 'every Monday at 9am', 'tomorrow at 6pm', 'every hour'). " +
|
||||
"Actions: 'create' (schedule a new task), 'list' (list scheduled tasks), " +
|
||||
"'update' (modify a task's title, prompt, cron or repetition), " +
|
||||
"'delete' (delete a task by its id)."
|
||||
}
|
||||
|
||||
func (t *SchedulerTool) Params() []domain.ToolParam {
|
||||
return []domain.ToolParam{}
|
||||
}
|
||||
|
||||
func (t *SchedulerTool) Definition(ctx context.Context) *domain.ToolDefinition {
|
||||
return &domain.ToolDefinition{
|
||||
Type: "function",
|
||||
Function: &domain.ToolFunction{
|
||||
Name: t.Name(),
|
||||
Description: t.Description(),
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"action": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []string{"create", "list", "update", "delete"},
|
||||
"description": "The scheduling action to perform.",
|
||||
},
|
||||
"title": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Short task title, e.g. 'Météo du matin'. Required for 'create'.",
|
||||
},
|
||||
"prompt": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Self-contained prompt to execute at the scheduled time. " +
|
||||
"It will run in a fresh conversation with no memory of this one: " +
|
||||
"include all needed context. Required for 'create'.",
|
||||
},
|
||||
"cron": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Cron spec, 5 fields 'minute hour day-of-month month day-of-week' " +
|
||||
"(e.g. '0 8 * * *' every day at 08:00, '0 9 * * 1' every Monday at 09:00, " +
|
||||
"'30 18 1 * *' on the 1st at 18:30) or a descriptor like '@daily', '@hourly', '@weekly'. " +
|
||||
"Required for 'create'.",
|
||||
},
|
||||
"repeat": map[string]any{
|
||||
"type": "boolean",
|
||||
"description": "true: the task repeats on schedule. " +
|
||||
"false: it runs only once, at the first time matching the cron spec, then is marked done. " +
|
||||
"Default true. Use false for one-shot requests like 'tomorrow at 8am'.",
|
||||
},
|
||||
"id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Task id, as returned by 'list'. Required for 'update' and 'delete'.",
|
||||
},
|
||||
},
|
||||
"required": []string{"action"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *SchedulerTool) Execute(ctx context.Context, input []byte, configuration string) ([]byte, error) {
|
||||
var params struct {
|
||||
Action string `json:"action"`
|
||||
Title string `json:"title"`
|
||||
Prompt string `json:"prompt"`
|
||||
Cron string `json:"cron"`
|
||||
Repeat *bool `json:"repeat"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(input, ¶ms); err != nil {
|
||||
return nil, fmt.Errorf("invalid parameters: %w", err)
|
||||
}
|
||||
|
||||
userID, ok := domain.UserIDFromContext(ctx)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("scheduler tool: missing user context")
|
||||
}
|
||||
teamID, ok := domain.TeamIDFromContext(ctx)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("scheduler tool: missing team context")
|
||||
}
|
||||
modelID, _ := domain.ModelIDFromContext(ctx)
|
||||
|
||||
switch params.Action {
|
||||
case "create":
|
||||
repeat := true
|
||||
if params.Repeat != nil {
|
||||
repeat = *params.Repeat
|
||||
}
|
||||
taskModel, err := t.taskService.CreateTask(userID, teamID, &domain.Task{
|
||||
ModelID: modelID,
|
||||
Label: params.Title,
|
||||
Prompt: params.Prompt,
|
||||
Cron: params.Cron,
|
||||
Repeat: repeat,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := json.Marshal(taskModel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
|
||||
case "list":
|
||||
tasks, err := t.taskService.ListTasks(userID, teamID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := json.Marshal(tasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
|
||||
case "update":
|
||||
if params.ID == "" {
|
||||
return nil, fmt.Errorf("'id' is required for action 'update'")
|
||||
}
|
||||
current, err := t.taskService.GetTask(userID, teamID, params.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// mise à jour partielle : seuls les champs fournis changent
|
||||
if params.Title != "" {
|
||||
current.Label = params.Title
|
||||
}
|
||||
if params.Prompt != "" {
|
||||
current.Prompt = params.Prompt
|
||||
}
|
||||
if params.Cron != "" {
|
||||
current.Cron = params.Cron
|
||||
}
|
||||
if params.Repeat != nil {
|
||||
current.Repeat = *params.Repeat
|
||||
}
|
||||
updated, err := t.taskService.UpdateTask(userID, teamID, current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := json.Marshal(updated)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
|
||||
case "delete":
|
||||
if params.ID == "" {
|
||||
return nil, fmt.Errorf("'id' is required for action 'delete'")
|
||||
}
|
||||
if err := t.taskService.DeleteTask(userID, teamID, params.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []byte("task deleted"), nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown action %q", params.Action)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user