1
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
+11
-1
@@ -5,9 +5,14 @@ const (
|
||||
DefaultEmbeddingDim = 1024 // embedding 默认维度(未配置时)
|
||||
VectorTopK = 20 // 向量检索召回数
|
||||
FtsTopK = 20 // 全文检索召回数
|
||||
HybridTopK = 10 // 混合检索融合后返回数
|
||||
HybridTopK = 5 // 混合检索最终返回数(重排+门槛过滤后)
|
||||
RrfK = 60 // RRF 融合常数
|
||||
|
||||
RerankTopK = 10 // 喂给 LLM 重排器的候选数(RRF 融合后截取)
|
||||
RerankKeepRatio = 0.5 // 重排分低于最高分该比例的条目剔除(门槛作用在语义分上,RRF 分因区间过窄无区分度)
|
||||
RerankMinScore = 6 // 重排分绝对下限:低于该分的条目直接剔除(重排器对泛化条款会给宽松低分,需绝对下限兜底)
|
||||
RerankMaxChars = 500 // 重排候选段截断字数(控制 prompt 长度)
|
||||
|
||||
DefaultChunkSize = 800 // 分块最大字数(数据集默认值)
|
||||
DefaultChunkOverlap = 150 // 分块重叠字数(数据集默认值)
|
||||
|
||||
@@ -18,4 +23,9 @@ const (
|
||||
ParsePollIntervalSeconds = 3 // 解析任务轮询间隔
|
||||
|
||||
EmbedBatchSize = 16 // 单次向量化请求的文本批量
|
||||
|
||||
// 合同标注参数
|
||||
AnnoRecallTopK = 15 // 每数据集向量+FTS 各召回数(标注宁滥毋缺,放宽召回)
|
||||
AnnoMaxCandidates = 60 // 多数据集融合后的候选上限(喂给 LLM 判定)
|
||||
AnnoMaxClauseChars = 2000 // 合同条款全文上限(超长截断,控制 prompt)
|
||||
)
|
||||
|
||||
+15
-12
@@ -1,18 +1,21 @@
|
||||
package consts
|
||||
|
||||
const (
|
||||
TableNameModelConfig = "model_config"
|
||||
TableNameAppConfig = "app_config"
|
||||
TableNameDataset = "kb_dataset"
|
||||
TableNameDocument = "kb_document"
|
||||
TableNameChunk = "kb_chunk"
|
||||
TableNameChunkVec = "kb_chunk_vec"
|
||||
TableNameChunkFts = "kb_chunk_fts"
|
||||
TableNameParseTask = "kb_parse_task"
|
||||
TableNameConversation = "chat_conversation"
|
||||
TableNameMessage = "chat_message"
|
||||
TableNameKgEntity = "kg_entity"
|
||||
TableNameKgRelation = "kg_relation"
|
||||
TableNameModelConfig = "model_config"
|
||||
TableNameAppConfig = "app_config"
|
||||
TableNameDataset = "kb_dataset"
|
||||
TableNameDocument = "kb_document"
|
||||
TableNameChunk = "kb_chunk"
|
||||
TableNameChunkVec = "kb_chunk_vec"
|
||||
TableNameChunkFts = "kb_chunk_fts"
|
||||
TableNameParseTask = "kb_parse_task"
|
||||
TableNameConversation = "chat_conversation"
|
||||
TableNameMessage = "chat_message"
|
||||
TableNameKgEntity = "kg_entity"
|
||||
TableNameKgRelation = "kg_relation"
|
||||
TableNameContractTask = "kb_contract_task"
|
||||
TableNameContractClause = "kb_contract_clause"
|
||||
TableNameContractMark = "kb_contract_mark"
|
||||
)
|
||||
|
||||
// 数据库组:默认组(default)=business.db、system=system.db、chat=chat.db
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rag-local/common"
|
||||
"rag-local/kb/dao"
|
||||
"rag-local/kb/model/dto"
|
||||
"rag-local/kb/model/entity"
|
||||
"rag-local/kb/service"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
type contract struct{}
|
||||
|
||||
var Contract = &contract{}
|
||||
|
||||
func (c *contract) Upload(ctx context.Context, req *dto.UploadContractReq) (*dto.UploadContractRes, error) {
|
||||
if req.File == nil {
|
||||
return nil, gerror.New("请选择合同文件")
|
||||
}
|
||||
var dsIds []int64
|
||||
if req.DatasetIds != "" {
|
||||
if err := json.Unmarshal([]byte(req.DatasetIds), &dsIds); err != nil {
|
||||
return nil, gerror.New("dataset_ids 格式错误,应为 JSON 数组")
|
||||
}
|
||||
}
|
||||
if len(dsIds) == 0 {
|
||||
return nil, gerror.New("请至少选择一个法律语料数据集")
|
||||
}
|
||||
f, err := req.File.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, gerror.New("文件内容为空")
|
||||
}
|
||||
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(req.File.Filename)), ".")
|
||||
supported := false
|
||||
for _, e := range common.SupportedExts() {
|
||||
if e == ext {
|
||||
supported = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !supported {
|
||||
return nil, gerror.New("不支持的文件类型,仅支持 txt/md/pdf/docx/doc/html")
|
||||
}
|
||||
relDir := filepath.Join("contract", time.Now().Format("20060102"))
|
||||
relPath := filepath.Join(relDir, common.RandomToken(16)+"."+ext)
|
||||
absPath := filepath.Join("workspace", relPath)
|
||||
if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(absPath, data, 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]string, 0, len(dsIds))
|
||||
for _, id := range dsIds {
|
||||
ids = append(ids, strconv.FormatInt(id, 10))
|
||||
}
|
||||
id, err := dao.ContractTask.Insert(ctx, req.File.Filename, relPath, strings.Join(ids, ","))
|
||||
if err != nil {
|
||||
_ = os.Remove(absPath)
|
||||
return nil, err
|
||||
}
|
||||
return &dto.UploadContractRes{Id: id}, nil
|
||||
}
|
||||
|
||||
func (c *contract) List(ctx context.Context, req *dto.ListContractTaskReq) (*dto.ListContractTaskRes, error) {
|
||||
list, total, err := service.AnnotationService.List(ctx, req.Page, req.PageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.ListContractTaskRes{
|
||||
List: list,
|
||||
Total: total,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *contract) Detail(ctx context.Context, req *dto.GetContractDetailReq) (*dto.GetContractDetailRes, error) {
|
||||
task, err := dao.ContractTask.GetOne(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if task == nil {
|
||||
return nil, gerror.New("任务不存在")
|
||||
}
|
||||
clauses, err := dao.ContractClause.ListByTask(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
marks := make(map[int64][]*entity.ContractMark)
|
||||
for _, cl := range clauses {
|
||||
list, err := dao.ContractMark.ListByClause(ctx, cl.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
marks[cl.Id] = list
|
||||
}
|
||||
return &dto.GetContractDetailRes{Task: task, Clauses: clauses, Marks: marks}, nil
|
||||
}
|
||||
|
||||
func (c *contract) Delete(ctx context.Context, req *dto.DeleteContractReq) (*dto.DeleteContractRes, error) {
|
||||
if err := service.AnnotationService.Delete(ctx, req.Id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.DeleteContractRes{}, nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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 ContractClause = &contractClauseDao{}
|
||||
|
||||
type contractClauseDao struct{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := g.DB(consts.DbGroupDefault).Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameContractClause+` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id INTEGER NOT NULL DEFAULT 0,
|
||||
seq INTEGER NOT NULL DEFAULT 0,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
status INTEGER NOT NULL DEFAULT 0,
|
||||
error_msg TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now','localtime')),
|
||||
updated_at DATETIME DEFAULT (datetime('now','localtime'))
|
||||
)`)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "create kb_contract_clause table failed: %v", err)
|
||||
}
|
||||
if _, err := g.DB(consts.DbGroupDefault).Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_kb_contract_clause_task ON "+consts.TableNameContractClause+"(task_id)"); err != nil {
|
||||
g.Log().Warningf(ctx, "create index idx_kb_contract_clause_task failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *contractClauseDao) InsertAll(ctx context.Context, taskId int64, clauses []entity.ContractClause) error {
|
||||
tx, err := g.DB(consts.DbGroupDefault).Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := gtime.Now().Format("Y-m-d H:i:s")
|
||||
for i := range clauses {
|
||||
clauses[i].TaskId = taskId
|
||||
clauses[i].Status = consts.TaskStatusPending
|
||||
clauses[i].CreatedAt = nil
|
||||
clauses[i].UpdatedAt = nil
|
||||
if _, err := tx.Model(consts.TableNameContractClause).Ctx(ctx).Data(g.Map{
|
||||
"task_id": clauses[i].TaskId,
|
||||
"seq": clauses[i].Seq,
|
||||
"title": clauses[i].Title,
|
||||
"content": clauses[i].Content,
|
||||
"status": clauses[i].Status,
|
||||
"error_msg": "",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}).Insert(); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (d *contractClauseDao) ListByTask(ctx context.Context, taskId int64) ([]*entity.ContractClause, error) {
|
||||
var list []*entity.ContractClause
|
||||
err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractClause).Ctx(ctx).
|
||||
Where("task_id", taskId).OrderAsc("seq").Scan(&list)
|
||||
if list == nil {
|
||||
list = make([]*entity.ContractClause, 0)
|
||||
}
|
||||
return list, err
|
||||
}
|
||||
|
||||
func (d *contractClauseDao) UpdateStatus(ctx context.Context, id int64, status int, errorMsg string) error {
|
||||
_, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractClause).Ctx(ctx).Data(g.Map{
|
||||
"status": status,
|
||||
"error_msg": errorMsg,
|
||||
"updated_at": gtime.Now().Format("Y-m-d H:i:s"),
|
||||
}).Where("id", id).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *contractClauseDao) DeleteByTask(ctx context.Context, taskId int64) error {
|
||||
_, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractClause).Ctx(ctx).
|
||||
Where("task_id", taskId).Delete()
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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 ContractMark = &contractMarkDao{}
|
||||
|
||||
type contractMarkDao struct{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := g.DB(consts.DbGroupDefault).Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameContractMark+` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
clause_id INTEGER NOT NULL DEFAULT 0,
|
||||
chunk_id INTEGER NOT NULL DEFAULT 0,
|
||||
dataset_id INTEGER NOT NULL DEFAULT 0,
|
||||
law_title TEXT NOT NULL DEFAULT '',
|
||||
law_item TEXT NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
score REAL NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT (datetime('now','localtime'))
|
||||
)`)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "create kb_contract_mark table failed: %v", err)
|
||||
}
|
||||
if _, err := g.DB(consts.DbGroupDefault).Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_kb_contract_mark_clause ON "+consts.TableNameContractMark+"(clause_id)"); err != nil {
|
||||
g.Log().Warningf(ctx, "create index idx_kb_contract_mark_clause failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *contractMarkDao) InsertAll(ctx context.Context, marks []*entity.ContractMark) error {
|
||||
if len(marks) == 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 _, m := range marks {
|
||||
if _, err := tx.Model(consts.TableNameContractMark).Ctx(ctx).Data(g.Map{
|
||||
"clause_id": m.ClauseId,
|
||||
"chunk_id": m.ChunkId,
|
||||
"dataset_id": m.DatasetId,
|
||||
"law_title": m.LawTitle,
|
||||
"law_item": m.LawItem,
|
||||
"content": m.Content,
|
||||
"reason": m.Reason,
|
||||
"score": m.Score,
|
||||
"created_at": now,
|
||||
}).Insert(); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (d *contractMarkDao) ListByClause(ctx context.Context, clauseId int64) ([]*entity.ContractMark, error) {
|
||||
var list []*entity.ContractMark
|
||||
err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractMark).Ctx(ctx).
|
||||
Where("clause_id", clauseId).OrderDesc("score").Scan(&list)
|
||||
if list == nil {
|
||||
list = make([]*entity.ContractMark, 0)
|
||||
}
|
||||
return list, err
|
||||
}
|
||||
|
||||
func (d *contractMarkDao) DeleteByClause(ctx context.Context, clauseId int64) error {
|
||||
_, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractMark).Ctx(ctx).
|
||||
Where("clause_id", clauseId).Delete()
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *contractMarkDao) DeleteByTask(ctx context.Context, taskId int64) error {
|
||||
_, err := g.DB(consts.DbGroupDefault).Exec(ctx,
|
||||
"DELETE FROM "+consts.TableNameContractMark+" WHERE clause_id IN (SELECT id FROM "+consts.TableNameContractClause+" WHERE task_id = ?)",
|
||||
taskId)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"rag-local/kb/consts"
|
||||
"rag-local/kb/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
var ContractTask = &contractTaskDao{}
|
||||
|
||||
type contractTaskDao struct{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := g.DB(consts.DbGroupDefault).Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameContractTask+` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
filename TEXT NOT NULL DEFAULT '',
|
||||
file_path TEXT NOT NULL DEFAULT '',
|
||||
dataset_ids TEXT NOT NULL DEFAULT '',
|
||||
status INTEGER NOT NULL DEFAULT 0,
|
||||
total_clauses INTEGER NOT NULL DEFAULT 0,
|
||||
done_clauses INTEGER NOT NULL DEFAULT 0,
|
||||
error_msg TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now','localtime')),
|
||||
updated_at DATETIME DEFAULT (datetime('now','localtime'))
|
||||
)`)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "create kb_contract_task table failed: %v", err)
|
||||
}
|
||||
if _, err := g.DB(consts.DbGroupDefault).Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_kb_contract_task_status ON "+consts.TableNameContractTask+"(status)"); err != nil {
|
||||
g.Log().Warningf(ctx, "create index idx_kb_contract_task_status failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *contractTaskDao) GetOne(ctx context.Context, id int64) (*entity.ContractTask, error) {
|
||||
var m entity.ContractTask
|
||||
err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractTask).Ctx(ctx).Where("id", id).Scan(&m)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (d *contractTaskDao) List(ctx context.Context, page, pageSize int) ([]*entity.ContractTask, int, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
total, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractTask).Ctx(ctx).Count()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var list []*entity.ContractTask
|
||||
err = g.DB(consts.DbGroupDefault).Model(consts.TableNameContractTask).Ctx(ctx).
|
||||
Page(page, pageSize).OrderDesc("id").Scan(&list)
|
||||
if list == nil {
|
||||
list = make([]*entity.ContractTask, 0)
|
||||
}
|
||||
return list, total, err
|
||||
}
|
||||
|
||||
func (d *contractTaskDao) Insert(ctx context.Context, filename, filePath, datasetIds string) (int64, error) {
|
||||
now := gtime.Now().Format("Y-m-d H:i:s")
|
||||
r, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractTask).Ctx(ctx).Data(g.Map{
|
||||
"filename": filename,
|
||||
"file_path": filePath,
|
||||
"dataset_ids": datasetIds,
|
||||
"status": consts.TaskStatusPending,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *contractTaskDao) UpdateStatus(ctx context.Context, id int64, status int, errorMsg string) error {
|
||||
_, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractTask).Ctx(ctx).Data(g.Map{
|
||||
"status": status,
|
||||
"error_msg": errorMsg,
|
||||
"updated_at": gtime.Now().Format("Y-m-d H:i:s"),
|
||||
}).Where("id", id).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *contractTaskDao) UpdateProgress(ctx context.Context, id int64, totalClauses, doneClauses int) error {
|
||||
_, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractTask).Ctx(ctx).Data(g.Map{
|
||||
"total_clauses": totalClauses,
|
||||
"done_clauses": doneClauses,
|
||||
"updated_at": gtime.Now().Format("Y-m-d H:i:s"),
|
||||
}).Where("id", id).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// NextPending 取待处理任务;status IN (Pending, Running) 使进程重启后遗留的 running 任务重新进入轮询
|
||||
func (d *contractTaskDao) NextPending(ctx context.Context) (*entity.ContractTask, error) {
|
||||
var m entity.ContractTask
|
||||
err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractTask).Ctx(ctx).
|
||||
WhereIn("status", []int{consts.TaskStatusPending, consts.TaskStatusRunning}).OrderAsc("id").Scan(&m)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (d *contractTaskDao) Delete(ctx context.Context, id int64) error {
|
||||
_, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameContractTask).Ctx(ctx).
|
||||
Where("id", id).Delete()
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"rag-local/kb/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
)
|
||||
|
||||
type UploadContractReq struct {
|
||||
g.Meta `path:"/upload" method:"post" tags:"合同标注" summary:"上传合同并启动标注"`
|
||||
DatasetIds string `json:"dataset_ids" dc:"法律语料数据集ID JSON 数组,如 [1,3]"`
|
||||
File *ghttp.UploadFile `json:"file" dc:"合同文件"`
|
||||
}
|
||||
|
||||
type UploadContractRes struct {
|
||||
Id int64 `json:"id"`
|
||||
}
|
||||
|
||||
type ListContractTaskReq struct {
|
||||
g.Meta `path:"/list" method:"get" tags:"合同标注" summary:"标注任务列表"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
type ListContractTaskRes struct {
|
||||
List []*entity.ContractTask `json:"list"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
type GetContractDetailReq struct {
|
||||
g.Meta `path:"/detail" method:"get" tags:"合同标注" summary:"任务详情(含条款与标注结果)"`
|
||||
Id int64 `v:"required" json:"id"`
|
||||
}
|
||||
|
||||
type GetContractDetailRes struct {
|
||||
Task *entity.ContractTask `json:"task"`
|
||||
Clauses []*entity.ContractClause `json:"clauses"`
|
||||
Marks map[int64][]*entity.ContractMark `json:"marks"`
|
||||
}
|
||||
|
||||
type DeleteContractReq struct {
|
||||
g.Meta `path:"/delete" method:"post" tags:"合同标注" summary:"删除标注任务"`
|
||||
Id int64 `v:"required" json:"id"`
|
||||
}
|
||||
|
||||
type DeleteContractRes struct{}
|
||||
@@ -0,0 +1,15 @@
|
||||
package entity
|
||||
|
||||
import "github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
type ContractClause struct {
|
||||
Id int64 `orm:"id" json:"id"`
|
||||
TaskId int64 `orm:"task_id" json:"task_id"`
|
||||
Seq int `orm:"seq" json:"seq"`
|
||||
Title string `orm:"title" json:"title"`
|
||||
Content string `orm:"content" json:"content"`
|
||||
Status int `orm:"status" json:"status"`
|
||||
ErrorMsg string `orm:"error_msg" json:"error_msg"`
|
||||
CreatedAt *gtime.Time `orm:"created_at" json:"created_at"`
|
||||
UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package entity
|
||||
|
||||
import "github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
type ContractMark struct {
|
||||
Id int64 `orm:"id" json:"id"`
|
||||
ClauseId int64 `orm:"clause_id" json:"clause_id"`
|
||||
ChunkId int64 `orm:"chunk_id" json:"chunk_id"`
|
||||
DatasetId int64 `orm:"dataset_id" json:"dataset_id"`
|
||||
LawTitle string `orm:"law_title" json:"law_title"`
|
||||
LawItem string `orm:"law_item" json:"law_item"`
|
||||
Content string `orm:"content" json:"content"`
|
||||
Reason string `orm:"reason" json:"reason"`
|
||||
Score float64 `orm:"score" json:"score"`
|
||||
CreatedAt *gtime.Time `orm:"created_at" json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package entity
|
||||
|
||||
import "github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
type ContractTask struct {
|
||||
Id int64 `orm:"id" json:"id"`
|
||||
Filename string `orm:"filename" json:"filename"`
|
||||
FilePath string `orm:"file_path" json:"file_path"`
|
||||
DatasetIds string `orm:"dataset_ids" json:"dataset_ids"`
|
||||
Status int `orm:"status" json:"status"`
|
||||
TotalClauses int `orm:"total_clauses" json:"total_clauses"`
|
||||
DoneClauses int `orm:"done_clauses" json:"done_clauses"`
|
||||
ErrorMsg string `orm:"error_msg" json:"error_msg"`
|
||||
CreatedAt *gtime.Time `orm:"created_at" json:"created_at"`
|
||||
UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rag-local/common"
|
||||
"rag-local/kb/consts"
|
||||
"rag-local/kb/dao"
|
||||
"rag-local/kb/model/domain"
|
||||
"rag-local/kb/model/entity"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var AnnotationService = &annotationService{}
|
||||
|
||||
type annotationService struct{}
|
||||
|
||||
// Clause 合同条款(切分中间态)
|
||||
type Clause struct {
|
||||
Seq int
|
||||
Title string
|
||||
Content string
|
||||
}
|
||||
|
||||
// 条款切分正则,按优先级探测(首个命中 >=2 条的采用)
|
||||
var clausePatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?m)^\s*(第[一二三四五六七八九十百千\d]+条[、\s::]?)`),
|
||||
regexp.MustCompile(`(?m)^\s*(\d{1,2}(\.\d{1,2})*[、..]\s*)`),
|
||||
regexp.MustCompile(`(?m)^\s*([一二三四五六七八九十]+[、..]\s*)`),
|
||||
}
|
||||
|
||||
var lawItemRe = regexp.MustCompile(`第[一二三四五六七八九十百千\d]+条`)
|
||||
|
||||
// annoCandidate 多数据集融合后的候选法条
|
||||
type annoCandidate struct {
|
||||
ChunkId int64
|
||||
DatasetId int64
|
||||
LawTitle string // 法律名 = dataset 名
|
||||
Content string // chunk 内容(截断)
|
||||
RrfScore float64
|
||||
}
|
||||
|
||||
// StartAnnotationPoller 启动标注任务轮询:单 goroutine 串行消费
|
||||
func (s *annotationService) StartAnnotationPoller(ctx context.Context) {
|
||||
go func() {
|
||||
g.Log().Info(ctx, "annotation task poller started")
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(consts.ParsePollIntervalSeconds * time.Second):
|
||||
s.processOne(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// SplitClauses 按行首标记切分条款;无结构时整篇作为单条
|
||||
func (s *annotationService) SplitClauses(text string) []Clause {
|
||||
var chosen *regexp.Regexp
|
||||
for _, p := range clausePatterns {
|
||||
if len(p.FindAllStringIndex(text, -1)) >= 2 {
|
||||
chosen = p
|
||||
break
|
||||
}
|
||||
}
|
||||
if chosen == nil {
|
||||
t := strings.TrimSpace(text)
|
||||
if t == "" {
|
||||
return nil
|
||||
}
|
||||
return []Clause{{Seq: 1, Title: "全文", Content: t}}
|
||||
}
|
||||
idxs := chosen.FindAllStringIndex(text, -1)
|
||||
var out []Clause
|
||||
for i, m := range idxs {
|
||||
end := len(text)
|
||||
if i+1 < len(idxs) {
|
||||
end = idxs[i+1][0]
|
||||
}
|
||||
seg := strings.TrimSpace(text[m[0]:end])
|
||||
if seg == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, Clause{
|
||||
Seq: len(out) + 1,
|
||||
Title: strings.TrimSpace(text[m[0]:m[1]]),
|
||||
Content: seg,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// processOne 处理一个待处理标注任务:解析 → 切分 → 逐条款召回+判定 → 落库
|
||||
func (s *annotationService) processOne(ctx context.Context) {
|
||||
task, err := dao.ContractTask.NextPending(ctx)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "next annotation task failed: %v", err)
|
||||
return
|
||||
}
|
||||
if task == nil {
|
||||
return
|
||||
}
|
||||
if err := dao.ContractTask.UpdateStatus(ctx, task.Id, consts.TaskStatusRunning, ""); err != nil {
|
||||
g.Log().Errorf(ctx, "mark annotation task running failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
dsIds := parseDatasetIds(task.DatasetIds)
|
||||
if len(dsIds) == 0 {
|
||||
s.fail(ctx, task, "未选择法律语料数据集")
|
||||
return
|
||||
}
|
||||
|
||||
text, err := common.ParseFile(filepath.Join("workspace", task.FilePath))
|
||||
if err != nil {
|
||||
s.fail(ctx, task, "解析合同文件失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 断点续跑:已有条款则跳过切分(保留已完成条款状态),仅首次切分落库
|
||||
clauses, err := dao.ContractClause.ListByTask(ctx, task.Id)
|
||||
if err != nil {
|
||||
s.fail(ctx, task, "读取条款失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(clauses) == 0 {
|
||||
split := s.SplitClauses(text)
|
||||
if len(split) == 0 {
|
||||
s.fail(ctx, task, "合同文本为空")
|
||||
return
|
||||
}
|
||||
es := make([]entity.ContractClause, 0, len(split))
|
||||
for _, c := range split {
|
||||
es = append(es, entity.ContractClause{
|
||||
Seq: c.Seq,
|
||||
Title: c.Title,
|
||||
Content: truncateRunes(c.Content, consts.AnnoMaxClauseChars),
|
||||
})
|
||||
}
|
||||
if err := dao.ContractClause.InsertAll(ctx, task.Id, es); err != nil {
|
||||
s.fail(ctx, task, "写入条款失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := dao.ContractTask.UpdateProgress(ctx, task.Id, len(es), 0); err != nil {
|
||||
g.Log().Warningf(ctx, "update annotation progress failed: %v", err)
|
||||
}
|
||||
clauses, err = dao.ContractClause.ListByTask(ctx, task.Id)
|
||||
if err != nil {
|
||||
s.fail(ctx, task, "读取条款失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
chatCfgId, err := dao.ModelConfig.GetDefault(ctx, consts.ModelTypeChat)
|
||||
if err != nil || chatCfgId <= 0 {
|
||||
s.fail(ctx, task, "未配置对话模型")
|
||||
return
|
||||
}
|
||||
chatModel, err := BuildChatModel(ctx, chatCfgId)
|
||||
if err != nil {
|
||||
s.fail(ctx, task, "构建对话模型失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
embedders := make(map[int64]*OpenAIEmbedder)
|
||||
dsNames := make(map[int64]string)
|
||||
for _, dsId := range dsIds {
|
||||
ds, err := dao.Dataset.GetOne(ctx, dsId)
|
||||
if err != nil || ds == nil {
|
||||
continue
|
||||
}
|
||||
dsNames[dsId] = ds.Name
|
||||
if cfgId, err := dao.Dataset.GetEmbeddingCfgId(ctx, dsId); err == nil && cfgId > 0 {
|
||||
if emb, err := BuildEmbedder(ctx, cfgId); err == nil {
|
||||
embedders[dsId] = emb
|
||||
} else {
|
||||
g.Log().Warningf(ctx, "dataset %d embedder build failed: %v", dsId, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
failed := 0
|
||||
for _, cl := range clauses {
|
||||
if cl.Status == consts.TaskStatusDone {
|
||||
continue
|
||||
}
|
||||
if err := dao.ContractClause.UpdateStatus(ctx, cl.Id, consts.TaskStatusRunning, ""); err != nil {
|
||||
g.Log().Errorf(ctx, "mark clause running failed: %v", err)
|
||||
continue
|
||||
}
|
||||
cands, err := s.recallCandidates(ctx, cl, dsIds, dsNames, embedders)
|
||||
if err != nil {
|
||||
failed++
|
||||
_ = dao.ContractClause.UpdateStatus(ctx, cl.Id, consts.TaskStatusFailed, err.Error())
|
||||
continue
|
||||
}
|
||||
if len(cands) == 0 {
|
||||
// 无候选视为完成(无标注),避免卡住进度
|
||||
_ = dao.ContractClause.UpdateStatus(ctx, cl.Id, consts.TaskStatusDone, "")
|
||||
s.updateProgress(ctx, task.Id)
|
||||
continue
|
||||
}
|
||||
marks, err := s.judgeClause(ctx, chatModel, cl, cands)
|
||||
if err != nil {
|
||||
failed++
|
||||
_ = dao.ContractClause.UpdateStatus(ctx, cl.Id, consts.TaskStatusFailed, err.Error())
|
||||
continue
|
||||
}
|
||||
// 幂等:重跑前清旧标注,避免断点续跑产生重复 mark
|
||||
if err := dao.ContractMark.DeleteByClause(ctx, cl.Id); err != nil {
|
||||
g.Log().Warningf(ctx, "clear old marks failed: %v", err)
|
||||
}
|
||||
for _, m := range marks {
|
||||
m.ClauseId = cl.Id
|
||||
}
|
||||
if err := dao.ContractMark.InsertAll(ctx, marks); err != nil {
|
||||
failed++
|
||||
_ = dao.ContractClause.UpdateStatus(ctx, cl.Id, consts.TaskStatusFailed, err.Error())
|
||||
continue
|
||||
}
|
||||
_ = dao.ContractClause.UpdateStatus(ctx, cl.Id, consts.TaskStatusDone, "")
|
||||
s.updateProgress(ctx, task.Id)
|
||||
}
|
||||
|
||||
msg := ""
|
||||
if failed > 0 {
|
||||
msg = fmt.Sprintf("%d 条条款标注失败", failed)
|
||||
}
|
||||
if err := dao.ContractTask.UpdateStatus(ctx, task.Id, consts.TaskStatusDone, msg); err != nil {
|
||||
g.Log().Errorf(ctx, "mark annotation task done failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// updateProgress 以库内实际完成数更新任务进度(断点续跑时跳过已 done 条款也能算对)
|
||||
func (s *annotationService) updateProgress(ctx context.Context, taskId int64) {
|
||||
doneList, err := dao.ContractClause.ListByTask(ctx, taskId)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
done := 0
|
||||
for _, c := range doneList {
|
||||
if c.Status == consts.TaskStatusDone {
|
||||
done++
|
||||
}
|
||||
}
|
||||
if err := dao.ContractTask.UpdateProgress(ctx, taskId, len(doneList), done); err != nil {
|
||||
g.Log().Warningf(ctx, "update annotation progress failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// recallCandidates 多数据集召回:每数据集向量+FTS 各取 AnnoRecallTopK,全局 RRF 融合截断
|
||||
func (s *annotationService) recallCandidates(ctx context.Context, clause *entity.ContractClause, dsIds []int64,
|
||||
dsNames map[int64]string, embedders map[int64]*OpenAIEmbedder) ([]annoCandidate, error) {
|
||||
merged := make(map[int64]*annoCandidate)
|
||||
clauseText := clause.Title + " " + clause.Content
|
||||
ftsText := clause.Title + " " + clause.Content
|
||||
if rs := []rune(ftsText); len(rs) > 200 {
|
||||
ftsText = string(rs[:200])
|
||||
}
|
||||
ftsQuery := common.TokenizeQuery(ftsText)
|
||||
for _, dsId := range dsIds {
|
||||
if emb := embedders[dsId]; emb != nil {
|
||||
vecs, err := emb.EmbedStrings(ctx, []string{clauseText})
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "clause embed failed (dataset %d): %v", dsId, err)
|
||||
} else if len(vecs) > 0 {
|
||||
hits, err := dao.Chunk.VecSearch(ctx, dsId, domain.VecJsonF64(vecs[0]), consts.AnnoRecallTopK)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "vec search failed (dataset %d): %v", dsId, err)
|
||||
} else {
|
||||
for i, h := range hits {
|
||||
s.mergeHit(merged, h.ChunkId, dsId, dsNames[dsId], float64(i))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
hits, err := dao.Chunk.FtsSearch(ctx, dsId, ftsQuery, consts.AnnoRecallTopK)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "fts search failed (dataset %d): %v", dsId, err)
|
||||
continue
|
||||
}
|
||||
for i, h := range hits {
|
||||
s.mergeHit(merged, h.ChunkId, dsId, dsNames[dsId], float64(i))
|
||||
}
|
||||
}
|
||||
cands := make([]annoCandidate, 0, len(merged))
|
||||
for _, c := range merged {
|
||||
cands = append(cands, *c)
|
||||
}
|
||||
sort.Slice(cands, func(i, j int) bool { return cands[i].RrfScore > cands[j].RrfScore })
|
||||
if len(cands) > consts.AnnoMaxCandidates {
|
||||
cands = cands[:consts.AnnoMaxCandidates]
|
||||
}
|
||||
for i := range cands {
|
||||
if chunk, err := dao.Chunk.GetOne(ctx, cands[i].ChunkId); err == nil && chunk != nil {
|
||||
cands[i].Content = truncateRunes(chunk.Content, consts.RerankMaxChars)
|
||||
}
|
||||
}
|
||||
return cands, nil
|
||||
}
|
||||
|
||||
func (s *annotationService) mergeHit(merged map[int64]*annoCandidate, chunkId, dsId int64, lawTitle string, rank float64) {
|
||||
c := merged[chunkId]
|
||||
if c == nil {
|
||||
c = &annoCandidate{ChunkId: chunkId, DatasetId: dsId, LawTitle: lawTitle}
|
||||
merged[chunkId] = c
|
||||
}
|
||||
c.RrfScore += 1 / (float64(consts.RrfK) + rank + 1)
|
||||
}
|
||||
|
||||
// judgeClause LLM 判定:一次非流式调用对全部候选打分并给出理由,全部保留按分降序
|
||||
func (s *annotationService) judgeClause(ctx context.Context, model *OpenAIChatModel, clause *entity.ContractClause, cands []annoCandidate) ([]*entity.ContractMark, error) {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("你是资深法律顾问,负责对合同条款进行法律条文标注。请逐条判定每个候选法律条文与合同条款的相关性。\n\n【合同条款】\n")
|
||||
sb.WriteString(clause.Title + " " + clause.Content)
|
||||
sb.WriteString("\n\n【候选法律条文】\n")
|
||||
for i, c := range cands {
|
||||
sb.WriteString(fmt.Sprintf("[%d]《%s》%s\n", i+1, c.LawTitle, c.Content))
|
||||
}
|
||||
sb.WriteString("\n请为每条候选输出 score(0-10 整数,10=直接适用,0=完全无关)与 reason(一句话说明该条文与合同条款的关联及适用性)。\n注意:候选内容可能合并了多条法条,law_item 必须是候选内容中与合同条款最相关的那一条法条的编号(如「第四十四条」),不要输出候选内容里没有的编号。\n只输出 JSON,不要其他内容:")
|
||||
sb.WriteString(`{"marks":[{"cand_id":1,"law_item":"第四十四条","score":9,"reason":"..."}]}`)
|
||||
|
||||
msg, err := model.Generate(ctx, []*schema.Message{{Role: schema.User, Content: sb.String()}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
content := msg.Content
|
||||
if i, j := strings.Index(content, "{"), strings.LastIndex(content, "}"); i >= 0 && j > i {
|
||||
content = content[i : j+1]
|
||||
}
|
||||
var resp struct {
|
||||
Marks []struct {
|
||||
CandId int `json:"cand_id"`
|
||||
LawItem string `json:"law_item"`
|
||||
Score float64 `json:"score"`
|
||||
Reason string `json:"reason"`
|
||||
} `json:"marks"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(content), &resp); err != nil {
|
||||
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) {
|
||||
continue
|
||||
}
|
||||
c := cands[m.CandId-1]
|
||||
lawItem := strings.TrimSpace(m.LawItem)
|
||||
if lawItem == "" {
|
||||
lawItem = lawItemRe.FindString(c.Content)
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
sort.Slice(marks, func(i, j int) bool { return marks[i].Score > marks[j].Score })
|
||||
return marks, nil
|
||||
}
|
||||
|
||||
func (s *annotationService) fail(ctx context.Context, task *entity.ContractTask, msg string) {
|
||||
_ = dao.ContractTask.UpdateStatus(ctx, task.Id, consts.TaskStatusFailed, msg)
|
||||
g.Log().Errorf(ctx, "annotation task %d failed: %s", task.Id, msg)
|
||||
}
|
||||
|
||||
// List 任务列表
|
||||
func (s *annotationService) List(ctx context.Context, page, pageSize int) ([]*entity.ContractTask, int, error) {
|
||||
return dao.ContractTask.List(ctx, page, pageSize)
|
||||
}
|
||||
|
||||
// Delete 删除任务及关联数据与文件
|
||||
func (s *annotationService) Delete(ctx context.Context, id int64) error {
|
||||
task, err := dao.ContractTask.GetOne(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if task == nil {
|
||||
return gerror.New("任务不存在")
|
||||
}
|
||||
if err := dao.ContractMark.DeleteByTask(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := dao.ContractClause.DeleteByTask(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := dao.ContractTask.Delete(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if task.FilePath != "" {
|
||||
_ = os.Remove(filepath.Join("workspace", task.FilePath))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseDatasetIds(s string) []int64 {
|
||||
var out []int64
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
var id int64
|
||||
if _, err := fmt.Sscanf(part, "%d", &id); err == nil && id > 0 {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func truncateRunes(s string, n int) string {
|
||||
rs := []rune(s)
|
||||
if len(rs) > n {
|
||||
return string(rs[:n])
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -219,14 +220,15 @@ func BuildEmbedder(ctx context.Context, cfgId int64) (*OpenAIEmbedder, error) {
|
||||
return NewOpenAIEmbedder(cfg), nil
|
||||
}
|
||||
|
||||
// HybridRetriever 混合检索器:向量 KNN + FTS5 BM25,RRF 融合,实现 eino retriever.Retriever
|
||||
// HybridRetriever 混合检索器:向量 KNN + FTS5 BM25,RRF 融合后可选 LLM 重排,实现 eino retriever.Retriever
|
||||
type HybridRetriever struct {
|
||||
embedder eembedding.Embedder
|
||||
datasetId int64
|
||||
reranker *OpenAIChatModel // LLM 重排器(默认对话模型),为 nil 时跳过重排走 RRF 顺序
|
||||
}
|
||||
|
||||
func NewHybridRetriever(embedder eembedding.Embedder, datasetId int64) *HybridRetriever {
|
||||
return &HybridRetriever{embedder: embedder, datasetId: datasetId}
|
||||
func NewHybridRetriever(embedder eembedding.Embedder, datasetId int64, reranker *OpenAIChatModel) *HybridRetriever {
|
||||
return &HybridRetriever{embedder: embedder, datasetId: datasetId, reranker: reranker}
|
||||
}
|
||||
|
||||
func (r *HybridRetriever) Retrieve(ctx context.Context, query string, opts ...eretriever.Option) ([]*schema.Document, error) {
|
||||
@@ -266,16 +268,40 @@ func (r *HybridRetriever) Retrieve(ctx context.Context, query string, opts ...er
|
||||
}
|
||||
}
|
||||
|
||||
type scoredChunk struct {
|
||||
id int64
|
||||
score float64
|
||||
sources []string
|
||||
}
|
||||
items := make([]scoredChunk, 0, len(scores))
|
||||
for id, s := range scores {
|
||||
items = append(items, scoredChunk{id: id, score: s, sources: srcs[id]})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].score > items[j].score })
|
||||
if len(items) > consts.RerankTopK {
|
||||
items = items[:consts.RerankTopK]
|
||||
}
|
||||
if r.reranker != nil && len(items) > 0 {
|
||||
before := len(items)
|
||||
if scores, err := r.rerankByLLM(ctx, query, items); err == nil {
|
||||
for i := range items {
|
||||
items[i].score = scores[items[i].id]
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].score > items[j].score })
|
||||
// 门槛作用在重排语义分上:最高分条目必留;其余需同时满足相对比例与绝对下限,
|
||||
// 防止重排器对泛化条款给出宽松低分(如 3 分)也能进引用
|
||||
maxScore := items[0].score
|
||||
if maxScore > 0 {
|
||||
floor := math.Max(maxScore*consts.RerankKeepRatio, consts.RerankMinScore)
|
||||
keep := items[:0]
|
||||
for i, it := range items {
|
||||
if i == 0 || it.score >= floor {
|
||||
keep = append(keep, it)
|
||||
}
|
||||
}
|
||||
items = keep
|
||||
g.Log().Infof(ctx, "rerank done: %d candidates → %d kept (max %.1f, floor %.1f)",
|
||||
before, len(items), maxScore, floor)
|
||||
}
|
||||
} else {
|
||||
g.Log().Warningf(ctx, "rerank failed, fallback to rrf order: %v", err)
|
||||
}
|
||||
}
|
||||
if len(items) > topK {
|
||||
items = items[:topK]
|
||||
}
|
||||
@@ -301,6 +327,61 @@ func (r *HybridRetriever) Retrieve(ctx context.Context, query string, opts ...er
|
||||
return docs, nil
|
||||
}
|
||||
|
||||
// scoredChunk RRF 融合后的候选条目(重排后 score 字段替换为语义分)
|
||||
type scoredChunk struct {
|
||||
id int64
|
||||
score float64
|
||||
sources []string
|
||||
}
|
||||
|
||||
// rerankByLLM 用默认对话模型对候选分块打分(0-10,JSON 输出),返回 chunk_id → 相关性分;
|
||||
// 任何失败(调用/解析/空结果)返回错误,由调用方回退 RRF 排序
|
||||
func (r *HybridRetriever) rerankByLLM(ctx context.Context, query string, items []scoredChunk) (map[int64]float64, error) {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("你是检索重排器。请评估每个候选段落与用户问题的相关性,为每个候选输出 0-10 的相关性分数(10=高度相关,0=完全不相关)。\n\n用户问题:\n")
|
||||
sb.WriteString(query)
|
||||
sb.WriteString("\n\n候选段落:\n")
|
||||
for i, it := range items {
|
||||
chunk, err := dao.Chunk.GetOne(ctx, it.id)
|
||||
if err != nil || chunk == nil {
|
||||
continue
|
||||
}
|
||||
content := chunk.Content
|
||||
if rs := []rune(content); len(rs) > consts.RerankMaxChars {
|
||||
content = string(rs[:consts.RerankMaxChars])
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("[%d] %s\n", i+1, content))
|
||||
}
|
||||
sb.WriteString("\n只输出 JSON,不要其他内容:{\"scores\":{\"1\":8,\"2\":3}}")
|
||||
msg, err := r.reranker.Generate(ctx, []*schema.Message{{Role: schema.User, Content: sb.String()}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 模型可能包裹 ```json 代码块,截取首尾花括号之间的 JSON 对象
|
||||
content := msg.Content
|
||||
if i, j := strings.Index(content, "{"), strings.LastIndex(content, "}"); i >= 0 && j > i {
|
||||
content = content[i : j+1]
|
||||
}
|
||||
var resp struct {
|
||||
Scores map[string]float64 `json:"scores"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(content), &resp); err != nil {
|
||||
return nil, gerror.Wrap(err, "解析重排结果失败: "+msg.Content)
|
||||
}
|
||||
scores := make(map[int64]float64, len(resp.Scores))
|
||||
for k, v := range resp.Scores {
|
||||
idx, err := strconv.ParseInt(k, 10, 64)
|
||||
if err != nil || idx < 1 || idx > int64(len(items)) {
|
||||
continue
|
||||
}
|
||||
scores[items[idx-1].id] = v
|
||||
}
|
||||
if len(scores) == 0 {
|
||||
return nil, gerror.New("重排结果为空")
|
||||
}
|
||||
return scores, nil
|
||||
}
|
||||
|
||||
// ---------- RAG 问答工作流 ----------
|
||||
|
||||
var ChatService = &chatService{}
|
||||
@@ -378,7 +459,16 @@ func (s *chatService) retrieve(ctx context.Context, datasetId int64, question st
|
||||
g.Log().Warningf(ctx, "build embedder failed, retrieve fts only: %v", err)
|
||||
}
|
||||
}
|
||||
return NewHybridRetriever(emb, datasetId).Retrieve(ctx, question)
|
||||
// LLM 重排器:默认对话模型;构建失败仅跳过重排,不影响检索主流程
|
||||
var reranker *OpenAIChatModel
|
||||
if defaultChatModel, err := dao.ModelConfig.GetDefault(ctx, consts.ModelTypeChat); err == nil && defaultChatModel > 0 {
|
||||
if m, err := BuildChatModel(ctx, defaultChatModel); err == nil {
|
||||
reranker = m
|
||||
} else {
|
||||
g.Log().Warningf(ctx, "build reranker failed, skip rerank: %v", err)
|
||||
}
|
||||
}
|
||||
return NewHybridRetriever(emb, datasetId, reranker).Retrieve(ctx, question)
|
||||
}
|
||||
|
||||
// buildCitations 从检索结果生成引用列表(编号从 1 开始,与提示词 [编号] 对应)
|
||||
|
||||
@@ -33,6 +33,7 @@ func main() {
|
||||
controller.Message,
|
||||
controller.KgEntity,
|
||||
controller.KgRelation,
|
||||
controller.Contract,
|
||||
})
|
||||
|
||||
// ==================== Workspace 文件服务(源文件访问,路径穿越防护) ====================
|
||||
@@ -76,6 +77,9 @@ func main() {
|
||||
// ==================== 解析任务轮询(文档流水线消费端) ====================
|
||||
service.ParseTaskService.StartParsePoller(ctx)
|
||||
|
||||
// ==================== 合同标注任务轮询 ====================
|
||||
service.AnnotationService.StartAnnotationPoller(ctx)
|
||||
|
||||
// ==================== 访问令牌(首次启动生成,打印到控制台) ====================
|
||||
token, err := service.SystemConfigService.EnsureAccessToken(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import request from './request.js'
|
||||
|
||||
export function uploadContract(formData) {
|
||||
return request.post('/contract/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
}
|
||||
|
||||
export function listContracts(params) {
|
||||
return request.get('/contract/list', { params })
|
||||
}
|
||||
|
||||
export function getContractDetail(id) {
|
||||
return request.get('/contract/detail', { params: { id } })
|
||||
}
|
||||
|
||||
export function deleteContract(id) {
|
||||
return request.post('/contract/delete', { id })
|
||||
}
|
||||
@@ -36,6 +36,12 @@ const routes = [
|
||||
meta: { title: '知识图谱' },
|
||||
component: () => import('../views/KgGraph.vue')
|
||||
},
|
||||
{
|
||||
path: 'contract',
|
||||
name: 'Contract',
|
||||
meta: { title: '合同标注' },
|
||||
component: () => import('../views/Contract.vue')
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'Settings',
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<div class="citation-head">
|
||||
<span class="citation-idx">[{{ c.index }}]</span>
|
||||
<el-tag v-for="s in c.sources" :key="s" size="small" type="info">{{ s }}</el-tag>
|
||||
<span class="citation-score">得分 {{ c.score.toFixed(4) }}</span>
|
||||
<span class="citation-score">得分 {{ c.score.toFixed(1) }}</span>
|
||||
</div>
|
||||
<div class="citation-content">{{ c.content }}</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
<template>
|
||||
<div class="contract-page">
|
||||
<!-- 上传区 -->
|
||||
<el-card class="upload-card" shadow="never">
|
||||
<div class="upload-row">
|
||||
<el-select v-model="dsIds" multiple placeholder="选择法律语料数据集(可多选)" style="width: 320px">
|
||||
<el-option v-for="d in datasets" :key="d.id" :label="d.name" :value="d.id" />
|
||||
</el-select>
|
||||
<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>
|
||||
</el-upload>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 任务列表 -->
|
||||
<el-card class="task-card" shadow="never">
|
||||
<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 }">
|
||||
<el-link type="primary" @click="openDetail(row)">{{ row.filename }}</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="法律语料" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="id in parseDsIds(row.dataset_ids)" :key="id" size="small" style="margin-right: 4px">
|
||||
{{ dsName(id) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="进度" width="160">
|
||||
<template #default="{ row }">
|
||||
<el-progress v-if="row.total_clauses > 0" :percentage="Math.round(row.done_clauses * 100 / row.total_clauses)" :stroke-width="12" />
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="160" />
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" @click="removeTask(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 详情抽屉:条款 + 标注 -->
|
||||
<el-drawer v-model="detailVisible" :title="detailTitle" size="80%" destroy-on-close>
|
||||
<div v-if="detail" class="detail-body">
|
||||
<div class="clause-panel">
|
||||
<div class="panel-title">合同条款({{ detail.clauses.length }})</div>
|
||||
<div v-for="cl in detail.clauses" :key="cl.id" class="clause-item"
|
||||
:class="{ active: cl.id === activeClauseId }" @click="activeClauseId = cl.id">
|
||||
<el-tag :type="clauseStatusType(cl.status)" size="small">{{ clauseStatusText(cl.status) }}</el-tag>
|
||||
<span class="clause-title">{{ cl.title }}</span>
|
||||
<div class="clause-preview">{{ cl.content.slice(0, 60) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mark-panel">
|
||||
<template v-if="activeClause">
|
||||
<div class="panel-title">标注结果({{ (marksFor(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>
|
||||
<div class="mark-content">{{ m.content }}</div>
|
||||
<div class="mark-reason">{{ m.reason }}</div>
|
||||
</div>
|
||||
<el-empty v-if="!(marksFor(activeClause.id) || []).length" description="暂无标注" :image-size="60" />
|
||||
</template>
|
||||
<el-empty v-else description="点击左侧条款查看标注" :image-size="80" />
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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 } from '../api/contract.js'
|
||||
|
||||
const datasets = ref([])
|
||||
const dsIds = ref([])
|
||||
const uploading = ref(false)
|
||||
const tasks = ref([])
|
||||
const loading = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
const detail = ref(null)
|
||||
const activeClauseId = ref(null)
|
||||
let pollTimer = null
|
||||
|
||||
const dsNameMap = computed(() => {
|
||||
const m = {}
|
||||
for (const d of datasets.value) m[d.id] = d.name
|
||||
return m
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const d = await listDatasets()
|
||||
if (d && d.list) datasets.value = d.list
|
||||
} catch { /* 忽略 */ }
|
||||
await loadTasks()
|
||||
})
|
||||
|
||||
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 statusType(s) { return ['info', 'warning', 'success', 'danger'][s] || 'info' }
|
||||
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' }
|
||||
|
||||
async function loadTasks() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await listContracts({ page: 1, page_size: 100 })
|
||||
tasks.value = (r && r.list) || []
|
||||
const hasRunning = tasks.value.some(t => t.status === 0 || t.status === 1)
|
||||
if (hasRunning) startPolling()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling()
|
||||
pollTimer = setInterval(async () => {
|
||||
const r = await listContracts({ page: 1, page_size: 100 })
|
||||
tasks.value = (r && r.list) || []
|
||||
if (!tasks.value.some(t => t.status === 0 || t.status === 1)) stopPolling()
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
}
|
||||
|
||||
async function doUpload({ file }) {
|
||||
if (!dsIds.value.length) {
|
||||
ElMessage.warning('请先选择法律语料数据集')
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('dataset_ids', JSON.stringify(dsIds.value))
|
||||
formData.append('file', file)
|
||||
await uploadContract(formData)
|
||||
ElMessage.success('已提交标注任务')
|
||||
await loadTasks()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '上传失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(row) {
|
||||
const r = await getContractDetail(row.id)
|
||||
detail.value = r
|
||||
activeClauseId.value = (r.clauses && r.clauses.length) ? r.clauses[0].id : null
|
||||
detailVisible.value = true
|
||||
if (row.status === 0 || row.status === 1) {
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTask(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm('删除任务将同时删除条款与标注结果,确认?', '删除任务', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await deleteContract(row.id)
|
||||
ElMessage.success('已删除')
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
const activeClause = computed(() => {
|
||||
if (!detail.value) return null
|
||||
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)
|
||||
}
|
||||
|
||||
const detailTitle = computed(() => detail.value ? detail.value.task.filename : '')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.contract-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.upload-card :deep(.el-upload) {
|
||||
width: 100%;
|
||||
}
|
||||
.upload-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.upload-hint {
|
||||
padding: 6px 0;
|
||||
}
|
||||
.upload-icon {
|
||||
font-size: 40px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
.detail-body {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
}
|
||||
.clause-panel {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
.panel-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: #303133;
|
||||
}
|
||||
.clause-item {
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 6px;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
.clause-item:hover, .clause-item.active {
|
||||
border-color: #409eff;
|
||||
background: #ecf5ff;
|
||||
}
|
||||
.clause-title {
|
||||
font-weight: 600;
|
||||
color: #409eff;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.clause-preview {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mark-panel {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.clause-full {
|
||||
background: #fafafa;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
color: #606266;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.clause-full-title {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
.mark-card {
|
||||
border: 1px solid #e4e7ed;
|
||||
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;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.mark-law {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
.mark-content {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.mark-reason {
|
||||
font-size: 12px;
|
||||
color: #409eff;
|
||||
background: #ecf5ff;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -15,6 +15,10 @@
|
||||
<el-icon><Share /></el-icon>
|
||||
<span>知识图谱</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/contract">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>合同标注</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/settings">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>设置</span>
|
||||
@@ -36,7 +40,7 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Folder, ChatDotRound, Setting, Share } from '@element-plus/icons-vue'
|
||||
import { Folder, ChatDotRound, Setting, Share, Document } from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '../stores/auth.js'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
@@ -25,6 +25,7 @@ export default defineConfig(({ mode }) => {
|
||||
'/message': { target, changeOrigin: true },
|
||||
'/kg-entity': { target, changeOrigin: true },
|
||||
'/kg-relation': { target, changeOrigin: true },
|
||||
'/contract': { target, changeOrigin: true },
|
||||
'/workspace': { target, changeOrigin: true }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user