1
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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(`<!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
|
||||
}
|
||||
|
||||
@@ -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 字段落如"可以/规定"等泛词不标)。
|
||||
|
||||
@@ -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 // 上下文标记命中下限
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user