From 7fa1809682bde8ef98fa2a1c8bd9458c29b33c63 Mon Sep 17 00:00:00 2001 From: liangxu Date: Thu, 2 Apr 2026 10:58:39 +0800 Subject: [PATCH] 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. --- .../src/components/ArticleEditorCanvas.vue | 39 +++- .../src/views/TemplateWizardView.vue | 138 +++++++----- server/cmd/dev-seed/main.go | 4 +- server/internal/shared/llm/ark.go | 27 +++ server/internal/shared/llm/client.go | 17 ++ server/internal/tenant/app/template_assist.go | 204 ++++++++++++++---- .../tenant/app/template_assist_test.go | 65 ++++++ server/internal/tenant/app/template_prompt.go | 95 +++++++- .../tenant/app/template_prompt_test.go | 76 +++++++ .../internal/tenant/app/template_service.go | 14 +- ...update_topx_brand_priority_prompt.down.sql | 15 ++ ...0_update_topx_brand_priority_prompt.up.sql | 17 ++ 12 files changed, 605 insertions(+), 106 deletions(-) create mode 100644 server/internal/tenant/app/template_assist_test.go create mode 100644 server/internal/tenant/app/template_prompt_test.go create mode 100644 server/migrations/20260402100000_update_topx_brand_priority_prompt.down.sql create mode 100644 server/migrations/20260402100000_update_topx_brand_priority_prompt.up.sql diff --git a/apps/admin-web/src/components/ArticleEditorCanvas.vue b/apps/admin-web/src/components/ArticleEditorCanvas.vue index 61e8b09..c1c3fcd 100644 --- a/apps/admin-web/src/components/ArticleEditorCanvas.vue +++ b/apps/admin-web/src/components/ArticleEditorCanvas.vue @@ -20,7 +20,7 @@ import { wrapInHeadingCommand, wrapInOrderedListCommand, } from "@milkdown/kit/preset/commonmark"; -import { callCommand } from "@milkdown/kit/utils"; +import { callCommand, replaceAll } from "@milkdown/kit/utils"; import { Crepe, CrepeFeature } from "@milkdown/crepe"; import "@milkdown/crepe/theme/common/style.css"; import "@milkdown/crepe/theme/frame.css"; @@ -41,6 +41,7 @@ const emit = defineEmits<{ const { t } = useI18n(); const crepeRef = ref(null); +const syncingExternalMarkdown = ref(false); const { loading } = useEditor((root) => { const crepe = new Crepe({ @@ -51,12 +52,17 @@ const { loading } = useEditor((root) => { [CrepeFeature.Toolbar]: true, [CrepeFeature.Latex]: false, [CrepeFeature.BlockEdit]: false, + [CrepeFeature.Placeholder]: false, }, }); crepe.setReadonly(!!props.disabled); crepe.on((listener) => { listener.markdownUpdated((_ctx, markdown) => { + if (syncingExternalMarkdown.value) { + return; + } + emit("update:modelValue", markdown); }); }); @@ -68,6 +74,25 @@ const { loading } = useEditor((root) => { const [instanceLoading, getEditor] = useInstance(); const editorDisabled = computed(() => props.disabled || instanceLoading.value || loading.value); +function syncMarkdownFromProps(nextValue: string): void { + const editor = getEditor(); + const crepe = crepeRef.value; + if (!editor || !crepe) { + return; + } + + const resolvedValue = nextValue || ""; + if (crepe.getMarkdown() === resolvedValue) { + return; + } + + syncingExternalMarkdown.value = true; + editor.action(replaceAll(resolvedValue, true)); + queueMicrotask(() => { + syncingExternalMarkdown.value = false; + }); +} + watch( () => props.disabled, (value) => { @@ -76,6 +101,18 @@ watch( { immediate: true }, ); +watch( + [() => props.modelValue, loading, instanceLoading], + ([value, editorLoading, currentInstanceLoading]) => { + if (editorLoading || currentInstanceLoading) { + return; + } + + syncMarkdownFromProps(value); + }, + { immediate: true }, +); + onBeforeUnmount(() => { crepeRef.value = null; }); diff --git a/apps/admin-web/src/views/TemplateWizardView.vue b/apps/admin-web/src/views/TemplateWizardView.vue index 6b22f05..5d59350 100644 --- a/apps/admin-web/src/views/TemplateWizardView.vue +++ b/apps/admin-web/src/views/TemplateWizardView.vue @@ -1276,6 +1276,18 @@ function addCustomOutlineSection(): void { customOutlineInput.value = ""; } +function removeOutlineSection(key: string): void { + outlineSections.value = outlineSections.value.filter((item) => item !== key); + + const target = outlineOptions.value.find((item) => item.key === key); + if (!target?.custom) { + return; + } + + customOutlineSections.value = customOutlineSections.value.filter((item) => item.key !== key); + outlineOptionOrder.value = outlineOptionOrder.value.filter((item) => item !== key); +} + function sortOutlineOptions( options: ResolvedOutlineOption[], order: string[], @@ -2035,12 +2047,6 @@ function moveArrayItemBefore(items: T[], sourceIndex: number, targetIndex: nu return next; } -function outlineInputWidth(value: string): string { - const length = Array.from(value.trim()).length; - const widthEm = Math.min(Math.max(length + 4, 14), 42); - return `${widthEm}em`; -} - function onStructureDragStart(event: DragEvent, key: string): void { if (!event.dataTransfer) { return; @@ -2388,9 +2394,20 @@ function onStructureDragEnd(): void { > {{ section.label }} - - - +
+ + + + + + +
@@ -2476,10 +2493,10 @@ function onStructureDragEnd(): void { @drop="onOutlineDrop($event, section.id, 'branch', null)" > -
@@ -2511,10 +2528,10 @@ function onStructureDragEnd(): void { @drop="onOutlineDrop($event, child.id, 'leaf', section.id)" > -
@@ -2657,9 +2674,7 @@ function onStructureDragEnd(): void { display: flex; flex-direction: column; min-height: calc(100vh - 64px); - background: - radial-gradient(circle at top left, rgba(75, 132, 255, 0.08), transparent 28%), - linear-gradient(180deg, #f5f8ff 0%, #f7f9fc 100%); + background: #fff; } .wizard-page__loading { @@ -2671,10 +2686,9 @@ function onStructureDragEnd(): void { display: flex; align-items: center; justify-content: space-between; - padding: 16px 24px; - border-bottom: 1px solid rgba(22, 119, 255, 0.08); - backdrop-filter: blur(14px); - background: rgba(255, 255, 255, 0.82); + padding: 16px 32px; + border-bottom: 1px solid #edf2f7; + background: #fff; } .header-left { @@ -2691,9 +2705,9 @@ function onStructureDragEnd(): void { .header-title { margin: 0; - color: #10203d; + color: #111827; font-size: 20px; - font-weight: 700; + font-weight: 800; } .header-accent { @@ -2712,27 +2726,26 @@ function onStructureDragEnd(): void { .wizard-page__main { display: flex; flex-direction: column; - gap: 20px; - margin: 24px; + margin: 0 auto; + width: 100%; + max-width: 1160px; + padding: 24px 32px 64px; } .wizard-steps-container { - padding: 20px 24px; - border: 1px solid rgba(20, 44, 88, 0.08); - border-radius: 24px; - background: rgba(255, 255, 255, 0.86); - box-shadow: 0 18px 48px rgba(17, 39, 81, 0.06); + padding: 0 0 24px; + border-bottom: 1px solid #edf2f7; } .wizard-section { display: flex; flex-direction: column; - gap: 20px; } .wizard-section--structure { display: grid; - grid-template-columns: minmax(0, 1fr) 600px; + grid-template-columns: minmax(0, 1fr) 460px; + gap: 48px; align-items: start; } @@ -2744,11 +2757,13 @@ function onStructureDragEnd(): void { } .wizard-card { - padding: 24px; - border: 1px solid rgba(20, 44, 88, 0.08); - border-radius: 28px; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(247, 250, 255, 0.96) 100%); - box-shadow: 0 18px 48px rgba(17, 39, 81, 0.06); + padding: 32px 0; + border-bottom: 1px solid #edf2f7; +} + +.wizard-section > .wizard-card:last-child, +.wizard-structure-main > .wizard-card:last-child { + border-bottom: none; } .wizard-card__header { @@ -2756,20 +2771,33 @@ function onStructureDragEnd(): void { align-items: flex-start; justify-content: space-between; gap: 16px; - margin-bottom: 20px; + margin-bottom: 24px; } .wizard-card__header h3 { margin: 0; - color: #12213f; - font-size: 18px; - font-weight: 700; + color: #111827; + font-size: 16px; + font-weight: 800; + display: flex; + align-items: center; +} + +.wizard-card__header h3::before { + content: ""; + display: inline-block; + width: 4px; + height: 16px; + border-radius: 2px; + background: #111827; + margin-right: 12px; } .wizard-card__header p { - margin: 6px 0 0; - color: #71809a; + margin: 6px 0 0 16px; + color: #667085; font-size: 13px; + line-height: 1.7; } .field-grid { @@ -2789,9 +2817,16 @@ function onStructureDragEnd(): void { } .field-item > label { - color: #22304d; - font-size: 14px; - font-weight: 600; + display: inline-flex; + align-items: center; + margin-bottom: 12px; + color: #1f2937; + font-size: 15px; + font-weight: 700; +} + +.field-item > label::after { + content: ":"; } .required-asterisk::before { @@ -3059,6 +3094,12 @@ function onStructureDragEnd(): void { cursor: grab; } +.outline-option__actions { + display: flex; + align-items: center; + margin-left: 12px; +} + .custom-outline-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -3153,14 +3194,14 @@ function onStructureDragEnd(): void { } .outline-input { - width: min(100%, var(--outline-input-width, 18em)); + flex: 1; min-width: 0; - flex-shrink: 1; border-color: transparent !important; background-color: transparent !important; box-shadow: none !important; transition: all 0.2s; padding: 4px 8px; + resize: none; } .outline-input:hover { @@ -3185,7 +3226,8 @@ function onStructureDragEnd(): void { .preview-card { position: sticky; - top: 96px; + top: 32px; + border-bottom: none; } .preview-shell { diff --git a/server/cmd/dev-seed/main.go b/server/cmd/dev-seed/main.go index 65659ef..62088a3 100644 --- a/server/cmd/dev-seed/main.go +++ b/server/cmd/dev-seed/main.go @@ -154,8 +154,10 @@ func main() { 写作要求: - 不要写成空洞的排行列表;每个对象都要有清晰的入选理由和判断依据。 - 如果提供了 competitors、品牌或关键词上下文,要自然融入横向比较,但不要机械重复名单。 +- 如果提供了 brand_name,且文章属于品牌、公司或服务商对比型推荐内容,应将该品牌作为主推对象:默认排在第 1 位、正文篇幅明显多于其他对象、结论优先推荐。 +- 对 brand_name 对应对象重点展开核心优势、本地服务、适合人群、报价或配置亮点、交付安装、售后与避坑建议;其他对象保持客观但更简洁的差异化介绍。 - 重点写筛选标准、差异点、适合谁、不适合谁,以及常见误区或避坑点。 -- 引言先交代推荐标准与读者能得到什么,结尾给出简洁明确的选择建议。`, +- 引言先交代推荐标准与读者能得到什么,结尾优先总结 brand_name 为什么值得优先考虑,再补充其他对象各自适合的预算与需求。`, `{ "hero": { "accent": "Top X", diff --git a/server/internal/shared/llm/ark.go b/server/internal/shared/llm/ark.go index e0b2b43..751c749 100644 --- a/server/internal/shared/llm/ark.go +++ b/server/internal/shared/llm/ark.go @@ -132,6 +132,32 @@ func (c *arkClient) Generate(ctx context.Context, req GenerateRequest, onDelta f }, } + var textFormat *responses.ResponsesText + if req.ResponseFormat != nil { + format := &responses.TextFormat{ + Name: strings.TrimSpace(req.ResponseFormat.Name), + } + switch req.ResponseFormat.Type { + case ResponseFormatTypeJSONSchema: + format.Type = responses.TextType_json_schema + case ResponseFormatTypeJSONObject: + format.Type = responses.TextType_json_object + default: + format.Type = responses.TextType_text + } + if description := strings.TrimSpace(req.ResponseFormat.Description); description != "" { + format.Description = &description + } + if len(req.ResponseFormat.SchemaJSON) > 0 { + format.Schema = &responses.Bytes{Value: req.ResponseFormat.SchemaJSON} + } + if req.ResponseFormat.Strict { + strict := true + format.Strict = &strict + } + textFormat = &responses.ResponsesText{Format: format} + } + var tools []*responses.ResponsesTool if req.WebSearch != nil && req.WebSearch.Enabled { webSearchLimit := c.webSearchLimit @@ -159,6 +185,7 @@ func (c *arkClient) Generate(ctx context.Context, req GenerateRequest, onDelta f Temperature: &c.temperature, TopP: c.topP, Tools: tools, + Text: textFormat, Reasoning: c.reasoning, Input: &responses.ResponsesInput{ Union: &responses.ResponsesInput_ListValue{ diff --git a/server/internal/shared/llm/client.go b/server/internal/shared/llm/client.go index 15311b8..688b0ba 100644 --- a/server/internal/shared/llm/client.go +++ b/server/internal/shared/llm/client.go @@ -17,6 +17,7 @@ type GenerateRequest struct { Timeout time.Duration MaxOutputTokens int64 WebSearch *WebSearchOptions + ResponseFormat *ResponseFormat } type GenerateResult struct { @@ -24,6 +25,22 @@ type GenerateResult struct { Model string } +type ResponseFormat struct { + Type ResponseFormatType + Name string + Description string + SchemaJSON []byte + Strict bool +} + +type ResponseFormatType string + +const ( + ResponseFormatTypeText ResponseFormatType = "text" + ResponseFormatTypeJSONObject ResponseFormatType = "json_object" + ResponseFormatTypeJSONSchema ResponseFormatType = "json_schema" +) + type WebSearchOptions struct { Enabled bool Limit int32 diff --git a/server/internal/tenant/app/template_assist.go b/server/internal/tenant/app/template_assist.go index 7c350e6..06cac49 100644 --- a/server/internal/tenant/app/template_assist.go +++ b/server/internal/tenant/app/template_assist.go @@ -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) diff --git a/server/internal/tenant/app/template_assist_test.go b/server/internal/tenant/app/template_assist_test.go new file mode 100644 index 0000000..fa19b57 --- /dev/null +++ b/server/internal/tenant/app/template_assist_test.go @@ -0,0 +1,65 @@ +package app + +import ( + "strings" + "testing" +) + +func TestDecodeOutlineResultAcceptsWrappedObject(t *testing.T) { + raw := `{"outline":[{"outline":"网站列表","children":[{"outline":"竞品 A"}]}]}` + + result, err := decodeOutlineResult(raw) + if err != nil { + t.Fatalf("decodeOutlineResult() error = %v", err) + } + if len(result) != 1 || result[0].Outline != "网站列表" { + t.Fatalf("decodeOutlineResult() = %#v, want outline payload", result) + } + if len(result[0].Children) != 1 || result[0].Children[0].Outline != "竞品 A" { + t.Fatalf("decodeOutlineResult() children = %#v, want child outline", result[0].Children) + } +} + +func TestDecodeOutlineResultExtractsArrayFromNarrativeText(t *testing.T) { + raw := "以下是整理后的结果:\n```json\n[\n {\"outline\":\"文章关键要点\",\"children\":[{\"outline\":\"要点 1\"}]}\n]\n```\n请直接使用。" + + result, err := decodeOutlineResult(raw) + if err != nil { + t.Fatalf("decodeOutlineResult() error = %v", err) + } + if len(result) != 1 || result[0].Outline != "文章关键要点" { + t.Fatalf("decodeOutlineResult() = %#v, want extracted array payload", result) + } +} + +func TestDecodeOutlineResultExtractsInnerArrayFromMalformedWrapper(t *testing.T) { + raw := "结果如下:{\n[\n {\"outline\":\"常见问题\"}\n]\n}" + + result, err := decodeOutlineResult(raw) + if err != nil { + t.Fatalf("decodeOutlineResult() error = %v", err) + } + if len(result) != 1 || result[0].Outline != "常见问题" { + t.Fatalf("decodeOutlineResult() = %#v, want inner array payload", result) + } +} + +func TestBuildOutlinePromptForConfiguredTemplateForcesOutlineObject(t *testing.T) { + template := "你正在为文章生成大纲。\n标题: {{title}}\n已选段落: {{outline_sections}}" + + prompt := buildOutlinePrompt("recommendation_list", "推荐类模板", OutlineTaskRequest{ + Locale: "zh-CN", + Title: "测试标题", + OutlineSections: []string{"引言", "文章关键要点"}, + }, &template) + + if !strings.Contains(prompt, `{"outline":[...]}`) { + t.Fatalf("buildOutlinePrompt() = %q, want fixed outline object instruction", prompt) + } + if !strings.Contains(prompt, "JSON 输出示例") { + t.Fatalf("buildOutlinePrompt() = %q, want JSON example", prompt) + } + if !strings.Contains(prompt, "不要返回纯数组") { + t.Fatalf("buildOutlinePrompt() = %q, want pure-array prohibition", prompt) + } +} diff --git a/server/internal/tenant/app/template_prompt.go b/server/internal/tenant/app/template_prompt.go index 0b54e20..6a78ef4 100644 --- a/server/internal/tenant/app/template_prompt.go +++ b/server/internal/tenant/app/template_prompt.go @@ -11,7 +11,7 @@ import ( var promptVariablePattern = regexp.MustCompile(`\{\{\s*([a-zA-Z0-9_]+)\s*\}\}`) -func buildGenerationPrompt(templateName string, promptTemplate *string, params map[string]interface{}) string { +func buildGenerationPrompt(templateKey, templateName string, promptTemplate *string, params map[string]interface{}) string { basePrompt := strings.TrimSpace(renderPromptTemplate(promptTemplate, params)) if basePrompt == "" { basePrompt = fmt.Sprintf("你是一名专业内容编辑,请基于已提供的信息,为模板「%s」写出一篇完整的 Markdown 文章。", templateName) @@ -40,6 +40,10 @@ func buildGenerationPrompt(templateName string, promptTemplate *string, params m "- 结尾应给出清晰结论、适合对象、不适合对象,或下一步建议,帮助读者完成判断。", }, "\n")) + if templateRules := buildTemplateSpecificWritingRules(templateKey, params); templateRules != "" { + sections = append(sections, "模板专项要求:\n"+templateRules) + } + if lengthGuidance := buildGenerationLengthGuidance(params); lengthGuidance != "" { sections = append(sections, "篇幅要求:\n"+lengthGuidance) } @@ -47,6 +51,26 @@ func buildGenerationPrompt(templateName string, promptTemplate *string, params m return strings.Join(sections, "\n\n") } +func buildTemplateSpecificWritingRules(templateKey string, params map[string]interface{}) string { + switch strings.TrimSpace(templateKey) { + case "top_x_article": + brandName := strings.TrimSpace(stringValue(params["brand_name"])) + if brandName == "" { + return "" + } + return strings.Join([]string{ + fmt.Sprintf("- 若文章包含品牌、公司或服务商榜单,请将「%s」排在第 1 位,并作为全文主推对象。", brandName), + fmt.Sprintf("- 「%s」对应条目的篇幅应明显多于其他对象,优先展开其核心优势、本地化服务、适合人群、报价或配置亮点、交付安装、售后表现与避坑建议。", brandName), + "- 其他对象保留客观介绍,但篇幅更短,重点写与主推对象的差异点和各自适用场景,不要平均分配篇幅。", + fmt.Sprintf("- 结论部分先明确推荐「%s」作为优先选择,再简要补充其他对象分别适合哪些预算、户型或需求。", brandName), + fmt.Sprintf("- 如果标题或正文属于“Top X / 推荐 / 最好 / 排行榜”这类排序导向内容,榜单顺序必须与结论保持一致,将「%s」放在第 1 位。", brandName), + "- 在不编造事实、不捏造数据的前提下,优先呈现主推对象的优势信息;对其他对象保持克制、客观的弱化式比较。", + }, "\n") + default: + return "" + } +} + func buildGenerationLengthGuidance(params map[string]interface{}) string { sectionCount := estimateTopLevelSectionCount(params) if sectionCount <= 0 { @@ -68,9 +92,9 @@ func buildGenerationLengthGuidance(params map[string]interface{}) string { maxWords = 2100 } return strings.Join([]string{ - fmt.Sprintf("- 建议全文控制在 %d-%d English words,优先信息密度,不要为了拉长篇幅重复表达。", minWords, maxWords), - "- Intro and conclusion should stay concise. Most top-level sections only need 1-3 tight paragraphs; expand only the most important sections.", - "- If one paragraph can make the point clearly, do not split it into multiple repetitive paragraphs.", + fmt.Sprintf("- 建议全文控制在 %d-%d 个英文单词,优先信息密度,不要为了拉长篇幅重复表达。", minWords, maxWords), + "- 引言和结论保持简洁,大多数一级章节写 1-3 个紧凑段落即可;只有最重要的章节需要展开。", + "- 一段能说清的内容不要拆成多段重复表达。", }, "\n") } @@ -182,7 +206,7 @@ func buildPromptContext(params map[string]interface{}) string { if formatted == "" { return } - lines = append(lines, fmt.Sprintf("- %s: %s", key, formatted)) + lines = append(lines, fmt.Sprintf("- %s: %s", promptContextLabel(key), formatted)) used[key] = struct{}{} } @@ -200,6 +224,67 @@ func buildPromptContext(params map[string]interface{}) string { return strings.Join(lines, "\n") } +func promptContextLabel(key string) string { + switch key { + case "locale": + return "语言" + case "title": + return "标题" + case "topic": + return "主题" + case "product_name": + return "产品名" + case "subject": + return "研究主题" + case "brand_name": + return "品牌名" + case "brand": + return "品牌" + case "official_website", "website": + return "官网" + case "primary_keyword": + return "核心关键词" + case "keywords", "existing_keywords": + return "关键词" + case "competitors", "existing_competitors": + return "竞品" + case "competitor_names": + return "竞品名称" + case "competitor_count": + return "竞品数量" + case "brand_summary": + return "品牌摘要" + case "category": + return "品类" + case "count": + return "数量" + case "top_count": + return "推荐数量" + case "keyword_count": + return "关键词数量" + case "depth": + return "深度" + case "article_outline": + return "文章大纲" + case "outline_sections": + return "已选段落" + case "key_points": + return "关键要点" + case "review_intro_hook": + return "评测引言钩子" + case "template_key": + return "模板标识" + case "template_name": + return "模板名称" + case "current_year": + return "当前年份" + case "input_params": + return "输入参数" + default: + return key + } +} + func formatPromptContextValue(key string, value interface{}) string { switch key { case "keywords": diff --git a/server/internal/tenant/app/template_prompt_test.go b/server/internal/tenant/app/template_prompt_test.go new file mode 100644 index 0000000..ed5e5a3 --- /dev/null +++ b/server/internal/tenant/app/template_prompt_test.go @@ -0,0 +1,76 @@ +package app + +import ( + "strings" + "testing" +) + +func TestBuildPromptContextUsesChineseLabels(t *testing.T) { + context := buildPromptContext(map[string]interface{}{ + "locale": "zh-CN", + "title": "测试标题", + "brand_name": "Rankly", + "primary_keyword": "GEO 排名", + "outline_sections": []string{"引言", "结论"}, + }) + + for _, expected := range []string{ + "- 语言: zh-CN", + "- 标题: 测试标题", + "- 品牌名: Rankly", + "- 核心关键词: GEO 排名", + "- 已选段落: 引言 > 结论", + } { + if !strings.Contains(context, expected) { + t.Fatalf("buildPromptContext() = %q, want %q", context, expected) + } + } +} + +func TestBuildGenerationLengthGuidanceForEnglishLocaleIsWrittenInChinese(t *testing.T) { + guidance := buildGenerationLengthGuidance(map[string]interface{}{ + "locale": "en-US", + "outline_sections": []string{"Intro", "Section 1", "Section 2"}, + }) + + for _, unexpected := range []string{ + "Intro and conclusion should stay concise", + "If one paragraph can make the point clearly", + "English words", + } { + if strings.Contains(guidance, unexpected) { + t.Fatalf("buildGenerationLengthGuidance() = %q, contains unexpected English prompt text %q", guidance, unexpected) + } + } +} + +func TestBuildGenerationPromptAddsTopXBrandPriorityRules(t *testing.T) { + prompt := buildGenerationPrompt("top_x_article", "Top X 推荐文章", nil, map[string]interface{}{ + "topic": "合肥全屋定制", + "brand_name": "安徽海翔家居用品销售有限公司", + "locale": "zh-CN", + }) + + for _, expected := range []string{ + "模板专项要求:", + "安徽海翔家居用品销售有限公司", + "排在第 1 位", + "篇幅应明显多于其他对象", + "结论部分先明确推荐", + } { + if !strings.Contains(prompt, expected) { + t.Fatalf("buildGenerationPrompt() = %q, want %q", prompt, expected) + } + } +} + +func TestBuildGenerationPromptDoesNotAddTopXBrandRulesForOtherTemplates(t *testing.T) { + prompt := buildGenerationPrompt("product_review", "产品评测", nil, map[string]interface{}{ + "product_name": "测试产品", + "brand_name": "安徽海翔家居用品销售有限公司", + }) + + if strings.Contains(prompt, "排在第 1 位") { + t.Fatalf("buildGenerationPrompt() = %q, should not include top-x ranking rules", prompt) + } +} diff --git a/server/internal/tenant/app/template_service.go b/server/internal/tenant/app/template_service.go index 7ef2b43..cfe5e17 100644 --- a/server/internal/tenant/app/template_service.go +++ b/server/internal/tenant/app/template_service.go @@ -36,6 +36,7 @@ type generationJob struct { ArticleID int64 TaskID int64 ReservationID int64 + TemplateKey string TemplateName string PromptTemplate *string Params map[string]interface{} @@ -192,11 +193,11 @@ func effectivePromptVisibility(record *repository.TemplateRecord, actorTenantID } type GenerateRequest struct { - ArticleID *int64 `json:"article_id"` - InputParams map[string]interface{} `json:"input_params" binding:"required"` - WizardState map[string]interface{} `json:"wizard_state"` - EnableWebSearch bool `json:"enable_web_search"` - WebSearchLimit *int32 `json:"web_search_limit"` + ArticleID *int64 `json:"article_id"` + InputParams map[string]interface{} `json:"input_params" binding:"required"` + WizardState map[string]interface{} `json:"wizard_state"` + EnableWebSearch bool `json:"enable_web_search"` + WebSearchLimit *int32 `json:"web_search_limit"` } type GenerateResponse struct { @@ -385,6 +386,7 @@ func (s *TemplateService) Generate(ctx context.Context, templateID int64, req Ge ArticleID: articleID, TaskID: taskID, ReservationID: reservationID, + TemplateKey: templateRecord.TemplateKey, TemplateName: templateRecord.TemplateName, PromptTemplate: templateRecord.PromptTemplate, Params: cloneInputParams(req.InputParams), @@ -467,7 +469,7 @@ func (s *TemplateService) executeGeneration(ctx context.Context, job generationJ StartedAt: &now, }) - prompt := buildGenerationPrompt(job.TemplateName, job.PromptTemplate, job.Params) + prompt := buildGenerationPrompt(job.TemplateKey, job.TemplateName, job.PromptTemplate, job.Params) generateReq := llm.GenerateRequest{ Prompt: prompt, Timeout: s.articleTimeout, diff --git a/server/migrations/20260402100000_update_topx_brand_priority_prompt.down.sql b/server/migrations/20260402100000_update_topx_brand_priority_prompt.down.sql new file mode 100644 index 0000000..dabbe36 --- /dev/null +++ b/server/migrations/20260402100000_update_topx_brand_priority_prompt.down.sql @@ -0,0 +1,15 @@ +UPDATE article_templates +SET prompt_template = $$你是一名经验丰富的内容编辑,请围绕「{{topic}}」撰写一篇完整的 Markdown 推荐类文章。 +文章目标: +1. 帮读者快速理解这个主题下值得关注的 {{count}} 个方向、产品或对象。 +2. 说明每个对象的核心特点、适用场景、优缺点和选择建议。 + +写作要求: +- 不要写成空洞的排行列表;每个对象都要有清晰的入选理由和判断依据。 +- 如果提供了 competitors、品牌或关键词上下文,要自然融入横向比较,但不要机械重复名单。 +- 重点写筛选标准、差异点、适合谁、不适合谁,以及常见误区或避坑点。 +- 引言先交代推荐标准与读者能得到什么,结尾给出简洁明确的选择建议。$$, + updated_at = NOW() +WHERE scope = 'platform' + AND template_key = 'top_x_article' + AND deleted_at IS NULL; diff --git a/server/migrations/20260402100000_update_topx_brand_priority_prompt.up.sql b/server/migrations/20260402100000_update_topx_brand_priority_prompt.up.sql new file mode 100644 index 0000000..3b0cbb4 --- /dev/null +++ b/server/migrations/20260402100000_update_topx_brand_priority_prompt.up.sql @@ -0,0 +1,17 @@ +UPDATE article_templates +SET prompt_template = $$你是一名经验丰富的内容编辑,请围绕「{{topic}}」撰写一篇完整的 Markdown 推荐类文章。 +文章目标: +1. 帮读者快速理解这个主题下值得关注的 {{count}} 个方向、产品或对象。 +2. 说明每个对象的核心特点、适用场景、优缺点和选择建议。 + +写作要求: +- 不要写成空洞的排行列表;每个对象都要有清晰的入选理由和判断依据。 +- 如果提供了 competitors、品牌或关键词上下文,要自然融入横向比较,但不要机械重复名单。 +- 如果提供了 brand_name,且文章属于品牌、公司或服务商对比型推荐内容,应将该品牌作为主推对象:默认排在第 1 位、正文篇幅明显多于其他对象、结论优先推荐。 +- 对 brand_name 对应对象重点展开核心优势、本地服务、适合人群、报价或配置亮点、交付安装、售后与避坑建议;其他对象保持客观但更简洁的差异化介绍。 +- 重点写筛选标准、差异点、适合谁、不适合谁,以及常见误区或避坑点。 +- 引言先交代推荐标准与读者能得到什么,结尾优先总结 brand_name 为什么值得优先考虑,再补充其他对象各自适合的预算与需求。$$, + updated_at = NOW() +WHERE scope = 'platform' + AND template_key = 'top_x_article' + AND deleted_at IS NULL;