2026-04-02 00:31:28 +08:00
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"
2026-04-05 17:14:13 +08:00
"github.com/geo-platform/tenant-api/internal/tenant/prompts"
2026-04-02 00:31:28 +08:00
"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 {
2026-05-13 15:59:39 +08:00
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" `
2026-04-02 00:31:28 +08:00
}
type AnalyzeTaskResult struct {
BrandSummary string ` json:"brand_summary" `
Keywords [ ] string ` json:"keywords" `
Competitors [ ] AssistCompetitor ` json:"competitors" `
}
type TitleTaskRequest struct {
2026-05-13 15:59:39 +08:00
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" `
2026-04-02 00:31:28 +08:00
}
type OutlineTaskRequest struct {
2026-05-13 15:59:39 +08:00
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" `
2026-04-02 00:31:28 +08:00
}
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
}
2026-04-15 14:20:26 +08:00
type AssistJob = assistJob
2026-04-02 00:31:28 +08:00
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 {
2026-04-15 16:11:05 +08:00
return nil , mapTemplateLookupError ( err )
2026-04-02 00:31:28 +08:00
}
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 ( )
2026-04-28 11:33:47 +08:00
reservation , err := s . reserveTemplateAssistAIPoints ( ctx , actor . TenantID , actor . UserID , templateID , taskID , templateAssistTaskTypeAnalyze , buildAnalyzeAIPointMeteredText ( req ) )
if err != nil {
return nil , err
}
2026-04-02 00:31:28 +08:00
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 {
2026-04-28 11:33:47 +08:00
s . refundReservedAIPoints ( context . Background ( ) , actor . TenantID , actor . UserID , reservation , err . Error ( ) )
2026-04-02 00:31:28 +08:00
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 ,
}
2026-04-15 14:20:26 +08:00
if err := publishTemplateAssistTask ( ctx , s . rabbitMQ , job ) ; err != nil {
2026-04-02 00:31:28 +08:00
_ = repo . FailTask ( context . Background ( ) , taskID , templateAssistTaskTypeAnalyze , actor . TenantID , err . Error ( ) , time . Now ( ) )
2026-04-28 11:33:47 +08:00
s . refundReservedAIPoints ( context . Background ( ) , actor . TenantID , actor . UserID , reservation , err . Error ( ) )
2026-04-02 00:31:28 +08:00
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 {
2026-04-15 16:11:05 +08:00
return nil , mapTemplateLookupError ( err )
2026-04-02 00:31:28 +08:00
}
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 ( )
2026-04-28 11:33:47 +08:00
reservation , err := s . reserveTemplateAssistAIPoints ( ctx , actor . TenantID , actor . UserID , templateID , taskID , templateAssistTaskTypeTitle , buildTitleAIPointMeteredText ( req ) )
if err != nil {
return nil , err
}
2026-04-02 00:31:28 +08:00
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 {
2026-04-28 11:33:47 +08:00
s . refundReservedAIPoints ( context . Background ( ) , actor . TenantID , actor . UserID , reservation , err . Error ( ) )
2026-04-02 00:31:28 +08:00
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 ,
}
2026-04-15 14:20:26 +08:00
if err := publishTemplateAssistTask ( ctx , s . rabbitMQ , job ) ; err != nil {
2026-04-02 00:31:28 +08:00
_ = repo . FailTask ( context . Background ( ) , taskID , templateAssistTaskTypeTitle , actor . TenantID , err . Error ( ) , time . Now ( ) )
2026-04-28 11:33:47 +08:00
s . refundReservedAIPoints ( context . Background ( ) , actor . TenantID , actor . UserID , reservation , err . Error ( ) )
2026-04-02 00:31:28 +08:00
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 {
2026-04-15 16:11:05 +08:00
return nil , mapTemplateLookupError ( err )
2026-04-02 00:31:28 +08:00
}
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 ( )
2026-04-28 11:33:47 +08:00
reservation , err := s . reserveTemplateAssistAIPoints ( ctx , actor . TenantID , actor . UserID , templateID , taskID , templateAssistTaskTypeOutline , buildOutlineAIPointMeteredText ( req ) )
if err != nil {
return nil , err
}
2026-04-02 00:31:28 +08:00
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 {
2026-04-28 11:33:47 +08:00
s . refundReservedAIPoints ( context . Background ( ) , actor . TenantID , actor . UserID , reservation , err . Error ( ) )
2026-04-02 00:31:28 +08:00
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 ,
}
2026-04-15 14:20:26 +08:00
if err := publishTemplateAssistTask ( ctx , s . rabbitMQ , job ) ; err != nil {
2026-04-02 00:31:28 +08:00
_ = repo . FailTask ( context . Background ( ) , taskID , templateAssistTaskTypeOutline , actor . TenantID , err . Error ( ) , time . Now ( ) )
2026-04-28 11:33:47 +08:00
s . refundReservedAIPoints ( context . Background ( ) , actor . TenantID , actor . UserID , reservation , err . Error ( ) )
2026-04-02 00:31:28 +08:00
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 ) )
}
}
2026-04-15 14:20:26 +08:00
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
}
2026-04-28 11:33:47 +08:00
switch record . Status {
case "completed" :
2026-07-09 17:04:37 +08:00
s . completeTemplateAssistAIPoints ( ctx , job . TenantID , job . TaskID , "" , string ( record . ResultJSON ) )
2026-04-28 11:33:47 +08:00
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
}
2026-04-15 14:20:26 +08:00
}
2026-04-02 00:31:28 +08:00
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 )
2026-04-28 11:33:47 +08:00
return
2026-04-02 00:31:28 +08:00
}
2026-07-09 17:04:37 +08:00
s . completeTemplateAssistAIPoints ( ctx , job . TenantID , job . TaskID , result . Model , result . Content )
2026-04-02 00:31:28 +08:00
}
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 )
2026-04-28 11:33:47 +08:00
return
2026-04-02 00:31:28 +08:00
}
2026-07-09 17:04:37 +08:00
s . completeTemplateAssistAIPoints ( ctx , job . TenantID , job . TaskID , result . Model , result . Content )
2026-04-02 00:31:28 +08:00
}
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 ) ,
2026-04-02 10:58:39 +08:00
ResponseFormat : & llm . ResponseFormat {
Type : llm . ResponseFormatTypeJSONObject ,
Name : "article_outline" ,
2026-04-05 17:14:13 +08:00
Description : prompts . OutlineResponseFormatDescription ( ) ,
2026-04-02 10:58:39 +08:00
} ,
2026-04-02 00:31:28 +08:00
} , 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 )
2026-04-28 11:33:47 +08:00
return
2026-04-02 00:31:28 +08:00
}
2026-07-09 17:04:37 +08:00
s . completeTemplateAssistAIPoints ( ctx , job . TenantID , job . TaskID , result . Model , result . Content )
2026-04-02 00:31:28 +08:00
}
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 ( ) )
2026-04-28 11:33:47 +08:00
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 )
}
2026-07-09 17:04:37 +08:00
func ( s * TemplateService ) completeTemplateAssistAIPoints ( ctx context . Context , tenantID int64 , taskID string , model string , meteredOutputText string ) {
2026-04-28 11:33:47 +08:00
resourceType := "template_assist_task"
resourceUID := taskID
2026-07-09 17:04:37 +08:00
_ , _ = CompletePendingAIPointsByResource ( ctx , s . pool , s . cache , tenantID , resourceType , nil , & resourceUID , model , meteredOutputText )
2026-04-28 11:33:47 +08:00
}
func isTemplateAssistAIPointTask ( taskType string ) bool {
return taskType == templateAssistTaskTypeAnalyze ||
taskType == templateAssistTaskTypeTitle ||
taskType == templateAssistTaskTypeOutline
2026-04-02 00:31:28 +08:00
}
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 )
2026-05-13 15:59:39 +08:00
req . BrandQuestion = strings . TrimSpace ( req . BrandQuestion )
req . SupplementalQuestions = [ ] string { }
2026-04-02 00:31:28 +08:00
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 )
2026-05-13 15:59:39 +08:00
req . BrandQuestion = strings . TrimSpace ( req . BrandQuestion )
req . SupplementalQuestions = normalizeStringList ( req . SupplementalQuestions , 3 )
2026-04-02 00:31:28 +08:00
req . Keywords = normalizeStringList ( req . Keywords , 12 )
2026-05-13 15:59:39 +08:00
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 )
}
2026-04-02 00:31:28 +08:00
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 )
2026-05-13 15:59:39 +08:00
req . BrandQuestion = strings . TrimSpace ( req . BrandQuestion )
req . SupplementalQuestions = normalizeStringList ( req . SupplementalQuestions , 3 )
2026-04-02 00:31:28 +08:00
req . Keywords = normalizeStringList ( req . Keywords , 12 )
2026-05-13 15:59:39 +08:00
if len ( req . Keywords ) == 0 {
req . Keywords = buildQuestionKeywordContext ( req . BrandQuestion , req . SupplementalQuestions , 12 )
}
2026-04-02 00:31:28 +08:00
req . Competitors = normalizeAssistCompetitors ( req . Competitors , 8 )
req . OutlineSections = normalizeStringList ( req . OutlineSections , 24 )
req . InputParams = normalizeAssistInputParams ( req . InputParams )
return req
}
2026-05-13 15:59:39 +08:00
func buildQuestionKeywordContext ( primaryQuestion string , supplementalQuestions [ ] string , max int ) [ ] string {
return normalizeStringList ( append ( [ ] string { primaryQuestion } , supplementalQuestions ... ) , max )
}
2026-04-02 00:31:28 +08:00
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 {
2026-05-13 15:59:39 +08:00
if req . BrandName != "" || req . Website != "" || req . BrandQuestion != "" {
return true
}
if len ( req . SupplementalQuestions ) > 0 {
2026-04-02 00:31:28 +08:00
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 {
2026-05-13 15:59:39 +08:00
if req . BrandName != "" || req . Website != "" || req . BrandSummary != "" || req . BrandQuestion != "" {
return true
}
if len ( req . SupplementalQuestions ) > 0 {
2026-04-02 00:31:28 +08:00
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
}
2026-05-13 15:59:39 +08:00
if req . BrandName != "" || req . Website != "" || req . BrandSummary != "" || req . KeyPoints != "" || req . BrandQuestion != "" {
return true
}
if len ( req . SupplementalQuestions ) > 0 {
2026-04-02 00:31:28 +08:00
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 {
2026-04-28 11:33:47 +08:00
contextPayload := analyzePromptParams ( templateKey , templateName , req )
2026-04-02 00:31:28 +08:00
if analyzePromptTemplate != nil && strings . TrimSpace ( * analyzePromptTemplate ) != "" {
basePrompt := strings . TrimSpace ( renderPromptTemplate ( analyzePromptTemplate , contextPayload ) )
if basePrompt != "" {
contextJSON , _ := json . MarshalIndent ( contextPayload , "" , " " )
2026-07-09 19:38:55 +08:00
sections := [ ] string { prompts . TemplateContextWithJSONExample ( basePrompt , string ( contextJSON ) , prompts . AnalyzeOutputExample ( ) ) }
if localeGuard := buildAssistLocaleOutputGuard ( req . Locale , "analyze" ) ; localeGuard != "" {
sections = append ( sections , localeGuard )
}
return strings . Join ( sections , "\n\n" )
2026-04-02 00:31:28 +08:00
}
}
contextJSON , _ := json . MarshalIndent ( contextPayload , "" , " " )
2026-07-09 19:38:55 +08:00
sections := [ ] string { prompts . AnalyzeFallbackPrompt ( string ( contextJSON ) ) }
if localeGuard := buildAssistLocaleOutputGuard ( req . Locale , "analyze" ) ; localeGuard != "" {
sections = append ( sections , localeGuard )
}
return strings . Join ( sections , "\n\n" )
2026-04-02 00:31:28 +08:00
}
2026-04-28 11:33:47 +08:00
func buildAnalyzeAIPointMeteredText ( req AnalyzeTaskRequest ) string {
payload , _ := json . Marshal ( analyzePromptParams ( "" , "" , req ) )
return string ( payload )
}
2026-04-02 00:31:28 +08:00
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 != "" {
2026-04-05 17:14:13 +08:00
sections = append ( sections , prompts . PromptContextSection ( contextBlock ) )
2026-04-02 00:31:28 +08:00
}
2026-04-05 17:14:13 +08:00
sections = append ( sections , prompts . TitleCustomOutputRequirementsSection ( ) )
2026-07-09 19:38:55 +08:00
if localeGuard := buildAssistLocaleOutputGuard ( req . Locale , "title" ) ; localeGuard != "" {
sections = append ( sections , localeGuard )
}
2026-04-02 00:31:28 +08:00
return strings . Join ( sections , "\n\n" )
}
}
contextJSON , _ := json . MarshalIndent ( contextPayload , "" , " " )
2026-07-09 19:38:55 +08:00
sections := [ ] string { prompts . TitleFallbackPrompt ( string ( contextJSON ) ) }
if localeGuard := buildAssistLocaleOutputGuard ( req . Locale , "title" ) ; localeGuard != "" {
sections = append ( sections , localeGuard )
}
return strings . Join ( sections , "\n\n" )
2026-04-02 00:31:28 +08:00
}
func buildOutlinePrompt ( templateKey , templateName string , req OutlineTaskRequest , outlinePromptTemplate * string ) string {
contextPayload := outlinePromptParams ( templateKey , templateName , req )
2026-04-05 17:14:13 +08:00
outputExample := prompts . OutlineOutputExample ( )
2026-04-02 10:58:39 +08:00
2026-04-02 00:31:28 +08:00
if outlinePromptTemplate != nil && strings . TrimSpace ( * outlinePromptTemplate ) != "" {
basePrompt := strings . TrimSpace ( renderPromptTemplate ( outlinePromptTemplate , contextPayload ) )
if basePrompt != "" {
sections := [ ] string { basePrompt }
if contextBlock := buildPromptContext ( contextPayload ) ; contextBlock != "" {
2026-04-05 17:14:13 +08:00
sections = append ( sections , prompts . PromptContextSection ( contextBlock ) )
2026-04-02 00:31:28 +08:00
}
2026-04-05 17:14:13 +08:00
sections = append ( sections , prompts . OutlineCustomOutputRequirementsSection ( ) )
sections = append ( sections , prompts . JSONOutputExampleSection ( outputExample ) )
2026-07-09 19:38:55 +08:00
if localeGuard := buildAssistLocaleOutputGuard ( req . Locale , "outline" ) ; localeGuard != "" {
sections = append ( sections , localeGuard )
}
2026-04-02 00:31:28 +08:00
return strings . Join ( sections , "\n\n" )
}
}
contextJSON , _ := json . MarshalIndent ( contextPayload , "" , " " )
2026-07-09 19:38:55 +08:00
sections := [ ] string { prompts . OutlineFallbackPrompt ( string ( contextJSON ) ) }
if localeGuard := buildAssistLocaleOutputGuard ( req . Locale , "outline" ) ; localeGuard != "" {
sections = append ( sections , localeGuard )
}
return strings . Join ( sections , "\n\n" )
}
func buildAssistLocaleOutputGuard ( locale , artifact string ) string {
if strings . TrimSpace ( locale ) != "en-US" {
return ""
}
switch artifact {
case "analyze" :
return strings . Join ( [ ] string {
"High-priority language consistency requirement:" ,
"- Return English JSON field values for brand_summary, keywords, competitor descriptions, and any generated explanatory text." ,
"- Competitor, brand, company, and product names may remain in their official language, but do not output Chinese prose or untranslated Chinese template wording." ,
"- Treat Chinese template wording and examples as internal instructions only." ,
} , "\n" )
case "title" :
return strings . Join ( [ ] string {
"High-priority language consistency requirement:" ,
"- Return English JSON strings only." ,
"- The titles must be natural, professional English titles; do not output Chinese words, Chinese numerals, or Chinese title patterns." ,
"- Treat Chinese template wording and examples as internal instructions only." ,
} , "\n" )
case "outline" :
return strings . Join ( [ ] string {
"High-priority language consistency requirement:" ,
"- Every outline and child outline value must be natural, professional English." ,
"- Do not output Chinese section names, Chinese prose, Chinese punctuation patterns, or untranslated Chinese template wording." ,
"- If any selected section, keyword, or context value is Chinese, translate or adapt the generated outline wording into English while preserving intent." ,
} , "\n" )
default :
return ""
}
2026-04-02 00:31:28 +08:00
}
2026-04-28 11:33:47 +08:00
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 { } {
2026-05-13 15:59:39 +08:00
keywords := buildQuestionKeywordContext ( req . BrandQuestion , nil , 12 )
2026-04-28 11:33:47 +08:00
return map [ string ] interface { } {
"template_key" : templateKey ,
"template_name" : templateName ,
"locale" : req . Locale ,
"brand_name" : req . BrandName ,
"official_website" : req . Website ,
2026-05-13 15:59:39 +08:00
"brand_question" : req . BrandQuestion ,
"primary_question" : req . BrandQuestion ,
2026-04-28 11:33:47 +08:00
"input_params" : req . InputParams ,
2026-05-13 15:59:39 +08:00
"keywords" : keywords ,
"primary_keyword" : req . BrandQuestion ,
2026-04-28 11:33:47 +08:00
"existing_keywords" : req . ExistingKeywords ,
"existing_competitors" : req . ExistingCompetitors ,
}
}
2026-04-02 00:31:28 +08:00
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 ,
2026-05-13 15:59:39 +08:00
"brand_question" : req . BrandQuestion ,
"primary_question" : req . BrandQuestion ,
2026-04-02 00:31:28 +08:00
"input_params" : req . InputParams ,
"keywords" : req . Keywords ,
"competitors" : req . Competitors ,
"competitor_count" : len ( req . Competitors ) ,
"current_year" : time . Now ( ) . Year ( ) ,
}
mergePromptParams ( params , req . InputParams )
2026-05-13 15:59:39 +08:00
if req . BrandQuestion != "" {
params [ "primary_keyword" ] = req . BrandQuestion
params [ "keyword_count" ] = 1
} else if len ( req . Keywords ) > 0 {
2026-04-02 00:31:28 +08:00
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 { } {
2026-05-13 15:59:39 +08:00
"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 ( ) ,
2026-04-02 00:31:28 +08:00
}
mergePromptParams ( params , req . InputParams )
2026-05-13 15:59:39 +08:00
if req . BrandQuestion != "" {
params [ "primary_keyword" ] = req . BrandQuestion
params [ "keyword_count" ] = len ( buildQuestionKeywordContext (
req . BrandQuestion ,
req . SupplementalQuestions ,
12 ,
) )
} else if len ( req . Keywords ) > 0 {
2026-04-02 00:31:28 +08:00
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 ) {
2026-04-02 10:58:39 +08:00
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
}
2026-04-02 00:31:28 +08:00
2026-04-02 10:58:39 +08:00
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" )
}
2026-04-02 00:31:28 +08:00
}
2026-04-02 10:58:39 +08:00
if lastErr == nil {
lastErr = fmt . Errorf ( "empty content" )
2026-04-02 00:31:28 +08:00
}
2026-04-02 10:58:39 +08:00
return nil , fmt . Errorf ( "decode outline result: %w" , lastErr )
2026-04-02 00:31:28 +08:00
}
func stripJSONFence ( raw string ) string {
cleaned := strings . TrimSpace ( raw )
if matches := jsonFencePattern . FindStringSubmatch ( cleaned ) ; len ( matches ) == 2 {
cleaned = strings . TrimSpace ( matches [ 1 ] )
}
return cleaned
}
2026-04-02 10:58:39 +08:00
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
}
2026-04-02 00:31:28 +08:00
func normalizeAnalyzeResult ( result AnalyzeTaskResult ) AnalyzeTaskResult {
result . BrandSummary = strings . TrimSpace ( result . BrandSummary )
2026-04-30 01:28:59 +08:00
result . Keywords = normalizeStringList ( result . Keywords , 10 )
2026-04-02 00:31:28 +08:00
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 {
2026-05-24 22:19:43 +08:00
outline := sanitizeGeneratedOutlineText ( node . Outline )
2026-04-02 00:31:28 +08:00
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
}
}