feat(keywords): rename brand-question terminology to search-keyword + add entity exclusion

- Rename UI/prompt labels: "品牌主问题" → "优化关键词", "搜索问题" → "搜索词"
- Add brand/competitor entity exclusion filter in question expansion (AI distill path)
- Refactor combination question text normalization, remove manual question-mark appending
- Update prompts to emphasize commercial-intent search queries over editorial phrasing
- Sync prompt changes across server/configs, deploy, and k3s configs
- Update i18n, frontend views (BrandQuestionCreate, TemplateWizard), and test fixtures

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 01:27:14 +08:00
parent 28633cf570
commit c89cad6643
19 changed files with 1211 additions and 668 deletions
@@ -51,7 +51,9 @@ let aiCandidateCloseTimer: number | null = null
const vFocus = {
mounted(el: HTMLElement) {
const input =
el instanceof HTMLInputElement ? el : el.querySelector<HTMLInputElement>('input')
el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement
? el
: el.querySelector<HTMLInputElement | HTMLTextAreaElement>('input, textarea')
if (input) {
input.focus()
const len = input.value.length
@@ -333,11 +335,11 @@ const materializeMutation = useMutation({
function splitLines(value: string | string[]): string[] {
if (Array.isArray(value)) {
return value.map((item) => item.trim()).filter(Boolean)
return value.map(normalizeSearchTermText).filter(Boolean)
}
return value
.split(/\r?\n|,/)
.map((item) => item.trim())
.map(normalizeSearchTermText)
.filter(Boolean)
}
@@ -494,30 +496,47 @@ function combinationPartCount(value: string | string[]): number {
}
function normalizeQuestionKey(value: string): string {
return value.trim().toLowerCase()
return normalizeSearchTermText(value).toLowerCase()
}
function normalizeSearchTermText(value: string): string {
return value
.trim()
.replace(/[\p{P}]/gu, '')
.replace(/\s+/g, '')
}
function questionLooksLikeQuestion(value: string): boolean {
const text = value.trim()
const text = normalizeSearchTermText(value)
if (text.length < 4) {
return false
}
return /[?]|什么|如何|怎么|哪|为什么|是否|能不能|适合|区别|对比|推荐/.test(text)
return /什么|如何|怎么|哪|为什么|是否|能不能|适合|区别|对比|推荐/.test(text)
}
function toCandidateRows(candidates: QuestionCandidate[]): CandidateRow[] {
return candidates.map((candidate, index) => ({
...candidate,
id: `${Date.now()}-${index}-${candidate.text}`,
selected: false,
}))
return candidates
.map((candidate, index) => {
const text = normalizeSearchTermText(candidate.text)
const tooShort = text.length < 4
const invalid = !questionLooksLikeQuestion(text)
return {
...candidate,
text,
too_short: candidate.too_short || tooShort,
suggest_skip: candidate.suggest_skip || tooShort || invalid,
id: `${Date.now()}-${index}-${text}`,
selected: false,
}
})
.filter((candidate) => candidate.text)
}
function buildLocalCandidates(texts: string[], source: QuestionSource): CandidateRow[] {
const seen = new Set<string>()
const rows: CandidateRow[] = []
for (const raw of texts) {
const text = raw.trim()
const text = normalizeSearchTermText(raw)
if (!text) {
continue
}
@@ -656,7 +675,7 @@ function removeCandidate(id: string): void {
}
function refreshCandidate(row: CandidateRow): void {
row.text = row.text.trim()
row.text = normalizeSearchTermText(row.text)
const key = normalizeQuestionKey(row.text)
const duplicateInBatch = candidateRows.value.some(
(item) => item.id !== row.id && normalizeQuestionKey(item.text) === key,
@@ -985,10 +1004,12 @@ function backToBrands(): void {
:disabled="isCandidateSelectionDisabled(row)"
@change="(event: Event) => updateCandidateSelection(row, (event.target as HTMLInputElement).checked)"
/>
<a-input
<a-textarea
v-if="editingCandidateId === row.id"
v-focus
v-model:value="row.text"
class="candidate-row__editor"
auto-size
@blur="stopEditingCandidate(row)"
@keydown.enter="stopEditingCandidate(row)"
/>
@@ -999,13 +1020,13 @@ function backToBrands(): void {
>
{{ row.text }}
</span>
<a-tag v-if="row.duplicate" color="orange">
<a-tag v-if="editingCandidateId !== row.id && row.duplicate" color="orange">
{{ t('brands.questions.badges.duplicate') }}
</a-tag>
<a-tag v-else-if="row.too_short" color="red">
<a-tag v-else-if="editingCandidateId !== row.id && row.too_short" color="red">
{{ t('brands.questions.badges.tooShort') }}
</a-tag>
<div class="candidate-row__actions">
<div v-if="editingCandidateId !== row.id" class="candidate-row__actions">
<a-button type="text" shape="circle" class="action-btn-edit" @click="startEditingCandidate(row.id)">
<EditOutlined />
</a-button>
@@ -1533,6 +1554,24 @@ function backToBrands(): void {
word-break: break-all;
}
.candidate-row__editor {
width: 100%;
min-width: 0;
min-height: 44px;
line-height: 1.6;
white-space: pre-wrap;
resize: none;
}
.candidate-row__editor :deep(.ant-input) {
width: 100%;
min-height: 44px;
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: anywhere;
resize: none;
}
.candidate-row__actions {
display: flex;
gap: 4px;