52997e36fe
Forbid URLs/domains/markdown links across prompt rules (runtime, platform templates, outline/imitation/title flows) and rename "官网" placeholder to "官网信息" with explicit instructions that links are background only. Add sanitizeGeneratedArticleMarkdown to strip URLs, HTML images, and "网站列表/官网列表" labels from LLM output, wired into article, prompt-rule, template, and outline generation. Drop the site_list outline section seed and filter blocked outline keys in the wizard so stored drafts cannot resurrect it.
1313 lines
42 KiB
Go
1313 lines
42 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"`
|
|
BrandQuestion string `json:"brand_question,omitempty"`
|
|
SupplementalQuestions []string `json:"supplemental_questions,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"`
|
|
BrandQuestion string `json:"brand_question,omitempty"`
|
|
SupplementalQuestions []string `json:"supplemental_questions,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"`
|
|
BrandQuestion string `json:"brand_question,omitempty"`
|
|
SupplementalQuestions []string `json:"supplemental_questions,omitempty"`
|
|
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.BrandQuestion = strings.TrimSpace(req.BrandQuestion)
|
|
req.SupplementalQuestions = []string{}
|
|
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.BrandQuestion = strings.TrimSpace(req.BrandQuestion)
|
|
req.SupplementalQuestions = normalizeStringList(req.SupplementalQuestions, 3)
|
|
req.Keywords = normalizeStringList(req.Keywords, 12)
|
|
if req.BrandQuestion != "" {
|
|
req.SupplementalQuestions = []string{}
|
|
req.Keywords = []string{req.BrandQuestion}
|
|
} else if len(req.Keywords) == 0 {
|
|
req.Keywords = buildQuestionKeywordContext(req.BrandQuestion, req.SupplementalQuestions, 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.BrandQuestion = strings.TrimSpace(req.BrandQuestion)
|
|
req.SupplementalQuestions = normalizeStringList(req.SupplementalQuestions, 3)
|
|
req.Keywords = normalizeStringList(req.Keywords, 12)
|
|
if len(req.Keywords) == 0 {
|
|
req.Keywords = buildQuestionKeywordContext(req.BrandQuestion, req.SupplementalQuestions, 12)
|
|
}
|
|
req.Competitors = normalizeAssistCompetitors(req.Competitors, 8)
|
|
req.OutlineSections = normalizeStringList(req.OutlineSections, 24)
|
|
req.InputParams = normalizeAssistInputParams(req.InputParams)
|
|
return req
|
|
}
|
|
|
|
func buildQuestionKeywordContext(primaryQuestion string, supplementalQuestions []string, max int) []string {
|
|
return normalizeStringList(append([]string{primaryQuestion}, supplementalQuestions...), max)
|
|
}
|
|
|
|
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 != "" || req.BrandQuestion != "" {
|
|
return true
|
|
}
|
|
if len(req.SupplementalQuestions) > 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 len(req.ExistingKeywords) > 0 || len(req.ExistingCompetitors) > 0
|
|
}
|
|
|
|
func hasTitleContext(req TitleTaskRequest) bool {
|
|
if req.BrandName != "" || req.Website != "" || req.BrandSummary != "" || req.BrandQuestion != "" {
|
|
return true
|
|
}
|
|
if len(req.SupplementalQuestions) > 0 {
|
|
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 != "" || req.BrandQuestion != "" {
|
|
return true
|
|
}
|
|
if len(req.SupplementalQuestions) > 0 {
|
|
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{} {
|
|
keywords := buildQuestionKeywordContext(req.BrandQuestion, nil, 12)
|
|
return map[string]interface{}{
|
|
"template_key": templateKey,
|
|
"template_name": templateName,
|
|
"locale": req.Locale,
|
|
"brand_name": req.BrandName,
|
|
"official_website": req.Website,
|
|
"brand_question": req.BrandQuestion,
|
|
"primary_question": req.BrandQuestion,
|
|
"input_params": req.InputParams,
|
|
"keywords": keywords,
|
|
"primary_keyword": req.BrandQuestion,
|
|
"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,
|
|
"brand_question": req.BrandQuestion,
|
|
"primary_question": req.BrandQuestion,
|
|
"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 req.BrandQuestion != "" {
|
|
params["primary_keyword"] = req.BrandQuestion
|
|
params["keyword_count"] = 1
|
|
} else 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,
|
|
"brand_question": req.BrandQuestion,
|
|
"primary_question": req.BrandQuestion,
|
|
"supplemental_questions": req.SupplementalQuestions,
|
|
"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 req.BrandQuestion != "" {
|
|
params["primary_keyword"] = req.BrandQuestion
|
|
params["keyword_count"] = len(buildQuestionKeywordContext(
|
|
req.BrandQuestion,
|
|
req.SupplementalQuestions,
|
|
12,
|
|
))
|
|
} else 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 := sanitizeGeneratedOutlineText(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
|
|
}
|
|
}
|