Files
rag-local/kb/service/annotation_service.go
T
2026-08-07 16:07:20 +08:00

854 lines
30 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"
"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
}