feat(schedule): support KOL subscription targets and random time windows
Extend schedule tasks to dispatch KOL subscription prompts in addition to prompt rules, add daily/weekly scheduling with fixed or random time windows, and track dispatch outcomes (last run, status, consecutive failures). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -36,6 +37,25 @@ type KolGenerationSubmitResponse struct {
|
||||
UsageLogID int64 `json:"usage_log_id"`
|
||||
}
|
||||
|
||||
type ScheduleKolGenerationInput struct {
|
||||
ScheduleTaskID int64
|
||||
OperatorID *int64
|
||||
TenantID int64
|
||||
WorkspaceID int64
|
||||
BrandID *int64
|
||||
SubscriptionPromptID int64
|
||||
Name string
|
||||
Variables map[string]any
|
||||
EnableWebSearch bool
|
||||
KnowledgeGroupIDs []int64
|
||||
AutoPublish bool
|
||||
PublishAccountIDs []string
|
||||
CoverAssetURL *string
|
||||
CoverImageAssetID *int64
|
||||
GenerateCount int
|
||||
ScheduledFor time.Time
|
||||
}
|
||||
|
||||
type KolSubscriptionPromptSchemaResponse struct {
|
||||
SubscriptionPromptID int64 `json:"subscription_prompt_id"`
|
||||
PackageID int64 `json:"package_id"`
|
||||
@@ -138,13 +158,58 @@ func (s *KolGenerationService) Submit(ctx context.Context, actor auth.Actor, sub
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.enqueue(ctx, kolGenerationEnqueueInput{
|
||||
Actor: actor,
|
||||
BrandID: brandID,
|
||||
SubscriptionPromptID: subPromptID,
|
||||
Variables: req.Variables,
|
||||
EnableWebSearch: req.EnableWebSearch,
|
||||
KnowledgeGroupIDs: req.KnowledgeGroupIDs,
|
||||
})
|
||||
}
|
||||
|
||||
row, prompt, err := s.loadAuthorizedPrompt(ctx, actor, subPromptID)
|
||||
func (s *KolGenerationService) EnqueueScheduledGeneration(ctx context.Context, input ScheduleKolGenerationInput) (*KolGenerationSubmitResponse, error) {
|
||||
if input.BrandID == nil || *input.BrandID <= 0 {
|
||||
return nil, response.ErrBadRequest(40033, "brand_context_required", "current brand is required")
|
||||
}
|
||||
operatorID := int64(0)
|
||||
if input.OperatorID != nil {
|
||||
operatorID = *input.OperatorID
|
||||
}
|
||||
actor := auth.Actor{
|
||||
TenantID: input.TenantID,
|
||||
UserID: operatorID,
|
||||
PrimaryWorkspaceID: input.WorkspaceID,
|
||||
}
|
||||
return s.enqueue(ctx, kolGenerationEnqueueInput{
|
||||
Actor: actor,
|
||||
BrandID: *input.BrandID,
|
||||
SubscriptionPromptID: input.SubscriptionPromptID,
|
||||
Variables: input.Variables,
|
||||
EnableWebSearch: input.EnableWebSearch,
|
||||
KnowledgeGroupIDs: input.KnowledgeGroupIDs,
|
||||
Schedule: &input,
|
||||
})
|
||||
}
|
||||
|
||||
type kolGenerationEnqueueInput struct {
|
||||
Actor auth.Actor
|
||||
BrandID int64
|
||||
SubscriptionPromptID int64
|
||||
Variables map[string]any
|
||||
EnableWebSearch bool
|
||||
KnowledgeGroupIDs []int64
|
||||
Schedule *ScheduleKolGenerationInput
|
||||
}
|
||||
|
||||
func (s *KolGenerationService) enqueue(ctx context.Context, input kolGenerationEnqueueInput) (*KolGenerationSubmitResponse, error) {
|
||||
actor := input.Actor
|
||||
row, prompt, err := s.loadAuthorizedPrompt(ctx, actor, input.SubscriptionPromptID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
variables := req.Variables
|
||||
variables := input.Variables
|
||||
if variables == nil {
|
||||
variables = map[string]any{}
|
||||
}
|
||||
@@ -172,16 +237,16 @@ func (s *KolGenerationService) Submit(ctx context.Context, actor auth.Actor, sub
|
||||
return nil, response.ErrBadRequest(40071, "kol_generation_variables_invalid", err.Error())
|
||||
}
|
||||
renderedPromptHash := HashPrompt(renderedPrompt)
|
||||
if req.EnableWebSearch && !kolCardConfigAllowsWebSearch(cardConfig) {
|
||||
if input.EnableWebSearch && !kolCardConfigAllowsWebSearch(cardConfig) {
|
||||
return nil, response.ErrBadRequest(40073, "kol_generation_web_search_disabled", "web search is disabled for this prompt")
|
||||
}
|
||||
|
||||
knowledgeGroupIDs := normalizeInt64IDs(req.KnowledgeGroupIDs)
|
||||
knowledgeGroupIDs := normalizeInt64IDs(input.KnowledgeGroupIDs)
|
||||
if len(knowledgeGroupIDs) > 0 && !kolCardConfigAllowsUserKnowledge(cardConfig) {
|
||||
return nil, response.ErrBadRequest(40074, "kol_generation_knowledge_disabled", "knowledge selection is disabled for this prompt")
|
||||
}
|
||||
|
||||
enableWebSearch := kolCardConfigAllowsWebSearch(cardConfig) && req.EnableWebSearch
|
||||
enableWebSearch := kolCardConfigAllowsWebSearch(cardConfig) && input.EnableWebSearch
|
||||
promptRevisionNo := 1
|
||||
if row.PublishedRevisionNo != nil && *row.PublishedRevisionNo > 0 {
|
||||
promptRevisionNo = *row.PublishedRevisionNo
|
||||
@@ -192,26 +257,30 @@ func (s *KolGenerationService) Submit(ctx context.Context, actor auth.Actor, sub
|
||||
return nil, response.ErrForbidden(40301, "quota_insufficient", "generation quota insufficient, please upgrade your plan")
|
||||
}
|
||||
|
||||
inputJSON, err := json.Marshal(kolGenerationTaskInput{
|
||||
SubscriptionPromptID: subPromptID,
|
||||
SubscriptionID: row.SubscriptionID,
|
||||
PackageID: row.PackageID,
|
||||
PromptID: row.PromptID,
|
||||
PromptName: row.PromptName,
|
||||
PlatformHint: derefString(row.PlatformHint),
|
||||
PromptRevisionNo: promptRevisionNo,
|
||||
Variables: variables,
|
||||
EnableWebSearch: enableWebSearch,
|
||||
KnowledgeGroupIDs: knowledgeGroupIDs,
|
||||
SchemaSnapshot: json.RawMessage(prompt.SchemaJSON),
|
||||
CardConfigSnapshot: json.RawMessage(prompt.CardConfigJSON),
|
||||
RenderedPromptHash: renderedPromptHash,
|
||||
RenderedPrompt: renderedPrompt,
|
||||
})
|
||||
taskInput := map[string]any{
|
||||
"subscription_prompt_id": input.SubscriptionPromptID,
|
||||
"subscription_id": row.SubscriptionID,
|
||||
"package_id": row.PackageID,
|
||||
"prompt_id": row.PromptID,
|
||||
"prompt_name": row.PromptName,
|
||||
"platform_hint": derefString(row.PlatformHint),
|
||||
"prompt_revision_no": promptRevisionNo,
|
||||
"variables": variables,
|
||||
"enable_web_search": enableWebSearch,
|
||||
"knowledge_group_ids": knowledgeGroupIDs,
|
||||
"schema_snapshot": json.RawMessage(prompt.SchemaJSON),
|
||||
"card_config_snapshot": json.RawMessage(prompt.CardConfigJSON),
|
||||
"rendered_prompt_hash": renderedPromptHash,
|
||||
"rendered_prompt": renderedPrompt,
|
||||
}
|
||||
if input.Schedule != nil {
|
||||
attachKolScheduleTaskInput(taskInput, *input.Schedule)
|
||||
}
|
||||
|
||||
inputJSON, err := json.Marshal(taskInput)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50099, "kol_generation_input_encode_failed", err.Error())
|
||||
}
|
||||
|
||||
variablesJSON, err := json.Marshal(variables)
|
||||
if err != nil {
|
||||
return nil, response.ErrBadRequest(40072, "kol_generation_variables_invalid", "variables must be valid json")
|
||||
@@ -258,7 +327,7 @@ func (s *KolGenerationService) Submit(ctx context.Context, actor auth.Actor, sub
|
||||
|
||||
articleID, err := articleTx.CreateArticle(ctx, repository.CreateArticleInput{
|
||||
TenantID: actor.TenantID,
|
||||
BrandID: brandID,
|
||||
BrandID: input.BrandID,
|
||||
SourceType: kolGenerationTaskType,
|
||||
KolPromptID: &row.PromptID,
|
||||
})
|
||||
@@ -268,6 +337,22 @@ func (s *KolGenerationService) Submit(ctx context.Context, actor auth.Actor, sub
|
||||
if err := articleTx.UpdateArticleGenerateStatus(ctx, articleID, actor.TenantID, "generating"); err != nil {
|
||||
return nil, response.ErrInternal(50112, "kol_generation_article_state_update_failed", err.Error())
|
||||
}
|
||||
if input.Schedule != nil {
|
||||
coverAssetURL, coverImageAssetID := scheduleCoverState(input.Schedule.CoverAssetURL, input.Schedule.CoverImageAssetID)
|
||||
title := strings.TrimSpace(input.Schedule.Name)
|
||||
if title == "" {
|
||||
title = row.PromptName
|
||||
}
|
||||
wizardStateJSON, _ := mergeArticleWizardState(nil, title, coverAssetURL, coverImageAssetID)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE articles
|
||||
SET wizard_state_json = $1,
|
||||
updated_at = NOW()
|
||||
WHERE id = $2 AND tenant_id = $3
|
||||
`, wizardStateJSON, articleID, actor.TenantID); err != nil {
|
||||
return nil, response.ErrInternal(50112, "kol_generation_article_state_update_failed", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if err := quotaTx.UpdateQuotaReservationResource(ctx, reservationID, actor.TenantID, articleID); err != nil {
|
||||
return nil, response.ErrInternal(50104, "kol_generation_reservation_update_failed", err.Error())
|
||||
@@ -357,6 +442,49 @@ func (s *KolGenerationService) Submit(ctx context.Context, actor auth.Actor, sub
|
||||
}, nil
|
||||
}
|
||||
|
||||
func attachKolScheduleTaskInput(input map[string]any, schedule ScheduleKolGenerationInput) {
|
||||
input["generation_mode"] = "schedule"
|
||||
input["schedule_task_id"] = schedule.ScheduleTaskID
|
||||
input["target_type"] = ScheduleTargetKolSubscriptionPrompt
|
||||
input["task_name"] = strings.TrimSpace(schedule.Name)
|
||||
if schedule.WorkspaceID > 0 {
|
||||
input["workspace_id"] = schedule.WorkspaceID
|
||||
}
|
||||
if schedule.GenerateCount > 0 {
|
||||
input["generate_count"] = schedule.GenerateCount
|
||||
}
|
||||
if schedule.BrandID != nil && *schedule.BrandID > 0 {
|
||||
input["brand_id"] = *schedule.BrandID
|
||||
}
|
||||
if !schedule.ScheduledFor.IsZero() {
|
||||
input["scheduled_for"] = schedule.ScheduledFor.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if schedule.AutoPublish {
|
||||
input["schedule_auto_publish"] = true
|
||||
input["schedule_publish_account_ids"] = schedule.PublishAccountIDs
|
||||
if schedule.CoverAssetURL != nil {
|
||||
input["schedule_cover_asset_url"] = strings.TrimSpace(*schedule.CoverAssetURL)
|
||||
}
|
||||
if schedule.CoverImageAssetID != nil {
|
||||
input["schedule_cover_image_asset_id"] = *schedule.CoverImageAssetID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func scheduleCoverState(coverURL *string, coverImageAssetID *int64) (*string, NullableInt64Input) {
|
||||
var normalizedURL *string
|
||||
var normalizedID NullableInt64Input
|
||||
if coverURL != nil {
|
||||
trimmed := strings.TrimSpace(*coverURL)
|
||||
if trimmed != "" {
|
||||
normalizedURL = &trimmed
|
||||
normalizedID.Set = true
|
||||
normalizedID.Value = coverImageAssetID
|
||||
}
|
||||
}
|
||||
return normalizedURL, normalizedID
|
||||
}
|
||||
|
||||
func (s *KolGenerationService) loadAuthorizedPrompt(ctx context.Context, actor auth.Actor, subPromptID int64) (*repository.KolSubscriptionPrompt, *repository.KolPrompt, error) {
|
||||
row, err := s.subRepo.GetSubscriptionPromptByID(ctx, actor.TenantID, subPromptID)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user