From 2c4f7d6fcf6d11c939ca566f51e2b7a09534186a Mon Sep 17 00:00:00 2001 From: liangxu Date: Tue, 26 May 2026 10:18:28 +0800 Subject: [PATCH] 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) --- apps/admin-web/src/i18n/messages/en-US.ts | 4 + apps/admin-web/src/i18n/messages/zh-CN.ts | 2 + apps/admin-web/src/lib/errors.ts | 9 ++ .../src/views/BrandQuestionCreateView.vue | 83 ++++++++++++++++++- .../tenant/app/question_expansion_service.go | 2 +- .../transport/question_expansion_handler.go | 14 ++++ 6 files changed, 112 insertions(+), 2 deletions(-) diff --git a/apps/admin-web/src/i18n/messages/en-US.ts b/apps/admin-web/src/i18n/messages/en-US.ts index 1e1492c..e89c311 100644 --- a/apps/admin-web/src/i18n/messages/en-US.ts +++ b/apps/admin-web/src/i18n/messages/en-US.ts @@ -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.', }, diff --git a/apps/admin-web/src/i18n/messages/zh-CN.ts b/apps/admin-web/src/i18n/messages/zh-CN.ts index 0cfe774..015fd74 100644 --- a/apps/admin-web/src/i18n/messages/zh-CN.ts +++ b/apps/admin-web/src/i18n/messages/zh-CN.ts @@ -1435,6 +1435,8 @@ const zhCN = { }, errors: { emptyInput: "请先输入搜索词内容", + aiSeedTopicTooShort: "{terms} 太短,请输入至少 2 个字的用户搜索词,例如“床垫推荐”或“儿童床怎么选”。", + aiSeedTopicTooShortWithMore: "{terms} 等 {count} 个搜索词太短,请输入至少 2 个字的用户搜索词,例如“床垫推荐”或“儿童床怎么选”。", noSelection: "请至少选择 1 条候选", noValidQuestions: "没有可保存的搜索词,请调整候选后重试", }, diff --git a/apps/admin-web/src/lib/errors.ts b/apps/admin-web/src/lib/errors.ts index 63016d8..3072490 100644 --- a/apps/admin-web/src/lib/errors.ts +++ b/apps/admin-web/src/lib/errors.ts @@ -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)) { diff --git a/apps/admin-web/src/views/BrandQuestionCreateView.vue b/apps/admin-web/src/views/BrandQuestionCreateView.vue index f6c7709..1653490 100644 --- a/apps/admin-web/src/views/BrandQuestionCreateView.vue +++ b/apps/admin-web/src/views/BrandQuestionCreateView.vue @@ -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 { pageNotice.value = '' + clearAiSeedTopicError() if (questionLimitReached.value) { pageNotice.value = t('brands.questions.messages.limitReached', { limit: maxQuestions.value, @@ -662,6 +728,10 @@ async function generateCandidates(): Promise { 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)" /> +

+ {{ aiSeedTopicError }} +

{{ t('brands.questions.ai.seedTopicHint') }}

@@ -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%; } diff --git a/server/internal/tenant/app/question_expansion_service.go b/server/internal/tenant/app/question_expansion_service.go index 78cfa40..7ff5e48 100644 --- a/server/internal/tenant/app/question_expansion_service.go +++ b/server/internal/tenant/app/question_expansion_service.go @@ -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) diff --git a/server/internal/tenant/transport/question_expansion_handler.go b/server/internal/tenant/transport/question_expansion_handler.go index ffec6a7..bc839ae 100644 --- a/server/internal/tenant/transport/question_expansion_handler.go +++ b/server/internal/tenant/transport/question_expansion_handler.go @@ -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") +}