This commit is contained in:
2026-08-06 10:05:40 +08:00
parent 139f6ccfd6
commit 74ecb6d277
22 changed files with 1444 additions and 24 deletions
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+11 -1
View File
@@ -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
View File
@@ -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
+124
View File
@@ -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
}
+89
View File
@@ -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
}
+88
View File
@@ -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
}
+125
View File
@@ -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
}
+49
View File
@@ -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{}
+15
View File
@@ -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"`
}
+16
View File
@@ -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"`
}
+16
View File
@@ -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"`
}
+436
View File
@@ -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请为每条候选输出 score0-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
}
+99 -9
View File
@@ -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 BM25RRF 融合,实现 eino retriever.Retriever
// HybridRetriever 混合检索器:向量 KNN + FTS5 BM25RRF 融合后可选 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 开始,与提示词 [编号] 对应)
+4
View File
@@ -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 {
+17
View File
@@ -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 })
}
+6
View File
@@ -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',
+1 -1
View File
@@ -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>
+327
View File
@@ -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>
+5 -1
View File
@@ -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()
+1
View File
@@ -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 }
}
}