diff --git a/data/business.db b/data/business.db index 000cdab..d68251d 100644 Binary files a/data/business.db and b/data/business.db differ diff --git a/data/chat.db b/data/chat.db index 4c77dc7..480946f 100644 Binary files a/data/chat.db and b/data/chat.db differ diff --git a/data/system.db b/data/system.db index 7f6a2f8..9fd4175 100644 Binary files a/data/system.db and b/data/system.db differ diff --git a/kb/consts/consts.go b/kb/consts/consts.go index 920ba3a..967dc8a 100644 --- a/kb/consts/consts.go +++ b/kb/consts/consts.go @@ -5,9 +5,14 @@ const ( DefaultEmbeddingDim = 1024 // embedding 默认维度(未配置时) VectorTopK = 20 // 向量检索召回数 FtsTopK = 20 // 全文检索召回数 - HybridTopK = 10 // 混合检索融合后返回数 + HybridTopK = 5 // 混合检索最终返回数(重排+门槛过滤后) RrfK = 60 // RRF 融合常数 + RerankTopK = 10 // 喂给 LLM 重排器的候选数(RRF 融合后截取) + RerankKeepRatio = 0.5 // 重排分低于最高分该比例的条目剔除(门槛作用在语义分上,RRF 分因区间过窄无区分度) + RerankMinScore = 6 // 重排分绝对下限:低于该分的条目直接剔除(重排器对泛化条款会给宽松低分,需绝对下限兜底) + RerankMaxChars = 500 // 重排候选段截断字数(控制 prompt 长度) + DefaultChunkSize = 800 // 分块最大字数(数据集默认值) DefaultChunkOverlap = 150 // 分块重叠字数(数据集默认值) @@ -18,4 +23,9 @@ const ( ParsePollIntervalSeconds = 3 // 解析任务轮询间隔 EmbedBatchSize = 16 // 单次向量化请求的文本批量 + + // 合同标注参数 + AnnoRecallTopK = 15 // 每数据集向量+FTS 各召回数(标注宁滥毋缺,放宽召回) + AnnoMaxCandidates = 60 // 多数据集融合后的候选上限(喂给 LLM 判定) + AnnoMaxClauseChars = 2000 // 合同条款全文上限(超长截断,控制 prompt) ) diff --git a/kb/consts/table_name.go b/kb/consts/table_name.go index 1332182..00ad1e1 100644 --- a/kb/consts/table_name.go +++ b/kb/consts/table_name.go @@ -1,18 +1,21 @@ package consts const ( - TableNameModelConfig = "model_config" - TableNameAppConfig = "app_config" - TableNameDataset = "kb_dataset" - TableNameDocument = "kb_document" - TableNameChunk = "kb_chunk" - TableNameChunkVec = "kb_chunk_vec" - TableNameChunkFts = "kb_chunk_fts" - TableNameParseTask = "kb_parse_task" - TableNameConversation = "chat_conversation" - TableNameMessage = "chat_message" - TableNameKgEntity = "kg_entity" - TableNameKgRelation = "kg_relation" + TableNameModelConfig = "model_config" + TableNameAppConfig = "app_config" + TableNameDataset = "kb_dataset" + TableNameDocument = "kb_document" + TableNameChunk = "kb_chunk" + TableNameChunkVec = "kb_chunk_vec" + TableNameChunkFts = "kb_chunk_fts" + TableNameParseTask = "kb_parse_task" + TableNameConversation = "chat_conversation" + TableNameMessage = "chat_message" + TableNameKgEntity = "kg_entity" + TableNameKgRelation = "kg_relation" + TableNameContractTask = "kb_contract_task" + TableNameContractClause = "kb_contract_clause" + TableNameContractMark = "kb_contract_mark" ) // 数据库组:默认组(default)=business.db、system=system.db、chat=chat.db diff --git a/kb/controller/contract_controller.go b/kb/controller/contract_controller.go new file mode 100644 index 0000000..6f2181c --- /dev/null +++ b/kb/controller/contract_controller.go @@ -0,0 +1,124 @@ +package controller + +import ( + "context" + "encoding/json" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "rag-local/common" + "rag-local/kb/dao" + "rag-local/kb/model/dto" + "rag-local/kb/model/entity" + "rag-local/kb/service" + + "github.com/gogf/gf/v2/errors/gerror" +) + +type contract struct{} + +var Contract = &contract{} + +func (c *contract) Upload(ctx context.Context, req *dto.UploadContractReq) (*dto.UploadContractRes, error) { + if req.File == nil { + return nil, gerror.New("请选择合同文件") + } + var dsIds []int64 + if req.DatasetIds != "" { + if err := json.Unmarshal([]byte(req.DatasetIds), &dsIds); err != nil { + return nil, gerror.New("dataset_ids 格式错误,应为 JSON 数组") + } + } + if len(dsIds) == 0 { + return nil, gerror.New("请至少选择一个法律语料数据集") + } + f, err := req.File.Open() + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + data, err := io.ReadAll(f) + if err != nil { + return nil, err + } + if len(data) == 0 { + return nil, gerror.New("文件内容为空") + } + ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(req.File.Filename)), ".") + supported := false + for _, e := range common.SupportedExts() { + if e == ext { + supported = true + break + } + } + if !supported { + return nil, gerror.New("不支持的文件类型,仅支持 txt/md/pdf/docx/doc/html") + } + relDir := filepath.Join("contract", time.Now().Format("20060102")) + relPath := filepath.Join(relDir, common.RandomToken(16)+"."+ext) + absPath := filepath.Join("workspace", relPath) + if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil { + return nil, err + } + if err := os.WriteFile(absPath, data, 0o644); err != nil { + return nil, err + } + ids := make([]string, 0, len(dsIds)) + for _, id := range dsIds { + ids = append(ids, strconv.FormatInt(id, 10)) + } + id, err := dao.ContractTask.Insert(ctx, req.File.Filename, relPath, strings.Join(ids, ",")) + if err != nil { + _ = os.Remove(absPath) + return nil, err + } + return &dto.UploadContractRes{Id: id}, nil +} + +func (c *contract) List(ctx context.Context, req *dto.ListContractTaskReq) (*dto.ListContractTaskRes, error) { + list, total, err := service.AnnotationService.List(ctx, req.Page, req.PageSize) + if err != nil { + return nil, err + } + return &dto.ListContractTaskRes{ + List: list, + Total: total, + Page: req.Page, + PageSize: req.PageSize, + }, nil +} + +func (c *contract) Detail(ctx context.Context, req *dto.GetContractDetailReq) (*dto.GetContractDetailRes, error) { + task, err := dao.ContractTask.GetOne(ctx, req.Id) + if err != nil { + return nil, err + } + if task == nil { + return nil, gerror.New("任务不存在") + } + clauses, err := dao.ContractClause.ListByTask(ctx, req.Id) + if err != nil { + return nil, err + } + marks := make(map[int64][]*entity.ContractMark) + for _, cl := range clauses { + list, err := dao.ContractMark.ListByClause(ctx, cl.Id) + if err != nil { + return nil, err + } + marks[cl.Id] = list + } + return &dto.GetContractDetailRes{Task: task, Clauses: clauses, Marks: marks}, nil +} + +func (c *contract) Delete(ctx context.Context, req *dto.DeleteContractReq) (*dto.DeleteContractRes, error) { + if err := service.AnnotationService.Delete(ctx, req.Id); err != nil { + return nil, err + } + return &dto.DeleteContractRes{}, nil +} diff --git a/kb/dao/contract_clause_dao.go b/kb/dao/contract_clause_dao.go new file mode 100644 index 0000000..a8bbbac --- /dev/null +++ b/kb/dao/contract_clause_dao.go @@ -0,0 +1,89 @@ +package dao + +import ( + "context" + + "rag-local/kb/consts" + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +var ContractClause = &contractClauseDao{} + +type contractClauseDao struct{} + +func init() { + ctx := context.Background() + _, err := g.DB(consts.DbGroupDefault).Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameContractClause+` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL DEFAULT 0, + seq INTEGER NOT NULL DEFAULT 0, + title TEXT NOT NULL DEFAULT '', + content TEXT NOT NULL DEFAULT '', + status INTEGER NOT NULL DEFAULT 0, + error_msg TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT (datetime('now','localtime')), + updated_at DATETIME DEFAULT (datetime('now','localtime')) + )`) + if err != nil { + g.Log().Warningf(ctx, "create kb_contract_clause table failed: %v", err) + } + if _, err := g.DB(consts.DbGroupDefault).Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_kb_contract_clause_task ON "+consts.TableNameContractClause+"(task_id)"); err != nil { + g.Log().Warningf(ctx, "create index idx_kb_contract_clause_task failed: %v", err) + } +} + +func (d *contractClauseDao) InsertAll(ctx context.Context, taskId int64, clauses []entity.ContractClause) error { + tx, err := g.DB(consts.DbGroupDefault).Begin(ctx) + if err != nil { + return err + } + now := gtime.Now().Format("Y-m-d H:i:s") + for i := range clauses { + clauses[i].TaskId = taskId + clauses[i].Status = consts.TaskStatusPending + clauses[i].CreatedAt = nil + clauses[i].UpdatedAt = nil + if _, err := tx.Model(consts.TableNameContractClause).Ctx(ctx).Data(g.Map{ + "task_id": clauses[i].TaskId, + "seq": clauses[i].Seq, + "title": clauses[i].Title, + "content": clauses[i].Content, + "status": clauses[i].Status, + "error_msg": "", + "created_at": now, + "updated_at": now, + }).Insert(); err != nil { + _ = tx.Rollback() + return err + } + } + return tx.Commit() +} + +func (d *contractClauseDao) ListByTask(ctx context.Context, taskId int64) ([]*entity.ContractClause, error) { + var list []*entity.ContractClause + err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractClause).Ctx(ctx). + Where("task_id", taskId).OrderAsc("seq").Scan(&list) + if list == nil { + list = make([]*entity.ContractClause, 0) + } + return list, err +} + +func (d *contractClauseDao) UpdateStatus(ctx context.Context, id int64, status int, errorMsg string) error { + _, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractClause).Ctx(ctx).Data(g.Map{ + "status": status, + "error_msg": errorMsg, + "updated_at": gtime.Now().Format("Y-m-d H:i:s"), + }).Where("id", id).Update() + return err +} + +func (d *contractClauseDao) DeleteByTask(ctx context.Context, taskId int64) error { + _, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractClause).Ctx(ctx). + Where("task_id", taskId).Delete() + return err +} diff --git a/kb/dao/contract_mark_dao.go b/kb/dao/contract_mark_dao.go new file mode 100644 index 0000000..2c7d5f0 --- /dev/null +++ b/kb/dao/contract_mark_dao.go @@ -0,0 +1,88 @@ +package dao + +import ( + "context" + + "rag-local/kb/consts" + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +var ContractMark = &contractMarkDao{} + +type contractMarkDao struct{} + +func init() { + ctx := context.Background() + _, err := g.DB(consts.DbGroupDefault).Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameContractMark+` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + clause_id INTEGER NOT NULL DEFAULT 0, + chunk_id INTEGER NOT NULL DEFAULT 0, + dataset_id INTEGER NOT NULL DEFAULT 0, + law_title TEXT NOT NULL DEFAULT '', + law_item TEXT NOT NULL DEFAULT '', + content TEXT NOT NULL DEFAULT '', + reason TEXT NOT NULL DEFAULT '', + score REAL NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT (datetime('now','localtime')) + )`) + if err != nil { + g.Log().Warningf(ctx, "create kb_contract_mark table failed: %v", err) + } + if _, err := g.DB(consts.DbGroupDefault).Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_kb_contract_mark_clause ON "+consts.TableNameContractMark+"(clause_id)"); err != nil { + g.Log().Warningf(ctx, "create index idx_kb_contract_mark_clause failed: %v", err) + } +} + +func (d *contractMarkDao) InsertAll(ctx context.Context, marks []*entity.ContractMark) error { + if len(marks) == 0 { + return nil + } + tx, err := g.DB(consts.DbGroupDefault).Begin(ctx) + if err != nil { + return err + } + now := gtime.Now().Format("Y-m-d H:i:s") + for _, m := range marks { + if _, err := tx.Model(consts.TableNameContractMark).Ctx(ctx).Data(g.Map{ + "clause_id": m.ClauseId, + "chunk_id": m.ChunkId, + "dataset_id": m.DatasetId, + "law_title": m.LawTitle, + "law_item": m.LawItem, + "content": m.Content, + "reason": m.Reason, + "score": m.Score, + "created_at": now, + }).Insert(); err != nil { + _ = tx.Rollback() + return err + } + } + return tx.Commit() +} + +func (d *contractMarkDao) ListByClause(ctx context.Context, clauseId int64) ([]*entity.ContractMark, error) { + var list []*entity.ContractMark + err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractMark).Ctx(ctx). + Where("clause_id", clauseId).OrderDesc("score").Scan(&list) + if list == nil { + list = make([]*entity.ContractMark, 0) + } + return list, err +} + +func (d *contractMarkDao) DeleteByClause(ctx context.Context, clauseId int64) error { + _, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractMark).Ctx(ctx). + Where("clause_id", clauseId).Delete() + return err +} + +func (d *contractMarkDao) DeleteByTask(ctx context.Context, taskId int64) error { + _, err := g.DB(consts.DbGroupDefault).Exec(ctx, + "DELETE FROM "+consts.TableNameContractMark+" WHERE clause_id IN (SELECT id FROM "+consts.TableNameContractClause+" WHERE task_id = ?)", + taskId) + return err +} diff --git a/kb/dao/contract_task_dao.go b/kb/dao/contract_task_dao.go new file mode 100644 index 0000000..07cabc0 --- /dev/null +++ b/kb/dao/contract_task_dao.go @@ -0,0 +1,125 @@ +package dao + +import ( + "context" + "database/sql" + "errors" + + "rag-local/kb/consts" + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +var ContractTask = &contractTaskDao{} + +type contractTaskDao struct{} + +func init() { + ctx := context.Background() + _, err := g.DB(consts.DbGroupDefault).Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameContractTask+` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filename TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL DEFAULT '', + dataset_ids TEXT NOT NULL DEFAULT '', + status INTEGER NOT NULL DEFAULT 0, + total_clauses INTEGER NOT NULL DEFAULT 0, + done_clauses INTEGER NOT NULL DEFAULT 0, + error_msg TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT (datetime('now','localtime')), + updated_at DATETIME DEFAULT (datetime('now','localtime')) + )`) + if err != nil { + g.Log().Warningf(ctx, "create kb_contract_task table failed: %v", err) + } + if _, err := g.DB(consts.DbGroupDefault).Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_kb_contract_task_status ON "+consts.TableNameContractTask+"(status)"); err != nil { + g.Log().Warningf(ctx, "create index idx_kb_contract_task_status failed: %v", err) + } +} + +func (d *contractTaskDao) GetOne(ctx context.Context, id int64) (*entity.ContractTask, error) { + var m entity.ContractTask + err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractTask).Ctx(ctx).Where("id", id).Scan(&m) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &m, nil +} + +func (d *contractTaskDao) List(ctx context.Context, page, pageSize int) ([]*entity.ContractTask, int, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + total, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractTask).Ctx(ctx).Count() + if err != nil { + return nil, 0, err + } + var list []*entity.ContractTask + err = g.DB(consts.DbGroupDefault).Model(consts.TableNameContractTask).Ctx(ctx). + Page(page, pageSize).OrderDesc("id").Scan(&list) + if list == nil { + list = make([]*entity.ContractTask, 0) + } + return list, total, err +} + +func (d *contractTaskDao) Insert(ctx context.Context, filename, filePath, datasetIds string) (int64, error) { + now := gtime.Now().Format("Y-m-d H:i:s") + r, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractTask).Ctx(ctx).Data(g.Map{ + "filename": filename, + "file_path": filePath, + "dataset_ids": datasetIds, + "status": consts.TaskStatusPending, + "created_at": now, + "updated_at": now, + }).Insert() + if err != nil { + return 0, err + } + return r.LastInsertId() +} + +func (d *contractTaskDao) UpdateStatus(ctx context.Context, id int64, status int, errorMsg string) error { + _, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractTask).Ctx(ctx).Data(g.Map{ + "status": status, + "error_msg": errorMsg, + "updated_at": gtime.Now().Format("Y-m-d H:i:s"), + }).Where("id", id).Update() + return err +} + +func (d *contractTaskDao) UpdateProgress(ctx context.Context, id int64, totalClauses, doneClauses int) error { + _, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractTask).Ctx(ctx).Data(g.Map{ + "total_clauses": totalClauses, + "done_clauses": doneClauses, + "updated_at": gtime.Now().Format("Y-m-d H:i:s"), + }).Where("id", id).Update() + return err +} + +// NextPending 取待处理任务;status IN (Pending, Running) 使进程重启后遗留的 running 任务重新进入轮询 +func (d *contractTaskDao) NextPending(ctx context.Context) (*entity.ContractTask, error) { + var m entity.ContractTask + err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractTask).Ctx(ctx). + WhereIn("status", []int{consts.TaskStatusPending, consts.TaskStatusRunning}).OrderAsc("id").Scan(&m) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &m, nil +} + +func (d *contractTaskDao) Delete(ctx context.Context, id int64) error { + _, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractTask).Ctx(ctx). + Where("id", id).Delete() + return err +} diff --git a/kb/model/dto/contract_dto.go b/kb/model/dto/contract_dto.go new file mode 100644 index 0000000..7925516 --- /dev/null +++ b/kb/model/dto/contract_dto.go @@ -0,0 +1,49 @@ +package dto + +import ( + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" +) + +type UploadContractReq struct { + g.Meta `path:"/upload" method:"post" tags:"合同标注" summary:"上传合同并启动标注"` + DatasetIds string `json:"dataset_ids" dc:"法律语料数据集ID JSON 数组,如 [1,3]"` + File *ghttp.UploadFile `json:"file" dc:"合同文件"` +} + +type UploadContractRes struct { + Id int64 `json:"id"` +} + +type ListContractTaskReq struct { + g.Meta `path:"/list" method:"get" tags:"合同标注" summary:"标注任务列表"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type ListContractTaskRes struct { + List []*entity.ContractTask `json:"list"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type GetContractDetailReq struct { + g.Meta `path:"/detail" method:"get" tags:"合同标注" summary:"任务详情(含条款与标注结果)"` + Id int64 `v:"required" json:"id"` +} + +type GetContractDetailRes struct { + Task *entity.ContractTask `json:"task"` + Clauses []*entity.ContractClause `json:"clauses"` + Marks map[int64][]*entity.ContractMark `json:"marks"` +} + +type DeleteContractReq struct { + g.Meta `path:"/delete" method:"post" tags:"合同标注" summary:"删除标注任务"` + Id int64 `v:"required" json:"id"` +} + +type DeleteContractRes struct{} diff --git a/kb/model/entity/contract_clause.go b/kb/model/entity/contract_clause.go new file mode 100644 index 0000000..4fec63e --- /dev/null +++ b/kb/model/entity/contract_clause.go @@ -0,0 +1,15 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type ContractClause struct { + Id int64 `orm:"id" json:"id"` + TaskId int64 `orm:"task_id" json:"task_id"` + Seq int `orm:"seq" json:"seq"` + Title string `orm:"title" json:"title"` + Content string `orm:"content" json:"content"` + Status int `orm:"status" json:"status"` + ErrorMsg string `orm:"error_msg" json:"error_msg"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` + UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"` +} diff --git a/kb/model/entity/contract_mark.go b/kb/model/entity/contract_mark.go new file mode 100644 index 0000000..e72887c --- /dev/null +++ b/kb/model/entity/contract_mark.go @@ -0,0 +1,16 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type ContractMark struct { + Id int64 `orm:"id" json:"id"` + ClauseId int64 `orm:"clause_id" json:"clause_id"` + ChunkId int64 `orm:"chunk_id" json:"chunk_id"` + DatasetId int64 `orm:"dataset_id" json:"dataset_id"` + LawTitle string `orm:"law_title" json:"law_title"` + LawItem string `orm:"law_item" json:"law_item"` + Content string `orm:"content" json:"content"` + Reason string `orm:"reason" json:"reason"` + Score float64 `orm:"score" json:"score"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` +} diff --git a/kb/model/entity/contract_task.go b/kb/model/entity/contract_task.go new file mode 100644 index 0000000..7dce254 --- /dev/null +++ b/kb/model/entity/contract_task.go @@ -0,0 +1,16 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type ContractTask struct { + Id int64 `orm:"id" json:"id"` + Filename string `orm:"filename" json:"filename"` + FilePath string `orm:"file_path" json:"file_path"` + DatasetIds string `orm:"dataset_ids" json:"dataset_ids"` + Status int `orm:"status" json:"status"` + TotalClauses int `orm:"total_clauses" json:"total_clauses"` + DoneClauses int `orm:"done_clauses" json:"done_clauses"` + ErrorMsg string `orm:"error_msg" json:"error_msg"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` + UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"` +} diff --git a/kb/service/annotation_service.go b/kb/service/annotation_service.go new file mode 100644 index 0000000..7e12d03 --- /dev/null +++ b/kb/service/annotation_service.go @@ -0,0 +1,436 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "rag-local/common" + "rag-local/kb/consts" + "rag-local/kb/dao" + "rag-local/kb/model/domain" + "rag-local/kb/model/entity" + + "github.com/cloudwego/eino/schema" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" +) + +var AnnotationService = &annotationService{} + +type annotationService struct{} + +// Clause 合同条款(切分中间态) +type Clause struct { + Seq int + Title string + Content string +} + +// 条款切分正则,按优先级探测(首个命中 >=2 条的采用) +var clausePatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?m)^\s*(第[一二三四五六七八九十百千\d]+条[、\s::]?)`), + regexp.MustCompile(`(?m)^\s*(\d{1,2}(\.\d{1,2})*[、..]\s*)`), + regexp.MustCompile(`(?m)^\s*([一二三四五六七八九十]+[、..]\s*)`), +} + +var lawItemRe = regexp.MustCompile(`第[一二三四五六七八九十百千\d]+条`) + +// annoCandidate 多数据集融合后的候选法条 +type annoCandidate struct { + ChunkId int64 + DatasetId int64 + LawTitle string // 法律名 = dataset 名 + Content string // chunk 内容(截断) + RrfScore float64 +} + +// StartAnnotationPoller 启动标注任务轮询:单 goroutine 串行消费 +func (s *annotationService) StartAnnotationPoller(ctx context.Context) { + go func() { + g.Log().Info(ctx, "annotation task poller started") + for { + select { + case <-ctx.Done(): + return + case <-time.After(consts.ParsePollIntervalSeconds * time.Second): + s.processOne(ctx) + } + } + }() +} + +// SplitClauses 按行首标记切分条款;无结构时整篇作为单条 +func (s *annotationService) SplitClauses(text string) []Clause { + var chosen *regexp.Regexp + for _, p := range clausePatterns { + if len(p.FindAllStringIndex(text, -1)) >= 2 { + chosen = p + break + } + } + if chosen == nil { + t := strings.TrimSpace(text) + if t == "" { + return nil + } + return []Clause{{Seq: 1, Title: "全文", Content: t}} + } + idxs := chosen.FindAllStringIndex(text, -1) + var out []Clause + for i, m := range idxs { + end := len(text) + if i+1 < len(idxs) { + end = idxs[i+1][0] + } + seg := strings.TrimSpace(text[m[0]:end]) + if seg == "" { + continue + } + out = append(out, Clause{ + Seq: len(out) + 1, + Title: strings.TrimSpace(text[m[0]:m[1]]), + Content: seg, + }) + } + return out +} + +// processOne 处理一个待处理标注任务:解析 → 切分 → 逐条款召回+判定 → 落库 +func (s *annotationService) processOne(ctx context.Context) { + task, err := dao.ContractTask.NextPending(ctx) + if err != nil { + g.Log().Errorf(ctx, "next annotation task failed: %v", err) + return + } + if task == nil { + return + } + if err := dao.ContractTask.UpdateStatus(ctx, task.Id, consts.TaskStatusRunning, ""); err != nil { + g.Log().Errorf(ctx, "mark annotation task running failed: %v", err) + return + } + + dsIds := parseDatasetIds(task.DatasetIds) + if len(dsIds) == 0 { + s.fail(ctx, task, "未选择法律语料数据集") + return + } + + text, err := common.ParseFile(filepath.Join("workspace", task.FilePath)) + if err != nil { + s.fail(ctx, task, "解析合同文件失败: "+err.Error()) + return + } + + // 断点续跑:已有条款则跳过切分(保留已完成条款状态),仅首次切分落库 + clauses, err := dao.ContractClause.ListByTask(ctx, task.Id) + if err != nil { + s.fail(ctx, task, "读取条款失败: "+err.Error()) + return + } + if len(clauses) == 0 { + split := s.SplitClauses(text) + if len(split) == 0 { + s.fail(ctx, task, "合同文本为空") + return + } + es := make([]entity.ContractClause, 0, len(split)) + for _, c := range split { + es = append(es, entity.ContractClause{ + Seq: c.Seq, + Title: c.Title, + Content: truncateRunes(c.Content, consts.AnnoMaxClauseChars), + }) + } + if err := dao.ContractClause.InsertAll(ctx, task.Id, es); err != nil { + s.fail(ctx, task, "写入条款失败: "+err.Error()) + return + } + if err := dao.ContractTask.UpdateProgress(ctx, task.Id, len(es), 0); err != nil { + g.Log().Warningf(ctx, "update annotation progress failed: %v", err) + } + clauses, err = dao.ContractClause.ListByTask(ctx, task.Id) + if err != nil { + s.fail(ctx, task, "读取条款失败: "+err.Error()) + return + } + } + + chatCfgId, err := dao.ModelConfig.GetDefault(ctx, consts.ModelTypeChat) + if err != nil || chatCfgId <= 0 { + s.fail(ctx, task, "未配置对话模型") + return + } + chatModel, err := BuildChatModel(ctx, chatCfgId) + if err != nil { + s.fail(ctx, task, "构建对话模型失败: "+err.Error()) + return + } + + embedders := make(map[int64]*OpenAIEmbedder) + dsNames := make(map[int64]string) + for _, dsId := range dsIds { + ds, err := dao.Dataset.GetOne(ctx, dsId) + if err != nil || ds == nil { + continue + } + dsNames[dsId] = ds.Name + if cfgId, err := dao.Dataset.GetEmbeddingCfgId(ctx, dsId); err == nil && cfgId > 0 { + if emb, err := BuildEmbedder(ctx, cfgId); err == nil { + embedders[dsId] = emb + } else { + g.Log().Warningf(ctx, "dataset %d embedder build failed: %v", dsId, err) + } + } + } + + failed := 0 + for _, cl := range clauses { + if cl.Status == consts.TaskStatusDone { + continue + } + if err := dao.ContractClause.UpdateStatus(ctx, cl.Id, consts.TaskStatusRunning, ""); err != nil { + g.Log().Errorf(ctx, "mark clause running failed: %v", err) + continue + } + cands, err := s.recallCandidates(ctx, cl, dsIds, dsNames, embedders) + if err != nil { + failed++ + _ = dao.ContractClause.UpdateStatus(ctx, cl.Id, consts.TaskStatusFailed, err.Error()) + continue + } + if len(cands) == 0 { + // 无候选视为完成(无标注),避免卡住进度 + _ = dao.ContractClause.UpdateStatus(ctx, cl.Id, consts.TaskStatusDone, "") + s.updateProgress(ctx, task.Id) + continue + } + marks, err := s.judgeClause(ctx, chatModel, cl, cands) + if err != nil { + failed++ + _ = dao.ContractClause.UpdateStatus(ctx, cl.Id, consts.TaskStatusFailed, err.Error()) + continue + } + // 幂等:重跑前清旧标注,避免断点续跑产生重复 mark + if err := dao.ContractMark.DeleteByClause(ctx, cl.Id); err != nil { + g.Log().Warningf(ctx, "clear old marks failed: %v", err) + } + for _, m := range marks { + m.ClauseId = cl.Id + } + if err := dao.ContractMark.InsertAll(ctx, marks); err != nil { + failed++ + _ = dao.ContractClause.UpdateStatus(ctx, cl.Id, consts.TaskStatusFailed, err.Error()) + continue + } + _ = dao.ContractClause.UpdateStatus(ctx, cl.Id, consts.TaskStatusDone, "") + s.updateProgress(ctx, task.Id) + } + + msg := "" + if failed > 0 { + msg = fmt.Sprintf("%d 条条款标注失败", failed) + } + if err := dao.ContractTask.UpdateStatus(ctx, task.Id, consts.TaskStatusDone, msg); err != nil { + g.Log().Errorf(ctx, "mark annotation task done failed: %v", err) + } +} + +// updateProgress 以库内实际完成数更新任务进度(断点续跑时跳过已 done 条款也能算对) +func (s *annotationService) updateProgress(ctx context.Context, taskId int64) { + doneList, err := dao.ContractClause.ListByTask(ctx, taskId) + if err != nil { + return + } + done := 0 + for _, c := range doneList { + if c.Status == consts.TaskStatusDone { + done++ + } + } + if err := dao.ContractTask.UpdateProgress(ctx, taskId, len(doneList), done); err != nil { + g.Log().Warningf(ctx, "update annotation progress failed: %v", err) + } +} + +// recallCandidates 多数据集召回:每数据集向量+FTS 各取 AnnoRecallTopK,全局 RRF 融合截断 +func (s *annotationService) recallCandidates(ctx context.Context, clause *entity.ContractClause, dsIds []int64, + dsNames map[int64]string, embedders map[int64]*OpenAIEmbedder) ([]annoCandidate, error) { + merged := make(map[int64]*annoCandidate) + clauseText := clause.Title + " " + clause.Content + ftsText := clause.Title + " " + clause.Content + if rs := []rune(ftsText); len(rs) > 200 { + ftsText = string(rs[:200]) + } + ftsQuery := common.TokenizeQuery(ftsText) + for _, dsId := range dsIds { + if emb := embedders[dsId]; emb != nil { + vecs, err := emb.EmbedStrings(ctx, []string{clauseText}) + if err != nil { + g.Log().Warningf(ctx, "clause embed failed (dataset %d): %v", dsId, err) + } else if len(vecs) > 0 { + hits, err := dao.Chunk.VecSearch(ctx, dsId, domain.VecJsonF64(vecs[0]), consts.AnnoRecallTopK) + if err != nil { + g.Log().Warningf(ctx, "vec search failed (dataset %d): %v", dsId, err) + } else { + for i, h := range hits { + s.mergeHit(merged, h.ChunkId, dsId, dsNames[dsId], float64(i)) + } + } + } + } + hits, err := dao.Chunk.FtsSearch(ctx, dsId, ftsQuery, consts.AnnoRecallTopK) + if err != nil { + g.Log().Warningf(ctx, "fts search failed (dataset %d): %v", dsId, err) + continue + } + for i, h := range hits { + s.mergeHit(merged, h.ChunkId, dsId, dsNames[dsId], float64(i)) + } + } + cands := make([]annoCandidate, 0, len(merged)) + for _, c := range merged { + cands = append(cands, *c) + } + sort.Slice(cands, func(i, j int) bool { return cands[i].RrfScore > cands[j].RrfScore }) + if len(cands) > consts.AnnoMaxCandidates { + cands = cands[:consts.AnnoMaxCandidates] + } + for i := range cands { + if chunk, err := dao.Chunk.GetOne(ctx, cands[i].ChunkId); err == nil && chunk != nil { + cands[i].Content = truncateRunes(chunk.Content, consts.RerankMaxChars) + } + } + return cands, nil +} + +func (s *annotationService) mergeHit(merged map[int64]*annoCandidate, chunkId, dsId int64, lawTitle string, rank float64) { + c := merged[chunkId] + if c == nil { + c = &annoCandidate{ChunkId: chunkId, DatasetId: dsId, LawTitle: lawTitle} + merged[chunkId] = c + } + c.RrfScore += 1 / (float64(consts.RrfK) + rank + 1) +} + +// judgeClause LLM 判定:一次非流式调用对全部候选打分并给出理由,全部保留按分降序 +func (s *annotationService) judgeClause(ctx context.Context, model *OpenAIChatModel, clause *entity.ContractClause, cands []annoCandidate) ([]*entity.ContractMark, error) { + var sb strings.Builder + sb.WriteString("你是资深法律顾问,负责对合同条款进行法律条文标注。请逐条判定每个候选法律条文与合同条款的相关性。\n\n【合同条款】\n") + sb.WriteString(clause.Title + " " + clause.Content) + sb.WriteString("\n\n【候选法律条文】\n") + for i, c := range cands { + sb.WriteString(fmt.Sprintf("[%d]《%s》%s\n", i+1, c.LawTitle, c.Content)) + } + sb.WriteString("\n请为每条候选输出 score(0-10 整数,10=直接适用,0=完全无关)与 reason(一句话说明该条文与合同条款的关联及适用性)。\n注意:候选内容可能合并了多条法条,law_item 必须是候选内容中与合同条款最相关的那一条法条的编号(如「第四十四条」),不要输出候选内容里没有的编号。\n只输出 JSON,不要其他内容:") + sb.WriteString(`{"marks":[{"cand_id":1,"law_item":"第四十四条","score":9,"reason":"..."}]}`) + + msg, err := model.Generate(ctx, []*schema.Message{{Role: schema.User, Content: sb.String()}}) + if err != nil { + return nil, err + } + content := msg.Content + if i, j := strings.Index(content, "{"), strings.LastIndex(content, "}"); i >= 0 && j > i { + content = content[i : j+1] + } + var resp struct { + Marks []struct { + CandId int `json:"cand_id"` + LawItem string `json:"law_item"` + Score float64 `json:"score"` + Reason string `json:"reason"` + } `json:"marks"` + } + if err := json.Unmarshal([]byte(content), &resp); err != nil { + return nil, gerror.Wrap(err, "解析标注结果失败: "+msg.Content) + } + if len(resp.Marks) == 0 { + return nil, gerror.New("标注结果为空") + } + marks := make([]*entity.ContractMark, 0, len(resp.Marks)) + for _, m := range resp.Marks { + if m.CandId < 1 || m.CandId > len(cands) { + continue + } + c := cands[m.CandId-1] + lawItem := strings.TrimSpace(m.LawItem) + if lawItem == "" { + lawItem = lawItemRe.FindString(c.Content) + } + marks = append(marks, &entity.ContractMark{ + ChunkId: c.ChunkId, + DatasetId: c.DatasetId, + LawTitle: c.LawTitle, + LawItem: lawItem, + Content: truncateRunes(c.Content, 800), + Reason: m.Reason, + Score: m.Score, + }) + } + sort.Slice(marks, func(i, j int) bool { return marks[i].Score > marks[j].Score }) + return marks, nil +} + +func (s *annotationService) fail(ctx context.Context, task *entity.ContractTask, msg string) { + _ = dao.ContractTask.UpdateStatus(ctx, task.Id, consts.TaskStatusFailed, msg) + g.Log().Errorf(ctx, "annotation task %d failed: %s", task.Id, msg) +} + +// List 任务列表 +func (s *annotationService) List(ctx context.Context, page, pageSize int) ([]*entity.ContractTask, int, error) { + return dao.ContractTask.List(ctx, page, pageSize) +} + +// Delete 删除任务及关联数据与文件 +func (s *annotationService) Delete(ctx context.Context, id int64) error { + task, err := dao.ContractTask.GetOne(ctx, id) + if err != nil { + return err + } + if task == nil { + return gerror.New("任务不存在") + } + if err := dao.ContractMark.DeleteByTask(ctx, id); err != nil { + return err + } + if err := dao.ContractClause.DeleteByTask(ctx, id); err != nil { + return err + } + if err := dao.ContractTask.Delete(ctx, id); err != nil { + return err + } + if task.FilePath != "" { + _ = os.Remove(filepath.Join("workspace", task.FilePath)) + } + return nil +} + +func parseDatasetIds(s string) []int64 { + var out []int64 + for _, part := range strings.Split(s, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + var id int64 + if _, err := fmt.Sscanf(part, "%d", &id); err == nil && id > 0 { + out = append(out, id) + } + } + return out +} + +func truncateRunes(s string, n int) string { + rs := []rune(s) + if len(rs) > n { + return string(rs[:n]) + } + return s +} diff --git a/kb/service/chat_service.go b/kb/service/chat_service.go index aac54ec..b2410b9 100644 --- a/kb/service/chat_service.go +++ b/kb/service/chat_service.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "math" "net/http" "sort" "strconv" @@ -219,14 +220,15 @@ func BuildEmbedder(ctx context.Context, cfgId int64) (*OpenAIEmbedder, error) { return NewOpenAIEmbedder(cfg), nil } -// HybridRetriever 混合检索器:向量 KNN + FTS5 BM25,RRF 融合,实现 eino retriever.Retriever +// HybridRetriever 混合检索器:向量 KNN + FTS5 BM25,RRF 融合后可选 LLM 重排,实现 eino retriever.Retriever type HybridRetriever struct { embedder eembedding.Embedder datasetId int64 + reranker *OpenAIChatModel // LLM 重排器(默认对话模型),为 nil 时跳过重排走 RRF 顺序 } -func NewHybridRetriever(embedder eembedding.Embedder, datasetId int64) *HybridRetriever { - return &HybridRetriever{embedder: embedder, datasetId: datasetId} +func NewHybridRetriever(embedder eembedding.Embedder, datasetId int64, reranker *OpenAIChatModel) *HybridRetriever { + return &HybridRetriever{embedder: embedder, datasetId: datasetId, reranker: reranker} } func (r *HybridRetriever) Retrieve(ctx context.Context, query string, opts ...eretriever.Option) ([]*schema.Document, error) { @@ -266,16 +268,40 @@ func (r *HybridRetriever) Retrieve(ctx context.Context, query string, opts ...er } } - type scoredChunk struct { - id int64 - score float64 - sources []string - } items := make([]scoredChunk, 0, len(scores)) for id, s := range scores { items = append(items, scoredChunk{id: id, score: s, sources: srcs[id]}) } sort.Slice(items, func(i, j int) bool { return items[i].score > items[j].score }) + if len(items) > consts.RerankTopK { + items = items[:consts.RerankTopK] + } + if r.reranker != nil && len(items) > 0 { + before := len(items) + if scores, err := r.rerankByLLM(ctx, query, items); err == nil { + for i := range items { + items[i].score = scores[items[i].id] + } + sort.Slice(items, func(i, j int) bool { return items[i].score > items[j].score }) + // 门槛作用在重排语义分上:最高分条目必留;其余需同时满足相对比例与绝对下限, + // 防止重排器对泛化条款给出宽松低分(如 3 分)也能进引用 + maxScore := items[0].score + if maxScore > 0 { + floor := math.Max(maxScore*consts.RerankKeepRatio, consts.RerankMinScore) + keep := items[:0] + for i, it := range items { + if i == 0 || it.score >= floor { + keep = append(keep, it) + } + } + items = keep + g.Log().Infof(ctx, "rerank done: %d candidates → %d kept (max %.1f, floor %.1f)", + before, len(items), maxScore, floor) + } + } else { + g.Log().Warningf(ctx, "rerank failed, fallback to rrf order: %v", err) + } + } if len(items) > topK { items = items[:topK] } @@ -301,6 +327,61 @@ func (r *HybridRetriever) Retrieve(ctx context.Context, query string, opts ...er return docs, nil } +// scoredChunk RRF 融合后的候选条目(重排后 score 字段替换为语义分) +type scoredChunk struct { + id int64 + score float64 + sources []string +} + +// rerankByLLM 用默认对话模型对候选分块打分(0-10,JSON 输出),返回 chunk_id → 相关性分; +// 任何失败(调用/解析/空结果)返回错误,由调用方回退 RRF 排序 +func (r *HybridRetriever) rerankByLLM(ctx context.Context, query string, items []scoredChunk) (map[int64]float64, error) { + var sb strings.Builder + sb.WriteString("你是检索重排器。请评估每个候选段落与用户问题的相关性,为每个候选输出 0-10 的相关性分数(10=高度相关,0=完全不相关)。\n\n用户问题:\n") + sb.WriteString(query) + sb.WriteString("\n\n候选段落:\n") + for i, it := range items { + chunk, err := dao.Chunk.GetOne(ctx, it.id) + if err != nil || chunk == nil { + continue + } + content := chunk.Content + if rs := []rune(content); len(rs) > consts.RerankMaxChars { + content = string(rs[:consts.RerankMaxChars]) + } + sb.WriteString(fmt.Sprintf("[%d] %s\n", i+1, content)) + } + sb.WriteString("\n只输出 JSON,不要其他内容:{\"scores\":{\"1\":8,\"2\":3}}") + msg, err := r.reranker.Generate(ctx, []*schema.Message{{Role: schema.User, Content: sb.String()}}) + if err != nil { + return nil, err + } + // 模型可能包裹 ```json 代码块,截取首尾花括号之间的 JSON 对象 + content := msg.Content + if i, j := strings.Index(content, "{"), strings.LastIndex(content, "}"); i >= 0 && j > i { + content = content[i : j+1] + } + var resp struct { + Scores map[string]float64 `json:"scores"` + } + if err := json.Unmarshal([]byte(content), &resp); err != nil { + return nil, gerror.Wrap(err, "解析重排结果失败: "+msg.Content) + } + scores := make(map[int64]float64, len(resp.Scores)) + for k, v := range resp.Scores { + idx, err := strconv.ParseInt(k, 10, 64) + if err != nil || idx < 1 || idx > int64(len(items)) { + continue + } + scores[items[idx-1].id] = v + } + if len(scores) == 0 { + return nil, gerror.New("重排结果为空") + } + return scores, nil +} + // ---------- RAG 问答工作流 ---------- var ChatService = &chatService{} @@ -378,7 +459,16 @@ func (s *chatService) retrieve(ctx context.Context, datasetId int64, question st g.Log().Warningf(ctx, "build embedder failed, retrieve fts only: %v", err) } } - return NewHybridRetriever(emb, datasetId).Retrieve(ctx, question) + // LLM 重排器:默认对话模型;构建失败仅跳过重排,不影响检索主流程 + var reranker *OpenAIChatModel + if defaultChatModel, err := dao.ModelConfig.GetDefault(ctx, consts.ModelTypeChat); err == nil && defaultChatModel > 0 { + if m, err := BuildChatModel(ctx, defaultChatModel); err == nil { + reranker = m + } else { + g.Log().Warningf(ctx, "build reranker failed, skip rerank: %v", err) + } + } + return NewHybridRetriever(emb, datasetId, reranker).Retrieve(ctx, question) } // buildCitations 从检索结果生成引用列表(编号从 1 开始,与提示词 [编号] 对应) diff --git a/main.go b/main.go index 90ee0d1..c594554 100644 --- a/main.go +++ b/main.go @@ -33,6 +33,7 @@ func main() { controller.Message, controller.KgEntity, controller.KgRelation, + controller.Contract, }) // ==================== Workspace 文件服务(源文件访问,路径穿越防护) ==================== @@ -76,6 +77,9 @@ func main() { // ==================== 解析任务轮询(文档流水线消费端) ==================== service.ParseTaskService.StartParsePoller(ctx) + // ==================== 合同标注任务轮询 ==================== + service.AnnotationService.StartAnnotationPoller(ctx) + // ==================== 访问令牌(首次启动生成,打印到控制台) ==================== token, err := service.SystemConfigService.EnsureAccessToken(ctx) if err != nil { diff --git a/ui-src/src/api/contract.js b/ui-src/src/api/contract.js new file mode 100644 index 0000000..347c51f --- /dev/null +++ b/ui-src/src/api/contract.js @@ -0,0 +1,17 @@ +import request from './request.js' + +export function uploadContract(formData) { + return request.post('/contract/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } }) +} + +export function listContracts(params) { + return request.get('/contract/list', { params }) +} + +export function getContractDetail(id) { + return request.get('/contract/detail', { params: { id } }) +} + +export function deleteContract(id) { + return request.post('/contract/delete', { id }) +} diff --git a/ui-src/src/router/index.js b/ui-src/src/router/index.js index ccb27da..71cbc49 100644 --- a/ui-src/src/router/index.js +++ b/ui-src/src/router/index.js @@ -36,6 +36,12 @@ const routes = [ meta: { title: '知识图谱' }, component: () => import('../views/KgGraph.vue') }, + { + path: 'contract', + name: 'Contract', + meta: { title: '合同标注' }, + component: () => import('../views/Contract.vue') + }, { path: 'settings', name: 'Settings', diff --git a/ui-src/src/views/Chat.vue b/ui-src/src/views/Chat.vue index 583465d..bb4d8b9 100644 --- a/ui-src/src/views/Chat.vue +++ b/ui-src/src/views/Chat.vue @@ -33,7 +33,7 @@
[{{ c.index }}] {{ s }} - 得分 {{ c.score.toFixed(4) }} + 得分 {{ c.score.toFixed(1) }}
{{ c.content }}
diff --git a/ui-src/src/views/Contract.vue b/ui-src/src/views/Contract.vue new file mode 100644 index 0000000..5215e09 --- /dev/null +++ b/ui-src/src/views/Contract.vue @@ -0,0 +1,327 @@ + + + + + diff --git a/ui-src/src/views/Layout.vue b/ui-src/src/views/Layout.vue index 6cbe068..664d34f 100644 --- a/ui-src/src/views/Layout.vue +++ b/ui-src/src/views/Layout.vue @@ -15,6 +15,10 @@ 知识图谱 + + + 合同标注 + 设置 @@ -36,7 +40,7 @@