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(`