Files
xk-ai-agent/internal/kb/library_service.go
2026-08-14 21:50:48 +08:00

398 lines
13 KiB
Go
Raw Permalink 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 kb
import (
"context"
"fmt"
"time"
"tcm-agent/internal/dao"
)
// ========================================================================
// LibraryService:库 / 文档 / 分段的业务管理(不含检索)
// ========================================================================
// 这是 KB Admin Handler 直接调用的服务,负责:
// - 库的 CRUD(list/create/delete)
// - 文档导入(解析文件 + 切分 + 入库 + 自动刷新库统计)
// - 文档/分段的 list / detail / delete
//
// 不在这里做:
// - 向量化(V1 不做;V2 单独有 VectorizeService 走异步任务)
// - 检索(走 Searcher)
// ========================================================================
// LibraryService 库管理服务
type LibraryService struct {
embedder Embedder // V1 是 NoopEmbedder
}
// NewLibraryService 构造
func NewLibraryService(embedder Embedder) *LibraryService {
return &LibraryService{embedder: embedder}
}
// ----------------------------------------------------------------------
// 库(library)
// ----------------------------------------------------------------------
// LibraryDTO 库的列表/详情 DTO(带人类可读字段)
type LibraryDTO struct {
dao.KBLibraryRow
// 额外展示字段(可空)
EmbeddingAvailable bool `json:"embedding_available"` // 该库的 embedder 是否可用(V1 noop=false)
}
// ListLibraries 列出所有库(含禁用的,便于后台展示)
func (s *LibraryService) ListLibraries(ctx context.Context) ([]LibraryDTO, error) {
rows, err := dao.KBListLibraries(true)
if err != nil {
return nil, err
}
out := make([]LibraryDTO, 0, len(rows))
for _, r := range rows {
out = append(out, LibraryDTO{
KBLibraryRow: r,
EmbeddingAvailable: s.embedder.Available(),
})
}
return out, nil
}
// GetLibrary 取单个库
func (s *LibraryService) GetLibrary(ctx context.Context, id uint) (*LibraryDTO, error) {
row, err := dao.KBGetLibrary(id)
if err != nil {
return nil, err
}
return &LibraryDTO{
KBLibraryRow: *row,
EmbeddingAvailable: s.embedder.Available(),
}, nil
}
// CreateLibraryInput 建库入参(HTTP body 直接反序列化)
type CreateLibraryInput struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Source string `json:"source"` // 默认 manual
EmbeddingProvider string `json:"embedding_provider"` // 默认 noop
}
// CreateLibrary 建库
func (s *LibraryService) CreateLibrary(ctx context.Context, in CreateLibraryInput) (*LibraryDTO, error) {
row, err := dao.KBCreateLibrary(dao.KBCreateLibraryInput{
Name: in.Name,
Description: in.Description,
Source: in.Source,
EmbeddingProvider: in.EmbeddingProvider,
})
if err != nil {
return nil, err
}
return &LibraryDTO{KBLibraryRow: *row}, nil
}
// DeleteLibrary 软删除库(连带该库下所有文档和分段)
func (s *LibraryService) DeleteLibrary(ctx context.Context, id uint) error {
return dao.KBDeleteLibrary(id)
}
// ----------------------------------------------------------------------
// 文档(doc)
// ----------------------------------------------------------------------
// ListDocs 列出某库下的所有文档
func (s *LibraryService) ListDocs(ctx context.Context, libraryID uint) ([]dao.KBDocRow, error) {
return dao.KBListDocs(libraryID)
}
// GetDoc 取单个文档
func (s *LibraryService) GetDoc(ctx context.Context, id uint) (*dao.KBDocRow, error) {
return dao.KBGetDoc(id)
}
// DeleteDoc 软删除文档(连带分段)
//
// 删除后自动刷新库的统计字段
func (s *LibraryService) DeleteDoc(ctx context.Context, id uint) error {
doc, err := dao.KBGetDoc(id)
if err != nil {
return err
}
if err := dao.KBDeleteDoc(id); err != nil {
return err
}
// 刷新库统计
return dao.KBUpdateLibraryStats(doc.LibraryID)
}
// ----------------------------------------------------------------------
// 文档导入
// ----------------------------------------------------------------------
// ImportDocInput 导入文档入参
type ImportDocInput struct {
LibraryID uint `json:"library_id" binding:"required"`
Filename string `json:"filename" binding:"required"` // 文件名(含扩展名)
Content []byte `json:"-"` // 文件二进制(HTTP multipart 上传)
Title string `json:"title"` // 自定义文档标题(可空,默认取文件名)
MaxLen int `json:"max_len"` // 自定义分段最大长度(0=用默认 500)
Overlap int `json:"overlap"` // 自定义分段重叠(-1 或缺省=用默认 50)
}
// resolveChunkOptions 把用户传入的自定义分段参数归一化成合法的 ChunkOptions
//
// 规则:
// - MaxLen 允许 100~2000(太小切得稀碎、太大失去检索意义),越界回落默认
// - Overlap 允许 0~500 且必须小于 MaxLen(否则滑窗永不前进会死循环)
// - 都不传(0 值)时用 DefaultChunkOptions,行为与旧版完全一致
func resolveChunkOptions(maxLen, overlap int) ChunkOptions {
opt := DefaultChunkOptions()
if maxLen >= 100 && maxLen <= 2000 {
opt.MaxLen = maxLen
}
if overlap >= 0 && overlap <= 500 && overlap < opt.MaxLen {
opt.Overlap = overlap
}
return opt
}
// ImportDocResult 导入结果
type ImportDocResult struct {
DocID uint `json:"doc_id"`
Title string `json:"title"`
ChunkCount int `json:"chunk_count"`
SourceType string `json:"source_type"`
}
// ImportDocument 导入单个文档
//
// 流程:
// 1. 解析文件 → ParsedDoc(含切分好的 chunks)
// 2. 构造 KBDocRow + []*KBChunkRow
// 3. 调 dao.KBInsertDocWithChunks 一次性入库
// 4. 刷新库统计(doc_count / chunk_count)
//
// 注意:V1 全程不调 embedding,content_vector 始终为 NULL
func (s *LibraryService) ImportDocument(ctx context.Context, in ImportDocInput) (*ImportDocResult, error) {
if in.LibraryID == 0 {
return nil, fmt.Errorf("kb: library_id 不能为空")
}
// 验证库存在
lib, err := dao.KBGetLibrary(in.LibraryID)
if err != nil {
return nil, fmt.Errorf("kb: 库不存在: %w", err)
}
_ = lib
// 1. 解析文件 + 切分(支持自定义 max_len/overlap,缺省走默认 500/50)
pdoc, err := ParseFileFromBytes(in.Filename, in.Content, resolveChunkOptions(in.MaxLen, in.Overlap))
if err != nil {
return nil, fmt.Errorf("kb: 解析文件失败: %w", err)
}
// 2. 构造 doc
docTitle := in.Title
if docTitle == "" {
docTitle = pdoc.Title
}
doc := &dao.KBDocRow{
LibraryID: in.LibraryID,
Title: docTitle,
Content: pdoc.RawContent,
SourceFile: pdoc.SourceFile,
SourceType: pdoc.SourceType,
}
// 3. 构造 chunks
chunks := make([]*dao.KBChunkRow, 0, len(pdoc.Chunks))
for _, c := range pdoc.Chunks {
row := &dao.KBChunkRow{
Title: c.Title,
Content: c.Content,
}
// related_questions 等元数据编进 meta_json
if len(c.Meta) > 0 {
row.MetaJSON = dao.MarshalMeta(c.Meta)
}
chunks = append(chunks, row)
}
// 4. 入库
if err := dao.KBInsertDocWithChunks(doc, chunks); err != nil {
return nil, fmt.Errorf("kb: 入库失败: %w", err)
}
// 5. 刷新库统计
_ = dao.KBUpdateLibraryStats(in.LibraryID)
return &ImportDocResult{
DocID: doc.ID,
Title: doc.Title,
ChunkCount: len(chunks),
SourceType: pdoc.SourceType,
}, nil
}
// ----------------------------------------------------------------------
// 分段(chunk)
// ----------------------------------------------------------------------
// ListChunks 列出某文档的分段
func (s *LibraryService) ListChunks(ctx context.Context, docID uint) ([]dao.KBChunkRow, error) {
return dao.KBListChunks(docID)
}
// UpdateChunkInput 编辑分段入参(HTTP body)
type UpdateChunkInput struct {
Title string `json:"title"`
Content string `json:"content"`
RelatedQuestions []string `json:"related_questions"` // 关联问题列表(自动塞到 meta_json.related_questions)
IsActive *int `json:"is_active"` // 分段启停开关:nil=不改 / 1=启用 / 0=禁用
}
// UpdateChunk 编辑分段
//
// 支持改:标题、内容、关联问题
// 不支持改:所属库、所属文档、chunk_index(结构信息)
//
// V2 改进点:内容变更后异步触发重新向量化
func (s *LibraryService) UpdateChunk(ctx context.Context, chunkID uint, in UpdateChunkInput) (*dao.KBChunkRow, error) {
// 先验证分段存在
chunk, err := dao.KBGetChunk(chunkID)
if err != nil {
return nil, err
}
// 构造更新入参(用指针区分"不改"和"清空")
//
// 特殊分支:只切换启停开关时(title/content 都是空串且带 is_active),
// 不能把空串写进 title/content 覆盖原内容——只更新 is_active
var input dao.KBUpdateChunkInput
onlyToggle := in.IsActive != nil && in.Title == "" && in.Content == "" && in.RelatedQuestions == nil
if !onlyToggle {
input.Title = &in.Title
input.Content = &in.Content
// 关联问题列表 → meta_json.related_questions
if in.RelatedQuestions != nil {
// 即使是空数组也写入(清空关联问题)
meta := map[string]any{}
if len(in.RelatedQuestions) > 0 {
meta["related_questions"] = in.RelatedQuestions
}
metaStr := dao.MarshalMeta(meta)
input.MetaJSON = metaStr
}
}
input.IsActive = in.IsActive
if err := dao.KBUpdateChunk(chunkID, input); err != nil {
return nil, err
}
// 启停开关影响库的 chunk_count 统计(只统计 is_active=1),需要刷新
if in.IsActive != nil {
_ = dao.KBUpdateLibraryStats(chunk.LibraryID)
}
// 返回更新后的分段
return dao.KBGetChunk(chunkID)
}
// BatchChunkInput 批量分段操作入参(HTTP body)
type BatchChunkInput struct {
IDs []uint `json:"ids" binding:"required"` // 分段 ID 列表
Action string `json:"action" binding:"required"` // enable / disable / delete
}
// BatchUpdateChunks 批量启用/禁用/删除分段
//
// 事务在 DAO 层保证原子性,这里负责入参校验 + 事后刷新受影响库的统计
func (s *LibraryService) BatchUpdateChunks(ctx context.Context, in BatchChunkInput) (int64, error) {
if len(in.IDs) == 0 {
return 0, fmt.Errorf("kb: ids 不能为空")
}
if len(in.IDs) > 500 {
return 0, fmt.Errorf("kb: 单次最多操作 500 条分段")
}
affected, libIDs, err := dao.KBBatchChunkAction(in.IDs, in.Action)
if err != nil {
return 0, err
}
// 刷新受影响库的统计(chunk_count 只统计 is_active=1 且未删除的)
for _, libID := range libIDs {
_ = dao.KBUpdateLibraryStats(libID)
}
return affected, nil
}
// RechunkInput 重新分段入参(HTTP body)
type RechunkInput struct {
MaxLen int `json:"max_len"` // 新的分段最大长度(0=默认 500)
Overlap int `json:"overlap"` // 新的分段重叠(-1=默认 50)
}
// RechunkResult 重新分段结果
type RechunkResult struct {
DocID uint `json:"doc_id"`
OldCount int `json:"old_count"` // 重切前的分段数
ChunkCount int `json:"chunk_count"` // 重切后的分段数
}
// RechunkDocument 用新分段参数对已导入文档重新切分
//
// 场景:调整 max_len/overlap 后不用删除重传——直接用 xk_kb_doc.content
// 存的原文重切。注意人工编辑过的分段内容会被重切结果覆盖(前端有二次确认提示)。
//
// 切分器选择与导入时一致:md/html 存的是带 # 标题的文本走 ChunkMarkdown,
// 其余(txt/pdf/docx/xlsx/csv)走 ChunkPlainText
func (s *LibraryService) RechunkDocument(ctx context.Context, docID uint, in RechunkInput) (*RechunkResult, error) {
doc, err := dao.KBGetDoc(docID)
if err != nil {
return nil, err
}
if doc.Content == "" {
return nil, fmt.Errorf("kb: 该文档没有保存原文,无法重新分段(早期导入的文档可删除后重新上传)")
}
opt := resolveChunkOptions(in.MaxLen, in.Overlap)
var parsed []*Chunk
if doc.SourceType == "md" || doc.SourceType == "html" {
parsed = ChunkMarkdown(doc.Content, opt)
} else {
parsed = ChunkPlainText(doc.Content, opt)
}
if len(parsed) == 0 {
return nil, fmt.Errorf("kb: 按新参数切分后没有产生任何分段,已保留原分段")
}
rows := make([]*dao.KBChunkRow, 0, len(parsed))
for _, c := range parsed {
row := &dao.KBChunkRow{Title: c.Title, Content: c.Content}
if len(c.Meta) > 0 {
row.MetaJSON = dao.MarshalMeta(c.Meta)
}
rows = append(rows, row)
}
oldCount := doc.ChunkCount
if err := dao.KBReplaceDocChunks(docID, rows); err != nil {
return nil, fmt.Errorf("kb: 替换分段失败: %w", err)
}
_ = dao.KBUpdateLibraryStats(doc.LibraryID)
return &RechunkResult{DocID: docID, OldCount: oldCount, ChunkCount: len(rows)}, nil
}
// ----------------------------------------------------------------------
// 工具:时间戳格式化(前端展示用)
// ----------------------------------------------------------------------
// FormatTime 把 int 时间戳转成人类可读字符串(前端可选不用)
func FormatTime(ts int) string {
if ts <= 0 {
return ""
}
return time.Unix(int64(ts), 0).Format("2006-01-02 15:04:05")
}