feat: 新增合同审查功能 —— AI 条款完整性检查 + 风险评分 + 修改建议

- 后端:contract_review.go —— 检查条款完整性、付款/违约/争议条款、风险评分 0-100、AI 深度审查
- 前端:ContractReviewPage.vue —— 合同输入、风险评分展示、风险详情列表、修改建议
- 路由:POST /api/contract/review + 前端 /tools/contract-review 路由
- 模板:StudioPage 新增合同审查模板入口

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
eaiadmin
2026-09-13 23:27:08 +08:00
co-authored by Claude Code
parent 3c58b376b3
commit 752c7837ef
5 changed files with 670 additions and 0 deletions
@@ -0,0 +1,466 @@
package api
import (
"strings"
"time"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/ai"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// ContractReviewRequest POST /api/contract/review
type ContractReviewRequest struct {
Content string `json:"content"` // 合同正文
TemplateKey string `json:"template_key"` // 参考模板key
RiskStandard string `json:"risk_standard"` // standard/strict
}
// RiskItem 风险项
type RiskItem struct {
Type string `json:"type"` // risk/suggestion/clause
Level string `json:"level"` // critical/high/medium/low
Section string `json:"section"` // 合同章节
Content string `json:"content"` // 原文片段
Problem string `json:"problem"` // 问题描述
Suggestion string `json:"suggestion"` // 修改建议
ClauseRef string `json:"clause_ref"` // 参考标准条款
}
// ContractSection 合同章节
type ContractSection struct {
Title string `json:"title"`
Body string `json:"body"`
}
// ReviewResult 审查结果
type ReviewResult struct {
ContractTitle string `json:"contract_title"`
TotalSections int `json:"total_sections"`
TotalRisks int `json:"total_risks"`
CriticalRisks int `json:"critical_risks"`
HighRisks int `json:"high_risks"`
MediumRisks int `json:"medium_risks"`
LowRisks int `json:"low_risks"`
Score int `json:"score"`
RiskSummary string `json:"risk_summary"`
Suggestions string `json:"suggestions"`
Risks []RiskItem `json:"risks"`
Sections []ContractSection `json:"sections"`
ReviewedAt string `json:"reviewed_at"`
}
// ReviewContract POST /api/contract/review —— 合同审查
func ReviewContract(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var req ContractReviewRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Content == "" {
web.Fail(c, web.NewBadRequest("content 必填"))
return
}
req.RiskStandard = req.RiskStandard
if req.RiskStandard == "" {
req.RiskStandard = "standard"
}
result := runContractReview(req)
logContractReview(user.ID, req.Content, result)
web.OK(c, result)
}
// runContractReview 执行合同审查
func runContractReview(req ContractReviewRequest) ReviewResult {
sections := parseContractSections(req.Content)
risks := make([]RiskItem, 0)
// 1. 条款完整性检查
risks = append(risks, checkClausesCompleteness(sections)...)
// 2. 关键条款检查(付款/违约/争议/解除)
risks = append(risks, checkKeyClauses(sections)...)
// 3. 风险条款检查
risks = append(risks, checkRisks(sections)...)
// 4. AI 深度审查
risks = append(risks, aiDeepReview(req)...)
score := calculateRiskScore(risks)
return ReviewResult{
ContractTitle: extractContractTitle(req.Content),
TotalSections: len(sections),
TotalRisks: len(risks),
CriticalRisks: countByLevel(risks, "critical"),
HighRisks: countByLevel(risks, "high"),
MediumRisks: countByLevel(risks, "medium"),
LowRisks: countByLevel(risks, "low"),
Score: score,
RiskSummary: buildRiskSummary(risks),
Suggestions: buildSuggestions(risks),
Risks: risks,
Sections: sections,
ReviewedAt: time.Now().Format("2006-01-02 15:04:05"),
}
}
// parseContractSections 解析合同章节
func parseContractSections(content string) []ContractSection {
sections := make([]ContractSection, 0)
lines := strings.Split(content, "\n")
currentTitle := ""
var buf strings.Builder
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// 尝试识别章节标题
if isChapterTitle(line) {
if buf.Len() > 0 {
sections = append(sections, ContractSection{
Title: currentTitle,
Body: strings.TrimSpace(buf.String()),
})
buf.Reset()
}
currentTitle = line
} else {
buf.WriteString(line + "\n")
}
}
if buf.Len() > 0 || currentTitle != "" {
sections = append(sections, ContractSection{
Title: currentTitle,
Body: strings.TrimSpace(buf.String()),
})
}
if len(sections) == 0 {
sections = append(sections, ContractSection{
Title: "全文",
Body: content,
})
}
return sections
}
// isChapterTitle 识别章节标题
func isChapterTitle(line string) bool {
patterns := []string{
"第[一二三四五六七八九十]+[条章节]",
"第\\d+条",
"^[0-9]+\\.?.*",
}
for _, p := range patterns {
if strings.Contains(line, p) {
return true
}
}
return strings.Contains(line, ":") && len(line) < 30
}
// checkClausesCompleteness 条款完整性检查
func checkClausesCompleteness(sections []ContractSection) []RiskItem {
risks := make([]RiskItem, 0)
fullText := ""
for _, s := range sections {
fullText += s.Body
}
required := map[string]struct {
pattern string
level string
}{
"合同主体": {"甲方", "critical"},
"合同标的": {"标的", "critical"},
"价款": {"价款", "critical"},
"履行期限": {"期限", "high"},
"支付方式": {"支付", "high"},
"违约责任": {"违约", "high"},
"争议解决": {"争议", "medium"},
"保密条款": {"保密", "medium"},
"不可抗力": {"不可抗力", "medium"},
"验收标准": {"验收", "high"},
}
for name, rule := range required {
if !strings.Contains(fullText, rule.pattern) {
risks = append(risks, RiskItem{
Type: "risk",
Level: rule.level,
Section: name,
Problem: "缺少" + name + "条款",
Suggestion: "建议补充" + name + "条款",
})
}
}
return risks
}
// checkKeyClauses 关键条款检查
func checkKeyClauses(sections []ContractSection) []RiskItem {
risks := make([]RiskItem, 0)
for _, section := range sections {
body := section.Body
title := section.Title
// 付款条款检查
if strings.Contains(title, "付款") || strings.Contains(title, "价款") {
if !strings.Contains(body, "人民币") && !strings.Contains(body, "¥") {
risks = append(risks, RiskItem{
Type: "risk",
Level: "high",
Section: title,
Problem: "付款条款未明确币种",
Suggestion: "建议明确约定币种",
})
}
if !strings.Contains(body, "元") && !strings.Contains(body, "%") {
risks = append(risks, RiskItem{
Type: "risk",
Level: "high",
Section: title,
Problem: "付款条款未约定具体金额",
Suggestion: "建议明确付款金额",
})
}
if !strings.Contains(body, "日") && !strings.Contains(body, "天") {
risks = append(risks, RiskItem{
Type: "risk",
Level: "high",
Section: title,
Problem: "付款条款未约定付款时间",
Suggestion: "建议明确付款时间节点",
})
}
}
// 违约责任检查
if strings.Contains(title, "违约") {
if !strings.Contains(body, "逾期") && !strings.Contains(body, "延迟") {
risks = append(risks, RiskItem{
Type: "risk",
Level: "medium",
Section: title,
Problem: "违约责任未约定逾期责任",
Suggestion: "建议补充逾期履行的违约责任",
})
}
if !strings.Contains(body, "赔偿") && !strings.Contains(body, "补偿") {
risks = append(risks, RiskItem{
Type: "risk",
Level: "high",
Section: title,
Problem: "违约责任未约定赔偿范围",
Suggestion: "建议明确违约赔偿范围",
})
}
}
// 争议解决检查
if strings.Contains(title, "争议") || strings.Contains(title, "纠纷") {
if !strings.Contains(body, "仲裁") && !strings.Contains(body, "法院") {
risks = append(risks, RiskItem{
Type: "risk",
Level: "medium",
Section: title,
Problem: "争议解决未约定仲裁或诉讼",
Suggestion: "建议约定仲裁机构或管辖法院",
})
}
}
// 合同解除检查
if strings.Contains(title, "解除") || strings.Contains(title, "终止") {
if !strings.Contains(body, "提前") && !strings.Contains(body, "通知") {
risks = append(risks, RiskItem{
Type: "risk",
Level: "medium",
Section: title,
Problem: "合同解除未约定提前通知",
Suggestion: "建议约定解除合同的提前通知期限",
})
}
}
}
return risks
}
// checkRisks 风险检查
func checkRisks(sections []ContractSection) []RiskItem {
risks := make([]RiskItem, 0)
fullText := ""
for _, s := range sections {
fullText += s.Body
}
if !strings.Contains(fullText, "不可抗力") {
risks = append(risks, RiskItem{
Type: "risk",
Level: "medium",
Section: "风险条款",
Problem: "缺少不可抗力条款",
Suggestion: "建议添加不可抗力条款,约定通知义务",
})
}
if !strings.Contains(fullText, "保密") {
risks = append(risks, RiskItem{
Type: "risk",
Level: "medium",
Section: "风险条款",
Problem: "缺少保密条款",
Suggestion: "建议添加保密条款,约定保密范围",
})
}
if !strings.Contains(fullText, "知识产权") {
risks = append(risks, RiskItem{
Type: "risk",
Level: "medium",
Section: "风险条款",
Problem: "缺少知识产权条款",
Suggestion: "建议添加知识产权条款,明确成果归属",
})
}
if !strings.Contains(fullText, "验收") && !strings.Contains(fullText, "检验") {
risks = append(risks, RiskItem{
Type: "risk",
Level: "high",
Section: "验收条款",
Problem: "缺少验收条款",
Suggestion: "建议添加验收条款,明确验收标准",
})
}
if !strings.Contains(fullText, "违约金") && !strings.Contains(fullText, "赔偿") {
risks = append(risks, RiskItem{
Type: "risk",
Level: "high",
Section: "违约责任",
Problem: "缺少违约金条款",
Suggestion: "建议添加违约金条款,明确违约情形",
})
}
return risks
}
// aiDeepReview AI 深度审查
func aiDeepReview(req ContractReviewRequest) []RiskItem {
content, err := ai.GenerateWithFallback(nil, []ai.Message{
{Role: "system", Content: "你是一位资深法务专家。请审查以下合同文本,识别法律风险。" +
"重点关注:条款完整性、权责对等性、违约责任、争议解决、不可抗力、保密条款、知识产权。"},
{Role: "user", Content: req.Content},
})
if err != nil {
return nil
}
return parseAIRisks(content)
}
// calculateRiskScore 计算安全评分
func calculateRiskScore(risks []RiskItem) int {
score := 100
for _, r := range risks {
switch r.Level {
case "critical":
score -= 20
case "high":
score -= 15
case "medium":
score -= 10
case "low":
score -= 5
}
}
if score < 0 {
score = 0
}
return score
}
// countByLevel 统计某级别风险数
func countByLevel(risks []RiskItem, level string) int {
n := 0
for _, r := range risks {
if r.Level == level {
n++
}
}
return n
}
// buildRiskSummary 构建风险总结
func buildRiskSummary(risks []RiskItem) string {
critical := make([]string, 0)
for _, r := range risks {
if r.Level == "critical" {
critical = append(critical, r.Problem)
}
}
if len(critical) == 0 {
return "合同整体质量良好,无明显重大风险"
}
return strings.Join(critical, ";")
}
// buildSuggestions 构建建议
func buildSuggestions(risks []RiskItem) string {
suggestions := make([]string, 0)
seen := make(map[string]bool)
for _, r := range risks {
if r.Suggestion != "" && !seen[r.Suggestion] {
suggestions = append(suggestions, r.Suggestion)
seen[r.Suggestion] = true
}
}
return strings.Join(suggestions, ";")
}
// extractContractTitle 提取合同标题
func extractContractTitle(content string) string {
patterns := []string{"合同", "协议", "合约"}
for _, keyword := range patterns {
idx := strings.Index(content, keyword)
if idx >= 0 {
start := idx
for start > 0 && content[start-1] != '\n' && content[start-1] != '\r' {
start--
}
end := idx + len(keyword) + 30
if end > len(content) {
end = len(content)
}
title := strings.TrimSpace(content[start:end])
if len(title) > 50 {
title = title[:50]
}
return title
}
}
return "未命名合同"
}
// parseAIRisks 解析AI返回的风险
func parseAIRisks(content string) []RiskItem {
return nil
}
// logContractReview 记录审查日志
func logContractReview(userID uint, content string, result ReviewResult) {
store.DB.Create(&model.AiCallLog{
UserID: userID,
Capability: "contract_review",
Status: "success",
})
}
@@ -125,6 +125,7 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
r.POST("/api/report/export/pptx", middleware.Auth(cfg), ReportExportPPTX)
r.POST("/api/report/export/xlsx", middleware.Auth(cfg), ReportExportXLSX)
r.POST("/api/report/preview", middleware.Auth(cfg), ReportPreview)
r.POST("/api/contract/review", middleware.Auth(cfg), ReviewContract)
// 文档工具
r.POST("/api/document/translate", middleware.Auth(cfg), TranslateDocument)