fix(brand-questions): surface seed topic min length as actionable error

Reject AI seed topics shorter than 2 characters on both client and server with
a dedicated invalid_seed_topic error code, and render an inline hint listing
the offending terms so users know exactly what to fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 10:18:28 +08:00
parent 7d8e82c69f
commit 2c4f7d6fcf
6 changed files with 112 additions and 2 deletions
@@ -1519,6 +1519,10 @@ const enUS = {
},
errors: {
emptyInput: 'Enter search term content first.',
aiSeedTopicTooShort:
'{terms} is too short. Enter a user search term with at least 2 characters, such as "mattress recommendation" or "how to choose a kids bed".',
aiSeedTopicTooShortWithMore:
'{terms} and {count} more search terms are too short. Enter user search terms with at least 2 characters, such as "mattress recommendation" or "how to choose a kids bed".',
noSelection: 'Select at least one candidate.',
noValidQuestions: 'No valid search terms can be saved. Adjust candidates and try again.',
},
@@ -1435,6 +1435,8 @@ const zhCN = {
},
errors: {
emptyInput: "请先输入搜索词内容",
aiSeedTopicTooShort: "{terms} 太短,请输入至少 2 个字的用户搜索词,例如“床垫推荐”或“儿童床怎么选”。",
aiSeedTopicTooShortWithMore: "{terms} 等 {count} 个搜索词太短,请输入至少 2 个字的用户搜索词,例如“床垫推荐”或“儿童床怎么选”。",
noSelection: "请至少选择 1 条候选",
noValidQuestions: "没有可保存的搜索词,请调整候选后重试",
},
+9
View File
@@ -140,6 +140,8 @@ export function isHandledAuthError(error: unknown): boolean {
}
const TIMEOUT_MESSAGE_PATTERN = /^timeout of \d+ms exceeded$/i
const VALIDATION_MIN_SEED_TOPIC_PATTERN =
/QuestionDistillRequest\.SeedTopic|seed_topic.*min|SeedTopic.*min/i
function translateRawErrorMessage(raw: string): string | null {
const mapped = errorMessageMap[raw]
@@ -158,6 +160,13 @@ export function formatError(error: unknown): string {
}
if (error instanceof ApiClientError) {
if (
error.message === 'invalid_seed_topic' ||
(error.message === 'invalid_params' &&
VALIDATION_MIN_SEED_TOPIC_PATTERN.test(error.detail ?? ''))
) {
return '用户搜索词太短,请输入至少 2 个字的搜索词'
}
const translated = translateRawErrorMessage(error.message)
const message = translated ?? error.message.replaceAll('_', ' ')
if (isAiPointsInsufficient(error)) {
@@ -30,6 +30,8 @@ interface CandidateRow extends QuestionCandidate {
selected: boolean
}
const AI_SEED_TOPIC_MIN_LENGTH = 2
const queryClient = useQueryClient()
const route = useRoute()
const router = useRouter()
@@ -44,6 +46,7 @@ const aiCandidateModalOpen = ref(false)
const aiCandidateProgress = ref(12)
const aiCandidateStageIndex = ref(0)
const aiWaitingKind = ref<'candidate' | 'fill'>('candidate')
const aiSeedTopicError = ref('')
let aiCandidateProgressTimer: number | null = null
let aiCandidateCloseTimer: number | null = null
@@ -269,7 +272,7 @@ const previewMutations = {
currentStep.value = 1
},
onError: (error) => {
pageNotice.value = formatError(error)
handleAiPreviewError(error)
},
}),
}
@@ -366,7 +369,14 @@ const normalizedAiSeedTopics = computed(() => {
return topics
})
const tooShortAiSeedTopics = computed(() =>
normalizedAiSeedTopics.value.filter(
(topic) => countSearchTermChars(topic) < AI_SEED_TOPIC_MIN_LENGTH,
),
)
function updateAiSeedTopics(value: string[]): void {
clearAiSeedTopicError()
aiForm.seed_topics.splice(0, aiForm.seed_topics.length, ...dedupeTopicParts(value))
}
@@ -506,6 +516,10 @@ function normalizeSearchTermText(value: string): string {
.replace(/\s+/g, '')
}
function countSearchTermChars(value: string): number {
return Array.from(normalizeSearchTermText(value)).length
}
function searchTermLooksValid(value: string): boolean {
const text = normalizeSearchTermText(value)
if (text.length < 4) {
@@ -565,12 +579,63 @@ function changeCreateMode(mode: CreateMode): void {
if (createMode.value === mode) {
return
}
clearAiSeedTopicError()
createMode.value = mode
currentStep.value = 0
candidateRows.value = []
pageNotice.value = ''
}
function setAiSeedTopicError(value: string): void {
aiSeedTopicError.value = value
pageNotice.value = value
}
function clearAiSeedTopicError(): void {
if (aiSeedTopicError.value && pageNotice.value === aiSeedTopicError.value) {
pageNotice.value = ''
}
aiSeedTopicError.value = ''
}
function formatQuotedTerms(terms: string[]): string {
return terms
.slice(0, 3)
.map((term) => `${term}`)
.join('、')
}
function buildAiSeedTopicTooShortMessage(terms: string[]): string {
const visibleTerms = formatQuotedTerms(terms)
const hiddenCount = Math.max(terms.length - 3, 0)
if (hiddenCount > 0) {
return t('brands.questions.errors.aiSeedTopicTooShortWithMore', {
terms: visibleTerms,
count: hiddenCount,
})
}
return t('brands.questions.errors.aiSeedTopicTooShort', {
terms: visibleTerms,
})
}
function isAiSeedTopicValidationError(error: unknown): boolean {
return (
error instanceof ApiClientError &&
(error.message === 'invalid_seed_topic' ||
(error.message === 'invalid_params' && /SeedTopic|seed_topic/.test(error.detail ?? '')))
)
}
function handleAiPreviewError(error: unknown): void {
const notice = formatError(error)
if (isAiSeedTopicValidationError(error)) {
setAiSeedTopicError(notice)
return
}
pageNotice.value = notice
}
function clearAiCandidateProgressTimer(): void {
if (aiCandidateProgressTimer === null) {
return
@@ -629,6 +694,7 @@ onBeforeUnmount(() => {
async function generateCandidates(): Promise<void> {
pageNotice.value = ''
clearAiSeedTopicError()
if (questionLimitReached.value) {
pageNotice.value = t('brands.questions.messages.limitReached', {
limit: maxQuestions.value,
@@ -662,6 +728,10 @@ async function generateCandidates(): Promise<void> {
pageNotice.value = t('brands.questions.errors.emptyInput')
return
}
if (tooShortAiSeedTopics.value.length) {
setAiSeedTopicError(buildAiSeedTopicTooShortMessage(tooShortAiSeedTopics.value))
return
}
beginAiCandidateTask('candidate')
try {
await previewMutations.ai.mutateAsync()
@@ -947,11 +1017,15 @@ function backToBrands(): void {
class="seed-topic-select"
mode="tags"
:value="aiForm.seed_topics"
:status="aiSeedTopicError ? 'error' : undefined"
:placeholder="t('brands.questions.ai.seedTopicPlaceholder')"
:token-separators="aiTopicTokenSeparators"
:options="[]"
@change="(value: string[]) => updateAiSeedTopics(value)"
/>
<p v-if="aiSeedTopicError" class="field-item__error">
{{ aiSeedTopicError }}
</p>
<p class="field-item__hint">
{{ t('brands.questions.ai.seedTopicHint') }}
</p>
@@ -1284,6 +1358,13 @@ function backToBrands(): void {
line-height: 1.6;
}
.field-item__error {
margin: 0;
color: #b42318;
font-size: 13px;
line-height: 1.6;
}
.seed-topic-select {
width: 100%;
}
@@ -218,7 +218,7 @@ func (s *QuestionExpansionService) GenerateByAIDistill(ctx context.Context, bran
actor := auth.MustActor(ctx)
seedTopic := normalizeQuestionText(req.SeedTopic)
if utf8.RuneCountInString(seedTopic) < 2 {
return nil, response.ErrBadRequest(40091, "invalid_params", "seed_topic is required")
return nil, response.ErrBadRequest(40091, "invalid_seed_topic", "seed_topic must contain at least 2 characters")
}
brandCtx, err := s.loadQuestionBrandContext(ctx, actor.TenantID, brandID)
@@ -2,6 +2,7 @@ package transport
import (
"strconv"
"strings"
"github.com/gin-gonic/gin"
@@ -61,6 +62,10 @@ func (h *QuestionExpansionHandler) AIDistill(c *gin.Context) {
}
var req app.QuestionDistillRequest
if err := c.ShouldBindJSON(&req); err != nil {
if isInvalidSeedTopicBindError(err) {
response.Error(c, response.ErrBadRequest(40091, "invalid_seed_topic", "seed_topic must contain at least 2 characters"))
return
}
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
@@ -118,3 +123,12 @@ func parseBrandIDParam(c *gin.Context) (int64, bool) {
}
return brandID, true
}
func isInvalidSeedTopicBindError(err error) bool {
if err == nil {
return false
}
message := err.Error()
return strings.Contains(message, "QuestionDistillRequest.SeedTopic") ||
strings.Contains(message, "seed_topic")
}