Files
xinmi/internal/types/faq.go
T
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

795 lines
29 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 替换为占位符 <URL>
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 "<URL>"
case URLKeepDomain:
domain, _ := parseURL(raw)
if domain != "" {
return domain
}
return "<URL>"
case URLKeepDomainAndPath:
domain, path := parseURL(raw)
if domain != "" {
return domain + path
}
return "<URL>"
default:
return "<URL>"
}
})
}
// 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)
}