398 lines
13 KiB
Go
398 lines
13 KiB
Go
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")
|
||
}
|