feat(imitation): simplify form fields and make brand_name required
Deployment Config CI / Deployment Config (push) Successful in 25s
Frontend CI / Frontend (push) Successful in 2m51s
Backend CI / Backend (push) Successful in 14m47s

Remove industry, target_audience, content_goal, tone, and length_goal from the imitation form; brand_name is now mandatory with autocomplete from brand library, keywords are pre-populated from the selected brand's search keywords, and output length is hardcoded to ~2000 characters in the prompt.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 02:16:45 +08:00
parent c44ddc55a6
commit 00eb514efc
8 changed files with 67 additions and 124 deletions
+5 -14
View File
@@ -723,14 +723,9 @@ const enUS = {
sourceURL: 'Article URL',
sourceTitle: 'Source title',
language: 'Article language',
industry: 'Industry / scenario',
brandName: 'Brand / subject',
region: 'Region',
targetAudience: 'Target audience',
contentGoal: 'Content goal',
tone: 'Tone',
lengthGoal: 'Length',
keywords: 'Keywords',
keywords: 'Optimization keywords',
preservePoints: 'Keep / emphasize',
avoidPoints: 'Avoid',
extraRequirements: 'Extra requirements',
@@ -743,21 +738,17 @@ const enUS = {
'Selected groups are retrieved in the backend and passed to generation as reference context.',
sourceURLPlaceholder: 'https://example.com/article',
sourceTitlePlaceholder: 'Optional. Citation titles are prefilled automatically.',
industryPlaceholder: 'For example: home improvement, education, SaaS, healthcare',
brandNamePlaceholder: 'Optional. Helps the article fit a specific brand or subject.',
brandNamePlaceholder: 'Required. Type the target subject, or choose from the brand library.',
regionPlaceholder: 'For example: Shanghai, East China, nationwide, North America',
targetAudiencePlaceholder:
'For example: first-time buyers, channel partners, procurement leads',
contentGoalPlaceholder: 'For example: education, conversion, brand trust, selection advice',
tonePlaceholder: 'For example: professional, conversational, comparative, news analysis',
lengthGoalPlaceholder: 'For example: around 1200 words, short post, long-form guide',
keywordsPlaceholder: 'Type keywords and press Enter',
keywordsPlaceholder:
'Type optimization keywords and press Enter. Brand-library options appear after choosing a subject.',
preservePointsPlaceholder: 'List facts, data, arguments, or examples that must be kept.',
avoidPointsPlaceholder: 'List claims, competitors, compliance risks, or wording to avoid.',
extraRequirementsPlaceholder:
'Add channel, structure, title style, or industry-specific instructions.',
queued: 'Rewrite task submitted and queued.',
sourceURLRequired: 'Enter the source article URL first.',
brandNameRequired: 'Enter the brand / subject first.',
submitError: 'Failed to submit rewrite task.',
backToList: 'Back to rewrites',
},
+4 -13
View File
@@ -691,14 +691,9 @@ const zhCN = {
sourceURL: "文章 URL",
sourceTitle: "来源标题",
language: "文章语言",
industry: "行业/场景",
brandName: "品牌/主体",
region: "地域",
targetAudience: "目标读者",
contentGoal: "内容目标",
tone: "语气风格",
lengthGoal: "篇幅要求",
keywords: "关键词",
keywords: "优化关键词",
preservePoints: "必须保留/强调",
avoidPoints: "需要规避",
extraRequirements: "其他要求",
@@ -709,19 +704,15 @@ const zhCN = {
knowledgeBaseHint: "选择后,后台会检索相关知识片段并作为生成参考,未选择则只基于来源文章和当前设置生成。",
sourceURLPlaceholder: "https://example.com/article",
sourceTitlePlaceholder: "可选,引用来源标题会自动带入",
industryPlaceholder: "例如:家居建材、教育培训、SaaS、医疗健康",
brandNamePlaceholder: "可选,填写后文章会自然贴合该品牌或主体",
brandNamePlaceholder: "必填,可输入目标达成词,或从品牌库选择",
regionPlaceholder: "例如:上海、华东、全国、北美市场",
targetAudiencePlaceholder: "例如:首次购买用户、渠道代理、企业采购负责人",
contentGoalPlaceholder: "例如:科普说明、转化种草、品牌信任、选型建议",
tonePlaceholder: "例如:专业可信、轻松口语、评测对比、新闻解读",
lengthGoalPlaceholder: "例如:1200字左右、短文、深度长文",
keywordsPlaceholder: "输入关键词后回车,可多选",
keywordsPlaceholder: "输入优化关键词后回车,可多选;选择品牌库主体后会显示搜索词候选",
preservePointsPlaceholder: "列出必须保留的事实、数据、观点或案例",
avoidPointsPlaceholder: "列出不能出现的说法、竞品、合规禁区或表达方式",
extraRequirementsPlaceholder: "补充行业、渠道、结构、标题风格等自由要求",
queued: "仿写任务已提交,正在排队生成",
sourceURLRequired: "请先填写来源文章 URL",
brandNameRequired: "请先填写品牌/主体",
submitError: "仿写任务提交失败",
backToList: "返回仿写列表",
},
@@ -1,13 +1,13 @@
<script setup lang="ts">
import { LeftOutlined, LinkOutlined } from '@ant-design/icons-vue'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { message } from 'ant-design-vue'
import { computed, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import KnowledgeGroupSelect from '@/components/KnowledgeGroupSelect.vue'
import { articlesApi } from '@/lib/api'
import { articlesApi, brandsApi } from '@/lib/api'
import { formatError } from '@/lib/errors'
const route = useRoute()
@@ -19,13 +19,8 @@ const form = reactive({
source_url: '',
source_title: '',
locale: 'zh-CN',
industry: '',
brand_name: '',
region: '',
target_audience: '',
content_goal: '',
tone: '',
length_goal: '',
keywords: [] as string[],
preserve_points: '',
avoid_points: '',
@@ -34,8 +29,40 @@ const form = reactive({
knowledge_group_ids: [] as number[],
})
const brandsQuery = useQuery({
queryKey: ['brands'],
queryFn: () => brandsApi.list(),
})
const brandNameOptions = computed(() =>
(brandsQuery.data.value ?? []).map((item) => ({
label: item.name,
value: item.name,
})),
)
const selectedBrand = computed(
() => brandsQuery.data.value?.find((item) => item.name === form.brand_name.trim()) ?? null,
)
const questionsQuery = useQuery({
queryKey: computed(() => ['brands', selectedBrand.value?.id, 'questions', 'imitation']),
enabled: computed(() => Boolean(selectedBrand.value?.id)),
queryFn: () => brandsApi.listQuestions(selectedBrand.value?.id as number),
})
const keywordOptions = computed(() =>
(questionsQuery.data.value ?? []).map((item) => ({
label: item.question_text,
value: item.question_text,
})),
)
const canSubmit = computed(
() => form.source_url.trim().length > 0 && !generateMutation.isPending.value,
() =>
form.source_url.trim().length > 0 &&
form.brand_name.trim().length > 0 &&
!generateMutation.isPending.value,
)
const generateMutation = useMutation({
@@ -44,13 +71,8 @@ const generateMutation = useMutation({
source_url: form.source_url.trim(),
source_title: form.source_title.trim(),
locale: form.locale,
industry: form.industry.trim(),
brand_name: form.brand_name.trim(),
region: form.region.trim(),
target_audience: form.target_audience.trim(),
content_goal: form.content_goal.trim(),
tone: form.tone.trim(),
length_goal: form.length_goal.trim(),
keywords: form.keywords.map((item) => item.trim()).filter(Boolean),
preserve_points: form.preserve_points.trim(),
avoid_points: form.avoid_points.trim(),
@@ -95,6 +117,10 @@ function submit(): void {
message.warning(t('imitation.create.sourceURLRequired'))
return
}
if (!form.brand_name.trim()) {
message.warning(t('imitation.create.brandNameRequired'))
return
}
generateMutation.mutate()
}
</script>
@@ -204,17 +230,11 @@ function submit(): void {
<div class="imitation-generate-page__field-grid">
<div class="imitation-generate-page__field">
<label>{{ t('imitation.create.industry') }}</label>
<a-input
v-model:value="form.industry"
:placeholder="t('imitation.create.industryPlaceholder')"
/>
</div>
<div class="imitation-generate-page__field">
<label>{{ t('imitation.create.brandName') }}</label>
<a-input
<label class="required-asterisk">{{ t('imitation.create.brandName') }}</label>
<a-auto-complete
v-model:value="form.brand_name"
allow-clear
:options="brandNameOptions"
:placeholder="t('imitation.create.brandNamePlaceholder')"
/>
</div>
@@ -227,44 +247,13 @@ function submit(): void {
/>
</div>
<div class="imitation-generate-page__field">
<label>{{ t('imitation.create.targetAudience') }}</label>
<a-input
v-model:value="form.target_audience"
:placeholder="t('imitation.create.targetAudiencePlaceholder')"
/>
</div>
<div class="imitation-generate-page__field">
<label>{{ t('imitation.create.contentGoal') }}</label>
<a-input
v-model:value="form.content_goal"
:placeholder="t('imitation.create.contentGoalPlaceholder')"
/>
</div>
<div class="imitation-generate-page__field">
<label>{{ t('imitation.create.tone') }}</label>
<a-input
v-model:value="form.tone"
:placeholder="t('imitation.create.tonePlaceholder')"
/>
</div>
<div class="imitation-generate-page__field">
<label>{{ t('imitation.create.lengthGoal') }}</label>
<a-input
v-model:value="form.length_goal"
:placeholder="t('imitation.create.lengthGoalPlaceholder')"
/>
</div>
<div class="imitation-generate-page__field imitation-generate-page__field--wide">
<label>{{ t('imitation.create.keywords') }}</label>
<a-select
v-model:value="form.keywords"
mode="tags"
style="width: 100%"
:options="keywordOptions"
:placeholder="t('imitation.create.keywordsPlaceholder')"
/>
</div>
+3 -6
View File
@@ -278,7 +278,8 @@ runtime:
- 文章结构不动,文章分析核心维度不动,只动目标达成词(也就是brand_name)
- 保留源文章中的事实信息、论证逻辑和可复用观点。
- 不要逐句同义替换,不要复制源文的独特措辞,不要编造源文、知识库和补充要求之外的事实。
- 如果给出了地域、品牌、目标读者或内容目标,标题和正文都要自然贴合这些上下文。
- 如果给出了地域、品牌/主体或优化关键词,标题和正文都要自然贴合这些上下文。
- 全文篇幅控制在 2000 字左右。
%s
输出格式:Markdown。正文结构清晰,可直接进入文章编辑器。
@@ -301,14 +302,10 @@ runtime:
zh-CN: "输出语言:中文简体。"
en-US: "Output language: English."
article_imitation_setting_labels:
industry: "行业/场景"
brand_name: "品牌/主体"
region: "地域"
target_audience: "目标读者"
content_goal: "内容目标"
tone: "语气风格"
length_goal: "篇幅要求"
keywords: "关键词"
keywords: "优化关键词"
preserve_points: "必须保留/强调"
avoid_points: "需要规避"
extra_requirements: "其他要求"
+3 -6
View File
@@ -278,7 +278,8 @@ runtime:
- 文章结构不动,文章分析核心维度不动,只动目标达成词(也就是brand_name)
- 保留源文章中的事实信息、论证逻辑和可复用观点。
- 不要逐句同义替换,不要复制源文的独特措辞,不要编造源文、知识库和补充要求之外的事实。
- 如果给出了地域、品牌、目标读者或内容目标,标题和正文都要自然贴合这些上下文。
- 如果给出了地域、品牌/主体或优化关键词,标题和正文都要自然贴合这些上下文。
- 全文篇幅控制在 2000 字左右。
%s
输出格式:Markdown。正文结构清晰,可直接进入文章编辑器。
@@ -301,14 +302,10 @@ runtime:
zh-CN: "输出语言:中文简体。"
en-US: "Output language: English."
article_imitation_setting_labels:
industry: "行业/场景"
brand_name: "品牌/主体"
region: "地域"
target_audience: "目标读者"
content_goal: "内容目标"
tone: "语气风格"
length_goal: "篇幅要求"
keywords: "关键词"
keywords: "优化关键词"
preserve_points: "必须保留/强调"
avoid_points: "需要规避"
extra_requirements: "其他要求"
+1 -6
View File
@@ -748,13 +748,8 @@ export interface GenerateImitationRequest {
source_url: string
source_title?: string
locale?: string
industry?: string
brand_name?: string
brand_name: string
region?: string
target_audience?: string
content_goal?: string
tone?: string
length_goal?: string
keywords?: string[]
preserve_points?: string
avoid_points?: string
+3 -6
View File
@@ -278,7 +278,8 @@ runtime:
- 文章结构不动,文章分析核心维度不动,只动目标达成词(也就是brand_name)
- 保留源文章中的事实信息、论证逻辑和可复用观点。
- 不要逐句同义替换,不要复制源文的独特措辞,不要编造源文、知识库和补充要求之外的事实。
- 如果给出了地域、品牌、目标读者或内容目标,标题和正文都要自然贴合这些上下文。
- 如果给出了地域、品牌/主体或优化关键词,标题和正文都要自然贴合这些上下文。
- 全文篇幅控制在 2000 字左右。
%s
输出格式:Markdown。正文结构清晰,可直接进入文章编辑器。
@@ -301,14 +302,10 @@ runtime:
zh-CN: "输出语言:中文简体。"
en-US: "Output language: English."
article_imitation_setting_labels:
industry: "行业/场景"
brand_name: "品牌/主体"
region: "地域"
target_audience: "目标读者"
content_goal: "内容目标"
tone: "语气风格"
length_goal: "篇幅要求"
keywords: "关键词"
keywords: "优化关键词"
preserve_points: "必须保留/强调"
avoid_points: "需要规避"
extra_requirements: "其他要求"
@@ -47,13 +47,8 @@ type GenerateImitationRequest struct {
SourceURL string `json:"source_url" binding:"required"`
SourceTitle string `json:"source_title"`
Locale string `json:"locale"`
Industry string `json:"industry"`
BrandName string `json:"brand_name"`
Region string `json:"region"`
TargetAudience string `json:"target_audience"`
ContentGoal string `json:"content_goal"`
Tone string `json:"tone"`
LengthGoal string `json:"length_goal"`
Keywords []string `json:"keywords"`
PreservePoints string `json:"preserve_points"`
AvoidPoints string `json:"avoid_points"`
@@ -135,6 +130,9 @@ func (s *ArticleImitationService) Generate(ctx context.Context, req GenerateImit
if err != nil {
return nil, err
}
if strings.TrimSpace(req.BrandName) == "" {
return nil, response.ErrBadRequest(40001, "brand_name_required", "brand_name is required")
}
quotaRepo := repository.NewQuotaRepository(s.pool)
balance, err := quotaRepo.GetCurrentBalance(ctx, actor.TenantID, "article_generation")
@@ -587,13 +585,9 @@ func buildImitationInputParams(sourceURL string, req GenerateImitationRequest) m
"source_url": sourceURL,
"source_title": strings.TrimSpace(req.SourceTitle),
"locale": normalizeImitationLocale(req.Locale),
"industry": strings.TrimSpace(req.Industry),
"brand_name": strings.TrimSpace(req.BrandName),
"region": strings.TrimSpace(req.Region),
"target_audience": strings.TrimSpace(req.TargetAudience),
"content_goal": strings.TrimSpace(req.ContentGoal),
"tone": strings.TrimSpace(req.Tone),
"length_goal": strings.TrimSpace(req.LengthGoal),
"length_goal": "2000字左右",
"keywords": normalizeImitationKeywords(req.Keywords),
"preserve_points": strings.TrimSpace(req.PreservePoints),
"avoid_points": strings.TrimSpace(req.AvoidPoints),
@@ -631,12 +625,8 @@ func buildImitationGenerationPrompt(params map[string]interface{}, sourceContent
}
var settings strings.Builder
appendPromptLine(&settings, "industry", extractString(params, "industry"))
appendPromptLine(&settings, "brand_name", extractString(params, "brand_name"))
appendPromptLine(&settings, "region", extractString(params, "region"))
appendPromptLine(&settings, "target_audience", extractString(params, "target_audience"))
appendPromptLine(&settings, "content_goal", extractString(params, "content_goal"))
appendPromptLine(&settings, "tone", extractString(params, "tone"))
appendPromptLine(&settings, "length_goal", extractString(params, "length_goal"))
if len(keywords) > 0 {
appendPromptLine(&settings, "keywords", strings.Join(keywords, "、"))
@@ -674,12 +664,8 @@ func buildImitationKnowledgeQuery(params map[string]interface{}) string {
}
appendValue(extractString(params, "source_title"))
appendValue(extractString(params, "industry"))
appendValue(extractString(params, "brand_name"))
appendValue(extractString(params, "region"))
appendValue(extractString(params, "target_audience"))
appendValue(extractString(params, "content_goal"))
appendValue(extractString(params, "tone"))
appendValue(strings.Join(extractStringList(params["keywords"], 16), "\n"))
appendValue(extractString(params, "extra_requirements"))