Files
geo/server/internal/tenant/app/template_assist.go
T
root 14a7921445 feat(prompts): expand keyword guidance to long-tail search queries
Tighten the analyze prompts (runtime + recommendation platform) so the
model returns 8-10 question-style search queries (哪家好/有哪些/怎么选/
价格/排名/避坑 …) instead of generic tag words, and lift the keyword cap
in normalizeAnalyzeResult from 5 to 10 to keep them. Add tests covering
the new ten-keyword window and the question-like guidance in the seeded
analyze prompt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 01:28:59 +08:00

1259 lines
39 KiB
Go

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
}
}