package app import ( "context" "encoding/json" "fmt" "regexp" "strings" "time" "github.com/google/uuid" "github.com/geo-platform/tenant-api/internal/shared/auth" "github.com/geo-platform/tenant-api/internal/shared/llm" "github.com/geo-platform/tenant-api/internal/shared/response" "github.com/geo-platform/tenant-api/internal/tenant/prompts" "github.com/geo-platform/tenant-api/internal/tenant/repository" ) const ( templateAssistTaskTypeAnalyze = "analyze" templateAssistTaskTypeTitle = "title" templateAssistTaskTypeOutline = "outline" ) type AssistCompetitor struct { Name string `json:"name"` Website string `json:"website,omitempty"` Description string `json:"description,omitempty"` } type AnalyzeTaskRequest struct { Locale string `json:"locale"` BrandID *int64 `json:"brand_id,omitempty"` BrandName string `json:"brand_name"` Website string `json:"website,omitempty"` InputParams map[string]interface{} `json:"input_params,omitempty"` ExistingKeywords []string `json:"existing_keywords,omitempty"` ExistingCompetitors []AssistCompetitor `json:"existing_competitors,omitempty"` } type AnalyzeTaskResult struct { BrandSummary string `json:"brand_summary"` Keywords []string `json:"keywords"` Competitors []AssistCompetitor `json:"competitors"` } type TitleTaskRequest struct { Locale string `json:"locale"` BrandID *int64 `json:"brand_id,omitempty"` BrandName string `json:"brand_name"` Website string `json:"website,omitempty"` BrandSummary string `json:"brand_summary,omitempty"` InputParams map[string]interface{} `json:"input_params,omitempty"` Keywords []string `json:"keywords,omitempty"` Competitors []AssistCompetitor `json:"competitors,omitempty"` } type OutlineTaskRequest struct { Locale string `json:"locale"` BrandID *int64 `json:"brand_id,omitempty"` BrandName string `json:"brand_name"` Website string `json:"website,omitempty"` BrandSummary string `json:"brand_summary,omitempty"` Title string `json:"title"` InputParams map[string]interface{} `json:"input_params,omitempty"` Keywords []string `json:"keywords,omitempty"` Competitors []AssistCompetitor `json:"competitors,omitempty"` OutlineSections []string `json:"outline_sections,omitempty"` KeyPoints string `json:"key_points,omitempty"` } type OutlineNode struct { Outline string `json:"outline"` Children []OutlineNode `json:"children,omitempty"` } type AssistTaskCreateResponse struct { TaskID string `json:"task_id"` Status string `json:"status"` Message string `json:"message"` } type AnalyzeTaskResultResponse struct { TaskID string `json:"task_id"` Status string `json:"status"` Message string `json:"message"` Result *AnalyzeTaskResult `json:"result,omitempty"` ErrorMessage *string `json:"error_message,omitempty"` } type TitleTaskResultResponse struct { TaskID string `json:"task_id"` Status string `json:"status"` Message string `json:"message"` Result []string `json:"result,omitempty"` ErrorMessage *string `json:"error_message,omitempty"` } type OutlineTaskResultResponse struct { TaskID string `json:"task_id"` Status string `json:"status"` Message string `json:"message"` Result []OutlineNode `json:"result,omitempty"` ErrorMessage *string `json:"error_message,omitempty"` } type assistJob struct { TaskID string TaskType string TenantID int64 TemplateID int64 TemplateKey string TemplateName string AnalyzePrompt *string TitlePrompt *string OutlinePrompt *string AnalyzeRequest *AnalyzeTaskRequest TitleRequest *TitleTaskRequest OutlineRequest *OutlineTaskRequest } type AssistJob = assistJob func (s *TemplateService) CreateAnalyzeTask(ctx context.Context, templateID int64, req AnalyzeTaskRequest) (*AssistTaskCreateResponse, error) { actor := auth.MustActor(ctx) templateRecord, err := s.templates.GetTemplateByID(ctx, templateID, actor.TenantID) if err != nil { return nil, mapTemplateLookupError(err) } if err := s.llm.Validate(); err != nil { return nil, response.ErrServiceUnavailable(50311, "llm_unavailable", err.Error()) } req = normalizeAnalyzeTaskRequest(req) if !hasAnalyzeContext(req) { return nil, response.ErrBadRequest(40012, "analyze_context_required", "brand info or template context is required") } payload, err := json.Marshal(req) if err != nil { return nil, response.ErrInternal(50020, "marshal_failed", "failed to encode analyze request") } taskID := uuid.NewString() reservation, err := s.reserveTemplateAssistAIPoints(ctx, actor.TenantID, actor.UserID, templateID, taskID, templateAssistTaskTypeAnalyze, buildAnalyzeAIPointMeteredText(req)) if err != nil { return nil, err } repo := repository.NewTemplateAssistTaskRepository(s.pool) if err := repo.CreateTask(ctx, repository.CreateTemplateAssistTaskInput{ ID: taskID, TaskType: templateAssistTaskTypeAnalyze, TenantID: actor.TenantID, OperatorID: actor.UserID, TemplateID: templateID, RequestJSON: payload, }); err != nil { s.refundReservedAIPoints(context.Background(), actor.TenantID, actor.UserID, reservation, err.Error()) return nil, response.ErrInternal(50021, "task_create_failed", err.Error()) } job := assistJob{ TaskID: taskID, TaskType: templateAssistTaskTypeAnalyze, TenantID: actor.TenantID, TemplateID: templateID, TemplateKey: templateRecord.TemplateKey, TemplateName: templateRecord.TemplateName, AnalyzePrompt: extractAnalyzePromptTemplate(templateRecord.CardConfigJSON), AnalyzeRequest: &req, } if err := publishTemplateAssistTask(ctx, s.rabbitMQ, job); err != nil { _ = repo.FailTask(context.Background(), taskID, templateAssistTaskTypeAnalyze, actor.TenantID, err.Error(), time.Now()) s.refundReservedAIPoints(context.Background(), actor.TenantID, actor.UserID, reservation, err.Error()) return nil, response.ErrServiceUnavailable(50312, "analyze_queue_unavailable", "analyze queue is busy, please retry") } return &AssistTaskCreateResponse{TaskID: taskID, Status: "queued", Message: "queued"}, nil } func (s *TemplateService) GetAnalyzeTask(ctx context.Context, templateID int64, taskID string) (*AnalyzeTaskResultResponse, error) { actor := auth.MustActor(ctx) repo := repository.NewTemplateAssistTaskRepository(s.pool) record, err := repo.GetTask(ctx, taskID, templateAssistTaskTypeAnalyze, actor.TenantID, templateID) if err != nil { if err == repository.ErrTemplateAssistTaskNotFound { return nil, response.ErrNotFound(40411, "analyze_task_not_found", "analyze task not found") } return nil, response.ErrInternal(50022, "task_query_failed", err.Error()) } resp := &AnalyzeTaskResultResponse{ TaskID: record.ID, Status: record.Status, Message: assistTaskMessage(record.Status), ErrorMessage: record.ErrorMessage, } if len(record.ResultJSON) > 0 { var result AnalyzeTaskResult if err := json.Unmarshal(record.ResultJSON, &result); err != nil { return nil, response.ErrInternal(50023, "task_result_invalid", err.Error()) } resp.Result = &result } return resp, nil } func (s *TemplateService) CreateTitleTask(ctx context.Context, templateID int64, req TitleTaskRequest) (*AssistTaskCreateResponse, error) { actor := auth.MustActor(ctx) templateRecord, err := s.templates.GetTemplateByID(ctx, templateID, actor.TenantID) if err != nil { return nil, mapTemplateLookupError(err) } if err := s.llm.Validate(); err != nil { return nil, response.ErrServiceUnavailable(50311, "llm_unavailable", err.Error()) } req = normalizeTitleTaskRequest(req) if !hasTitleContext(req) { return nil, response.ErrBadRequest(40013, "title_context_required", "title generation context is required") } payload, err := json.Marshal(req) if err != nil { return nil, response.ErrInternal(50024, "marshal_failed", "failed to encode title request") } taskID := uuid.NewString() reservation, err := s.reserveTemplateAssistAIPoints(ctx, actor.TenantID, actor.UserID, templateID, taskID, templateAssistTaskTypeTitle, buildTitleAIPointMeteredText(req)) if err != nil { return nil, err } repo := repository.NewTemplateAssistTaskRepository(s.pool) if err := repo.CreateTask(ctx, repository.CreateTemplateAssistTaskInput{ ID: taskID, TaskType: templateAssistTaskTypeTitle, TenantID: actor.TenantID, OperatorID: actor.UserID, TemplateID: templateID, RequestJSON: payload, }); err != nil { s.refundReservedAIPoints(context.Background(), actor.TenantID, actor.UserID, reservation, err.Error()) return nil, response.ErrInternal(50025, "task_create_failed", err.Error()) } job := assistJob{ TaskID: taskID, TaskType: templateAssistTaskTypeTitle, TenantID: actor.TenantID, TemplateID: templateID, TemplateKey: templateRecord.TemplateKey, TemplateName: templateRecord.TemplateName, TitlePrompt: extractTitlePromptTemplate(templateRecord.CardConfigJSON), TitleRequest: &req, } if err := publishTemplateAssistTask(ctx, s.rabbitMQ, job); err != nil { _ = repo.FailTask(context.Background(), taskID, templateAssistTaskTypeTitle, actor.TenantID, err.Error(), time.Now()) s.refundReservedAIPoints(context.Background(), actor.TenantID, actor.UserID, reservation, err.Error()) return nil, response.ErrServiceUnavailable(50313, "title_queue_unavailable", "title queue is busy, please retry") } return &AssistTaskCreateResponse{TaskID: taskID, Status: "queued", Message: "queued"}, nil } func (s *TemplateService) GetTitleTask(ctx context.Context, templateID int64, taskID string) (*TitleTaskResultResponse, error) { actor := auth.MustActor(ctx) repo := repository.NewTemplateAssistTaskRepository(s.pool) record, err := repo.GetTask(ctx, taskID, templateAssistTaskTypeTitle, actor.TenantID, templateID) if err != nil { if err == repository.ErrTemplateAssistTaskNotFound { return nil, response.ErrNotFound(40412, "title_task_not_found", "title task not found") } return nil, response.ErrInternal(50026, "task_query_failed", err.Error()) } resp := &TitleTaskResultResponse{ TaskID: record.ID, Status: record.Status, Message: assistTaskMessage(record.Status), ErrorMessage: record.ErrorMessage, } if len(record.ResultJSON) > 0 { var result []string if err := json.Unmarshal(record.ResultJSON, &result); err != nil { return nil, response.ErrInternal(50027, "task_result_invalid", err.Error()) } resp.Result = result } return resp, nil } func (s *TemplateService) CreateOutlineTask(ctx context.Context, templateID int64, req OutlineTaskRequest) (*AssistTaskCreateResponse, error) { actor := auth.MustActor(ctx) templateRecord, err := s.templates.GetTemplateByID(ctx, templateID, actor.TenantID) if err != nil { return nil, mapTemplateLookupError(err) } if err := s.llm.Validate(); err != nil { return nil, response.ErrServiceUnavailable(50311, "llm_unavailable", err.Error()) } req = normalizeOutlineTaskRequest(req) if !hasOutlineContext(req) { return nil, response.ErrBadRequest(40014, "outline_context_required", "outline generation context is required") } payload, err := json.Marshal(req) if err != nil { return nil, response.ErrInternal(50028, "marshal_failed", "failed to encode outline request") } taskID := uuid.NewString() reservation, err := s.reserveTemplateAssistAIPoints(ctx, actor.TenantID, actor.UserID, templateID, taskID, templateAssistTaskTypeOutline, buildOutlineAIPointMeteredText(req)) if err != nil { return nil, err } repo := repository.NewTemplateAssistTaskRepository(s.pool) if err := repo.CreateTask(ctx, repository.CreateTemplateAssistTaskInput{ ID: taskID, TaskType: templateAssistTaskTypeOutline, TenantID: actor.TenantID, OperatorID: actor.UserID, TemplateID: templateID, RequestJSON: payload, }); err != nil { s.refundReservedAIPoints(context.Background(), actor.TenantID, actor.UserID, reservation, err.Error()) return nil, response.ErrInternal(50029, "task_create_failed", err.Error()) } job := assistJob{ TaskID: taskID, TaskType: templateAssistTaskTypeOutline, TenantID: actor.TenantID, TemplateID: templateID, TemplateKey: templateRecord.TemplateKey, TemplateName: templateRecord.TemplateName, OutlinePrompt: extractOutlinePromptTemplate(templateRecord.CardConfigJSON), OutlineRequest: &req, } if err := publishTemplateAssistTask(ctx, s.rabbitMQ, job); err != nil { _ = repo.FailTask(context.Background(), taskID, templateAssistTaskTypeOutline, actor.TenantID, err.Error(), time.Now()) s.refundReservedAIPoints(context.Background(), actor.TenantID, actor.UserID, reservation, err.Error()) return nil, response.ErrServiceUnavailable(50314, "outline_queue_unavailable", "outline queue is busy, please retry") } return &AssistTaskCreateResponse{TaskID: taskID, Status: "queued", Message: "queued"}, nil } func (s *TemplateService) GetOutlineTask(ctx context.Context, templateID int64, taskID string) (*OutlineTaskResultResponse, error) { actor := auth.MustActor(ctx) repo := repository.NewTemplateAssistTaskRepository(s.pool) record, err := repo.GetTask(ctx, taskID, templateAssistTaskTypeOutline, actor.TenantID, templateID) if err != nil { if err == repository.ErrTemplateAssistTaskNotFound { return nil, response.ErrNotFound(40413, "outline_task_not_found", "outline task not found") } return nil, response.ErrInternal(50030, "task_query_failed", err.Error()) } resp := &OutlineTaskResultResponse{ TaskID: record.ID, Status: record.Status, Message: assistTaskMessage(record.Status), ErrorMessage: record.ErrorMessage, } if len(record.ResultJSON) > 0 { var result []OutlineNode if err := json.Unmarshal(record.ResultJSON, &result); err != nil { return nil, response.ErrInternal(50031, "task_result_invalid", err.Error()) } resp.Result = result } return resp, nil } func (s *TemplateService) executeAssist(ctx context.Context, job assistJob) { repo := repository.NewTemplateAssistTaskRepository(s.pool) now := time.Now() _ = repo.MarkRunning(ctx, job.TaskID, job.TaskType, job.TenantID, now) switch job.TaskType { case templateAssistTaskTypeAnalyze: s.executeAnalyzeTask(ctx, repo, job) case templateAssistTaskTypeTitle: s.executeTitleTask(ctx, repo, job) case templateAssistTaskTypeOutline: s.executeOutlineTask(ctx, repo, job) default: s.failAssist(ctx, job.TaskID, job.TaskType, job.TenantID, fmt.Errorf("unknown assist task type %q", job.TaskType)) } } func (s *TemplateService) ProcessQueuedAssist(ctx context.Context, job assistJob) { s.executeAssist(ctx, job) } func (s *TemplateService) ProcessQueuedAssistJob(ctx context.Context, job AssistJob) { s.ProcessQueuedAssist(ctx, job) } func (s *TemplateService) IsAssistTaskTerminal(ctx context.Context, job AssistJob) (bool, error) { record, err := repository.NewTemplateAssistTaskRepository(s.pool).GetTask( ctx, job.TaskID, job.TaskType, job.TenantID, job.TemplateID, ) if err != nil { return false, err } switch record.Status { case "completed": s.completeTemplateAssistAIPoints(ctx, job.TenantID, job.TaskID, "") return true, nil case "failed": if isTemplateAssistAIPointTask(job.TaskType) { resourceType := "template_assist_task" resourceUID := job.TaskID errorMessage := "template assist task failed" if record.ErrorMessage != nil && strings.TrimSpace(*record.ErrorMessage) != "" { errorMessage = strings.TrimSpace(*record.ErrorMessage) } _ = RefundPendingAIPointsByResource(ctx, s.pool, s.cache, job.TenantID, resourceType, nil, &resourceUID, errorMessage) } return true, nil default: return false, nil } } func (s *TemplateService) executeAnalyzeTask(ctx context.Context, repo repository.TemplateAssistTaskRepository, job assistJob) { if job.AnalyzeRequest == nil { s.failAssist(ctx, job.TaskID, job.TaskType, job.TenantID, fmt.Errorf("missing analyze request")) return } result, err := s.llm.Generate(ctx, llm.GenerateRequest{ Prompt: buildAnalyzePrompt(job.TemplateKey, job.TemplateName, *job.AnalyzeRequest, job.AnalyzePrompt), }, nil) if err != nil { s.failAssist(ctx, job.TaskID, job.TaskType, job.TenantID, err) return } analyzeResult, err := decodeAnalyzeResult(result.Content) if err != nil { s.failAssist(ctx, job.TaskID, job.TaskType, job.TenantID, err) return } normalized := normalizeAnalyzeResult(*analyzeResult) resultJSON, err := json.Marshal(normalized) if err != nil { s.failAssist(ctx, job.TaskID, job.TaskType, job.TenantID, err) return } if err := repo.CompleteTask(ctx, job.TaskID, job.TaskType, job.TenantID, resultJSON, time.Now()); err != nil { s.failAssist(ctx, job.TaskID, job.TaskType, job.TenantID, err) return } s.completeTemplateAssistAIPoints(ctx, job.TenantID, job.TaskID, result.Model) } func (s *TemplateService) executeTitleTask(ctx context.Context, repo repository.TemplateAssistTaskRepository, job assistJob) { if job.TitleRequest == nil { s.failAssist(ctx, job.TaskID, job.TaskType, job.TenantID, fmt.Errorf("missing title request")) return } result, err := s.llm.Generate(ctx, llm.GenerateRequest{ Prompt: buildTitlePrompt(job.TemplateKey, job.TemplateName, *job.TitleRequest, job.TitlePrompt), }, nil) if err != nil { s.failAssist(ctx, job.TaskID, job.TaskType, job.TenantID, err) return } titles, err := decodeTitleResult(result.Content) if err != nil { s.failAssist(ctx, job.TaskID, job.TaskType, job.TenantID, err) return } normalized := normalizeStringList(titles, 5) if len(normalized) == 0 { s.failAssist(ctx, job.TaskID, job.TaskType, job.TenantID, fmt.Errorf("title result is empty")) return } resultJSON, err := json.Marshal(normalized) if err != nil { s.failAssist(ctx, job.TaskID, job.TaskType, job.TenantID, err) return } if err := repo.CompleteTask(ctx, job.TaskID, job.TaskType, job.TenantID, resultJSON, time.Now()); err != nil { s.failAssist(ctx, job.TaskID, job.TaskType, job.TenantID, err) return } s.completeTemplateAssistAIPoints(ctx, job.TenantID, job.TaskID, result.Model) } func (s *TemplateService) executeOutlineTask(ctx context.Context, repo repository.TemplateAssistTaskRepository, job assistJob) { if job.OutlineRequest == nil { s.failAssist(ctx, job.TaskID, job.TaskType, job.TenantID, fmt.Errorf("missing outline request")) return } result, err := s.llm.Generate(ctx, llm.GenerateRequest{ Prompt: buildOutlinePrompt(job.TemplateKey, job.TemplateName, *job.OutlineRequest, job.OutlinePrompt), ResponseFormat: &llm.ResponseFormat{ Type: llm.ResponseFormatTypeJSONObject, Name: "article_outline", Description: prompts.OutlineResponseFormatDescription(), }, }, nil) if err != nil { s.failAssist(ctx, job.TaskID, job.TaskType, job.TenantID, err) return } outline, err := decodeOutlineResult(result.Content) if err != nil { s.failAssist(ctx, job.TaskID, job.TaskType, job.TenantID, err) return } normalized := normalizeOutlineNodes(outline, 2) if len(normalized) == 0 { s.failAssist(ctx, job.TaskID, job.TaskType, job.TenantID, fmt.Errorf("outline result is empty")) return } resultJSON, err := json.Marshal(normalized) if err != nil { s.failAssist(ctx, job.TaskID, job.TaskType, job.TenantID, err) return } if err := repo.CompleteTask(ctx, job.TaskID, job.TaskType, job.TenantID, resultJSON, time.Now()); err != nil { s.failAssist(ctx, job.TaskID, job.TaskType, job.TenantID, err) return } s.completeTemplateAssistAIPoints(ctx, job.TenantID, job.TaskID, result.Model) } func (s *TemplateService) failAssist(ctx context.Context, taskID, taskType string, tenantID int64, err error) { repo := repository.NewTemplateAssistTaskRepository(s.pool) _ = repo.FailTask(ctx, taskID, taskType, tenantID, err.Error(), time.Now()) if isTemplateAssistAIPointTask(taskType) { resourceType := "template_assist_task" resourceUID := taskID _ = RefundPendingAIPointsByResource(ctx, s.pool, s.cache, tenantID, resourceType, nil, &resourceUID, err.Error()) } } func (s *TemplateService) reserveTemplateAssistAIPoints( ctx context.Context, tenantID int64, operatorID int64, templateID int64, taskID string, taskType string, meteredText string, ) (*AIPointReservation, error) { usageType := AIUsageTypeTemplateTitleGenerate fixedPoints := 0 switch taskType { case templateAssistTaskTypeAnalyze: usageType = AIUsageTypeTemplateAnalyze fixedPoints = 1 case templateAssistTaskTypeOutline: usageType = AIUsageTypeTemplateOutlineGenerate } resourceType := "template_assist_task" resourceUID := taskID return ReserveAIPoints(ctx, s.pool, s.cache, AIPointReserveInput{ TenantID: tenantID, OperatorID: operatorID, UsageType: usageType, ResourceType: &resourceType, ResourceID: &templateID, ResourceUID: &resourceUID, MeteredText: meteredText, FixedPoints: fixedPoints, Metadata: map[string]any{ "template_id": templateID, "task_id": taskID, "task_type": taskType, }, }) } func (s *TemplateService) refundReservedAIPoints(ctx context.Context, tenantID, operatorID int64, reservation *AIPointReservation, errorMessage string) { if reservation == nil { return } _ = RefundAIPoints(ctx, s.pool, s.cache, tenantID, operatorID, *reservation, errorMessage) } func (s *TemplateService) completeTemplateAssistAIPoints(ctx context.Context, tenantID int64, taskID string, model string) { resourceType := "template_assist_task" resourceUID := taskID _ = CompletePendingAIPointsByResource(ctx, s.pool, tenantID, resourceType, nil, &resourceUID, model) } func isTemplateAssistAIPointTask(taskType string) bool { return taskType == templateAssistTaskTypeAnalyze || taskType == templateAssistTaskTypeTitle || taskType == templateAssistTaskTypeOutline } func normalizeAnalyzeTaskRequest(req AnalyzeTaskRequest) AnalyzeTaskRequest { req.Locale = strings.TrimSpace(req.Locale) if req.Locale == "" { req.Locale = "zh-CN" } req.BrandName = strings.TrimSpace(req.BrandName) req.Website = strings.TrimSpace(req.Website) req.ExistingKeywords = normalizeStringList(req.ExistingKeywords, 8) req.ExistingCompetitors = normalizeAssistCompetitors(req.ExistingCompetitors, 8) req.InputParams = normalizeAssistInputParams(req.InputParams) return req } func normalizeTitleTaskRequest(req TitleTaskRequest) TitleTaskRequest { req.Locale = strings.TrimSpace(req.Locale) if req.Locale == "" { req.Locale = "zh-CN" } req.BrandName = strings.TrimSpace(req.BrandName) req.Website = strings.TrimSpace(req.Website) req.BrandSummary = strings.TrimSpace(req.BrandSummary) req.Keywords = normalizeStringList(req.Keywords, 12) req.Competitors = normalizeAssistCompetitors(req.Competitors, 8) req.InputParams = normalizeAssistInputParams(req.InputParams) return req } func normalizeOutlineTaskRequest(req OutlineTaskRequest) OutlineTaskRequest { req.Locale = strings.TrimSpace(req.Locale) if req.Locale == "" { req.Locale = "zh-CN" } req.BrandName = strings.TrimSpace(req.BrandName) req.Website = strings.TrimSpace(req.Website) req.BrandSummary = strings.TrimSpace(req.BrandSummary) req.Title = strings.TrimSpace(req.Title) req.KeyPoints = strings.TrimSpace(req.KeyPoints) req.Keywords = normalizeStringList(req.Keywords, 12) req.Competitors = normalizeAssistCompetitors(req.Competitors, 8) req.OutlineSections = normalizeStringList(req.OutlineSections, 24) req.InputParams = normalizeAssistInputParams(req.InputParams) return req } func normalizeAssistInputParams(params map[string]interface{}) map[string]interface{} { if params == nil { return map[string]interface{}{} } for key, value := range params { if text, ok := value.(string); ok { params[key] = strings.TrimSpace(text) } } return params } func hasAnalyzeContext(req AnalyzeTaskRequest) bool { if req.BrandName != "" || req.Website != "" { return true } for _, value := range req.InputParams { switch typed := value.(type) { case string: if strings.TrimSpace(typed) != "" { return true } case []interface{}: if len(typed) > 0 { return true } case []string: if len(typed) > 0 { return true } case float64, float32, int, int32, int64: return true } } return len(req.ExistingKeywords) > 0 || len(req.ExistingCompetitors) > 0 } func hasTitleContext(req TitleTaskRequest) bool { if req.BrandName != "" || req.Website != "" || req.BrandSummary != "" { return true } if len(req.Keywords) > 0 || len(req.Competitors) > 0 { return true } for _, value := range req.InputParams { switch typed := value.(type) { case string: if strings.TrimSpace(typed) != "" { return true } case []interface{}: if len(typed) > 0 { return true } case []string: if len(typed) > 0 { return true } case float64, float32, int, int32, int64: return true } } return false } func hasOutlineContext(req OutlineTaskRequest) bool { if req.Title == "" || len(req.OutlineSections) == 0 { return false } if req.BrandName != "" || req.Website != "" || req.BrandSummary != "" || req.KeyPoints != "" { return true } if len(req.Keywords) > 0 || len(req.Competitors) > 0 { return true } for _, value := range req.InputParams { switch typed := value.(type) { case string: if strings.TrimSpace(typed) != "" { return true } case []interface{}: if len(typed) > 0 { return true } case []string: if len(typed) > 0 { return true } case float64, float32, int, int32, int64: return true } } return true } func buildAnalyzePrompt(templateKey, templateName string, req AnalyzeTaskRequest, analyzePromptTemplate *string) string { contextPayload := analyzePromptParams(templateKey, templateName, req) if analyzePromptTemplate != nil && strings.TrimSpace(*analyzePromptTemplate) != "" { basePrompt := strings.TrimSpace(renderPromptTemplate(analyzePromptTemplate, contextPayload)) if basePrompt != "" { contextJSON, _ := json.MarshalIndent(contextPayload, "", " ") return prompts.TemplateContextWithJSONExample(basePrompt, string(contextJSON), prompts.AnalyzeOutputExample()) } } contextJSON, _ := json.MarshalIndent(contextPayload, "", " ") return prompts.AnalyzeFallbackPrompt(string(contextJSON)) } func buildAnalyzeAIPointMeteredText(req AnalyzeTaskRequest) string { payload, _ := json.Marshal(analyzePromptParams("", "", req)) return string(payload) } func buildTitlePrompt(templateKey, templateName string, req TitleTaskRequest, titlePromptTemplate *string) string { contextPayload := titlePromptParams(templateKey, templateName, req) if titlePromptTemplate != nil && strings.TrimSpace(*titlePromptTemplate) != "" { basePrompt := strings.TrimSpace(renderPromptTemplate(titlePromptTemplate, contextPayload)) if basePrompt != "" { sections := []string{basePrompt} if contextBlock := buildPromptContext(contextPayload); contextBlock != "" { sections = append(sections, prompts.PromptContextSection(contextBlock)) } sections = append(sections, prompts.TitleCustomOutputRequirementsSection()) return strings.Join(sections, "\n\n") } } contextJSON, _ := json.MarshalIndent(contextPayload, "", " ") return prompts.TitleFallbackPrompt(string(contextJSON)) } func buildOutlinePrompt(templateKey, templateName string, req OutlineTaskRequest, outlinePromptTemplate *string) string { contextPayload := outlinePromptParams(templateKey, templateName, req) outputExample := prompts.OutlineOutputExample() if outlinePromptTemplate != nil && strings.TrimSpace(*outlinePromptTemplate) != "" { basePrompt := strings.TrimSpace(renderPromptTemplate(outlinePromptTemplate, contextPayload)) if basePrompt != "" { sections := []string{basePrompt} if contextBlock := buildPromptContext(contextPayload); contextBlock != "" { sections = append(sections, prompts.PromptContextSection(contextBlock)) } sections = append(sections, prompts.OutlineCustomOutputRequirementsSection()) sections = append(sections, prompts.JSONOutputExampleSection(outputExample)) return strings.Join(sections, "\n\n") } } contextJSON, _ := json.MarshalIndent(contextPayload, "", " ") return prompts.OutlineFallbackPrompt(string(contextJSON)) } func buildTitleAIPointMeteredText(req TitleTaskRequest) string { payload, _ := json.Marshal(titlePromptParams("", "", req)) return string(payload) } func buildOutlineAIPointMeteredText(req OutlineTaskRequest) string { payload, _ := json.Marshal(outlinePromptParams("", "", req)) return string(payload) } func analyzePromptParams(templateKey, templateName string, req AnalyzeTaskRequest) map[string]interface{} { return map[string]interface{}{ "template_key": templateKey, "template_name": templateName, "locale": req.Locale, "brand_name": req.BrandName, "official_website": req.Website, "input_params": req.InputParams, "existing_keywords": req.ExistingKeywords, "existing_competitors": req.ExistingCompetitors, } } func titlePromptParams(templateKey, templateName string, req TitleTaskRequest) map[string]interface{} { params := map[string]interface{}{ "template_key": templateKey, "template_name": templateName, "locale": req.Locale, "brand_name": req.BrandName, "official_website": req.Website, "brand_summary": req.BrandSummary, "input_params": req.InputParams, "keywords": req.Keywords, "competitors": req.Competitors, "competitor_count": len(req.Competitors), "current_year": time.Now().Year(), } mergePromptParams(params, req.InputParams) if len(req.Keywords) > 0 { params["primary_keyword"] = req.Keywords[0] params["keyword_count"] = len(req.Keywords) } if count, ok := req.InputParams["count"]; ok { params["count"] = count } else if len(req.Competitors) > 0 { params["count"] = len(req.Competitors) } if currentCount, ok := params["count"]; ok { params["top_count"] = currentCount } if len(req.Competitors) > 0 { names := make([]string, 0, len(req.Competitors)) for _, competitor := range req.Competitors { if name := strings.TrimSpace(competitor.Name); name != "" { names = append(names, name) } } if len(names) > 0 { params["competitor_names"] = names } } return params } func outlinePromptParams(templateKey, templateName string, req OutlineTaskRequest) map[string]interface{} { params := map[string]interface{}{ "template_key": templateKey, "template_name": templateName, "locale": req.Locale, "title": req.Title, "brand_name": req.BrandName, "official_website": req.Website, "brand_summary": req.BrandSummary, "input_params": req.InputParams, "keywords": req.Keywords, "competitors": req.Competitors, "competitor_count": len(req.Competitors), "outline_sections": req.OutlineSections, "key_points": req.KeyPoints, "current_year": time.Now().Year(), } mergePromptParams(params, req.InputParams) if len(req.Keywords) > 0 { params["primary_keyword"] = req.Keywords[0] params["keyword_count"] = len(req.Keywords) } if count, ok := req.InputParams["count"]; ok { params["count"] = count } else if len(req.Competitors) > 0 { params["count"] = len(req.Competitors) } if currentCount, ok := params["count"]; ok { params["top_count"] = currentCount } if len(req.Competitors) > 0 { names := make([]string, 0, len(req.Competitors)) for _, competitor := range req.Competitors { if name := strings.TrimSpace(competitor.Name); name != "" { names = append(names, name) } } if len(names) > 0 { params["competitor_names"] = names } } return params } func mergePromptParams(target map[string]interface{}, input map[string]interface{}) { for key, value := range input { if _, exists := target[key]; exists { continue } target[key] = value } } func extractAnalyzePromptTemplate(cardConfigJSON []byte) *string { return extractWizardPromptTemplate(cardConfigJSON, "analyze_prompt_template") } func extractTitlePromptTemplate(cardConfigJSON []byte) *string { return extractWizardPromptTemplate(cardConfigJSON, "title_prompt_template") } func extractOutlinePromptTemplate(cardConfigJSON []byte) *string { return extractWizardPromptTemplate(cardConfigJSON, "outline_prompt_template") } func extractWizardPromptTemplate(cardConfigJSON []byte, field string) *string { if len(cardConfigJSON) == 0 { return nil } var cardConfig map[string]interface{} if err := json.Unmarshal(cardConfigJSON, &cardConfig); err != nil { return nil } wizard, _ := cardConfig["wizard"].(map[string]interface{}) if wizard == nil { return nil } if structure, _ := wizard["structure"].(map[string]interface{}); structure != nil { prompt, _ := structure[field].(string) prompt = strings.TrimSpace(prompt) if prompt != "" { return &prompt } } prompt, _ := wizard[field].(string) prompt = strings.TrimSpace(prompt) if prompt == "" { return nil } return &prompt } var jsonFencePattern = regexp.MustCompile("(?s)^```(?:json)?\\s*(.*?)\\s*```$") func decodeAnalyzeResult(raw string) (*AnalyzeTaskResult, error) { cleaned := stripJSONFence(raw) var result AnalyzeTaskResult if err := json.Unmarshal([]byte(cleaned), &result); err != nil { return nil, fmt.Errorf("decode analyze result: %w", err) } return &result, nil } func decodeTitleResult(raw string) ([]string, error) { cleaned := stripJSONFence(raw) var result []string if err := json.Unmarshal([]byte(cleaned), &result); err == nil { return result, nil } var wrapped struct { Titles []string `json:"titles"` } if err := json.Unmarshal([]byte(cleaned), &wrapped); err != nil { return nil, fmt.Errorf("decode title result: %w", err) } return wrapped.Titles, nil } func decodeOutlineResult(raw string) ([]OutlineNode, error) { var lastErr error for _, candidate := range extractJSONCandidates(raw) { var result []OutlineNode if err := json.Unmarshal([]byte(candidate), &result); err == nil { return result, nil } else { lastErr = err } var wrapped struct { Result []OutlineNode `json:"result"` Outline []OutlineNode `json:"outline"` Items []OutlineNode `json:"items"` Data []OutlineNode `json:"data"` Sections []OutlineNode `json:"sections"` } if err := json.Unmarshal([]byte(candidate), &wrapped); err != nil { lastErr = err continue } switch { case len(wrapped.Result) > 0: return wrapped.Result, nil case len(wrapped.Outline) > 0: return wrapped.Outline, nil case len(wrapped.Items) > 0: return wrapped.Items, nil case len(wrapped.Data) > 0: return wrapped.Data, nil case len(wrapped.Sections) > 0: return wrapped.Sections, nil default: lastErr = fmt.Errorf("outline object does not contain a supported outline field") } } if lastErr == nil { lastErr = fmt.Errorf("empty content") } return nil, fmt.Errorf("decode outline result: %w", lastErr) } func stripJSONFence(raw string) string { cleaned := strings.TrimSpace(raw) if matches := jsonFencePattern.FindStringSubmatch(cleaned); len(matches) == 2 { cleaned = strings.TrimSpace(matches[1]) } return cleaned } func extractJSONCandidates(raw string) []string { cleaned := stripJSONFence(raw) if cleaned == "" { return nil } candidates := make([]string, 0, 8) seen := make(map[string]struct{}, 8) addCandidate := func(value string) { value = strings.TrimSpace(value) if value == "" { return } if _, exists := seen[value]; exists { return } seen[value] = struct{}{} candidates = append(candidates, value) } addCandidate(cleaned) for i := 0; i < len(cleaned); i++ { switch cleaned[i] { case '{', '[': if fragment, ok := extractBalancedJSONFragment(cleaned[i:]); ok { addCandidate(fragment) } } } return candidates } func extractBalancedJSONFragment(input string) (string, bool) { if input == "" { return "", false } var open, close byte switch input[0] { case '{': open = '{' close = '}' case '[': open = '[' close = ']' default: return "", false } depth := 0 inString := false escaped := false for i := 0; i < len(input); i++ { ch := input[i] if inString { if escaped { escaped = false continue } switch ch { case '\\': escaped = true case '"': inString = false } continue } switch ch { case '"': inString = true case open: depth += 1 case close: depth -= 1 if depth == 0 { return input[:i+1], true } } } return "", false } func normalizeAnalyzeResult(result AnalyzeTaskResult) AnalyzeTaskResult { result.BrandSummary = strings.TrimSpace(result.BrandSummary) result.Keywords = normalizeStringList(result.Keywords, 10) result.Competitors = normalizeAssistCompetitors(result.Competitors, 6) return result } func normalizeOutlineNodes(nodes []OutlineNode, depth int) []OutlineNode { if len(nodes) == 0 || depth <= 0 { return []OutlineNode{} } items := make([]OutlineNode, 0, len(nodes)) for _, node := range nodes { outline := strings.TrimSpace(node.Outline) if outline == "" { continue } item := OutlineNode{Outline: outline} if depth > 1 && len(node.Children) > 0 { item.Children = normalizeOutlineNodes(node.Children, depth-1) } items = append(items, item) } return items } func normalizeStringList(values []string, max int) []string { if len(values) == 0 { return []string{} } seen := make(map[string]struct{}, len(values)) items := make([]string, 0, len(values)) for _, value := range values { trimmed := strings.TrimSpace(value) if trimmed == "" { continue } key := strings.ToLower(trimmed) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} items = append(items, trimmed) if max > 0 && len(items) >= max { break } } return items } func normalizeAssistCompetitors(values []AssistCompetitor, max int) []AssistCompetitor { if len(values) == 0 { return []AssistCompetitor{} } seen := make(map[string]struct{}, len(values)) items := make([]AssistCompetitor, 0, len(values)) for _, value := range values { competitor := AssistCompetitor{ Name: strings.TrimSpace(value.Name), Website: strings.TrimSpace(value.Website), Description: strings.TrimSpace(value.Description), } if competitor.Name == "" && competitor.Website == "" { continue } identity := strings.ToLower(strings.TrimSpace(competitor.Website)) if identity == "" { identity = strings.ToLower(strings.TrimSpace(competitor.Name)) } if identity == "" { continue } if _, ok := seen[identity]; ok { continue } seen[identity] = struct{}{} items = append(items, competitor) if max > 0 && len(items) >= max { break } } return items } func assistTaskMessage(status string) string { switch status { case "queued": return "pending" case "running": return "running" case "completed": return "success" case "failed": return "failed" default: return status } }