This commit is contained in:
2026-08-06 11:04:14 +08:00
parent 46f034d628
commit 57e662b414
6 changed files with 130 additions and 2 deletions
BIN
View File
Binary file not shown.
+13
View File
@@ -17,6 +17,7 @@ import (
"rag-local/kb/service"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
)
type contract struct{}
@@ -116,6 +117,18 @@ func (c *contract) Detail(ctx context.Context, req *dto.GetContractDetailReq) (*
return &dto.GetContractDetailRes{Task: task, Clauses: clauses, Marks: marks}, nil
}
func (c *contract) Annotated(ctx context.Context, req *dto.AnnotatedContractReq) (*dto.AnnotatedContractRes, error) {
htmlStr, err := service.AnnotationService.AnnotatedHTML(ctx, req.Id)
if err != nil {
return nil, err
}
// 直接写响应体(html),中间件检测到已写入则不包装 JSON
r := g.RequestFromCtx(ctx)
r.Response.Header().Set("Content-Type", "text/html; charset=utf-8")
r.Response.Write(htmlStr)
return &dto.AnnotatedContractRes{}, 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
+7
View File
@@ -41,6 +41,13 @@ type GetContractDetailRes struct {
Marks map[int64][]*entity.ContractMark `json:"marks"`
}
type AnnotatedContractReq struct {
g.Meta `path:"/annotated" method:"get" tags:"合同标注" summary:"导出标注版合同(HTML,可打印/另存 PDF"`
Id int64 `v:"required" json:"id"`
}
type AnnotatedContractRes struct{}
type DeleteContractReq struct {
g.Meta `path:"/delete" method:"post" tags:"合同标注" summary:"删除标注任务"`
Id int64 `v:"required" json:"id"`
+75
View File
@@ -4,10 +4,12 @@ import (
"context"
"encoding/json"
"fmt"
"html"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
@@ -378,6 +380,79 @@ func (s *annotationService) judgeClause(ctx context.Context, model *OpenAIChatMo
return marks, nil
}
// AnnotatedHTML 生成标注版合同 HTML:条款原文 + 内嵌法条标注,可打印/另存 PDF
func (s *annotationService) AnnotatedHTML(ctx context.Context, taskId int64) (string, error) {
task, err := dao.ContractTask.GetOne(ctx, taskId)
if err != nil {
return "", err
}
if task == nil {
return "", gerror.New("任务不存在")
}
clauses, err := dao.ContractClause.ListByTask(ctx, taskId)
if err != nil {
return "", err
}
statusText := map[int]string{0: "待处理", 1: "标注中", 2: "完成", 3: "失败"}[task.Status]
var sb strings.Builder
sb.WriteString(`<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8"><title>标注版-` +
html.EscapeString(task.Filename) + `</title><style>
body{font-family:"PingFang SC","Microsoft YaHei",sans-serif;max-width:820px;margin:24px auto;padding:0 16px;color:#333;line-height:1.8}
h1{font-size:20px;text-align:center;margin-bottom:4px}
.meta{text-align:center;color:#999;font-size:12px;margin-bottom:24px}
.clause{border:1px solid #e4e7ed;border-radius:8px;padding:12px 16px;margin-bottom:14px}
.clause-title{font-weight:600;font-size:15px;color:#303133}
.clause-content{font-size:13px;color:#555;white-space:pre-wrap;margin:8px 0}
.mark{border-left:3px solid #909399;background:#f4f4f5;padding:6px 10px;margin-bottom:6px;border-radius:0 4px 4px 0;font-size:12px;color:#606266}
.mark.strong{border-left-color:#67c23a;background:#f0f9eb}
.mark.good{border-left-color:#409eff;background:#ecf5ff}
.mark-law{font-weight:600;color:#303133}
.mark-score{float:right;color:#999}
.mark-content{color:#606266;margin-top:2px}
.mark-reason{color:#409eff;margin-top:2px}
.toolbar{position:fixed;top:12px;right:16px;z-index:10}
.toolbar button{padding:6px 14px;border:1px solid #409eff;background:#409eff;color:#fff;border-radius:6px;cursor:pointer;font-size:13px}
@media print{.toolbar{display:none}}
</style></head><body>
<div class="toolbar"><button onclick="window.print()">打印 / 另存为 PDF</button></div>
<h1>合同法律条款标注</h1>
<div class="meta">文件名:` + html.EscapeString(task.Filename) + ` 导出时间:` + time.Now().Format("2006-01-02 15:04") +
` 任务状态:` + statusText + `</div>`)
for _, cl := range clauses {
marks, err := dao.ContractMark.ListByClause(ctx, cl.Id)
if err != nil {
return "", err
}
sb.WriteString(`<div class="clause"><div class="clause-title">` + html.EscapeString(cl.Title) + `</div>` +
`<div class="clause-content">` + html.EscapeString(cl.Content) + `</div>`)
if len(marks) == 0 {
sb.WriteString(`<div class="mark">无标注</div>`)
}
for _, m := range marks {
cls := "mark weak"
if m.Score >= 8 {
cls = "mark strong"
} else if m.Score >= 5 {
cls = "mark good"
}
sb.WriteString(`<div class="` + cls + `"><span class="mark-law">《` + html.EscapeString(m.LawTitle) + `` +
html.EscapeString(m.LawItem) + `</span><span class="mark-score">` + formatScore(m.Score) + ` 分</span>` +
`<div class="mark-content">` + html.EscapeString(m.Content) + `</div>` +
`<div class="mark-reason">` + html.EscapeString(m.Reason) + `</div></div>`)
}
sb.WriteString(`</div>`)
}
sb.WriteString(`</body></html>`)
return sb.String(), nil
}
func formatScore(score float64) string {
if score == float64(int(score)) {
return strconv.Itoa(int(score))
}
return strconv.FormatFloat(score, 'f', 1, 64)
}
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)
+9
View File
@@ -15,3 +15,12 @@ export function getContractDetail(id) {
export function deleteContract(id) {
return request.post('/contract/delete', { id })
}
// 导出标注版 HTML:走原生 fetch(拦截器会误判 blob 响应),token 手动带上
export async function exportAnnotatedContract(id) {
const resp = await fetch(`/contract/annotated?id=${id}`, {
headers: { Authorization: 'Bearer ' + (localStorage.getItem('token') || '') }
})
if (!resp.ok) throw new Error('导出失败')
return resp.blob()
}
+26 -2
View File
@@ -53,6 +53,9 @@
<!-- 详情抽屉条款 + 标注 -->
<el-drawer v-model="detailVisible" :title="detailTitle" size="80%" destroy-on-close>
<div v-if="detail" class="detail-toolbar">
<el-button type="primary" size="small" @click="exportAnnotated">导出标注版HTML</el-button>
</div>
<div v-if="detail" class="detail-body">
<div class="clause-panel">
<div class="panel-title">合同条款{{ detail.clauses.length }}</div>
@@ -92,7 +95,7 @@ import { onMounted, onUnmounted, computed, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
import { listDatasets } from '../api/dataset.js'
import { uploadContract, listContracts, getContractDetail, deleteContract } from '../api/contract.js'
import { uploadContract, listContracts, getContractDetail, deleteContract, exportAnnotatedContract } from '../api/contract.js'
const datasets = ref([])
const dsIds = ref([])
@@ -183,6 +186,22 @@ async function openDetail(row) {
}
}
async function exportAnnotated() {
if (!detail.value) return
try {
const blob = await exportAnnotatedContract(detail.value.task.id)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '标注版-' + detail.value.task.filename.replace(/\.[^.]+$/, '') + '.html'
a.click()
URL.revokeObjectURL(url)
ElMessage.success('已导出标注版')
} catch (e) {
ElMessage.error(e.message || '导出失败')
}
}
async function removeTask(row) {
try {
await ElMessageBox.confirm('删除任务将同时删除条款与标注结果,确认?', '删除任务', { type: 'warning' })
@@ -231,10 +250,15 @@ const detailTitle = computed(() => detail.value ? detail.value.task.filename : '
font-size: 40px;
color: #c0c4cc;
}
.detail-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
}
.detail-body {
display: flex;
gap: 12px;
height: 100%;
height: calc(100% - 44px);
}
.clause-panel {
width: 280px;