7fa1809682
- Added support for external markdown synchronization in ArticleEditorCanvas.vue. - Implemented a new function to sync markdown from props, ensuring the editor reflects external changes. - Introduced a removeOutlineSection function in TemplateWizardView.vue to manage outline sections dynamically. - Updated UI components to improve user experience, including replacing a-input with a-textarea for better text handling. - Refactored CSS styles for a cleaner layout and improved responsiveness in TemplateWizardView.vue. - Enhanced LLM generation requests to include response format options in ark.go and client.go. - Updated template assist logic to enforce structured outline outputs in template_assist.go. - Added tests for outline result decoding and prompt generation to ensure robustness. - Created migration scripts to update prompt templates with new writing requirements for top X articles.
1248 lines
38 KiB
Go
1248 lines
38 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/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
|
|
}
|
|
|
|
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, response.ErrNotFound(40410, "template_not_found", "template not found")
|
|
}
|
|
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()
|
|
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 {
|
|
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 := s.enqueueAssist(job); err != nil {
|
|
_ = repo.FailTask(context.Background(), taskID, templateAssistTaskTypeAnalyze, actor.TenantID, err.Error(), time.Now())
|
|
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, response.ErrNotFound(40410, "template_not_found", "template not found")
|
|
}
|
|
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()
|
|
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 {
|
|
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 := s.enqueueAssist(job); err != nil {
|
|
_ = repo.FailTask(context.Background(), taskID, templateAssistTaskTypeTitle, actor.TenantID, err.Error(), time.Now())
|
|
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, response.ErrNotFound(40410, "template_not_found", "template not found")
|
|
}
|
|
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()
|
|
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 {
|
|
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 := s.enqueueAssist(job); err != nil {
|
|
_ = repo.FailTask(context.Background(), taskID, templateAssistTaskTypeOutline, actor.TenantID, err.Error(), time.Now())
|
|
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) enqueueAssist(job assistJob) error {
|
|
select {
|
|
case s.assistJobs <- job:
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("assist queue is full")
|
|
}
|
|
}
|
|
|
|
func (s *TemplateService) runAssistWorker() {
|
|
for job := range s.assistJobs {
|
|
s.executeAssist(context.Background(), job)
|
|
}
|
|
}
|
|
|
|
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) 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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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: "文章大纲 JSON 对象,必须包含 outline 数组字段。",
|
|
},
|
|
}, 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)
|
|
}
|
|
}
|
|
|
|
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())
|
|
}
|
|
|
|
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 := 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,
|
|
}
|
|
|
|
if analyzePromptTemplate != nil && strings.TrimSpace(*analyzePromptTemplate) != "" {
|
|
basePrompt := strings.TrimSpace(renderPromptTemplate(analyzePromptTemplate, contextPayload))
|
|
if basePrompt != "" {
|
|
contextJSON, _ := json.MarshalIndent(contextPayload, "", " ")
|
|
outputExample := `{
|
|
"brand_summary": "一句简洁的品牌/主题描述",
|
|
"keywords": ["关键词1", "关键词2", "关键词3", "关键词4", "关键词5"],
|
|
"competitors": [
|
|
{
|
|
"name": "竞品名",
|
|
"website": "https://example.com",
|
|
"description": "一句简介"
|
|
}
|
|
]
|
|
}`
|
|
return strings.TrimSpace(fmt.Sprintf("%s\n\n模板上下文:\n%s\n\nJSON 输出示例:\n%s", basePrompt, string(contextJSON), outputExample))
|
|
}
|
|
}
|
|
|
|
contextJSON, _ := json.MarshalIndent(contextPayload, "", " ")
|
|
outputExample := `{
|
|
"brand_summary": "一句简洁的品牌/主题描述",
|
|
"keywords": ["关键词1", "关键词2", "关键词3", "关键词4", "关键词5"],
|
|
"competitors": [
|
|
{
|
|
"name": "竞品名",
|
|
"website": "https://example.com",
|
|
"description": "一句简介"
|
|
}
|
|
]
|
|
}`
|
|
|
|
return strings.TrimSpace(fmt.Sprintf(`
|
|
你是一位专业的 GEO 内容策略师。
|
|
|
|
任务:
|
|
1. 分析品牌/主题上下文。
|
|
2. 推荐最相关的 GEO 文章关键词。
|
|
3. 推荐可信的竞品网站或竞争品牌,用于对比或引用。
|
|
|
|
规则:
|
|
- 仅返回 JSON,不要用 Markdown 代码块包裹。
|
|
- 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。
|
|
- 关键词应为简洁的搜索短语。
|
|
- 竞品应去重且真实。不确定时 website 字段可留空,但不要编造虚假链接。
|
|
- brand_summary 限 1-2 句。
|
|
- 最多返回 6 个竞品和 5 个关键词。
|
|
|
|
模板上下文:
|
|
%s
|
|
|
|
JSON 输出示例:
|
|
%s
|
|
`, string(contextJSON), outputExample))
|
|
}
|
|
|
|
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, "当前上下文:\n"+contextBlock)
|
|
}
|
|
sections = append(sections, strings.Join([]string{
|
|
"输出要求:",
|
|
"- 仅返回 JSON,不要用 Markdown 代码块包裹。",
|
|
"- 返回恰好 5 个字符串的 JSON 数组。",
|
|
"- 尊重当前关键词列表和竞品上下文,因为用户可能已手动编辑。",
|
|
}, "\n"))
|
|
return strings.Join(sections, "\n\n")
|
|
}
|
|
}
|
|
|
|
contextJSON, _ := json.MarshalIndent(contextPayload, "", " ")
|
|
outputExample := `[
|
|
"标题 1",
|
|
"标题 2",
|
|
"标题 3",
|
|
"标题 4",
|
|
"标题 5"
|
|
]`
|
|
|
|
return strings.TrimSpace(fmt.Sprintf(`
|
|
你是一位专业的 GEO 内容策略师。
|
|
|
|
任务:
|
|
为当前内容摘要生成 5 个优质文章标题候选。
|
|
|
|
规则:
|
|
- 仅返回 JSON,不要用 Markdown 代码块包裹。
|
|
- 返回 5 个字符串的 JSON 数组,不要返回对象。
|
|
- 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。
|
|
- 标题应实用、具体,并契合当前模板的内容方向。
|
|
- 尊重当前关键词列表和竞品上下文,因为用户可能已手动编辑。
|
|
- 避免泛泛的广告文案和空洞口号。
|
|
|
|
模板上下文:
|
|
%s
|
|
|
|
JSON 输出示例:
|
|
%s
|
|
`, string(contextJSON), outputExample))
|
|
}
|
|
|
|
func buildOutlinePrompt(templateKey, templateName string, req OutlineTaskRequest, outlinePromptTemplate *string) string {
|
|
contextPayload := outlinePromptParams(templateKey, templateName, req)
|
|
outputExample := `{
|
|
"outline": [
|
|
{
|
|
"outline": "网站列表",
|
|
"children": [
|
|
{ "outline": "竞品 A" },
|
|
{ "outline": "竞品 B" }
|
|
]
|
|
},
|
|
{
|
|
"outline": "文章关键要点",
|
|
"children": [
|
|
{ "outline": "要点 1" },
|
|
{ "outline": "要点 2" }
|
|
]
|
|
}
|
|
]
|
|
}`
|
|
|
|
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, "当前上下文:\n"+contextBlock)
|
|
}
|
|
sections = append(sections, strings.Join([]string{
|
|
"输出要求:",
|
|
"- 仅返回 JSON,不要用 Markdown 代码块包裹。",
|
|
`- 返回 JSON 对象,格式固定为 {"outline":[...]}`,
|
|
`- outline 字段是顶层大纲节点数组,每个节点格式为 {"outline":"...","children":[...]}`,
|
|
"- 每个已选大纲段落应作为顶层节点出现,语言与请求保持一致。",
|
|
"- 尊重当前关键词、竞品和关键要点,因为用户可能已手动编辑。",
|
|
"- 不要返回额外字段,不要返回纯数组,不要返回说明文字。",
|
|
}, "\n"))
|
|
sections = append(sections, "JSON 输出示例:\n"+outputExample)
|
|
return strings.Join(sections, "\n\n")
|
|
}
|
|
}
|
|
|
|
contextJSON, _ := json.MarshalIndent(contextPayload, "", " ")
|
|
|
|
return strings.TrimSpace(fmt.Sprintf(`
|
|
你是一位专业的 GEO 内容策略师。
|
|
|
|
任务:
|
|
为当前内容摘要生成一份实用的文章大纲。
|
|
|
|
规则:
|
|
- 仅返回 JSON,不要用 Markdown 代码块包裹。
|
|
- 返回 JSON 对象,格式固定为 {"outline":[...]}。
|
|
- outline 字段中的每个顶层节点 "outline" 值必须保持已选段落标签原文。
|
|
- 在合适的位置为每个顶层段落添加简洁的子大纲条目。
|
|
- 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。
|
|
- 尊重当前标题、关键词、竞品、关键要点和品牌上下文,因为用户可能已手动编辑。
|
|
- 大纲应具体、不含推广语气,适合后续完整成文。
|
|
- 不要返回额外字段,不要返回纯数组,不要返回说明文字。
|
|
|
|
模板上下文:
|
|
%s
|
|
|
|
JSON 输出示例:
|
|
%s
|
|
`, string(contextJSON), outputExample))
|
|
}
|
|
|
|
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, 5)
|
|
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
|
|
}
|
|
}
|