WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
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

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+794
View File
@@ -0,0 +1,794 @@
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)
}