diff --git a/data/business.db b/data/business.db index 930dc2e..460faca 100644 Binary files a/data/business.db and b/data/business.db differ diff --git a/data/system.db b/data/system.db index e704f2b..d914927 100644 Binary files a/data/system.db and b/data/system.db differ diff --git a/kb/consts/consts.go b/kb/consts/consts.go index e188c9d..35f4d61 100644 --- a/kb/consts/consts.go +++ b/kb/consts/consts.go @@ -40,6 +40,13 @@ const ( AnnoMaxCandidates = 60 // 多数据集融合后的候选上限(喂给 LLM 判定) AnnoMaxClauseChars = 2000 // 合同条款全文上限(超长截断,控制 prompt) + // 合同风险识别 + RiskLevelHigh = "high" // 高风险(违反强制性规定、可能导致合同无效/赔偿) + RiskLevelMid = "mid" // 中风险(约定与法律不符但可补救) + RiskLevelLow = "low" // 低风险(表述瑕疵/建议性提示) + RiskMaxPerClause = 3 // 单条款风险点上限(控制 prompt 与展示) + AnnoCandidateMaxChars = 2000 // 风险判定候选条文内容上限(需覆盖整 chunk,chunk 由多条短条文合并而成,截断会切掉块尾条文) + // 协程池默认大小(config.yml pool 段缺失时兜底) KgExtractPoolSize = 4 // 知识图谱:逐 chunk LLM 抽取 AnnotationClausePoolSize = 4 // 合同标注:逐条款 (recall+judge) diff --git a/kb/consts/table_name.go b/kb/consts/table_name.go index 00ad1e1..4e74179 100644 --- a/kb/consts/table_name.go +++ b/kb/consts/table_name.go @@ -16,6 +16,7 @@ const ( TableNameContractTask = "kb_contract_task" TableNameContractClause = "kb_contract_clause" TableNameContractMark = "kb_contract_mark" + TableNameContractRisk = "kb_contract_risk" ) // 数据库组:默认组(default)=business.db、system=system.db、chat=chat.db diff --git a/kb/controller/contract_controller.go b/kb/controller/contract_controller.go index 3596c0f..2d33d6a 100644 --- a/kb/controller/contract_controller.go +++ b/kb/controller/contract_controller.go @@ -107,14 +107,35 @@ func (c *contract) Detail(ctx context.Context, req *dto.GetContractDetailReq) (* return nil, err } marks := make(map[int64][]*entity.ContractMark) + risks := make(map[int64][]*entity.ContractRisk) for _, cl := range clauses { list, err := dao.ContractMark.ListByClause(ctx, cl.Id) if err != nil { return nil, err } marks[cl.Id] = list + riskList, err := dao.ContractRisk.ListByClause(ctx, cl.Id) + if err != nil { + return nil, err + } + risks[cl.Id] = riskList } - return &dto.GetContractDetailRes{Task: task, Clauses: clauses, Marks: marks}, nil + return &dto.GetContractDetailRes{Task: task, Clauses: clauses, Marks: marks, Risks: risks}, nil +} + +func (c *contract) Summary(ctx context.Context, req *dto.SummaryContractReq) (*dto.SummaryContractRes, error) { + task, err := dao.ContractTask.GetOne(ctx, req.Id) + if err != nil { + return nil, err + } + if task == nil { + return nil, gerror.New("任务不存在") + } + sum, err := service.AnnotationService.Summary(ctx, req.Id) + if err != nil { + return nil, err + } + return &dto.SummaryContractRes{Task: task, RiskSummary: *sum}, nil } func (c *contract) Annotated(ctx context.Context, req *dto.AnnotatedContractReq) (*dto.AnnotatedContractRes, error) { diff --git a/kb/dao/contract_risk_dao.go b/kb/dao/contract_risk_dao.go new file mode 100644 index 0000000..0f4ae60 --- /dev/null +++ b/kb/dao/contract_risk_dao.go @@ -0,0 +1,95 @@ +package dao + +import ( + "context" + + "rag-local/kb/consts" + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +var ContractRisk = &contractRiskDao{} + +type contractRiskDao struct{} + +func init() { + ctx := context.Background() + _, err := g.DB(consts.DbGroupDefault).Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameContractRisk+` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL DEFAULT 0, + clause_id INTEGER NOT NULL DEFAULT 0, + level TEXT NOT NULL DEFAULT 'mid', + desc TEXT NOT NULL DEFAULT '', + laws TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT (datetime('now','localtime')) + )`) + if err != nil { + g.Log().Warningf(ctx, "create kb_contract_risk table failed: %v", err) + } + if _, err := g.DB(consts.DbGroupDefault).Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_kb_contract_risk_clause ON "+consts.TableNameContractRisk+"(clause_id)"); err != nil { + g.Log().Warningf(ctx, "create index idx_kb_contract_risk_clause failed: %v", err) + } + if _, err := g.DB(consts.DbGroupDefault).Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_kb_contract_risk_task ON "+consts.TableNameContractRisk+"(task_id)"); err != nil { + g.Log().Warningf(ctx, "create index idx_kb_contract_risk_task failed: %v", err) + } +} + +func (d *contractRiskDao) InsertAll(ctx context.Context, risks []*entity.ContractRisk) error { + if len(risks) == 0 { + return nil + } + tx, err := g.DB(consts.DbGroupDefault).Begin(ctx) + if err != nil { + return err + } + now := gtime.Now().Format("Y-m-d H:i:s") + for _, r := range risks { + if _, err := tx.Model(consts.TableNameContractRisk).Ctx(ctx).Data(g.Map{ + "task_id": r.TaskId, + "clause_id": r.ClauseId, + "level": r.Level, + "desc": r.Desc, + "laws": r.Laws, + "created_at": now, + }).Insert(); err != nil { + _ = tx.Rollback() + return err + } + } + return tx.Commit() +} + +func (d *contractRiskDao) ListByClause(ctx context.Context, clauseId int64) ([]*entity.ContractRisk, error) { + var list []*entity.ContractRisk + err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractRisk).Ctx(ctx). + Where("clause_id", clauseId).OrderAsc("id").Scan(&list) + if list == nil { + list = make([]*entity.ContractRisk, 0) + } + return list, err +} + +func (d *contractRiskDao) ListByTask(ctx context.Context, taskId int64) ([]*entity.ContractRisk, error) { + var list []*entity.ContractRisk + err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractRisk).Ctx(ctx). + Where("task_id", taskId).OrderAsc("id").Scan(&list) + if list == nil { + list = make([]*entity.ContractRisk, 0) + } + return list, err +} + +func (d *contractRiskDao) DeleteByClause(ctx context.Context, clauseId int64) error { + _, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractRisk).Ctx(ctx). + Where("clause_id", clauseId).Delete() + return err +} + +func (d *contractRiskDao) DeleteByTask(ctx context.Context, taskId int64) error { + _, err := g.DB(consts.DbGroupDefault).Exec(ctx, + "DELETE FROM "+consts.TableNameContractRisk+" WHERE clause_id IN (SELECT id FROM "+consts.TableNameContractClause+" WHERE task_id = ?)", + taskId) + return err +} diff --git a/kb/model/domain/contract_risk.go b/kb/model/domain/contract_risk.go new file mode 100644 index 0000000..4ef2777 --- /dev/null +++ b/kb/model/domain/contract_risk.go @@ -0,0 +1,22 @@ +package domain + +import "rag-local/kb/model/entity" + +// RiskSummary 合同风险汇总报告(分组 + LLM 总览) +type RiskSummary struct { + Overview string `json:"overview"` // LLM 整体评述(≤3 段) + Total int `json:"total"` // 风险点总数 + HighCount int `json:"high_count"` + MidCount int `json:"mid_count"` + LowCount int `json:"low_count"` + NoRiskClauses int `json:"no_risk_clauses"` // 未发现风险的条款数 + Groups map[string][]RiskGroupItem `json:"groups"` // key: high/mid/low +} + +// RiskGroupItem 风险汇总中按条款归组的一项 +type RiskGroupItem struct { + ClauseId int64 `json:"clause_id"` + ClauseTitle string `json:"clause_title"` + ClauseSeq int `json:"clause_seq"` + Risks []*entity.ContractRisk `json:"risks"` +} diff --git a/kb/model/dto/contract_dto.go b/kb/model/dto/contract_dto.go index b87b6e8..2411e75 100644 --- a/kb/model/dto/contract_dto.go +++ b/kb/model/dto/contract_dto.go @@ -1,6 +1,7 @@ package dto import ( + "rag-local/kb/model/domain" "rag-local/kb/model/entity" "github.com/gogf/gf/v2/frame/g" @@ -39,6 +40,17 @@ type GetContractDetailRes struct { Task *entity.ContractTask `json:"task"` Clauses []*entity.ContractClause `json:"clauses"` Marks map[int64][]*entity.ContractMark `json:"marks"` + Risks map[int64][]*entity.ContractRisk `json:"risks"` +} + +type SummaryContractReq struct { + g.Meta `path:"/summary" method:"get" tags:"合同标注" summary:"合同风险汇总报告(分级清单+LLM 整体评述)"` + Id int64 `v:"required" json:"id"` +} + +type SummaryContractRes struct { + Task *entity.ContractTask `json:"task"` + domain.RiskSummary } type AnnotatedContractReq struct { diff --git a/kb/model/entity/contract_risk.go b/kb/model/entity/contract_risk.go new file mode 100644 index 0000000..3ff4c27 --- /dev/null +++ b/kb/model/entity/contract_risk.go @@ -0,0 +1,49 @@ +package entity + +import ( + "encoding/json" + + "github.com/gogf/gf/v2/os/gtime" +) + +// ContractRisk 合同条款风险点(level: high/mid/low;Laws 存支撑法条 JSON 文本) +type ContractRisk struct { + Id int64 `orm:"id" json:"id"` + TaskId int64 `orm:"task_id" json:"task_id"` + ClauseId int64 `orm:"clause_id" json:"clause_id"` + Level string `orm:"level" json:"level"` + Desc string `orm:"desc" json:"desc"` + Laws string `orm:"laws" json:"-"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` +} + +// LawRef 支撑法条引用(laws JSON 元素) +type LawRef struct { + LawTitle string `json:"law_title"` + LawItem string `json:"law_item"` + Content string `json:"content"` +} + +// LawsRefs 解析 laws JSON 文本为法条引用数组(非法 JSON 返回空数组) +func (r *ContractRisk) LawsRefs() []LawRef { + if r.Laws == "" { + return nil + } + var refs []LawRef + if err := json.Unmarshal([]byte(r.Laws), &refs); err != nil { + return nil + } + return refs +} + +// MarshalJSON Laws 以数组形式输出(存储为 JSON 文本,对外为结构化数组) +func (r *ContractRisk) MarshalJSON() ([]byte, error) { + type alias ContractRisk + return json.Marshal(struct { + *alias + Laws []LawRef `json:"laws"` + }{ + alias: (*alias)(r), + Laws: r.LawsRefs(), + }) +} diff --git a/kb/service/annotation_service.go b/kb/service/annotation_service.go index 09dd80c..7ac000b 100644 --- a/kb/service/annotation_service.go +++ b/kb/service/annotation_service.go @@ -39,20 +39,24 @@ type Clause struct { // 条款切分正则,按优先级探测(首个命中 >=2 条的采用) var clausePatterns = []*regexp.Regexp{ - regexp.MustCompile(`(?m)^\s*(第[一二三四五六七八九十百千\d]+条[、\s::]?)`), + 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]+条`) +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 内容(截断) - RrfScore float64 + ChunkId int64 + DatasetId int64 + LawTitle string // 法律名 = dataset 名 + Content string // chunk 内容(喂给 LLM,截断到 AnnoCandidateMaxChars) + ContentFull string // chunk 完整内容(抽取具体条文文本用) + RrfScore float64 } // StartAnnotationPoller 启动标注任务轮询:gtimer 单例定时器串行消费(job 未结束不重入) @@ -192,15 +196,23 @@ func (s *annotationService) processOne(ctx context.Context) { // 落库(SQLite 写)收敛回主 goroutine 串行,避免无 WAL 下的 database is locked。 type clauseJobOut struct { clauseId int64 - marks []*entity.ContractMark + 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 { - continue + 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) @@ -221,7 +233,7 @@ func (s *annotationService) processOne(ctx context.Context) { ch <- out return } - out.marks, out.err = s.judgeClause(ctx, chatModel, cl, cands) + out.risks, out.err = s.judgeRisks(ctx, chatModel, cl, cands) ch <- out }, func(ctx context.Context, e error) { defer wg.Done() @@ -246,17 +258,23 @@ func (s *annotationService) processOne(ctx context.Context) { s.updateProgress(ctx, task.Id) continue } - // 幂等:重跑前清旧标注,避免断点续跑产生重复 mark + // 幂等:重跑前清旧风险与旧格式法条标注,避免重复记录 + 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 _, m := range out.marks { - m.ClauseId = out.clauseId + for _, r := range out.risks { + r.TaskId = task.Id + r.ClauseId = out.clauseId } - if err := dao.ContractMark.InsertAll(ctx, out.marks); err != nil { - failed++ - _ = dao.ContractClause.UpdateStatus(ctx, out.clauseId, consts.TaskStatusFailed, err.Error()) - continue + 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) @@ -341,7 +359,8 @@ func (s *annotationService) recallCandidates(ctx context.Context, clause *entity } 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) + cands[i].ContentFull = chunk.Content + cands[i].Content = truncateRunes(chunk.Content, consts.AnnoCandidateMaxChars) } } return cands, nil @@ -385,17 +404,21 @@ func (s *annotationService) mergeHit(merged map[int64]*annoCandidate, chunkId, d 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) { +// 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("你是资深法律顾问,负责审查合同条款的法律风险。请结合候选法律条文,识别该合同条款存在的法律风险点(条款与法律强制性规定冲突、遗漏法定必备内容、赔偿/补偿标准低于法定标准、期限或程序违法、表述模糊导致争议等)。\n\n【合同条款】\n") sb.WriteString(clause.Title + " " + clause.Content) sb.WriteString("\n\n【候选法律条文】\n") for i, c := range cands { sb.WriteString(fmt.Sprintf("[%d]《%s》%s\n", i+1, c.LawTitle, c.Content)) } - sb.WriteString("\n请为每条候选输出 score(0-10 整数,10=直接适用,0=完全无关)与 reason(一句话说明该条文与合同条款的关联及适用性)。\n注意:候选内容可能合并了多条法条,law_item 必须是候选内容中与合同条款最相关的那一条法条的编号(如「第四十四条」),不要输出候选内容里没有的编号。\n只输出 JSON,不要其他内容:") - sb.WriteString(`{"marks":[{"cand_id":1,"law_item":"第四十四条","score":9,"reason":"..."}]}`) + 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 { @@ -406,44 +429,156 @@ func (s *annotationService) judgeClause(ctx context.Context, model *OpenAIChatMo 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"` + 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) + 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) { + risks := make([]*entity.ContractRisk, 0, len(resp.Risks)) + for _, r := range resp.Risks { + desc := strings.TrimSpace(r.Desc) + if desc == "" { continue } - c := cands[m.CandId-1] - lawItem := strings.TrimSpace(m.LawItem) - if lawItem == "" { - lawItem = lawItemRe.FindString(c.Content) + level := strings.TrimSpace(r.Level) + switch level { + case consts.RiskLevelHigh, consts.RiskLevelMid, consts.RiskLevelLow: + default: + level = consts.RiskLevelMid } - 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, + 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), }) } - sort.Slice(marks, func(i, j int) bool { return marks[i].Score > marks[j].Score }) - return marks, nil + return risks, nil } -// AnnotatedHTML 生成标注版合同 HTML:条款原文 + 内嵌法条标注,可打印/另存 PDF +// 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 { @@ -457,51 +592,96 @@ func (s *annotationService) AnnotatedHTML(ctx context.Context, taskId int64) (st 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(`标注版-` + + sb.WriteString(`<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8"><title>风险审查-` + html.EscapeString(task.Filename) + `
-

合同法律条款标注

+

合同法律风险审查报告

文件名:` + html.EscapeString(task.Filename) + ` 导出时间:` + time.Now().Format("2006-01-02 15:04") + ` 任务状态:` + statusText + `
`) - for _, cl := range clauses { - marks, err := dao.ContractMark.ListByClause(ctx, cl.Id) - if err != nil { - return "", err + // 顶部风险汇总 + sb.WriteString(`
风险汇总
高风险 ` + + strconv.Itoa(levelCounts[consts.RiskLevelHigh]) + ` 项 · 中风险 ` + strconv.Itoa(levelCounts[consts.RiskLevelMid]) + + ` 项 · 低风险 ` + strconv.Itoa(levelCounts[consts.RiskLevelLow]) + ` 项 · 无风险条款 ` + strconv.Itoa(noRisk) + ` 条
`) + 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, `
`+ + html.EscapeString(cl.Title)+` `+html.EscapeString(r.Desc)+`
`) + } } + if len(group) > 0 { + sb.WriteString(`
` + + levelText[lv] + `(` + strconv.Itoa(len(group)) + `)
` + strings.Join(group, "") + `
`) + } + } + sb.WriteString(`
`) + // 逐条款风险 + for _, cl := range clauses { sb.WriteString(`
` + html.EscapeString(cl.Title) + `
` + `
` + html.EscapeString(cl.Content) + `
`) - if len(marks) == 0 { - sb.WriteString(`
无标注
`) + risks := byClause[cl.Id] + if len(risks) == 0 { + sb.WriteString(`
未发现明显风险
`) } - for _, m := range marks { - cls := "mark weak" - if m.Score >= 8 { - cls = "mark strong" - } else if m.Score >= 5 { - cls = "mark good" + for _, r := range risks { + sb.WriteString(`
` + + `` + levelText[r.Level] + `` + + `
` + html.EscapeString(r.Desc) + `
`) + for _, law := range r.LawsRefs() { + sb.WriteString(`
《` + html.EscapeString(law.LawTitle) + `》` + + html.EscapeString(law.LawItem) + ` ` + html.EscapeString(truncateRunes(law.Content, 200)) + `
`) } - sb.WriteString(`
《` + html.EscapeString(m.LawTitle) + `》` + - html.EscapeString(m.LawItem) + `` + formatScore(m.Score) + ` 分` + - `
` + html.EscapeString(m.Content) + `
` + - `
` + html.EscapeString(m.Reason) + `
`) + sb.WriteString(`
`) } sb.WriteString(`
`) } @@ -509,11 +689,107 @@ h1{font-size:20px;text-align:center;margin-bottom:4px} return sb.String(), nil } -func formatScore(score float64) string { - if score == float64(int(score)) { - return strconv.Itoa(int(score)) +// 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 } - return strconv.FormatFloat(score, 'f', 1, 64) + 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) { @@ -538,6 +814,9 @@ func (s *annotationService) Delete(ctx context.Context, id int64) error { 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 } diff --git a/kb/service/chat_service.go b/kb/service/chat_service.go index 85773b2..b8af69d 100644 --- a/kb/service/chat_service.go +++ b/kb/service/chat_service.go @@ -1025,7 +1025,7 @@ func buildCitations(docs []*schema.Document, question string) []domain.Citation return cits } -var clauseStartRe = regexp.MustCompile(`(?m)^\s*第[一二三四五六七八九十百千]+条`) +var clauseStartRe = regexp.MustCompile(`(?m)^\s*第[零一二三四五六七八九十百千]+条`) // computeHighlight 定位引用内容中与问题最相关的段落(条文/句段),返回其在 content 中的 UTF-16 偏移。 // 打分 = Σ 命中的问题词字数(长词权重高);总分低于 3 视为未命中(单个 2 字段落如"可以/规定"等泛词不标)。 diff --git a/kb/service/chunk_service.go b/kb/service/chunk_service.go index 2dd1d4b..1fdf54a 100644 --- a/kb/service/chunk_service.go +++ b/kb/service/chunk_service.go @@ -81,19 +81,19 @@ func hasHeadings(text string) bool { // 结构标记候选:单元模式按优先级排列(条文 > 章回 > 小节 > 章节 > 篇部 > 数字编号 > 括号序号) var ( unitPatternCandidates = []string{ - `第[一二三四五六七八九十百千]+条`, - `第[一二三四五六七八九十百千]+回`, - `第[一二三四五六七八九十百千]+节`, - `第[一二三四五六七八九十百千]+章`, - `第[一二三四五六七八九十百千]+[篇部]`, + `第[零一二三四五六七八九十百千]+条`, + `第[零一二三四五六七八九十百千]+回`, + `第[零一二三四五六七八九十百千]+节`, + `第[零一二三四五六七八九十百千]+章`, + `第[零一二三四五六七八九十百千]+[篇部]`, `[一二三四五六七八九十]{1,3}[、.]`, `\d{1,3}[、..]`, `[((][一二三四五六七八九十\d]{1,3}[))]`, } ctxPatternCandidates = []string{ - `第[一二三四五六七八九十百千]+[篇部]`, - `第[一二三四五六七八九十百千]+章`, - `第[一二三四五六七八九十百千]+节`, + `第[零一二三四五六七八九十百千]+[篇部]`, + `第[零一二三四五六七八九十百千]+章`, + `第[零一二三四五六七八九十百千]+节`, } minUnitHits = 5 // 行首命中至少 5 次才认定是结构单元(排除正文偶然提及) minCtxHits = 3 // 上下文标记命中下限 diff --git a/ui-src/src/api/contract.js b/ui-src/src/api/contract.js index d5121c3..c98be3e 100644 --- a/ui-src/src/api/contract.js +++ b/ui-src/src/api/contract.js @@ -12,6 +12,10 @@ export function getContractDetail(id) { return request.get('/contract/detail', { params: { id } }) } +export function getContractSummary(id) { + return request.get('/contract/summary', { params: { id } }) +} + export function deleteContract(id) { return request.post('/contract/delete', { id }) } diff --git a/ui-src/src/views/Contract.vue b/ui-src/src/views/Contract.vue index ceb28be..3cf9651 100644 --- a/ui-src/src/views/Contract.vue +++ b/ui-src/src/views/Contract.vue @@ -9,7 +9,7 @@
-
上传合同文件,逐条款标注对应法律条文
+
上传合同文件,识别合同条款法律风险
@@ -17,7 +17,7 @@ - + @@ -95,7 +133,7 @@ import { onMounted, onUnmounted, computed, ref } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { UploadFilled } from '@element-plus/icons-vue' import { listDatasets } from '../api/dataset.js' -import { uploadContract, listContracts, getContractDetail, deleteContract, exportAnnotatedContract } from '../api/contract.js' +import { uploadContract, listContracts, getContractDetail, getContractSummary, deleteContract, exportAnnotatedContract } from '../api/contract.js' const datasets = ref([]) const dsIds = ref([]) @@ -105,6 +143,9 @@ const loading = ref(false) const detailVisible = ref(false) const detail = ref(null) const activeClauseId = ref(null) +const summaryVisible = ref(false) +const summaryLoading = ref(false) +const summary = ref(null) let pollTimer = null const dsNameMap = computed(() => { @@ -125,11 +166,12 @@ onUnmounted(stopPolling) function dsName(id) { return dsNameMap.value[id] || ('#' + id) } function parseDsIds(s) { return (s || '').split(',').filter(Boolean).map(Number) } -function statusText(s) { return ['待处理', '标注中', '完成', '失败'][s] || '-' } +function statusText(s) { return ['待处理', '审查中', '完成', '失败'][s] || '-' } function statusType(s) { return ['info', 'warning', 'success', 'danger'][s] || 'info' } -function clauseStatusText(s) { return ['待处理', '标注中', '完成', '失败'][s] || '-' } +function clauseStatusText(s) { return ['待处理', '审查中', '完成', '失败'][s] || '-' } function clauseStatusType(s) { return ['info', 'warning', 'success', 'danger'][s] || 'info' } -function scoreType(score) { return score >= 8 ? 'success' : score >= 5 ? 'warning' : 'info' } +function levelText(lv) { return { high: '高风险', mid: '中风险', low: '低风险' }[lv] || lv } +function levelType(lv) { return { high: 'danger', mid: 'warning', low: 'primary' }[lv] || 'info' } async function loadTasks() { loading.value = true @@ -167,7 +209,7 @@ async function doUpload({ file }) { formData.append('dataset_ids', JSON.stringify(dsIds.value)) formData.append('file', file) await uploadContract(formData) - ElMessage.success('已提交标注任务') + ElMessage.success('已提交风险审查任务') await loadTasks() } catch (e) { ElMessage.error(e.message || '上传失败') @@ -186,6 +228,20 @@ async function openDetail(row) { } } +async function openSummary() { + if (!detail.value) return + summaryVisible.value = true + summaryLoading.value = true + summary.value = null + try { + summary.value = await getContractSummary(detail.value.task.id) + } catch (e) { + ElMessage.error(e.message || '生成报告失败') + } finally { + summaryLoading.value = false + } +} + async function exportAnnotated() { if (!detail.value) return try { @@ -193,10 +249,10 @@ async function exportAnnotated() { const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url - a.download = '标注版-' + detail.value.task.filename.replace(/\.[^.]+$/, '') + '.html' + a.download = '风险审查-' + detail.value.task.filename.replace(/\.[^.]+$/, '') + '.html' a.click() URL.revokeObjectURL(url) - ElMessage.success('已导出标注版') + ElMessage.success('已导出风险审查版') } catch (e) { ElMessage.error(e.message || '导出失败') } @@ -204,7 +260,7 @@ async function exportAnnotated() { async function removeTask(row) { try { - await ElMessageBox.confirm('删除任务将同时删除条款与标注结果,确认?', '删除任务', { type: 'warning' }) + await ElMessageBox.confirm('删除任务将同时删除条款与风险识别结果,确认?', '删除任务', { type: 'warning' }) } catch { return } @@ -218,12 +274,8 @@ const activeClause = computed(() => { return detail.value.clauses.find(c => c.id === activeClauseId.value) || null }) -function marksFor(clauseId) { - return (detail.value && detail.value.marks && detail.value.marks[clauseId]) || [] -} - -function sortedMarks(clauseId) { - return [...marksFor(clauseId)].sort((a, b) => b.score - a.score) +function risksFor(clauseId) { + return (detail.value && detail.value.risks && detail.value.risks[clauseId]) || [] } const detailTitle = computed(() => detail.value ? detail.value.task.filename : '') @@ -253,6 +305,7 @@ const detailTitle = computed(() => detail.value ? detail.value.task.filename : ' .detail-toolbar { display: flex; justify-content: flex-end; + gap: 8px; margin-bottom: 12px; } .detail-body { @@ -297,7 +350,7 @@ const detailTitle = computed(() => detail.value ? detail.value.task.filename : ' text-overflow: ellipsis; white-space: nowrap; } -.mark-panel { +.risk-panel { flex: 1; overflow-y: auto; } @@ -315,37 +368,113 @@ const detailTitle = computed(() => detail.value ? detail.value.task.filename : ' font-weight: 600; color: #303133; } -.mark-card { +.risk-card { border: 1px solid #e4e7ed; + border-left: 3px solid #909399; border-radius: 8px; padding: 10px 12px; margin-bottom: 10px; background: #fff; } -.mark-card.weak { - background: #fafafa; -} -.mark-head { - display: flex; - align-items: center; - gap: 8px; +.risk-card.high { border-left-color: #f56c6c; } +.risk-card.mid { border-left-color: #e6a23c; } +.risk-card.low { border-left-color: #409eff; } +.risk-head { margin-bottom: 6px; } -.mark-law { - font-weight: 600; - color: #303133; -} -.mark-content { +.risk-desc { font-size: 13px; + color: #303133; + line-height: 1.7; + margin-bottom: 4px; +} +.risk-laws { + margin-top: 4px; +} +.risk-law { + font-size: 12px; color: #606266; line-height: 1.7; margin-bottom: 6px; } -.mark-reason { +.risk-law .law-title { + font-weight: 600; + color: #303133; +} +.law-content { font-size: 12px; - color: #409eff; - background: #ecf5ff; + color: #909399; + margin-top: 2px; + background: #fafafa; border-radius: 4px; padding: 4px 8px; } +/* 风险报告弹窗 */ +.summary-body { + min-height: 200px; +} +.summary-stats { + margin-bottom: 12px; + display: flex; + gap: 8px; +} +.summary-overview { + background: #f0f9eb; + border-left: 3px solid #67c23a; + border-radius: 0 6px 6px 0; + padding: 10px 12px; + font-size: 13px; + line-height: 1.8; + color: #303133; + white-space: pre-wrap; + margin-bottom: 16px; +} +.summary-group { + margin-bottom: 14px; +} +.summary-group-title { + font-weight: 600; + font-size: 14px; + margin-bottom: 8px; + padding-left: 8px; + border-left: 3px solid #909399; +} +.summary-group-title.high { border-left-color: #f56c6c; color: #f56c6c; } +.summary-group-title.mid { border-left-color: #e6a23c; color: #e6a23c; } +.summary-group-title.low { border-left-color: #409eff; color: #409eff; } +.summary-item { + border: 1px solid #e4e7ed; + border-left: 3px solid #909399; + border-radius: 6px; + padding: 8px 10px; + margin-bottom: 8px; + background: #fff; +} +.summary-item.high { border-left-color: #f56c6c; } +.summary-item.mid { border-left-color: #e6a23c; } +.summary-item.low { border-left-color: #409eff; } +.summary-item-head { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; +} +.summary-clause { + font-weight: 600; + font-size: 13px; + color: #409eff; +} +.summary-item-desc { + font-size: 13px; + color: #303133; + line-height: 1.6; + margin-bottom: 4px; +} +.summary-law { + font-size: 12px; + color: #909399; +} +.summary-law .law-title { + color: #409eff; +} diff --git a/ui-src/src/views/DatasetList.vue b/ui-src/src/views/DatasetList.vue index 795d2b0..97ac4b9 100644 --- a/ui-src/src/views/DatasetList.vue +++ b/ui-src/src/views/DatasetList.vue @@ -27,43 +27,67 @@ - + - - - + + + + + + + + + + + + + + - - - - - - - -
每块最大字数。系统自动组合分块策略:优先识别文档结构(条文/章节/编号等)按结构切分,无结构时依次按标题感知、语义、递归切分
-
- - -
相邻分块间的重叠字数,用于保持上下文连贯(语义切分路径自动忽略)
-
- - -
0=关闭(单次检索问答);1~10=启用 ReAct 智能体模式,模型可自主多次调用检索工具,此为轮次上限
-
- - -
向量检索原始召回数。0=全局默认(20),-1=尽量多(上限100),正数=固定值
-
- - -
全文检索(BM25)原始召回数。0=全局默认(20),-1=尽量多(上限100),正数=固定值
-
- - -
融合后喂给 LLM 重排的候选数,需 ≥ 最终返回数。0=全局默认(10),-1=尽量多(上限60)
-
+ + + + +
每块最大字数。系统自动组合分块策略:优先识别文档结构(条文/章节/编号等)按结构切分,无结构时依次按标题感知、语义、递归切分
+
+
+ + + +
相邻分块间的重叠字数,用于保持上下文连贯(语义切分路径自动忽略)
+
+
+
+ + + + +
0=关闭(单次检索问答);1~10=启用 ReAct 智能体模式,模型可自主多次调用检索工具,此为轮次上限
+
+
+ + + +
向量检索原始召回数。0=全局默认(20),-1=尽量多(上限100),正数=固定值
+
+
+
+ + + + +
全文检索(BM25)原始召回数。0=全局默认(20),-1=尽量多(上限100),正数=固定值
+
+
+ + + +
融合后喂给 LLM 重排的候选数,需 ≥ 最终返回数。0=全局默认(10),-1=尽量多(上限60)
+
+
+
重排后最终返回给回答的引用条数。0=全局默认(5),-1=全部召回(不限制条数,达到相关性门槛的全部返回,仅受总字符物理保护),正数=固定条数