diff --git a/data/business.db b/data/business.db
index 9365533..03e17f7 100644
Binary files a/data/business.db and b/data/business.db differ
diff --git a/kb/controller/contract_controller.go b/kb/controller/contract_controller.go
index 6f2181c..3596c0f 100644
--- a/kb/controller/contract_controller.go
+++ b/kb/controller/contract_controller.go
@@ -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
diff --git a/kb/model/dto/contract_dto.go b/kb/model/dto/contract_dto.go
index 7925516..b87b6e8 100644
--- a/kb/model/dto/contract_dto.go
+++ b/kb/model/dto/contract_dto.go
@@ -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"`
diff --git a/kb/service/annotation_service.go b/kb/service/annotation_service.go
index 7e12d03..e7f8e8c 100644
--- a/kb/service/annotation_service.go
+++ b/kb/service/annotation_service.go
@@ -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(`
标注版-` +
+ html.EscapeString(task.Filename) + `
+
+合同法律条款标注
+文件名:` + html.EscapeString(task.Filename) + ` 导出时间:` + time.Now().Format("2006-01-02 15:04") +
+ ` 任务状态:` + statusText + `
`)
+ for _, cl := range clauses {
+ marks, err := dao.ContractMark.ListByClause(ctx, cl.Id)
+ if err != nil {
+ return "", err
+ }
+ sb.WriteString(`` + html.EscapeString(cl.Title) + `
` +
+ `
` + html.EscapeString(cl.Content) + `
`)
+ if len(marks) == 0 {
+ sb.WriteString(`
无标注
`)
+ }
+ for _, m := range marks {
+ cls := "mark weak"
+ if m.Score >= 8 {
+ cls = "mark strong"
+ } else if m.Score >= 5 {
+ cls = "mark good"
+ }
+ sb.WriteString(`
《` + html.EscapeString(m.LawTitle) + `》` +
+ html.EscapeString(m.LawItem) + `` + formatScore(m.Score) + ` 分` +
+ `
` + html.EscapeString(m.Content) + `
` +
+ `
` + html.EscapeString(m.Reason) + `
`)
+ }
+ sb.WriteString(`
`)
+ }
+ sb.WriteString(``)
+ 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)
diff --git a/ui-src/src/api/contract.js b/ui-src/src/api/contract.js
index 347c51f..d5121c3 100644
--- a/ui-src/src/api/contract.js
+++ b/ui-src/src/api/contract.js
@@ -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()
+}
diff --git a/ui-src/src/views/Contract.vue b/ui-src/src/views/Contract.vue
index 5215e09..ceb28be 100644
--- a/ui-src/src/views/Contract.vue
+++ b/ui-src/src/views/Contract.vue
@@ -53,6 +53,9 @@
+
+ 导出标注版(HTML)
+
合同条款({{ detail.clauses.length }})
@@ -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;