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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user