Files
rag-local/kb/service/annotation_service.go
T
2026-08-20 12:05:53 +08:00

1049 lines
37 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
"html"
"io"
"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"
emodel "github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"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
}
// 推理模型默认输出长思考链,烧光上下文(与 kg_extract 同策略);须在提交池前预置(共享实例可变字段)
chatModel.DisableThinking()
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))
runIds := make([]int64, 0, 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
}
}
runIds = append(runIds, cl.Id)
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)
}
}
// 批量标记进行中(避免逐条款 UPDATE)
if len(runIds) > 0 {
if err := dao.ContractClause.UpdateStatuses(ctx, runIds, consts.TaskStatusRunning, ""); err != nil {
g.Log().Errorf(ctx, "mark clauses running failed: %v", err)
}
}
go func() { wg.Wait(); close(ch) }()
// 进度按本地计数推进(断点续跑时已完成条款计入基数),每完成一条刷一次,避免逐条款读库统计
progressDone := 0
for _, cl := range clauses {
if cl.Status == consts.TaskStatusDone {
progressDone++
}
}
totalClauses := len(clauses)
tickProgress := func() {
progressDone++
if err := dao.ContractTask.UpdateProgress(ctx, task.Id, totalClauses, progressDone); err != nil {
g.Log().Warningf(ctx, "update annotation progress failed: %v", err)
}
}
failed := 0
doneIds := make([]int64, 0, len(runIds))
for out := range ch {
if out.err != nil {
failed++
_ = dao.ContractClause.UpdateStatus(ctx, out.clauseId, consts.TaskStatusFailed, out.err.Error())
// 失败也计入进度(已处理条款数),避免任务收尾时进度停在 <100%
tickProgress()
continue
}
if out.noCands {
// 无候选视为完成(无标注),避免卡住进度
doneIds = append(doneIds, out.clauseId)
tickProgress()
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())
tickProgress()
continue
}
}
doneIds = append(doneIds, out.clauseId)
tickProgress()
}
// 完成状态批量落库(成功与无候选统一刷一次;失败走上面逐条,error_msg 各异)
if len(doneIds) > 0 {
if err := dao.ContractClause.UpdateStatuses(ctx, doneIds, consts.TaskStatusDone, ""); err != nil {
g.Log().Errorf(ctx, "mark clauses done failed: %v", err)
}
}
// 任一条款失败 → 任务置失败(此前无条件置 Done,出现「任务完成 + 进度 75% + 2 条失败」不一致)
status := consts.TaskStatusDone
msg := ""
if failed > 0 {
status = consts.TaskStatusFailed
msg = fmt.Sprintf("%d 条条款标注失败", failed)
}
if err := dao.ContractTask.UpdateStatus(ctx, task.Id, status, msg); err != nil {
g.Log().Errorf(ctx, "mark annotation task done 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]
}
// 候选内容批量加载(单次 IN 查回内存映射,禁止逐条 GetOne 的 N+1);chunk 已删除的候选丢弃
chunkIds := make([]int64, 0, len(cands))
for _, c := range cands {
chunkIds = append(chunkIds, c.ChunkId)
}
chunks, err := dao.Chunk.ListByIds(ctx, chunkIds)
if err != nil {
g.Log().Warningf(ctx, "load candidate chunks failed: %v", err)
chunks = nil
}
contentByChunk := make(map[int64]string, len(chunks))
for _, ch := range chunks {
contentByChunk[ch.Id] = ch.Content
}
kept := cands[:0]
for _, c := range cands {
content, ok := contentByChunk[c.ChunkId]
if !ok {
continue
}
c.ContentFull = content
c.Content = truncateRunes(content, consts.AnnoCandidateMaxChars)
kept = append(kept, c)
}
return kept, 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)
}
// riskItem LLM 判定的单条风险点(JSON 解析中间态)
type riskItem struct {
Level string `json:"level"`
Desc string `json:"desc"`
Laws []struct {
Cand int `json:"cand"`
LawItem string `json:"law_item"`
} `json:"laws"`
}
// parseJudgeRisks 容错解析判定输出:对象 {"risks":[...]} 优先,失败回退裸数组 [...](视为 risks 列表)。
// 剥 Markdown 代码围栏后按首个 {..} / [..] 区间解析;Qwen3.5 实测偶发输出裸 [] 或带杂字(如 `[]"`),
// 不按约定包装对象即解析失败,裸数组一律按「无风险」或数组内容处理。
func parseJudgeRisks(content string) ([]riskItem, error) {
content = strings.TrimSpace(content)
content = strings.TrimPrefix(content, "```json")
content = strings.TrimPrefix(content, "```")
content = strings.TrimSuffix(content, "```")
content = strings.TrimSpace(content)
if i, j := strings.Index(content, "{"), strings.LastIndex(content, "}"); i >= 0 && j > i {
var obj struct {
Risks []riskItem `json:"risks"`
}
if err := json.Unmarshal([]byte(content[i:j+1]), &obj); err == nil {
return obj.Risks, nil
}
}
if i, j := strings.Index(content, "["), strings.LastIndex(content, "]"); i >= 0 && j > i {
var arr []riskItem
if err := json.Unmarshal([]byte(content[i:j+1]), &arr); err == nil {
return arr, nil
}
}
return nil, gerror.New("无法从模型输出中解析 JSON: " + content)
}
// 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")
// 候选按相关度降序,按上下文预算贪心填充(首条强制入队保底);被截断的候选不展示,
// 编号连续映射回 cands,LLM 引用编号受展示条数约束
shown := 0
budget := consts.AnnoJudgePromptBudget
for i, c := range cands {
item := fmt.Sprintf("[%d]《%s》%s\n", i+1, c.LawTitle, truncateRunes(c.Content, consts.AnnoJudgeCandidateChars))
itemLen := len([]rune(item))
if shown > 0 && itemLen > budget {
break
}
sb.WriteString(item)
shown++
budget -= itemLen
}
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()}},
emodel.WithMaxTokens(consts.AnnoJudgeMaxTokens))
if err != nil {
return nil, err
}
items, err := parseJudgeRisks(msg.Content)
if err != nil {
return nil, gerror.Wrap(err, "解析风险判定结果失败: "+msg.Content)
}
// 法条来源溯源(展示增强,失败仅告警不阻断判定):候选 chunk → 文档名,单表两条 SQL + 内存组装
srcByChunk := make(map[int64]string, len(cands))
chunkIds := make([]int64, 0, len(cands))
seenChunk := make(map[int64]bool, len(cands))
for _, c := range cands {
if c.ChunkId > 0 && !seenChunk[c.ChunkId] {
seenChunk[c.ChunkId] = true
chunkIds = append(chunkIds, c.ChunkId)
}
}
if len(chunkIds) > 0 {
docByChunk, err := dao.Chunk.ListDocumentIdsByChunkIds(ctx, chunkIds)
if err != nil {
g.Log().Warningf(ctx, "resolve chunk document failed: %v", err)
} else {
docIds := make([]int64, 0, len(docByChunk))
for _, docId := range docByChunk {
docIds = append(docIds, docId)
}
if len(docIds) > 0 {
docs, err := dao.Document.ListByIds(ctx, docIds)
if err != nil {
g.Log().Warningf(ctx, "resolve document name failed: %v", err)
} else {
nameByDoc := make(map[int64]string, len(docs))
for _, doc := range docs {
nameByDoc[doc.Id] = doc.Filename
}
for chunkId, docId := range docByChunk {
if f := nameByDoc[docId]; f != "" {
srcByChunk[chunkId] = f
}
}
}
}
}
}
risks := make([]*entity.ContractRisk, 0, len(items))
for _, r := range items {
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 > shown {
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,
SourceFile: srcByChunk[c.ChunkId],
})
}
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() {
lawSrc := ""
if law.SourceFile != "" {
lawSrc = "(来源:" + html.EscapeString(law.SourceFile) + ""
}
sb.WriteString(`<div class="risk-law"><span class="law-title">《` + html.EscapeString(law.LawTitle) + `》` +
html.EscapeString(law.LawItem) + lawSrc + `</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) (*entity.ContractTask, *domain.RiskSummary, error) {
task, err := dao.ContractTask.GetOne(ctx, taskId)
if err != nil {
return nil, nil, err
}
if task == nil {
return nil, nil, gerror.New("任务不存在")
}
clauses, err := dao.ContractClause.ListByTask(ctx, taskId)
if err != nil {
return nil, nil, err
}
risks, err := dao.ContractRisk.ListByTask(ctx, taskId)
if err != nil {
return nil, 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 task, 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)
}
// Upload 保存合同文件到 workspace/contract/{yyyymmdd}/{token}.ext 并创建标注任务
func (s *annotationService) Upload(ctx context.Context, file *ghttp.UploadFile, datasetIdsJSON string, caseId int64) (int64, error) {
var dsIds []int64
if datasetIdsJSON != "" {
if err := json.Unmarshal([]byte(datasetIdsJSON), &dsIds); err != nil {
return 0, gerror.New("dataset_ids 格式错误,应为 JSON 数组")
}
}
// 未指定数据集时自动使用所有已有数据集
if len(dsIds) == 0 {
allDs, _ := dao.Dataset.List(ctx)
for _, d := range allDs {
dsIds = append(dsIds, d.Id)
}
}
if len(dsIds) == 0 {
return 0, gerror.New("系统中暂无数据集,请先在数据集模块创建")
}
f, err := file.Open()
if err != nil {
return 0, err
}
defer func() { _ = f.Close() }()
data, err := io.ReadAll(f)
if err != nil {
return 0, err
}
if len(data) == 0 {
return 0, gerror.New("文件内容为空")
}
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Filename)), ".")
if !isSupportedExt(ext) {
return 0, 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 0, err
}
if err := os.WriteFile(absPath, data, 0o644); err != nil {
return 0, err
}
ids := make([]string, 0, len(dsIds))
for _, id := range dsIds {
ids = append(ids, strconv.FormatInt(id, 10))
}
id, err := dao.ContractTask.Insert(ctx, file.Filename, relPath, strings.Join(ids, ","), caseId)
if err != nil {
_ = os.Remove(absPath)
return 0, err
}
return id, nil
}
// Detail 任务详情:任务 + 条款 + 每条款的标注与风险
func (s *annotationService) Detail(ctx context.Context, taskId int64) (*entity.ContractTask, []*entity.ContractClause, map[int64][]*entity.ContractMark, map[int64][]*entity.ContractRisk, error) {
task, err := dao.ContractTask.GetOne(ctx, taskId)
if err != nil {
return nil, nil, nil, nil, err
}
if task == nil {
return nil, nil, nil, nil, gerror.New("任务不存在")
}
clauses, err := dao.ContractClause.ListByTask(ctx, taskId)
if err != nil {
return nil, nil, nil, nil, err
}
// 一次取回全部标注/风险后按条款分组,避免逐条款查询(N+1)
marks, err := dao.ContractMark.ListByTask(ctx, taskId)
if err != nil {
return nil, nil, nil, nil, err
}
risks, err := dao.ContractRisk.ListByTask(ctx, taskId)
if err != nil {
return nil, nil, nil, nil, err
}
markMap := make(map[int64][]*entity.ContractMark)
for _, m := range marks {
markMap[m.ClauseId] = append(markMap[m.ClauseId], m)
}
riskMap := make(map[int64][]*entity.ContractRisk)
for _, r := range risks {
riskMap[r.ClauseId] = append(riskMap[r.ClauseId], r)
}
return task, clauses, markMap, riskMap, nil
}
// 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("任务不存在")
}
// 关联数据删除在 dao 层事务内完成
if err := dao.ContractTask.DeleteWithRelated(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
}