release: v1.1.0
移除 V1 死代码(6 个导出方法、4 个内部函数、~300 行), 新增 cache.go/ai.go/matcher.go/export.go 拆分 app.go(原 1645 行), 修复 AICache 3 个并发 bug(TOCTOU、反序列化、全排序), 提取 8 个命名常量,前端添加 isRunning 守卫和 CSS 变量。 Go 升级至 1.24.0。
This commit is contained in:
179
ai.go
Normal file
179
ai.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---------- Deepseek API 类型 ----------
|
||||
|
||||
type deepseekMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type deepseekRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []deepseekMessage `json:"messages"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
}
|
||||
|
||||
type deepseekResponse struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ---------- Deepseek AI 增强匹配 ----------
|
||||
|
||||
// hashPrompt 对 prompt 消息计算 SHA256(用于缓存键)
|
||||
func hashPrompt(messages []deepseekMessage) string {
|
||||
h := sha256.New()
|
||||
for _, m := range messages {
|
||||
h.Write([]byte(m.Role))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(m.Content))
|
||||
h.Write([]byte{0})
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// callDeepseekAPI 调用 Deepseek Chat API(带缓存)
|
||||
func (a *App) callDeepseekAPI(messages []deepseekMessage) (string, error) {
|
||||
if a.deepseekKey == "" {
|
||||
return "", fmt.Errorf("请先设置 Deepseek API 密钥")
|
||||
}
|
||||
|
||||
hash := hashPrompt(messages)
|
||||
|
||||
// 先查缓存
|
||||
if cached, ok := a.aiCache.get(hash); ok {
|
||||
fmt.Printf("[CACHE] ✓ 命中 AI 缓存 (hash=%s)\n", hash[:12])
|
||||
return cached, nil
|
||||
}
|
||||
fmt.Printf("[CACHE] ✗ 缓存未命中 (hash=%s),调用 API...\n", hash[:12])
|
||||
|
||||
reqBody := deepseekRequest{
|
||||
Model: deepseekModel,
|
||||
Messages: messages,
|
||||
Temperature: deepseekTemperature,
|
||||
MaxTokens: deepseekMaxTokens,
|
||||
}
|
||||
|
||||
bodyBytes, _ := json.Marshal(reqBody)
|
||||
httpReq, err := http.NewRequest("POST", "https://api.deepseek.com/v1/chat/completions",
|
||||
bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建请求失败: %v", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+a.deepseekKey)
|
||||
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("调用 Deepseek API 失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBytes, _ := io.ReadAll(resp.Body)
|
||||
var dr deepseekResponse
|
||||
if err := json.Unmarshal(respBytes, &dr); err != nil {
|
||||
return "", fmt.Errorf("解析 Deepseek 响应失败: %v", err)
|
||||
}
|
||||
|
||||
if dr.Error != nil {
|
||||
return "", fmt.Errorf("Deepseek API 错误: %s", dr.Error.Message)
|
||||
}
|
||||
|
||||
if len(dr.Choices) == 0 {
|
||||
return "", fmt.Errorf("Deepseek 未返回有效结果")
|
||||
}
|
||||
|
||||
result := strings.TrimSpace(dr.Choices[0].Message.Content)
|
||||
|
||||
// 写入缓存并持久化
|
||||
a.aiCache.put(hash, result)
|
||||
a.aiCache.saveToFile()
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// buildGenericAIPrompt 构建通用 AI 匹配提示词
|
||||
func (a *App) buildGenericAIPrompt(unmatched, bRows [][]string, config MatchConfig, windowDuration time.Duration, hasTime bool) []deepseekMessage {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("你是一个数据匹配专家。请根据以下 A 表记录,从 B 表数据中找出最匹配的记录。\n\n")
|
||||
sb.WriteString("匹配规则:\n")
|
||||
sb.WriteString("1. 根据文本相似度匹配(注意中文字段的核心含义,忽略字母数字前缀后缀)\n")
|
||||
if hasTime {
|
||||
sb.WriteString(fmt.Sprintf("2. 时间差应在 %.0f 小时内\n", windowDuration.Hours()))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("3. 返回匹配到的 B 表记录的目标列值(第 %d 列)\n\n", config.ColBExtractIndex+1))
|
||||
|
||||
sb.WriteString("请严格按照以下 JSON 格式返回结果:\n")
|
||||
sb.WriteString(`{"matches":[{"index":0,"value":"匹配到的目标列值"},{"index":1,"value":""}]}` + "\n")
|
||||
sb.WriteString("如果某条无法匹配,value 设为空字符串。\n\n")
|
||||
|
||||
sb.WriteString(fmt.Sprintf("A 表记录(需要匹配,共 %d 条):\n", len(unmatched)))
|
||||
for i, row := range unmatched {
|
||||
matchVal := getCell(row, config.ColAMatchIndex)
|
||||
sb.WriteString(fmt.Sprintf("- 索引 %d: 「%s」", i, matchVal))
|
||||
if hasTime {
|
||||
sb.WriteString(fmt.Sprintf(", 时间=%s", getCell(row, config.ColATimeIndex)))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\nB 表参考数据(共 %d 条):\n", len(bRows)))
|
||||
for _, row := range bRows {
|
||||
matchVal := getCell(row, config.ColBMatchIndex)
|
||||
extractVal := getCell(row, config.ColBExtractIndex)
|
||||
sb.WriteString(fmt.Sprintf(" 「%s」 → 目标列值: 「%s」", matchVal, extractVal))
|
||||
if hasTime {
|
||||
sb.WriteString(fmt.Sprintf(", 时间=%s", getCell(row, config.ColBTimeIndex)))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("\n请返回 JSON 格式的匹配结果。")
|
||||
|
||||
return []deepseekMessage{
|
||||
{Role: "system", Content: "你是一个数据匹配专家。请严格按照 JSON 格式返回结果,不要添加额外说明。"},
|
||||
{Role: "user", Content: sb.String()},
|
||||
}
|
||||
}
|
||||
|
||||
// formatTimeDiff 格式化时间差为可读字符串
|
||||
func formatTimeDiff(d time.Duration) string {
|
||||
abs := d
|
||||
if abs < 0 {
|
||||
abs = -abs
|
||||
}
|
||||
hours := int(abs.Hours())
|
||||
mins := int(abs.Minutes()) % 60
|
||||
secs := int(abs.Seconds()) % 60
|
||||
|
||||
sign := ""
|
||||
if d < 0 {
|
||||
sign = "-"
|
||||
}
|
||||
if hours > 0 {
|
||||
return fmt.Sprintf("%s%dh%dm%ds", sign, hours, mins, secs)
|
||||
} else if mins > 0 {
|
||||
return fmt.Sprintf("%s%dm%ds", sign, mins, secs)
|
||||
}
|
||||
return fmt.Sprintf("%s%ds", sign, secs)
|
||||
}
|
||||
Reference in New Issue
Block a user