Files
rag-local/kb/service/annotation_service.go
T
2026-08-06 10:05:40 +08:00

437 lines
13 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 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请为每条候选输出 score0-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
}