package types import ( "crypto/sha256" "encoding/hex" "encoding/json" "regexp" "sort" "strings" "time" "unicode" "github.com/longbridgeapp/opencc" ) // 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"` AnswerStrategy AnswerStrategy `json:"answer_strategy,omitempty"` Version int `json:"version,omitempty"` Source string `json:"source,omitempty"` } // GeneratedQuestion 表示AI生成的单个问题 type GeneratedQuestion struct { ID string `json:"id"` // 唯一标识,用于构造 source_id Question string `json:"question"` // 问题内容 ContentRevision *int `json:"content_revision,omitempty"` // 该问题对应的 Chunk 内容版本 } const maxGeneratedQuestionSourceIDLength = 64 // GeneratedQuestionSourceID builds the retrieval source identifier for a // generated question. PostgreSQL stores source_id as varchar(64), while a // chunk UUID plus a question UUID would be 73 bytes. Preserve the historical // representation for short IDs and hash only oversized question IDs so // existing index rows remain addressable by delete/reindex operations. func GeneratedQuestionSourceID(chunkID, questionID string) string { candidate := chunkID + "-" + questionID if len(candidate) <= maxGeneratedQuestionSourceIDLength { return candidate } digest := sha256.Sum256([]byte(questionID)) // UUID chunk IDs use 36 bytes; "-q" plus 24 hex characters keeps the // complete identifier at 62 bytes while retaining ample collision space. return chunkID + "-q" + hex.EncodeToString(digest[:12]) } // DocumentChunkMetadata 定义文档 Chunk 的元数据结构 // 用于存储AI生成的问题等增强信息 type DocumentChunkMetadata struct { // GeneratedQuestions 存储AI为该Chunk生成的相关问题 // 这些问题会被独立索引以提高召回率 GeneratedQuestions []GeneratedQuestion `json:"generated_questions,omitempty"` // GeneratedQuestionsRevision ties the questions to Chunk.ContentRevision. GeneratedQuestionsRevision int `json:"generated_questions_revision,omitempty"` } // IsQuestionCurrent reports whether a generated question was authored for the // current chunk body. This is advisory metadata for the UI: questions remain // valid retrieval aliases across chunk edits. Legacy rows fall back to the // metadata-level revision. func (m *DocumentChunkMetadata) IsQuestionCurrent(question GeneratedQuestion, chunkRevision int) bool { if question.ContentRevision != nil { return *question.ContentRevision == chunkRevision } return m != nil && m.GeneratedQuestionsRevision == chunkRevision } // GetQuestionStrings 返回问题内容字符串列表(兼容旧代码) func (m *DocumentChunkMetadata) GetQuestionStrings() []string { if m == nil || len(m.GeneratedQuestions) == 0 { return nil } result := make([]string, len(m.GeneratedQuestions)) for i, q := range m.GeneratedQuestions { result[i] = q.Question } return result } // DocumentMetadata 解析 Chunk 中的文档元数据 func (c *Chunk) DocumentMetadata() (*DocumentChunkMetadata, error) { if c == nil || len(c.Metadata) == 0 { return nil, nil } var meta DocumentChunkMetadata if err := json.Unmarshal(c.Metadata, &meta); err != nil { return nil, err } return &meta, nil } // SetDocumentMetadata 设置 Chunk 的文档元数据 func (c *Chunk) SetDocumentMetadata(meta *DocumentChunkMetadata) error { if c == nil { return nil } if meta == nil { c.Metadata = nil return nil } bytes, err := json.Marshal(meta) if err != nil { return err } c.Metadata = JSON(bytes) return nil } // Sanitize 对元数据进行基础清理(去除首尾空白、去重),保留原始内容 // 用于 DB 存储,不做语义归一化 func (m *FAQChunkMetadata) Sanitize() { if m == nil { return } m.StandardQuestion = strings.TrimSpace(m.StandardQuestion) m.SimilarQuestions = SanitizeStrings(m.SimilarQuestions) m.NegativeQuestions = SanitizeStrings(m.NegativeQuestions) m.Answers = SanitizeStrings(m.Answers) if m.Version <= 0 { m.Version = 1 } } // Normalize 返回归一化后的副本,用于 Hash 计算和向量索引 // 原始数据不变,返回新的归一化副本 func (m *FAQChunkMetadata) Normalize() *FAQChunkMetadata { if m == nil { return nil } return &FAQChunkMetadata{ StandardQuestion: NormalizeQuestion(m.StandardQuestion), SimilarQuestions: normalizeQuestionStrings(m.SimilarQuestions), NegativeQuestions: normalizeQuestionStrings(m.NegativeQuestions), Answers: SanitizeStrings(m.Answers), // 答案只做基础清理 AnswerStrategy: m.AnswerStrategy, Version: m.Version, Source: m.Source, } } // SanitizeStrings 对字符串列表进行基础清理(TrimSpace + 去重) func SanitizeStrings(values []string) []string { if len(values) == 0 { return nil } dedup := make([]string, 0, len(values)) seen := make(map[string]struct{}, len(values)) for _, v := range values { trimmed := strings.TrimSpace(v) if trimmed == "" { continue } if _, exists := seen[trimmed]; exists { continue } seen[trimmed] = struct{}{} dedup = append(dedup, trimmed) } if len(dedup) == 0 { return nil } return dedup } // FAQMetadata 解析 Chunk 中的 FAQ 元数据 // 返回原始数据(仅做基础清理) func (c *Chunk) FAQMetadata() (*FAQChunkMetadata, error) { if c == nil || len(c.Metadata) == 0 { return nil, nil } var meta FAQChunkMetadata if err := json.Unmarshal(c.Metadata, &meta); err != nil { return nil, err } meta.Sanitize() // 只做基础清理,保留原始内容 return &meta, nil } // SetFAQMetadata 设置 Chunk 的 FAQ 元数据 // DB 存储原始数据,ContentHash 基于归一化数据计算 func (c *Chunk) SetFAQMetadata(meta *FAQChunkMetadata) error { if c == nil { return nil } if meta == nil { c.Metadata = nil c.ContentHash = "" return nil } // 基础清理后存储到 DB(保留原始内容) meta.Sanitize() bytes, err := json.Marshal(meta) if err != nil { return err } c.Metadata = JSON(bytes) // ContentHash 基于归一化后的数据计算,用于去重匹配 normalized := meta.Normalize() c.ContentHash = CalculateFAQContentHash(normalized) return nil } // CalculateFAQContentHash 计算 FAQ 内容的 hash 值 // hash 基于:标准问 + 相似问(排序后)+ 反例(排序后)+ 答案(排序后) // 用于快速匹配和去重 func CalculateFAQContentHash(meta *FAQChunkMetadata) string { if meta == nil { return "" } // Normalize() returns a new copy; the old code discarded the return value. normalized := meta.Normalize() if normalized == nil { return "" } // 对数组进行排序(确保相同内容产生相同 hash) similarQuestions := make([]string, len(normalized.SimilarQuestions)) copy(similarQuestions, normalized.SimilarQuestions) sort.Strings(similarQuestions) negativeQuestions := make([]string, len(normalized.NegativeQuestions)) copy(negativeQuestions, normalized.NegativeQuestions) sort.Strings(negativeQuestions) answers := make([]string, len(normalized.Answers)) copy(answers, normalized.Answers) sort.Strings(answers) // 构建用于 hash 的字符串:标准问 + 相似问 + 反例 + 答案 var builder strings.Builder builder.WriteString(normalized.StandardQuestion) builder.WriteString("|") builder.WriteString(strings.Join(similarQuestions, ",")) builder.WriteString("|") builder.WriteString(strings.Join(negativeQuestions, ",")) builder.WriteString("|") builder.WriteString(strings.Join(answers, ",")) // 计算 SHA256 hash hash := sha256.Sum256([]byte(builder.String())) return hex.EncodeToString(hash[:]) } // AnswerStrategy 定义答案返回策略 type AnswerStrategy string const ( // AnswerStrategyAll 返回所有答案 AnswerStrategyAll AnswerStrategy = "all" // AnswerStrategyRandom 随机返回一个答案 AnswerStrategyRandom AnswerStrategy = "random" ) // FAQEntry 表示返回给前端的 FAQ 条目 type FAQEntry struct { ID int64 `json:"id"` ChunkID string `json:"chunk_id"` KnowledgeID string `json:"knowledge_id"` KnowledgeBaseID string `json:"knowledge_base_id"` TagID int64 `json:"tag_id"` TagName string `json:"tag_name"` 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"` // MatchedQuestion is the actual question text that was matched in FAQ search // Could be the standard question or one of the similar questions MatchedQuestion string `json:"matched_question,omitempty"` } // FAQExportEntry 表示 JSON 导出的 FAQ 条目,与 FAQEntryPayload 的导入格式兼容, // 便于"导出 → 编辑 → 重新导入"循环。新增字段时务必保留 omitempty,避免破坏 // 历史导出文件的兼容性。 type FAQExportEntry struct { ID int64 `json:"id"` TagName string `json:"tag_name,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"` IsEnabled bool `json:"is_enabled"` IsRecommended bool `json:"is_recommended"` } // FAQEntryPayload 用于创建/更新 FAQ 条目的 payload type FAQEntryPayload struct { // ID 可选,用于数据迁移时指定 seq_id(必须小于自增起始值 100000000) ID *int64 `json:"id,omitempty"` StandardQuestion string `json:"standard_question" binding:"required"` SimilarQuestions []string `json:"similar_questions"` NegativeQuestions []string `json:"negative_questions"` Answers []string `json:"answers"` AnswerStrategy *AnswerStrategy `json:"answer_strategy,omitempty"` TagID int64 `json:"tag_id"` TagName string `json:"tag_name"` IsEnabled *bool `json:"is_enabled,omitempty"` IsRecommended *bool `json:"is_recommended,omitempty"` } const ( FAQBatchModeAppend = "append" FAQBatchModeReplace = "replace" ) // FAQBatchUpsertPayload 批量导入 FAQ 条目 type FAQBatchUpsertPayload struct { Entries []FAQEntryPayload `json:"entries" binding:"required"` Mode string `json:"mode" binding:"oneof=append replace"` KnowledgeID string `json:"knowledge_id"` TaskID string `json:"task_id"` // 可选,如果不传则自动生成UUID DryRun bool `json:"dry_run"` // 仅验证,不实际导入 } // FAQFailedEntry 表示导入/验证失败的条目 type FAQFailedEntry struct { Index int `json:"index"` // 条目在批次中的索引(从0开始) Reason string `json:"reason"` // 失败原因 FailureType string `json:"failure_type,omitempty"` // 失败类型:pre_validation / post_validation IsPartialFailure bool `json:"is_partial_failure,omitempty"` // 是否为部分失败(相似问/反例被移除,但整条仍可导入) TagName string `json:"tag_name,omitempty"` // 分类 StandardQuestion string `json:"standard_question"` // 标准问题 SimilarQuestions []string `json:"similar_questions,omitempty"` // 相似问题 NegativeQuestions []string `json:"negative_questions,omitempty"` // 反例问题 Answers []string `json:"answers,omitempty"` // 答案 AnswerAll bool `json:"answer_all,omitempty"` // 是否全部回复 IsDisabled bool `json:"is_disabled,omitempty"` // 是否停用 // 部分失败详情(当 IsPartialFailure 为 true 时) RemovedSimilarQuestions []string `json:"removed_similar_questions,omitempty"` // 被移除的相似问及原因 RemovedNegativeQuestions []string `json:"removed_negative_questions,omitempty"` // 被移除的反例及原因 } // FAQMergeDetail 表示一条 FAQ 在 append 模式下与已有 chunk 合并的结果摘要。 type FAQMergeDetail struct { Index int `json:"index"` // 行号(在批次中的索引) StandardQuestion string `json:"standard_question"` // 标准问 AnswerChanged bool `json:"answer_changed"` // 答案是否变更 NewSimilarCount int `json:"new_similar_count"` // 新增相似问数量 NewNegativeCount int `json:"new_negative_count"` // 新增反例数量 } // FAQSuccessEntry 表示导入成功的条目简单信息 type FAQSuccessEntry struct { Index int `json:"index"` // 条目在批次中的索引(从0开始) SeqID int64 `json:"seq_id"` // 导入后的条目序列ID TagID int64 `json:"tag_id,omitempty"` // 分类ID(seq_id) TagName string `json:"tag_name,omitempty"` // 分类名称 StandardQuestion string `json:"standard_question"` // 标准问题 } // FAQDryRunResult 表示 dry_run 模式的验证结果 type FAQDryRunResult struct { TaskID string `json:"task_id,omitempty"` // 异步任务ID(异步模式时返回) Total int `json:"total"` // 总条目数 SuccessCount int `json:"success_count"` // 验证通过的条目数 FailedCount int `json:"failed_count"` // 验证失败的条目数 FailedEntries []FAQFailedEntry `json:"failed_entries"` // 失败条目详情 } // FAQSearchRequest FAQ检索请求参数 type FAQSearchRequest struct { QueryText string `json:"query_text" binding:"required"` VectorThreshold float64 `json:"vector_threshold"` MatchCount int `json:"match_count"` FirstPriorityTagIDs []int64 `json:"first_priority_tag_ids"` // 第一优先级标签ID列表,限定命中范围,优先级最高 SecondPriorityTagIDs []int64 `json:"second_priority_tag_ids"` // 第二优先级标签ID列表,限定命中范围,优先级低于第一优先级 OnlyRecommended bool `json:"only_recommended"` // 是否仅返回推荐的条目 } // UntaggedTagName is the default tag name for entries without a tag const UntaggedTagName = "未分类" // FAQEntryFieldsUpdate 单个FAQ条目的字段更新 type FAQEntryFieldsUpdate struct { IsEnabled *bool `json:"is_enabled,omitempty"` IsRecommended *bool `json:"is_recommended,omitempty"` TagID *int64 `json:"tag_id,omitempty"` // 后续可扩展更多字段 } // FAQEntryFieldsBatchUpdate 批量更新FAQ条目字段的请求 // 支持两种模式: // 1. 按条目ID更新:使用 ByID 字段 // 2. 按Tag更新:使用 ByTag 字段,将该Tag下所有条目应用相同的更新 type FAQEntryFieldsBatchUpdate struct { // ByID 按条目ID更新,key为条目ID (seq_id) ByID map[int64]FAQEntryFieldsUpdate `json:"by_id,omitempty"` // ByTag 按Tag批量更新,key为TagID (seq_id) ByTag map[int64]FAQEntryFieldsUpdate `json:"by_tag,omitempty"` // ExcludeIDs 在ByTag操作中需要排除的ID列表 (seq_id) ExcludeIDs []int64 `json:"exclude_ids,omitempty"` } // FAQImportTaskStatus 导入任务状态 type FAQImportTaskStatus string const ( // FAQImportStatusPending represents the pending status of the FAQ import task FAQImportStatusPending FAQImportTaskStatus = "pending" // FAQImportStatusProcessing represents the processing status of the FAQ import task FAQImportStatusProcessing FAQImportTaskStatus = "processing" // FAQImportStatusCompleted represents the completed status of the FAQ import task FAQImportStatusCompleted FAQImportTaskStatus = "completed" // FAQImportStatusFailed represents the failed status of the FAQ import task FAQImportStatusFailed FAQImportTaskStatus = "failed" ) // FAQImportProgress represents the progress of an FAQ import task stored in Redis // When Status is "completed", the result fields (SkippedCount, ImportMode, ImportedAt, DisplayStatus, ProcessingTime) are populated. type FAQImportProgress struct { TaskID string `json:"task_id"` // UUID for the import task KBID string `json:"kb_id"` // Knowledge Base ID KnowledgeID string `json:"knowledge_id"` // FAQ Knowledge ID Status FAQImportTaskStatus `json:"status"` // Task status Progress int `json:"progress"` // 0-100 percentage Total int `json:"total"` // Total entries to import Processed int `json:"processed"` // Entries processed so far SuccessCount int `json:"success_count"` // 完全成功的条目数(不包含部分成功/部分失败) FailedCount int `json:"failed_count"` // 失败的条目数 PartialFailedCount int `json:"partial_failed_count,omitempty"` // 部分失败的条目数(相似问/反例被移除) SkippedCount int `json:"skipped_count,omitempty"` // 跳过的条目数(如重复等) FailedEntries []FAQFailedEntry `json:"failed_entries,omitempty"` // 失败条目详情(少量时直接返回) FailedEntriesURL string `json:"failed_entries_url,omitempty"` // 失败条目CSV下载URL(大量时返回URL) SuccessEntries []FAQSuccessEntry `json:"success_entries,omitempty"` // 成功条目简单信息(少量时直接返回) ValidEntryIndices []int `json:"valid_entry_indices,omitempty"` // 验证通过的条目索引(用于重试时跳过验证) MergeEntryIndices []int `json:"merge_entry_indices,omitempty"` // 需要合并的条目索引(内部使用,用于重试时跳过识别) MergedCount int `json:"merged_count,omitempty"` // 合并更新的条目数 AddedCount int `json:"added_count,omitempty"` // 新增的条目数 MergeDetails []FAQMergeDetail `json:"merge_details,omitempty"` // 合并详情 Message string `json:"message"` // Status message Error string `json:"error"` // Error message if failed CreatedAt int64 `json:"created_at"` // Task creation timestamp UpdatedAt int64 `json:"updated_at"` // Last update timestamp DryRun bool `json:"dry_run,omitempty"` // 是否为 dry run 模式 // Result fields (populated when Status == "completed") ImportMode string `json:"import_mode,omitempty"` // 导入模式:append 或 replace ImportedAt time.Time `json:"imported_at,omitempty"` // 导入完成时间 DisplayStatus string `json:"display_status,omitempty"` // 显示状态:open 或 close ProcessingTime int64 `json:"processing_time,omitempty"` // 处理耗时(毫秒) } // FAQImportMetadata 存储在Knowledge.Metadata中的FAQ导入任务信息 // Deprecated: Use FAQImportProgress with Redis storage instead type FAQImportMetadata struct { ImportProgress int `json:"import_progress"` // 0-100 ImportTotal int `json:"import_total"` ImportProcessed int `json:"import_processed"` } // FAQImportResult 存储FAQ导入完成后的统计结果 // 这个信息是持久化的,不跟随进度状态,直到下次导入时被替换 type FAQImportResult struct { // 导入统计信息 TotalEntries int `json:"total_entries"` // 总条目数 SuccessCount int `json:"success_count"` // 完全成功的条目数(不包含部分成功/部分失败) FailedCount int `json:"failed_count"` // 完全失败的条目数 PartialFailedCount int `json:"partial_failed_count"` // 部分失败的条目数(相似问/反例被移除但已导入) SkippedCount int `json:"skipped_count"` // 跳过的条目数(如重复等) MergedCount int `json:"merged_count"` // 合并更新的条目数 AddedCount int `json:"added_count"` // 新增的条目数 // 导入模式和时间信息 ImportMode string `json:"import_mode"` // 导入模式:append 或 replace ImportedAt time.Time `json:"imported_at"` // 导入完成时间 TaskID string `json:"task_id"` // 导入任务ID // 失败详情URL(失败条目较多时提供下载链接) FailedEntriesURL string `json:"failed_entries_url,omitempty"` // 失败条目CSV下载URL // 显示控制 DisplayStatus string `json:"display_status"` // 显示状态:open 或 close // 额外统计信息 ProcessingTime int64 `json:"processing_time"` // 处理耗时(毫秒) } // ToJSON converts the metadata to JSON type. func (m *FAQImportMetadata) ToJSON() (JSON, error) { if m == nil { return nil, nil } bytes, err := json.Marshal(m) if err != nil { return nil, err } return JSON(bytes), nil } // ToJSON converts the import result to JSON type. func (r *FAQImportResult) ToJSON() (JSON, error) { if r == nil { return nil, nil } bytes, err := json.Marshal(r) if err != nil { return nil, err } return JSON(bytes), nil } // ParseFAQImportMetadata parses FAQ import metadata from Knowledge. func ParseFAQImportMetadata(k *Knowledge) (*FAQImportMetadata, error) { if k == nil || len(k.Metadata) == 0 { return nil, nil } var metadata FAQImportMetadata if err := json.Unmarshal(k.Metadata, &metadata); err != nil { return nil, err } return &metadata, nil } // normalizeQuestionStrings 对问题列表进行归一化处理 // 包括全角转半角、去除末尾标点、合并空格等,同时去重 func normalizeQuestionStrings(values []string) []string { if len(values) == 0 { return nil } dedup := make([]string, 0, len(values)) seen := make(map[string]struct{}, len(values)) for _, v := range values { normalized := NormalizeQuestion(v) if normalized == "" { continue } if _, exists := seen[normalized]; exists { continue } seen[normalized] = struct{}{} dedup = append(dedup, normalized) } if len(dedup) == 0 { return nil } return dedup } // multiSpaceRegex 用于匹配多个连续空白字符 var multiSpaceRegex = regexp.MustCompile(`\s+`) // urlRegex 用于匹配 URL var urlRegex = regexp.MustCompile(`https?://[^\s]+`) // URLNormMode 定义 URL 归一化模式 type URLNormMode int const ( // URLRemove 完全移除 URL URLRemove URLNormMode = iota // URLPlaceholder 替换为占位符 URLPlaceholder // URLKeepDomain 只保留域名 URLKeepDomain // URLKeepDomainAndPath 保留域名和路径 URLKeepDomainAndPath ) // t2sConverter 繁体转简体转换器(单例) var t2sConverter *opencc.OpenCC func init() { var err error t2sConverter, err = opencc.New("t2s") // Traditional to Simplified if err != nil { // 初始化失败时使用空转换器,不影响其他功能 t2sConverter = nil } } // NormalizeQuestion 对问题文本进行归一化处理以提高向量匹配命中率 // 处理顺序参考: query = convert_st(trim_url(query.lower().strip().strip("?。,;、:""!?.,;!:'\"")), 1) // 1. 去除首尾空白 // 2. 移除 URL // 3. 转小写 // 4. 去除首尾标点 // 5. 繁体转简体 // 6. 全角符号转半角 // 7. 智能空格处理(中文间去空格,英文/数字间保留) func NormalizeQuestion(q string) string { q = strings.TrimSpace(q) if q == "" { return "" } // 1. 移除 URL q = trimURL(q) // 2. 转小写(对英文有效) q = strings.ToLower(q) // 3. 去除首尾标点符号 q = strings.Trim(q, `?。,;、:""!?.,;!:'""`) // 4. 繁体转简体 q = toSimplified(q) // 5. 全角字符转半角 q = toHalfWidth(q) // 6. 智能空格处理:中文间去空格,英文/数字间保留 q = normalizeSpaces(q) return strings.TrimSpace(q) } // normalizeSpaces 智能处理空格 // 规则: // - 去除中文语境中的多余空格 // - 保留英文/数字之间的必要空格 // 示例: // - "怎么 绑定 手机" → "怎么绑定手机" // - "iphone 15 怎么 激活" → "iphone 15怎么激活" func normalizeSpaces(s string) string { // 先合并多个连续空格为单个空格 s = multiSpaceRegex.ReplaceAllString(s, " ") runes := []rune(s) if len(runes) == 0 { return "" } var builder strings.Builder builder.Grow(len(s)) for i := 0; i < len(runes); i++ { r := runes[i] // 如果不是空格,直接写入 if r != ' ' { builder.WriteRune(r) continue } // 处理空格:检查前后字符决定是否保留 // 获取前一个非空字符 var prevRune rune if i > 0 { prevRune = runes[i-1] } // 获取后一个非空字符 var nextRune rune for j := i + 1; j < len(runes); j++ { if runes[j] != ' ' { nextRune = runes[j] break } } // 只有当前后都是英文字母或数字时才保留空格 // 其他情况(包括中文)都去除空格 if isASCIIAlphaNum(prevRune) && isASCIIAlphaNum(nextRune) { builder.WriteRune(' ') } // 否则跳过空格 } return builder.String() } // isASCIIAlphaNum 判断是否为 ASCII 字母或数字 func isASCIIAlphaNum(r rune) bool { return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') } // trimURL 移除字符串中的 URL(使用默认的 URLRemove 模式) func trimURL(s string) string { return NormalizeURL(s, URLRemove) } // NormalizeURL 根据指定模式处理文本中的 URL func NormalizeURL(text string, mode URLNormMode) string { return urlRegex.ReplaceAllStringFunc(text, func(raw string) string { switch mode { case URLRemove: return "" case URLPlaceholder: return "" case URLKeepDomain: domain, _ := parseURL(raw) if domain != "" { return domain } return "" case URLKeepDomainAndPath: domain, path := parseURL(raw) if domain != "" { return domain + path } return "" default: return "" } }) } // parseURL 解析 URL,返回域名和路径 func parseURL(raw string) (domain, path string) { // 移除协议前缀 u := raw if strings.HasPrefix(u, "https://") { u = u[8:] } else if strings.HasPrefix(u, "http://") { u = u[7:] } // 分离域名和路径 slashIdx := strings.Index(u, "/") if slashIdx == -1 { // 没有路径,整个是域名(可能带查询参数) queryIdx := strings.Index(u, "?") if queryIdx != -1 { domain = u[:queryIdx] } else { domain = u } return domain, "" } domain = u[:slashIdx] path = u[slashIdx:] // 移除查询参数和片段 if queryIdx := strings.Index(path, "?"); queryIdx != -1 { path = path[:queryIdx] } if fragIdx := strings.Index(path, "#"); fragIdx != -1 { path = path[:fragIdx] } return domain, path } // toSimplified 繁体中文转简体中文 func toSimplified(s string) string { if t2sConverter == nil { return s } result, err := t2sConverter.Convert(s) if err != nil { return s } return result } // toHalfWidth 将全角字符转换为半角字符 // 主要处理:全角空格、全角ASCII字符(包括标点符号) func toHalfWidth(s string) string { var builder strings.Builder builder.Grow(len(s)) for _, r := range s { switch { // 全角空格 -> 半角空格 case r == '\u3000': builder.WriteRune(' ') // 全角ASCII字符 (!到~,范围 0xFF01-0xFF5E) -> 半角 (0x0021-0x007E) case r >= 0xFF01 && r <= 0xFF5E: builder.WriteRune(r - 0xFF01 + 0x21) // 其他字符保持不变 default: builder.WriteRune(r) } } return builder.String() } // NormalizeQueryText 对搜索查询文本进行归一化处理 // 与 NormalizeQuestion 相同的处理逻辑,用于搜索时对查询文本进行归一化 func NormalizeQueryText(q string) string { return NormalizeQuestion(q) } // IsChineseChar 判断是否为中文字符 func IsChineseChar(r rune) bool { return unicode.Is(unicode.Han, r) }