feat: Enhance article generation and outline handling
- Added support for external markdown synchronization in ArticleEditorCanvas.vue. - Implemented a new function to sync markdown from props, ensuring the editor reflects external changes. - Introduced a removeOutlineSection function in TemplateWizardView.vue to manage outline sections dynamically. - Updated UI components to improve user experience, including replacing a-input with a-textarea for better text handling. - Refactored CSS styles for a cleaner layout and improved responsiveness in TemplateWizardView.vue. - Enhanced LLM generation requests to include response format options in ark.go and client.go. - Updated template assist logic to enforce structured outline outputs in template_assist.go. - Added tests for outline result decoding and prompt generation to ensure robustness. - Created migration scripts to update prompt templates with new writing requirements for top X articles.
This commit is contained in:
@@ -471,6 +471,11 @@ func (s *TemplateService) executeOutlineTask(ctx context.Context, repo repositor
|
||||
|
||||
result, err := s.llm.Generate(ctx, llm.GenerateRequest{
|
||||
Prompt: buildOutlinePrompt(job.TemplateKey, job.TemplateName, *job.OutlineRequest, job.OutlinePrompt),
|
||||
ResponseFormat: &llm.ResponseFormat{
|
||||
Type: llm.ResponseFormatTypeJSONObject,
|
||||
Name: "article_outline",
|
||||
Description: "文章大纲 JSON 对象,必须包含 outline 数组字段。",
|
||||
},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
s.failAssist(ctx, job.TaskID, job.TaskType, job.TenantID, err)
|
||||
@@ -699,9 +704,9 @@ func buildAnalyzePrompt(templateKey, templateName string, req AnalyzeTaskRequest
|
||||
|
||||
规则:
|
||||
- 仅返回 JSON,不要用 Markdown 代码块包裹。
|
||||
- 使用与请求 locale 一致的语言。zh-CN 用简体中文,en-US 用英文。
|
||||
- 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。
|
||||
- 关键词应为简洁的搜索短语。
|
||||
- 竞品应去重且真实。不确定时 website 可留空,但不要编造虚假 URL。
|
||||
- 竞品应去重且真实。不确定时 website 字段可留空,但不要编造虚假链接。
|
||||
- brand_summary 限 1-2 句。
|
||||
- 最多返回 6 个竞品和 5 个关键词。
|
||||
|
||||
@@ -750,7 +755,7 @@ func buildTitlePrompt(templateKey, templateName string, req TitleTaskRequest, ti
|
||||
规则:
|
||||
- 仅返回 JSON,不要用 Markdown 代码块包裹。
|
||||
- 返回 5 个字符串的 JSON 数组,不要返回对象。
|
||||
- 使用与请求 locale 一致的语言。zh-CN 用简体中文,en-US 用英文。
|
||||
- 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。
|
||||
- 标题应实用、具体,并契合当前模板的内容方向。
|
||||
- 尊重当前关键词列表和竞品上下文,因为用户可能已手动编辑。
|
||||
- 避免泛泛的广告文案和空洞口号。
|
||||
@@ -765,6 +770,25 @@ JSON 输出示例:
|
||||
|
||||
func buildOutlinePrompt(templateKey, templateName string, req OutlineTaskRequest, outlinePromptTemplate *string) string {
|
||||
contextPayload := outlinePromptParams(templateKey, templateName, req)
|
||||
outputExample := `{
|
||||
"outline": [
|
||||
{
|
||||
"outline": "网站列表",
|
||||
"children": [
|
||||
{ "outline": "竞品 A" },
|
||||
{ "outline": "竞品 B" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"outline": "文章关键要点",
|
||||
"children": [
|
||||
{ "outline": "要点 1" },
|
||||
{ "outline": "要点 2" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
if outlinePromptTemplate != nil && strings.TrimSpace(*outlinePromptTemplate) != "" {
|
||||
basePrompt := strings.TrimSpace(renderPromptTemplate(outlinePromptTemplate, contextPayload))
|
||||
if basePrompt != "" {
|
||||
@@ -775,31 +799,18 @@ func buildOutlinePrompt(templateKey, templateName string, req OutlineTaskRequest
|
||||
sections = append(sections, strings.Join([]string{
|
||||
"输出要求:",
|
||||
"- 仅返回 JSON,不要用 Markdown 代码块包裹。",
|
||||
`- 返回大纲节点的 JSON 数组,每个节点格式为 {"outline":"...","children":[...]}`,
|
||||
`- 返回 JSON 对象,格式固定为 {"outline":[...]}`,
|
||||
`- outline 字段是顶层大纲节点数组,每个节点格式为 {"outline":"...","children":[...]}`,
|
||||
"- 每个已选大纲段落应作为顶层节点出现,语言与请求保持一致。",
|
||||
"- 尊重当前关键词、竞品和关键要点,因为用户可能已手动编辑。",
|
||||
"- 不要返回额外字段,不要返回纯数组,不要返回说明文字。",
|
||||
}, "\n"))
|
||||
sections = append(sections, "JSON 输出示例:\n"+outputExample)
|
||||
return strings.Join(sections, "\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
contextJSON, _ := json.MarshalIndent(contextPayload, "", " ")
|
||||
outputExample := `[
|
||||
{
|
||||
"outline": "网站列表",
|
||||
"children": [
|
||||
{ "outline": "竞品 A" },
|
||||
{ "outline": "竞品 B" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"outline": "文章关键要点",
|
||||
"children": [
|
||||
{ "outline": "要点 1" },
|
||||
{ "outline": "要点 2" }
|
||||
]
|
||||
}
|
||||
]`
|
||||
|
||||
return strings.TrimSpace(fmt.Sprintf(`
|
||||
你是一位专业的 GEO 内容策略师。
|
||||
@@ -809,17 +820,18 @@ func buildOutlinePrompt(templateKey, templateName string, req OutlineTaskRequest
|
||||
|
||||
规则:
|
||||
- 仅返回 JSON,不要用 Markdown 代码块包裹。
|
||||
- 返回顶层大纲节点的 JSON 数组。
|
||||
- 每个顶层节点的 "outline" 值必须保持已选段落标签原文。
|
||||
- 返回 JSON 对象,格式固定为 {"outline":[...]}。
|
||||
- outline 字段中的每个顶层节点 "outline" 值必须保持已选段落标签原文。
|
||||
- 在合适的位置为每个顶层段落添加简洁的子大纲条目。
|
||||
- 使用与请求 locale 一致的语言。zh-CN 用简体中文,en-US 用英文。
|
||||
- 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。
|
||||
- 尊重当前标题、关键词、竞品、关键要点和品牌上下文,因为用户可能已手动编辑。
|
||||
- 大纲应具体、不含推广语气,适合后续完整成文。
|
||||
- 不要返回额外字段,不要返回纯数组,不要返回说明文字。
|
||||
|
||||
Template context:
|
||||
模板上下文:
|
||||
%s
|
||||
|
||||
Output JSON example:
|
||||
JSON 输出示例:
|
||||
%s
|
||||
`, string(contextJSON), outputExample))
|
||||
}
|
||||
@@ -996,29 +1008,45 @@ func decodeTitleResult(raw string) ([]string, error) {
|
||||
}
|
||||
|
||||
func decodeOutlineResult(raw string) ([]OutlineNode, error) {
|
||||
cleaned := stripJSONFence(raw)
|
||||
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 result []OutlineNode
|
||||
if err := json.Unmarshal([]byte(cleaned), &result); err == nil {
|
||||
return result, nil
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
var wrapped struct {
|
||||
Result []OutlineNode `json:"result"`
|
||||
Outline []OutlineNode `json:"outline"`
|
||||
Items []OutlineNode `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(cleaned), &wrapped); err != nil {
|
||||
return nil, fmt.Errorf("decode outline result: %w", err)
|
||||
}
|
||||
switch {
|
||||
case len(wrapped.Result) > 0:
|
||||
return wrapped.Result, nil
|
||||
case len(wrapped.Outline) > 0:
|
||||
return wrapped.Outline, nil
|
||||
default:
|
||||
return wrapped.Items, nil
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("empty content")
|
||||
}
|
||||
return nil, fmt.Errorf("decode outline result: %w", lastErr)
|
||||
}
|
||||
|
||||
func stripJSONFence(raw string) string {
|
||||
@@ -1029,6 +1057,92 @@ func stripJSONFence(raw string) string {
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func extractJSONCandidates(raw string) []string {
|
||||
cleaned := stripJSONFence(raw)
|
||||
if cleaned == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
candidates := make([]string, 0, 8)
|
||||
seen := make(map[string]struct{}, 8)
|
||||
addCandidate := func(value string) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
if _, exists := seen[value]; exists {
|
||||
return
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
candidates = append(candidates, value)
|
||||
}
|
||||
|
||||
addCandidate(cleaned)
|
||||
for i := 0; i < len(cleaned); i++ {
|
||||
switch cleaned[i] {
|
||||
case '{', '[':
|
||||
if fragment, ok := extractBalancedJSONFragment(cleaned[i:]); ok {
|
||||
addCandidate(fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
func extractBalancedJSONFragment(input string) (string, bool) {
|
||||
if input == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
var open, close byte
|
||||
switch input[0] {
|
||||
case '{':
|
||||
open = '{'
|
||||
close = '}'
|
||||
case '[':
|
||||
open = '['
|
||||
close = ']'
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
|
||||
depth := 0
|
||||
inString := false
|
||||
escaped := false
|
||||
|
||||
for i := 0; i < len(input); i++ {
|
||||
ch := input[i]
|
||||
if inString {
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
switch ch {
|
||||
case '\\':
|
||||
escaped = true
|
||||
case '"':
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
switch ch {
|
||||
case '"':
|
||||
inString = true
|
||||
case open:
|
||||
depth += 1
|
||||
case close:
|
||||
depth -= 1
|
||||
if depth == 0 {
|
||||
return input[:i+1], true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func normalizeAnalyzeResult(result AnalyzeTaskResult) AnalyzeTaskResult {
|
||||
result.BrandSummary = strings.TrimSpace(result.BrandSummary)
|
||||
result.Keywords = normalizeStringList(result.Keywords, 5)
|
||||
|
||||
Reference in New Issue
Block a user