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