From 8b6731439ab935ad3329f122e6216740e2888b13 Mon Sep 17 00:00:00 2001 From: wizardchen Date: Mon, 15 Dec 2025 15:08:42 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9EFAQ=E6=9D=A1?= =?UTF-8?q?=E7=9B=AE=E6=8E=A8=E8=8D=90=E5=AD=97=E6=AE=B5=E5=92=8C=E6=8C=89?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E6=89=B9=E9=87=8F=E6=9B=B4=E6=96=B0=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/faq.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/client/faq.go b/client/faq.go index 232a5c87..eca2dffa 100644 --- a/client/faq.go +++ b/client/faq.go @@ -17,6 +17,7 @@ type FAQEntry struct { KnowledgeBaseID string `json:"knowledge_base_id"` TagID string `json:"tag_id"` IsEnabled bool `json:"is_enabled"` + IsRecommended bool `json:"is_recommended"` StandardQuestion string `json:"standard_question"` SimilarQuestions []string `json:"similar_questions"` NegativeQuestions []string `json:"negative_questions"` @@ -38,6 +39,7 @@ type FAQEntryPayload struct { TagID string `json:"tag_id,omitempty"` TagName string `json:"tag_name,omitempty"` IsEnabled *bool `json:"is_enabled,omitempty"` + IsRecommended *bool `json:"is_recommended,omitempty"` } // FAQBatchUpsertPayload represents the request body for batch import (append/replace). @@ -220,11 +222,14 @@ func (c *Client) UpdateFAQEntry(ctx context.Context, // UpdateFAQEntryFieldsBatch updates multiple fields for FAQ entries in bulk. // Supports updating is_enabled, is_recommended, tag_id in a single call. +// Supports two modes: +// - byID: update by entry ID, key is entry ID +// - byTag: update all entries under a tag, key is tag ID (empty string for uncategorized) func (c *Client) UpdateFAQEntryFieldsBatch(ctx context.Context, - knowledgeBaseID string, byID map[string]FAQEntryFieldsUpdate, + knowledgeBaseID string, byID map[string]FAQEntryFieldsUpdate, byTag map[string]FAQEntryFieldsUpdate, ) error { path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries/fields", knowledgeBaseID) - resp, err := c.doRequest(ctx, http.MethodPut, path, &FAQEntryFieldsBatchRequest{ByID: byID}, nil) + resp, err := c.doRequest(ctx, http.MethodPut, path, &FAQEntryFieldsBatchRequest{ByID: byID, ByTag: byTag}, nil) if err != nil { return err } From de9b6dfd314c1b76833dcdba26685ddeb1ead612 Mon Sep 17 00:00:00 2001 From: wizardchen Date: Mon, 15 Dec 2025 17:22:09 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9EFAQ=E6=9D=A1?= =?UTF-8?q?=E7=9B=AE=E7=AD=94=E6=A1=88=E7=AD=96=E7=95=A5=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81'all'=E5=92=8C'random'=E4=B8=A4?= =?UTF-8?q?=E7=A7=8D=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/faq.go | 2 + internal/application/service/knowledge.go | 17 +++++ internal/types/faq.go | 75 +++++++++++++---------- 3 files changed, 63 insertions(+), 31 deletions(-) diff --git a/client/faq.go b/client/faq.go index eca2dffa..5757dbf4 100644 --- a/client/faq.go +++ b/client/faq.go @@ -22,6 +22,7 @@ type FAQEntry struct { SimilarQuestions []string `json:"similar_questions"` NegativeQuestions []string `json:"negative_questions"` Answers []string `json:"answers"` + AnswerStrategy string `json:"answer_strategy"` IndexMode string `json:"index_mode"` UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"` @@ -36,6 +37,7 @@ type FAQEntryPayload struct { SimilarQuestions []string `json:"similar_questions,omitempty"` NegativeQuestions []string `json:"negative_questions,omitempty"` Answers []string `json:"answers"` + AnswerStrategy *string `json:"answer_strategy,omitempty"` TagID string `json:"tag_id,omitempty"` TagName string `json:"tag_name,omitempty"` IsEnabled *bool `json:"is_enabled,omitempty"` diff --git a/internal/application/service/knowledge.go b/internal/application/service/knowledge.go index 088861cd..472d8b81 100644 --- a/internal/application/service/knowledge.go +++ b/internal/application/service/knowledge.go @@ -3990,6 +3990,11 @@ func (s *knowledgeService) chunkToFAQEntry(chunk *types.Chunk, kb *types.Knowled if meta == nil { meta = &types.FAQChunkMetadata{StandardQuestion: chunk.Content} } + // 默认使用 all 策略 + answerStrategy := meta.AnswerStrategy + if answerStrategy == "" { + answerStrategy = types.AnswerStrategyAll + } entry := &types.FAQEntry{ ID: chunk.ID, ChunkID: chunk.ID, @@ -4002,6 +4007,7 @@ func (s *knowledgeService) chunkToFAQEntry(chunk *types.Chunk, kb *types.Knowled SimilarQuestions: meta.SimilarQuestions, NegativeQuestions: meta.NegativeQuestions, Answers: meta.Answers, + AnswerStrategy: answerStrategy, IndexMode: kb.FAQConfig.IndexMode, UpdatedAt: chunk.UpdatedAt, CreatedAt: chunk.CreatedAt, @@ -4110,11 +4116,22 @@ func (s *knowledgeService) resolveTagID(ctx context.Context, kbID string, payloa } func sanitizeFAQEntryPayload(payload *types.FAQEntryPayload) (*types.FAQChunkMetadata, error) { + // 处理 AnswerStrategy,默认为 all + answerStrategy := types.AnswerStrategyAll + if payload.AnswerStrategy != nil && *payload.AnswerStrategy != "" { + switch *payload.AnswerStrategy { + case types.AnswerStrategyAll, types.AnswerStrategyRandom: + answerStrategy = *payload.AnswerStrategy + default: + return nil, werrors.NewBadRequestError("answer_strategy 必须是 'all' 或 'random'") + } + } meta := &types.FAQChunkMetadata{ StandardQuestion: strings.TrimSpace(payload.StandardQuestion), SimilarQuestions: payload.SimilarQuestions, NegativeQuestions: payload.NegativeQuestions, Answers: payload.Answers, + AnswerStrategy: answerStrategy, Version: 1, Source: "faq", } diff --git a/internal/types/faq.go b/internal/types/faq.go index 0c134fe9..b1915fb3 100644 --- a/internal/types/faq.go +++ b/internal/types/faq.go @@ -11,12 +11,13 @@ import ( // FAQChunkMetadata 定义 FAQ 条目在 Chunk.Metadata 中的结构 type FAQChunkMetadata struct { - StandardQuestion string `json:"standard_question"` - SimilarQuestions []string `json:"similar_questions,omitempty"` - NegativeQuestions []string `json:"negative_questions,omitempty"` - Answers []string `json:"answers,omitempty"` - Version int `json:"version,omitempty"` - Source string `json:"source,omitempty"` + StandardQuestion string `json:"standard_question"` + SimilarQuestions []string `json:"similar_questions,omitempty"` + NegativeQuestions []string `json:"negative_questions,omitempty"` + Answers []string `json:"answers,omitempty"` + AnswerStrategy AnswerStrategy `json:"answer_strategy,omitempty"` + Version int `json:"version,omitempty"` + Source string `json:"source,omitempty"` } // GeneratedQuestion 表示AI生成的单个问题 @@ -162,37 +163,49 @@ func CalculateFAQContentHash(meta *FAQChunkMetadata) string { return hex.EncodeToString(hash[:]) } +// AnswerStrategy 定义答案返回策略 +type AnswerStrategy string + +const ( + // AnswerStrategyAll 返回所有答案 + AnswerStrategyAll AnswerStrategy = "all" + // AnswerStrategyRandom 随机返回一个答案 + AnswerStrategyRandom AnswerStrategy = "random" +) + // FAQEntry 表示返回给前端的 FAQ 条目 type FAQEntry struct { - ID string `json:"id"` - ChunkID string `json:"chunk_id"` - KnowledgeID string `json:"knowledge_id"` - KnowledgeBaseID string `json:"knowledge_base_id"` - TagID string `json:"tag_id"` - IsEnabled bool `json:"is_enabled"` - IsRecommended bool `json:"is_recommended"` - StandardQuestion string `json:"standard_question"` - SimilarQuestions []string `json:"similar_questions"` - NegativeQuestions []string `json:"negative_questions"` - Answers []string `json:"answers"` - IndexMode FAQIndexMode `json:"index_mode"` - UpdatedAt time.Time `json:"updated_at"` - CreatedAt time.Time `json:"created_at"` - Score float64 `json:"score,omitempty"` - MatchType MatchType `json:"match_type,omitempty"` - ChunkType ChunkType `json:"chunk_type"` + ID string `json:"id"` + ChunkID string `json:"chunk_id"` + KnowledgeID string `json:"knowledge_id"` + KnowledgeBaseID string `json:"knowledge_base_id"` + TagID string `json:"tag_id"` + IsEnabled bool `json:"is_enabled"` + IsRecommended bool `json:"is_recommended"` + StandardQuestion string `json:"standard_question"` + SimilarQuestions []string `json:"similar_questions"` + NegativeQuestions []string `json:"negative_questions"` + Answers []string `json:"answers"` + AnswerStrategy AnswerStrategy `json:"answer_strategy"` + IndexMode FAQIndexMode `json:"index_mode"` + UpdatedAt time.Time `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` + Score float64 `json:"score,omitempty"` + MatchType MatchType `json:"match_type,omitempty"` + ChunkType ChunkType `json:"chunk_type"` } // FAQEntryPayload 用于创建/更新 FAQ 条目的 payload type FAQEntryPayload struct { - StandardQuestion string `json:"standard_question" binding:"required"` - SimilarQuestions []string `json:"similar_questions"` - NegativeQuestions []string `json:"negative_questions"` - Answers []string `json:"answers" binding:"required"` - TagID string `json:"tag_id"` - TagName string `json:"tag_name"` - IsEnabled *bool `json:"is_enabled,omitempty"` - IsRecommended *bool `json:"is_recommended,omitempty"` + StandardQuestion string `json:"standard_question" binding:"required"` + SimilarQuestions []string `json:"similar_questions"` + NegativeQuestions []string `json:"negative_questions"` + Answers []string `json:"answers" binding:"required"` + AnswerStrategy *AnswerStrategy `json:"answer_strategy,omitempty"` + TagID string `json:"tag_id"` + TagName string `json:"tag_name"` + IsEnabled *bool `json:"is_enabled,omitempty"` + IsRecommended *bool `json:"is_recommended,omitempty"` } const ( From 46fc21f182972a3cd5de2991c726fff8ce05598f Mon Sep 17 00:00:00 2001 From: wizardchen Date: Mon, 15 Dec 2025 19:13:58 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0Dockerfile?= =?UTF-8?q?=EF=BC=8C=E8=B0=83=E6=95=B4uvx=E6=9D=83=E9=99=90=EF=BC=8C?= =?UTF-8?q?=E5=8D=87=E7=BA=A7node=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/Dockerfile.app | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/docker/Dockerfile.app b/docker/Dockerfile.app index 94a8b3b0..93fac820 100644 --- a/docker/Dockerfile.app +++ b/docker/Dockerfile.app @@ -51,20 +51,25 @@ WORKDIR /app ARG APK_MIRROR_ARG="mirrors.tencent.com" +# Create a non-root user first +RUN id -u appuser >/dev/null 2>&1 || adduser -D -g '' appuser + RUN if [ -n "$APK_MIRROR_ARG" ]; then \ sed -i "s@dl-cdn.alpinelinux.org@${APK_MIRROR_ARG}@g" /etc/apk/repositories; \ fi && \ apk update && apk upgrade && \ apk add --no-cache build-base postgresql-client mysql-client ca-certificates tzdata sed curl bash vim wget \ - nodejs npm python3 py3-pip python3-dev libffi-dev openssl-dev cargo && \ + python3 py3-pip python3-dev libffi-dev openssl-dev && \ python3 -m pip install --break-system-packages --upgrade pip setuptools wheel && \ - # 使用官方安装脚本安装 uvx - curl -LsSf https://astral.sh/uv/install.sh | sh && \ - ln -sf /root/.cargo/bin/uvx /usr/local/bin/uvx + apk add --no-cache nodejs-current npm && \ + mkdir -p /home/appuser/.local/bin && \ + curl -LsSf https://astral.sh/uv/install.sh | CARGO_HOME=/home/appuser/.cargo UV_INSTALL_DIR=/home/appuser/.local/bin sh && \ + chown -R appuser:appuser /home/appuser && \ + ln -sf /home/appuser/.local/bin/uvx /usr/local/bin/uvx && \ + chmod +x /usr/local/bin/uvx -# Create a non-root user and switch to it +# Create data directories and set permissions RUN mkdir -p /data/files && \ - id -u appuser >/dev/null 2>&1 || adduser -D -g '' appuser && \ chown -R appuser:appuser /app /data/files # Copy migrate tool from builder stage