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;