854 lines
30 KiB
Go
854 lines
30 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"html"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"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"
|
||
"github.com/gogf/gf/v2/os/gtimer"
|
||
)
|
||
|
||
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]+条`)
|
||
|
||
// trailingChapterRe 匹配文本末尾独立成行的章节标记(条文提取时裁掉;语料用 \xa0 分隔章号与标题)
|
||
var trailingChapterRe = regexp.MustCompile(`(?m)\n第[零一二三四五六七八九十百千]+[章回篇部节][ \t \xa0]*[^\n]*$`)
|
||
|
||
// annoCandidate 多数据集融合后的候选法条
|
||
type annoCandidate struct {
|
||
ChunkId int64
|
||
DatasetId int64
|
||
LawTitle string // 法律名 = dataset 名
|
||
Content string // chunk 内容(喂给 LLM,截断到 AnnoCandidateMaxChars)
|
||
ContentFull string // chunk 完整内容(抽取具体条文文本用)
|
||
RrfScore float64
|
||
}
|
||
|
||
// StartAnnotationPoller 启动标注任务轮询:gtimer 单例定时器串行消费(job 未结束不重入)
|
||
func (s *annotationService) StartAnnotationPoller(ctx context.Context) {
|
||
g.Log().Info(ctx, "annotation task poller started")
|
||
gtimer.AddSingleton(ctx, consts.ParsePollIntervalSeconds*time.Second, func(ctx context.Context) {
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 并行标注:召回+判定(纯读与 LLM 调用)提交 common.AnnotationClausePool,
|
||
// 落库(SQLite 写)收敛回主 goroutine 串行,避免无 WAL 下的 database is locked。
|
||
type clauseJobOut struct {
|
||
clauseId int64
|
||
risks []*entity.ContractRisk
|
||
noCands bool
|
||
err error
|
||
}
|
||
ch := make(chan clauseJobOut, len(clauses))
|
||
var wg sync.WaitGroup
|
||
for _, cl := range clauses {
|
||
// 断点续跑:已完成且已有风险记录 → 跳过;已完成但无风险记录 → 仅当存在旧格式法条标注时重跑迁移
|
||
if cl.Status == consts.TaskStatusDone {
|
||
existing, err := dao.ContractRisk.ListByClause(ctx, cl.Id)
|
||
if err == nil && len(existing) > 0 {
|
||
continue
|
||
}
|
||
marks, err := dao.ContractMark.ListByClause(ctx, cl.Id)
|
||
if err == nil && len(marks) == 0 {
|
||
continue
|
||
}
|
||
}
|
||
if err := dao.ContractClause.UpdateStatus(ctx, cl.Id, consts.TaskStatusRunning, ""); err != nil {
|
||
g.Log().Errorf(ctx, "mark clause running failed: %v", err)
|
||
continue
|
||
}
|
||
wg.Add(1)
|
||
if err := common.AnnotationClausePool.AddWithRecover(ctx, func(ctx context.Context) {
|
||
defer wg.Done()
|
||
out := clauseJobOut{clauseId: cl.Id}
|
||
cands, err := s.recallCandidates(ctx, cl, dsIds, dsNames, embedders)
|
||
if err != nil {
|
||
out.err = err
|
||
ch <- out
|
||
return
|
||
}
|
||
if len(cands) == 0 {
|
||
out.noCands = true
|
||
ch <- out
|
||
return
|
||
}
|
||
out.risks, out.err = s.judgeRisks(ctx, chatModel, cl, cands)
|
||
ch <- out
|
||
}, func(ctx context.Context, e error) {
|
||
defer wg.Done()
|
||
ch <- clauseJobOut{clauseId: cl.Id, err: e}
|
||
}); err != nil {
|
||
wg.Done()
|
||
g.Log().Errorf(ctx, "submit clause %d failed: %v", cl.Id, err)
|
||
}
|
||
}
|
||
go func() { wg.Wait(); close(ch) }()
|
||
|
||
failed := 0
|
||
for out := range ch {
|
||
if out.err != nil {
|
||
failed++
|
||
_ = dao.ContractClause.UpdateStatus(ctx, out.clauseId, consts.TaskStatusFailed, out.err.Error())
|
||
continue
|
||
}
|
||
if out.noCands {
|
||
// 无候选视为完成(无标注),避免卡住进度
|
||
_ = dao.ContractClause.UpdateStatus(ctx, out.clauseId, consts.TaskStatusDone, "")
|
||
s.updateProgress(ctx, task.Id)
|
||
continue
|
||
}
|
||
// 幂等:重跑前清旧风险与旧格式法条标注,避免重复记录
|
||
if err := dao.ContractRisk.DeleteByClause(ctx, out.clauseId); err != nil {
|
||
g.Log().Warningf(ctx, "clear old risks failed: %v", err)
|
||
}
|
||
if err := dao.ContractMark.DeleteByClause(ctx, out.clauseId); err != nil {
|
||
g.Log().Warningf(ctx, "clear old marks failed: %v", err)
|
||
}
|
||
for _, r := range out.risks {
|
||
r.TaskId = task.Id
|
||
r.ClauseId = out.clauseId
|
||
}
|
||
if len(out.risks) > 0 {
|
||
if err := dao.ContractRisk.InsertAll(ctx, out.risks); err != nil {
|
||
failed++
|
||
_ = dao.ContractClause.UpdateStatus(ctx, out.clauseId, consts.TaskStatusFailed, err.Error())
|
||
continue
|
||
}
|
||
}
|
||
_ = dao.ContractClause.UpdateStatus(ctx, out.clauseId, 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)
|
||
}
|
||
}
|
||
|
||
// annoRecallHit 单数据集召回结果(排名用于 RRF 融合)
|
||
type annoRecallHit struct {
|
||
ChunkId int64
|
||
DsId int64
|
||
LawTitle string
|
||
Rank float64
|
||
}
|
||
|
||
// recallCandidates 多数据集召回:每数据集向量+FTS 各取 AnnoRecallTopK,全局 RRF 融合截断。
|
||
// 各数据集召回(向量化+vec+fts,纯读)提交 common.AnnotationDatasetPool 并行,融合回主 goroutine 串行。
|
||
func (s *annotationService) recallCandidates(ctx context.Context, clause *entity.ContractClause, dsIds []int64,
|
||
dsNames map[int64]string, embedders map[int64]*OpenAIEmbedder) ([]annoCandidate, error) {
|
||
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)
|
||
|
||
ch := make(chan []annoRecallHit, len(dsIds))
|
||
var wg sync.WaitGroup
|
||
for _, dsId := range dsIds {
|
||
wg.Add(1)
|
||
if err := common.AnnotationDatasetPool.AddWithRecover(ctx, func(ctx context.Context) {
|
||
defer wg.Done()
|
||
ch <- s.recallOneDataset(ctx, dsId, dsNames[dsId], clauseText, ftsQuery, embedders[dsId])
|
||
}, func(ctx context.Context, e error) {
|
||
defer wg.Done()
|
||
ch <- nil
|
||
g.Log().Warningf(ctx, "dataset %d recall failed: %v", dsId, e)
|
||
}); err != nil {
|
||
wg.Done()
|
||
g.Log().Warningf(ctx, "submit dataset %d recall failed: %v", dsId, err)
|
||
}
|
||
}
|
||
go func() { wg.Wait(); close(ch) }()
|
||
|
||
merged := make(map[int64]*annoCandidate)
|
||
for hits := range ch {
|
||
for _, h := range hits {
|
||
s.mergeHit(merged, h.ChunkId, h.DsId, h.LawTitle, h.Rank)
|
||
}
|
||
}
|
||
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].ContentFull = chunk.Content
|
||
cands[i].Content = truncateRunes(chunk.Content, consts.AnnoCandidateMaxChars)
|
||
}
|
||
}
|
||
return cands, nil
|
||
}
|
||
|
||
// recallOneDataset 单数据集召回:向量检索 + FTS 检索(纯读,供池内并发调用)
|
||
func (s *annotationService) recallOneDataset(ctx context.Context, dsId int64, lawTitle, clauseText, ftsQuery string, emb *OpenAIEmbedder) []annoRecallHit {
|
||
var hits []annoRecallHit
|
||
if 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 {
|
||
res, 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 res {
|
||
hits = append(hits, annoRecallHit{ChunkId: h.ChunkId, DsId: dsId, LawTitle: lawTitle, Rank: float64(i)})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
res, err := dao.Chunk.FtsSearch(ctx, dsId, ftsQuery, consts.AnnoRecallTopK)
|
||
if err != nil {
|
||
g.Log().Warningf(ctx, "fts search failed (dataset %d): %v", dsId, err)
|
||
return hits
|
||
}
|
||
for i, h := range res {
|
||
hits = append(hits, annoRecallHit{ChunkId: h.ChunkId, DsId: dsId, LawTitle: lawTitle, Rank: float64(i)})
|
||
}
|
||
return hits
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
// judgeRisks LLM 风险判定:一次非流式调用识别该条款的风险点(等级+描述+支撑法条),无风险返回空数组
|
||
func (s *annotationService) judgeRisks(ctx context.Context, model *OpenAIChatModel, clause *entity.ContractClause, cands []annoCandidate) ([]*entity.ContractRisk, 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(fmt.Sprintf("\n请输出该条款的风险点(0~%d 个,没有风险输出空数组)。每条风险点:\n", consts.RiskMaxPerClause))
|
||
sb.WriteString("- level:风险等级,high=违反强制性规定/可能导致合同无效或赔偿,mid=约定与法律不符但可补救,low=表述瑕疵或建议性提示\n")
|
||
sb.WriteString("- desc:风险描述,说明该条款与法律的冲突点或缺失点,直接引用法律名称和条文号(如「《劳动法》第二十五条」),不要出现「候选编号」「候选条文N」等字样\n")
|
||
sb.WriteString("- laws:直接支撑该风险的法律条文数组(最多 2 项),每项 cand 为候选编号,law_item 为该候选内容中作为依据的具体条文号(如「第九十二条」,必须是候选内容里真实出现的条文号,候选内容可能包含多条条文)。只有与风险点直接相关、能支撑判定的条文才可引用;候选里找不到直接相关的条文时输出空数组,不要为了凑数引用不相关条文\n")
|
||
sb.WriteString("只输出 JSON,不要其他内容:")
|
||
sb.WriteString(`{"risks":[{"level":"high|mid|low","desc":"...","laws":[{"cand":1,"law_item":"第九十二条"}]}]}`)
|
||
|
||
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 {
|
||
Risks []struct {
|
||
Level string `json:"level"`
|
||
Desc string `json:"desc"`
|
||
Laws []struct {
|
||
Cand int `json:"cand"`
|
||
LawItem string `json:"law_item"`
|
||
} `json:"laws"`
|
||
} `json:"risks"`
|
||
}
|
||
if err := json.Unmarshal([]byte(content), &resp); err != nil {
|
||
return nil, gerror.Wrap(err, "解析风险判定结果失败: "+msg.Content)
|
||
}
|
||
risks := make([]*entity.ContractRisk, 0, len(resp.Risks))
|
||
for _, r := range resp.Risks {
|
||
desc := strings.TrimSpace(r.Desc)
|
||
if desc == "" {
|
||
continue
|
||
}
|
||
level := strings.TrimSpace(r.Level)
|
||
switch level {
|
||
case consts.RiskLevelHigh, consts.RiskLevelMid, consts.RiskLevelLow:
|
||
default:
|
||
level = consts.RiskLevelMid
|
||
}
|
||
refs := make([]entity.LawRef, 0, len(r.Laws))
|
||
for _, lr := range r.Laws {
|
||
if lr.Cand < 1 || lr.Cand > len(cands) {
|
||
continue
|
||
}
|
||
c := cands[lr.Cand-1]
|
||
lawItem, lawContent := extractLawItem(c.ContentFull, strings.TrimSpace(lr.LawItem))
|
||
if lawContent == "" {
|
||
lawContent = c.Content
|
||
}
|
||
refs = append(refs, entity.LawRef{
|
||
LawTitle: c.LawTitle,
|
||
LawItem: lawItem,
|
||
Content: lawContent,
|
||
})
|
||
}
|
||
lawsJson, _ := json.Marshal(refs)
|
||
risks = append(risks, &entity.ContractRisk{
|
||
Level: level,
|
||
Desc: desc,
|
||
Laws: string(lawsJson),
|
||
})
|
||
}
|
||
return risks, nil
|
||
}
|
||
|
||
// extractLawItem 从 chunk 完整内容中定位指定条文(如「第九十二条」),抽取该条文完整文本(到下一条文前)。
|
||
// 语料中条文头后跟空格(「第二十八条 用人…」),条文正文里引用的其他条文号无空格(「第二十四条、…」),据此过滤出真正的条文头。
|
||
// 找不到指定条文时回退到第一条文;内容无条文头时返回空。
|
||
func extractLawItem(content, lawItem string) (item, text string) {
|
||
full := strings.TrimSpace(content)
|
||
if full == "" {
|
||
return "", ""
|
||
}
|
||
idxs := lawItemRe.FindAllStringIndex(full, -1)
|
||
var headers [][]int
|
||
for _, m := range idxs {
|
||
if m[1] >= len(full) || strings.ContainsRune(" \n ", rune(full[m[1]])) {
|
||
headers = append(headers, m)
|
||
}
|
||
}
|
||
if len(headers) == 0 {
|
||
return "", ""
|
||
}
|
||
// 匹配指定条文号:优先原文精确匹配,其次尝试阿拉伯数字形式(第92条)
|
||
idx := -1
|
||
for i, m := range headers {
|
||
mark := full[m[0]:m[1]]
|
||
if mark == lawItem {
|
||
idx = i
|
||
break
|
||
}
|
||
if idx < 0 && mark == normalizeLawItem(lawItem) {
|
||
idx = i
|
||
}
|
||
}
|
||
if idx < 0 {
|
||
idx = 0
|
||
}
|
||
start := headers[idx][1] // 正文从条文号之后开始,避免展示时「第二十一条」重复
|
||
end := len(full)
|
||
if idx+1 < len(headers) {
|
||
end = headers[idx+1][0]
|
||
}
|
||
text = strings.TrimSpace(full[start:end])
|
||
// 条文恰好位于 chunk 末尾时,可能粘连下一章的标题(如「第六章 劳动安全卫生」),裁掉尾部章节标记
|
||
text = trailingChapterRe.ReplaceAllString(text, "")
|
||
if len([]rune(text)) > 2000 {
|
||
text = truncateRunes(text, 2000)
|
||
}
|
||
return full[headers[idx][0]:headers[idx][1]], text
|
||
}
|
||
|
||
// normalizeLawItem 把「第92条」这类阿拉伯数字形式转为「第九十二条」汉字形式
|
||
func normalizeLawItem(s string) string {
|
||
nums := regexp.MustCompile(`第([0-9]+)条`).FindStringSubmatch(s)
|
||
if len(nums) != 2 {
|
||
return s
|
||
}
|
||
n, err := strconv.Atoi(nums[1])
|
||
if err != nil || n < 1 || n > 9999 {
|
||
return s
|
||
}
|
||
var sb strings.Builder
|
||
sb.WriteString("第")
|
||
sb.WriteString(numToHan(n))
|
||
sb.WriteString("条")
|
||
return sb.String()
|
||
}
|
||
|
||
var digitHan = []string{"零", "一", "二", "三", "四", "五", "六", "七", "八", "九"}
|
||
|
||
// numToHan 阿拉伯数字转汉字数字(1~9999,如 92→九十二、108→一百零八、1000→一千)
|
||
func numToHan(n int) string {
|
||
if n < 1 || n > 9999 {
|
||
return ""
|
||
}
|
||
units := []string{"", "十", "百", "千"}
|
||
digits := make([]int, 0, 4)
|
||
for t := n; t > 0; t /= 10 {
|
||
digits = append(digits, t%10)
|
||
}
|
||
var sb strings.Builder
|
||
pendingZero := false
|
||
for i := len(digits) - 1; i >= 0; i-- {
|
||
d := digits[i]
|
||
if d == 0 {
|
||
pendingZero = true
|
||
continue
|
||
}
|
||
if pendingZero && sb.Len() > 0 {
|
||
sb.WriteString("零")
|
||
}
|
||
pendingZero = false
|
||
// 十位为一(10~19)时省略「一十」中的「一」
|
||
if d == 1 && i == 1 && sb.Len() == 0 {
|
||
sb.WriteString(units[i])
|
||
} else {
|
||
sb.WriteString(digitHan[d])
|
||
sb.WriteString(units[i])
|
||
}
|
||
}
|
||
return sb.String()
|
||
}
|
||
|
||
// AnnotatedHTML 生成风险审查版合同 HTML:顶部风险汇总 + 逐条款风险点(含支撑法条),可打印/另存 PDF
|
||
func (s *annotationService) AnnotatedHTML(ctx context.Context, taskId int64) (string, error) {
|
||
task, err := dao.ContractTask.GetOne(ctx, taskId)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if task == nil {
|
||
return "", gerror.New("任务不存在")
|
||
}
|
||
clauses, err := dao.ContractClause.ListByTask(ctx, taskId)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
statusText := map[int]string{0: "待处理", 1: "标注中", 2: "完成", 3: "失败"}[task.Status]
|
||
byClause := make(map[int64][]*entity.ContractRisk)
|
||
for _, cl := range clauses {
|
||
risks, err := dao.ContractRisk.ListByClause(ctx, cl.Id)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
byClause[cl.Id] = risks
|
||
}
|
||
levelCounts := map[string]int{consts.RiskLevelHigh: 0, consts.RiskLevelMid: 0, consts.RiskLevelLow: 0}
|
||
noRisk := 0
|
||
for _, risks := range byClause {
|
||
if len(risks) == 0 {
|
||
noRisk++
|
||
continue
|
||
}
|
||
for _, r := range risks {
|
||
levelCounts[r.Level]++
|
||
}
|
||
}
|
||
levelColor := map[string]string{consts.RiskLevelHigh: "#f56c6c", consts.RiskLevelMid: "#e6a23c", consts.RiskLevelLow: "#409eff"}
|
||
levelText := map[string]string{consts.RiskLevelHigh: "高风险", consts.RiskLevelMid: "中风险", consts.RiskLevelLow: "低风险"}
|
||
order := []string{consts.RiskLevelHigh, consts.RiskLevelMid, consts.RiskLevelLow}
|
||
|
||
var sb strings.Builder
|
||
sb.WriteString(`<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8"><title>风险审查-` +
|
||
html.EscapeString(task.Filename) + `</title><style>
|
||
body{font-family:"PingFang SC","Microsoft YaHei",sans-serif;max-width:820px;margin:24px auto;padding:0 16px;color:#333;line-height:1.8}
|
||
h1{font-size:20px;text-align:center;margin-bottom:4px}
|
||
.meta{text-align:center;color:#999;font-size:12px;margin-bottom:24px}
|
||
.summary{border:1px solid #e4e7ed;border-radius:8px;padding:12px 16px;margin-bottom:20px;background:#fafafa}
|
||
.summary-title{font-weight:600;font-size:15px;color:#303133;margin-bottom:8px}
|
||
.summary-stats{font-size:13px;color:#606266;margin-bottom:10px}
|
||
.summary-group{margin-bottom:8px}
|
||
.summary-group-title{font-weight:600;font-size:13px;margin-bottom:4px}
|
||
.summary-item{font-size:12px;color:#606266;padding:2px 0 2px 12px;border-left:3px solid #dcdfe6;margin-bottom:4px}
|
||
.clause{border:1px solid #e4e7ed;border-radius:8px;padding:12px 16px;margin-bottom:14px}
|
||
.clause-title{font-weight:600;font-size:15px;color:#303133}
|
||
.clause-content{font-size:13px;color:#555;white-space:pre-wrap;margin:8px 0}
|
||
.risk{border-left:3px solid #909399;background:#f4f4f5;padding:6px 10px;margin-bottom:6px;border-radius:0 4px 4px 0;font-size:12px;color:#606266}
|
||
.risk-level{display:inline-block;font-size:11px;color:#fff;border-radius:3px;padding:1px 6px;margin-right:6px}
|
||
.risk-desc{color:#303133;margin-top:3px}
|
||
.risk-law{color:#409eff;margin-top:3px}
|
||
.risk-law .law-title{font-weight:600}
|
||
.risk-none{color:#999;font-size:12px}
|
||
.toolbar{position:fixed;top:12px;right:16px;z-index:10}
|
||
.toolbar button{padding:6px 14px;border:1px solid #409eff;background:#409eff;color:#fff;border-radius:6px;cursor:pointer;font-size:13px}
|
||
@media print{.toolbar{display:none}}
|
||
</style></head><body>
|
||
<div class="toolbar"><button onclick="window.print()">打印 / 另存为 PDF</button></div>
|
||
<h1>合同法律风险审查报告</h1>
|
||
<div class="meta">文件名:` + html.EscapeString(task.Filename) + ` 导出时间:` + time.Now().Format("2006-01-02 15:04") +
|
||
` 任务状态:` + statusText + `</div>`)
|
||
// 顶部风险汇总
|
||
sb.WriteString(`<div class="summary"><div class="summary-title">风险汇总</div><div class="summary-stats">高风险 ` +
|
||
strconv.Itoa(levelCounts[consts.RiskLevelHigh]) + ` 项 · 中风险 ` + strconv.Itoa(levelCounts[consts.RiskLevelMid]) +
|
||
` 项 · 低风险 ` + strconv.Itoa(levelCounts[consts.RiskLevelLow]) + ` 项 · 无风险条款 ` + strconv.Itoa(noRisk) + ` 条</div>`)
|
||
for _, lv := range order {
|
||
var group []string
|
||
for _, cl := range clauses {
|
||
for _, r := range byClause[cl.Id] {
|
||
if r.Level != lv {
|
||
continue
|
||
}
|
||
group = append(group, `<div class="summary-item" style="border-left-color:`+levelColor[lv]+`"><b>`+
|
||
html.EscapeString(cl.Title)+`</b> `+html.EscapeString(r.Desc)+`</div>`)
|
||
}
|
||
}
|
||
if len(group) > 0 {
|
||
sb.WriteString(`<div class="summary-group"><div class="summary-group-title" style="color:` + levelColor[lv] + `">` +
|
||
levelText[lv] + `(` + strconv.Itoa(len(group)) + `)</div>` + strings.Join(group, "") + `</div>`)
|
||
}
|
||
}
|
||
sb.WriteString(`</div>`)
|
||
// 逐条款风险
|
||
for _, cl := range clauses {
|
||
sb.WriteString(`<div class="clause"><div class="clause-title">` + html.EscapeString(cl.Title) + `</div>` +
|
||
`<div class="clause-content">` + html.EscapeString(cl.Content) + `</div>`)
|
||
risks := byClause[cl.Id]
|
||
if len(risks) == 0 {
|
||
sb.WriteString(`<div class="risk-none">未发现明显风险</div>`)
|
||
}
|
||
for _, r := range risks {
|
||
sb.WriteString(`<div class="risk" style="border-left-color:` + levelColor[r.Level] + `">` +
|
||
`<span class="risk-level" style="background:` + levelColor[r.Level] + `">` + levelText[r.Level] + `</span>` +
|
||
`<div class="risk-desc">` + html.EscapeString(r.Desc) + `</div>`)
|
||
for _, law := range r.LawsRefs() {
|
||
sb.WriteString(`<div class="risk-law"><span class="law-title">《` + html.EscapeString(law.LawTitle) + `》` +
|
||
html.EscapeString(law.LawItem) + `</span> ` + html.EscapeString(truncateRunes(law.Content, 200)) + `</div>`)
|
||
}
|
||
sb.WriteString(`</div>`)
|
||
}
|
||
sb.WriteString(`</div>`)
|
||
}
|
||
sb.WriteString(`</body></html>`)
|
||
return sb.String(), nil
|
||
}
|
||
|
||
// Summary 生成整份合同的风险汇总报告:按等级分组统计(规则聚合)+ LLM 整体评述
|
||
func (s *annotationService) Summary(ctx context.Context, taskId int64) (*domain.RiskSummary, error) {
|
||
task, err := dao.ContractTask.GetOne(ctx, taskId)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if task == nil {
|
||
return nil, gerror.New("任务不存在")
|
||
}
|
||
clauses, err := dao.ContractClause.ListByTask(ctx, taskId)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
risks, err := dao.ContractRisk.ListByTask(ctx, taskId)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
clauseTitles := make(map[int64]entity.ContractClause, len(clauses))
|
||
for _, cl := range clauses {
|
||
clauseTitles[cl.Id] = *cl
|
||
}
|
||
groups := map[string][]domain.RiskGroupItem{
|
||
consts.RiskLevelHigh: {}, consts.RiskLevelMid: {}, consts.RiskLevelLow: {},
|
||
}
|
||
byClause := make(map[int64][]*entity.ContractRisk)
|
||
for _, r := range risks {
|
||
byClause[r.ClauseId] = append(byClause[r.ClauseId], r)
|
||
}
|
||
sum := &domain.RiskSummary{
|
||
Overview: "",
|
||
Total: len(risks),
|
||
NoRiskClauses: 0,
|
||
Groups: groups,
|
||
}
|
||
// 按等级分组:每个风险点一条分组项(带条款标题上下文)
|
||
for clauseId, list := range byClause {
|
||
cl := clauseTitles[clauseId]
|
||
for _, r := range list {
|
||
switch r.Level {
|
||
case consts.RiskLevelHigh:
|
||
sum.HighCount++
|
||
case consts.RiskLevelLow:
|
||
sum.LowCount++
|
||
default:
|
||
sum.MidCount++
|
||
}
|
||
groups[r.Level] = append(groups[r.Level], domain.RiskGroupItem{
|
||
ClauseId: clauseId,
|
||
ClauseTitle: cl.Title,
|
||
ClauseSeq: cl.Seq,
|
||
Risks: []*entity.ContractRisk{r},
|
||
})
|
||
}
|
||
}
|
||
if sum.HighCount == 0 && sum.MidCount == 0 && sum.LowCount == 0 {
|
||
sum.NoRiskClauses = len(clauses)
|
||
} else {
|
||
sum.NoRiskClauses = len(clauses) - len(byClause)
|
||
}
|
||
if err := s.fillOverview(ctx, taskId, sum); err != nil {
|
||
g.Log().Warningf(ctx, "risk summary overview failed (task %d): %v", taskId, err)
|
||
sum.Overview = ""
|
||
}
|
||
return sum, nil
|
||
}
|
||
|
||
// fillOverview 用 LLM 生成整体风险评述(≤3 段:概况 / 重点高风险 / 建议),失败不阻断汇总
|
||
func (s *annotationService) fillOverview(ctx context.Context, taskId int64, sum *domain.RiskSummary) error {
|
||
if sum.Total == 0 {
|
||
sum.Overview = "未发现明显法律风险。"
|
||
return nil
|
||
}
|
||
chatCfgId, err := dao.ModelConfig.GetDefault(ctx, consts.ModelTypeChat)
|
||
if err != nil || chatCfgId <= 0 {
|
||
return nil
|
||
}
|
||
chatModel, err := BuildChatModel(ctx, chatCfgId)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
var sb strings.Builder
|
||
sb.WriteString("你是资深劳动法律顾问。以下是某份合同的逐条款法律风险点清单(条款标题:风险等级 - 风险描述 - 依据法条)。请输出整体风险评述,不超过 3 段:第一段整体风险概况(风险数量、最集中的领域),第二段最需要关注的高风险点(列出条款与核心问题),第三段处理建议(按优先序)。不要使用 Markdown 标题,直接分段输出。\n\n")
|
||
order := []string{consts.RiskLevelHigh, consts.RiskLevelMid, consts.RiskLevelLow}
|
||
levelText := map[string]string{consts.RiskLevelHigh: "高", consts.RiskLevelMid: "中", consts.RiskLevelLow: "低"}
|
||
for _, lv := range order {
|
||
for _, item := range sum.Groups[lv] {
|
||
for _, r := range item.Risks {
|
||
sb.WriteString(fmt.Sprintf("【%s】%s:%s。依据:", item.ClauseTitle, levelText[r.Level], r.Desc))
|
||
for _, law := range r.LawsRefs() {
|
||
sb.WriteString(fmt.Sprintf("《%s》%s;", law.LawTitle, law.LawItem))
|
||
}
|
||
sb.WriteString("\n")
|
||
}
|
||
}
|
||
}
|
||
msg, err := chatModel.Generate(ctx, []*schema.Message{{Role: schema.User, Content: sb.String()}})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
sum.Overview = strings.TrimSpace(msg.Content)
|
||
return 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.ContractRisk.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
|
||
}
|