This commit is contained in:
2026-08-07 16:07:20 +08:00
parent 828594baef
commit 54de49a6de
15 changed files with 811 additions and 168 deletions
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+7
View File
@@ -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)
+1
View File
@@ -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
+22 -1
View File
@@ -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) {
+95
View File
@@ -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
}
+22
View File
@@ -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"`
}
+12
View File
@@ -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 {
+49
View File
@@ -0,0 +1,49 @@
package entity
import (
"encoding/json"
"github.com/gogf/gf/v2/os/gtime"
)
// ContractRisk 合同条款风险点(level: high/mid/lowLaws 存支撑法条 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(),
})
}
+359 -80
View File
@@ -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请为每条候选输出 score0-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(`<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8"><title>标注版-` +
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}
.mark{border-left:3px solid #909399;background:#f4f4f5;padding:6px 10px;margin-bottom:6px;border-radius:0 4px 4px 0;font-size:12px;color:#606266}
.mark.strong{border-left-color:#67c23a;background:#f0f9eb}
.mark.good{border-left-color:#409eff;background:#ecf5ff}
.mark-law{font-weight:600;color:#303133}
.mark-score{float:right;color:#999}
.mark-content{color:#606266;margin-top:2px}
.mark-reason{color:#409eff;margin-top:2px}
.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>
<h1>合同法律风险审查报告</h1>
<div class="meta">文件名:` + html.EscapeString(task.Filename) + ` 导出时间:` + time.Now().Format("2006-01-02 15:04") +
` 任务状态:` + statusText + `</div>`)
for _, cl := range clauses {
marks, err := dao.ContractMark.ListByClause(ctx, cl.Id)
if err != nil {
return "", err
// 顶部风险汇总
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>`)
if len(marks) == 0 {
sb.WriteString(`<div class="mark">无标注</div>`)
risks := byClause[cl.Id]
if len(risks) == 0 {
sb.WriteString(`<div class="risk-none">未发现明显风险</div>`)
}
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(`<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 class="` + cls + `"><span class="mark-law">《` + html.EscapeString(m.LawTitle) + `` +
html.EscapeString(m.LawItem) + `</span><span class="mark-score">` + formatScore(m.Score) + ` 分</span>` +
`<div class="mark-content">` + html.EscapeString(m.Content) + `</div>` +
`<div class="mark-reason">` + html.EscapeString(m.Reason) + `</div></div>`)
sb.WriteString(`</div>`)
}
sb.WriteString(`</div>`)
}
@@ -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
}
+1 -1
View File
@@ -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 字段落如"可以/规定"等泛词不标)。
+8 -8
View File
@@ -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 // 上下文标记命中下限
+4
View File
@@ -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 })
}
+174 -45
View File
@@ -9,7 +9,7 @@
<el-upload drag :show-file-list="false" :http-request="doUpload" :disabled="uploading" accept=".txt,.md,.pdf,.docx,.doc,.html,.htm">
<div class="upload-hint">
<el-icon class="upload-icon"><UploadFilled /></el-icon>
<div>上传合同文件逐条款标注对应法律条文</div>
<div>上传合同文件识别合同条款法律风险</div>
</div>
</el-upload>
</div>
@@ -17,7 +17,7 @@
<!-- 任务列表 -->
<el-card class="task-card" shadow="never">
<template #header><span>标注任务</span></template>
<template #header><span>风险审查任务</span></template>
<el-table :data="tasks" v-loading="loading" size="default">
<el-table-column prop="filename" label="合同文件" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
@@ -51,10 +51,11 @@
</el-table>
</el-card>
<!-- 详情抽屉条款 + 标注 -->
<!-- 详情抽屉条款 + 风险点 -->
<el-drawer v-model="detailVisible" :title="detailTitle" size="80%" destroy-on-close>
<div v-if="detail" class="detail-toolbar">
<el-button type="primary" size="small" @click="exportAnnotated">导出标注版HTML</el-button>
<el-button type="primary" size="small" @click="openSummary" :disabled="detail.task.status !== 2">风险报告</el-button>
<el-button size="small" @click="exportAnnotated">导出风险审查版HTML</el-button>
</div>
<div v-if="detail" class="detail-body">
<div class="clause-panel">
@@ -66,27 +67,64 @@
<div class="clause-preview">{{ cl.content.slice(0, 60) }}</div>
</div>
</div>
<div class="mark-panel">
<div class="risk-panel">
<template v-if="activeClause">
<div class="panel-title">标注结果{{ (marksFor(activeClause.id) || []).length }}</div>
<div class="panel-title">风险识别{{ (risksFor(activeClause.id) || []).length }}</div>
<div class="clause-full">
<span class="clause-full-title">{{ activeClause.title }}</span>
{{ activeClause.content }}
</div>
<div v-for="m in sortedMarks(activeClause.id)" :key="m.id" class="mark-card" :class="{ weak: m.score < 5 }">
<div class="mark-head">
<el-tag :type="scoreType(m.score)" size="small">{{ m.score }} </el-tag>
<span class="mark-law">{{ m.law_title }}{{ m.law_item }}</span>
<div v-for="r in risksFor(activeClause.id)" :key="r.id" class="risk-card" :class="r.level">
<div class="risk-head">
<el-tag :type="levelType(r.level)" size="small">{{ levelText(r.level) }}</el-tag>
</div>
<div class="mark-content">{{ m.content }}</div>
<div class="mark-reason">{{ m.reason }}</div>
<div class="risk-desc">{{ r.desc }}</div>
<el-collapse v-if="(r.laws || []).length" class="risk-laws">
<el-collapse-item :title="`法律依据(${r.laws.length}`">
<div v-for="(law, i) in r.laws" :key="i" class="risk-law">
<span class="law-title">{{ law.law_title }}{{ law.law_item }}</span>
<div class="law-content">{{ law.content }}</div>
</div>
</el-collapse-item>
</el-collapse>
</div>
<el-empty v-if="!(marksFor(activeClause.id) || []).length" description="暂无标注" :image-size="60" />
<el-empty v-if="!(risksFor(activeClause.id) || []).length" description="未发现明显风险" :image-size="60" />
</template>
<el-empty v-else description="点击左侧条款查看标注" :image-size="80" />
<el-empty v-else description="点击左侧条款查看风险识别" :image-size="80" />
</div>
</div>
</el-drawer>
<!-- 风险报告弹窗 -->
<el-dialog v-model="summaryVisible" title="合同风险报告" width="760px" top="4vh" destroy-on-close>
<div v-loading="summaryLoading" class="summary-body">
<template v-if="summary">
<div class="summary-stats">
<el-tag type="danger" size="small">高风险 {{ summary.high_count }}</el-tag>
<el-tag type="warning" size="small">中风险 {{ summary.mid_count }}</el-tag>
<el-tag type="primary" size="small">低风险 {{ summary.low_count }}</el-tag>
<el-tag type="info" size="small">无风险条款 {{ summary.no_risk_clauses }} </el-tag>
</div>
<div class="summary-overview">{{ summary.overview }}</div>
<div v-for="lv in ['high', 'mid', 'low']" :key="lv" class="summary-group">
<div v-if="(summary.groups[lv] || []).length" class="summary-group-title" :class="lv">
{{ levelText(lv) }}{{ summary.groups[lv].length }}
</div>
<div v-for="(item, i) in (summary.groups[lv] || [])" :key="i" class="summary-item" :class="lv">
<div class="summary-item-head">
<span class="summary-clause">{{ item.clause_title }}</span>
<el-tag :type="levelType(lv)" size="small">{{ levelText(lv) }}</el-tag>
</div>
<div class="summary-item-desc">{{ item.risks[0].desc }}</div>
<div v-for="(law, j) in (item.risks[0].laws || [])" :key="j" class="summary-law">
<span class="law-title">{{ law.law_title }}{{ law.law_item }}</span>
</div>
</div>
</div>
<el-empty v-if="!summary.total" description="未发现明显法律风险" :image-size="60" />
</template>
</div>
</el-dialog>
</div>
</template>
@@ -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;
}
</style>
+57 -33
View File
@@ -27,43 +27,67 @@
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="editing ? '编辑数据集' : '新建数据集'" width="480px">
<el-dialog v-model="dialogVisible" :title="editing ? '编辑数据集' : '新建数据集'" width="640px">
<el-form :model="form" label-width="90px">
<el-form-item label="名称" required>
<el-input v-model="form.name" placeholder="如:产品手册" />
</el-form-item>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="名称" required>
<el-input v-model="form.name" placeholder="如:产品手册" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="向量模型" required>
<el-select v-model="form.embedding_cfg_id" placeholder="请选择向量模型" style="width: 100%">
<el-option v-for="m in embedders" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" />
</el-form-item>
<el-form-item label="向量模型" required>
<el-select v-model="form.embedding_cfg_id" placeholder="请选择向量模型" style="width: 100%">
<el-option v-for="m in embedders" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
</el-form-item>
<el-form-item label="分块大小">
<el-input-number v-model="form.chunk_size" :min="50" :max="5000" :step="50" style="width: 100%" />
<div class="ds-tip">每块最大字数系统自动组合分块策略优先识别文档结构条文/章节/编号等按结构切分无结构时依次按标题感知语义递归切分</div>
</el-form-item>
<el-form-item label="重叠字数">
<el-input-number v-model="form.chunk_overlap" :min="0" :max="500" :step="10" style="width: 100%" />
<div class="ds-tip">相邻分块间的重叠字数用于保持上下文连贯语义切分路径自动忽略</div>
</el-form-item>
<el-form-item label="智能体轮次">
<el-input-number v-model="form.react_rounds" :min="0" :max="10" :step="1" style="width: 100%" />
<div class="ds-tip">0=关闭单次检索问答1~10=启用 ReAct 智能体模式模型可自主多次调用检索工具此为轮次上限</div>
</el-form-item>
<el-form-item label="向量召回数">
<el-input-number v-model="form.vec_top_k" :min="-1" :max="50" :step="1" style="width: 100%" />
<div class="ds-tip">向量检索原始召回数0=全局默认(20)-1=尽量多(上限100)正数=固定值</div>
</el-form-item>
<el-form-item label="全文召回数">
<el-input-number v-model="form.fts_top_k" :min="-1" :max="50" :step="1" style="width: 100%" />
<div class="ds-tip">全文检索(BM25)原始召回数0=全局默认(20)-1=尽量多(上限100)正数=固定值</div>
</el-form-item>
<el-form-item label="重排候选数">
<el-input-number v-model="form.rerank_top_k" :min="-1" :max="50" :step="1" style="width: 100%" />
<div class="ds-tip">融合后喂给 LLM 重排的候选数 最终返回数0=全局默认(10)-1=尽量多(上限60)</div>
</el-form-item>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="分块大小">
<el-input-number v-model="form.chunk_size" :min="50" :max="5000" :step="50" style="width: 100%" />
<div class="ds-tip">每块最大字数系统自动组合分块策略优先识别文档结构条文/章节/编号等按结构切分无结构时依次按标题感知语义递归切分</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="重叠字数">
<el-input-number v-model="form.chunk_overlap" :min="0" :max="500" :step="10" style="width: 100%" />
<div class="ds-tip">相邻分块间的重叠字数用于保持上下文连贯语义切分路径自动忽略</div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="智能体轮次">
<el-input-number v-model="form.react_rounds" :min="0" :max="10" :step="1" style="width: 100%" />
<div class="ds-tip">0=关闭单次检索问答1~10=启用 ReAct 智能体模式模型可自主多次调用检索工具此为轮次上限</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="向量召回数">
<el-input-number v-model="form.vec_top_k" :min="-1" :max="50" :step="1" style="width: 100%" />
<div class="ds-tip">向量检索原始召回数0=全局默认(20)-1=尽量多(上限100)正数=固定值</div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="全文召回数">
<el-input-number v-model="form.fts_top_k" :min="-1" :max="50" :step="1" style="width: 100%" />
<div class="ds-tip">全文检索(BM25)原始召回数0=全局默认(20)-1=尽量多(上限100)正数=固定值</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="重排候选数">
<el-input-number v-model="form.rerank_top_k" :min="-1" :max="50" :step="1" style="width: 100%" />
<div class="ds-tip">融合后喂给 LLM 重排的候选数 最终返回数0=全局默认(10)-1=尽量多(上限60)</div>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="最终返回数">
<el-input-number v-model="form.recall_top_k" :min="-1" :max="50" :step="1" style="width: 100%" />
<div class="ds-tip">重排后最终返回给回答的引用条数0=全局默认(5)-1=全部召回不限制条数达到相关性门槛的全部返回仅受总字符物理保护正数=固定条数</div>