From 40c3966e9a3c5b4a71c57536e9dffea022ce17ae Mon Sep 17 00:00:00 2001 From: RainySY Date: Mon, 11 May 2026 14:27:15 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E5=85=A8=E9=83=A818=E9=A1=B9=E9=97=AE?= =?UTF-8?q?=E9=A2=98=EF=BC=8C=E9=87=8D=E6=9E=84=E5=AF=BC=E5=87=BA=E4=B8=8E?= =?UTF-8?q?=E5=8C=B9=E9=85=8D=E5=BC=95=E6=93=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A级(严重): - ExportResults 支持 CSV 格式导出和 IncludeHeader 配置,使用实际表头名 - RunMatchWithAI 消除重复文件读取,提取 runMatchOnData() 内部函数 - AI 缓存文件权限收紧至 0600 B级(中等): - 移除废弃代码约400行 (MonthlyReport/DailyReport/StartMatching/DeepseekEnhanceMatching) - 替换自定义 parseCSVLine 为标准 encoding/csv - GetAICacheInfo 返回命名结构体 AICacheInfo - 时间差排序改为数值比较 - App.vue 提取 buildMatchConfig() 工厂函数消除配置重复 - AllMatches=false 时命中 1.0 相似度可提前结束 B 表循环 C级(轻微): - 魔法数字提取为命名常量 - main.go 替换 println 为 log.Fatalf - 清理未使用变量 Co-Authored-By: Claude Opus 4.7 --- app.go | 936 ++++++++++++------------------------------- frontend/src/App.vue | 53 +-- main.go | 3 +- 3 files changed, 288 insertions(+), 704 deletions(-) diff --git a/app.go b/app.go index 9561c83..2174bba 100644 --- a/app.go +++ b/app.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/sha256" + "encoding/csv" "encoding/hex" "encoding/json" "fmt" @@ -24,22 +25,7 @@ import ( // ---------- 数据结构 ---------- -// MonthlyRecord 月报记录(向后兼容) -type MonthlyRecord struct { - CellName string `json:"cellName"` - OccurTime time.Time `json:"-"` - OccurTimeStr string `json:"occurTimeStr"` -} - -// DailyRecord 日报记录(向后兼容) -type DailyRecord struct { - CellID string `json:"cellId"` - OccurTime time.Time `json:"-"` - OccurTimeStr string `json:"occurTimeStr"` - InterruptReason string `json:"interruptReason"` -} - -// MatchResult 匹配结果(新旧字段兼容) +// MatchResult 匹配结果 type MatchResult struct { // 新字段(通用化) RowAData []string `json:"rowAData"` // A 表原始所有列(新) @@ -121,6 +107,12 @@ type deepseekResponse struct { // ---------- AI 缓存 ---------- +// AICacheInfo 缓存信息(前端展示用) +type AICacheInfo struct { + Count int `json:"count"` + FilePath string `json:"filePath"` +} + // AICacheEntry 单条缓存记录 type AICacheEntry struct { PromptHash string `json:"promptHash"` @@ -139,6 +131,16 @@ type AICache struct { // cacheFileName 缓存文件名 const cacheFileName = "data-matcher-ai-cache.json" +// 默认常量 +const ( + defaultThreshold = 0.65 + defaultTimeWindowH = 12.0 + defaultBatchSize = 8 + defaultMaxPreview = 3 + defaultMaxBNoTime = 200 + defaultAIWindowPadH = 3.0 +) + // newAICache 创建缓存实例并加载已有数据 func newAICache() *AICache { c := &AICache{ @@ -168,7 +170,7 @@ func (c *AICache) saveToFile() { if err != nil { return } - _ = os.WriteFile(c.filePath, data, 0644) + _ = os.WriteFile(c.filePath, data, 0600) } // get 根据 hash 查找缓存,命中返回响应,否则返回空 @@ -234,6 +236,11 @@ type App struct { ctx context.Context deepseekKey string aiCache *AICache + + // 最近一次匹配的配置和表头(供导出使用) + lastConfig MatchConfig + headersA []string + headersB []string } // NewApp 创建 App 实例 @@ -285,12 +292,9 @@ func (a *App) ClearAICache() string { } // GetAICacheInfo 返回 AI 缓存信息(条目数、文件路径) -func (a *App) GetAICacheInfo() map[string]interface{} { +func (a *App) GetAICacheInfo() AICacheInfo { count, path := a.aiCache.stat() - return map[string]interface{}{ - "count": count, - "filePath": path, - } + return AICacheInfo{Count: count, FilePath: path} } // ---------- 文件选择对话框 ---------- @@ -496,29 +500,28 @@ func (a *App) readRawRows(path string) ([][]string, error) { } func (a *App) readCSVRaw(path string) ([][]string, error) { - content, err := os.ReadFile(path) + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("打开 CSV 文件失败: %v", err) + } + defer f.Close() + + reader := csv.NewReader(f) + reader.LazyQuotes = true + reader.TrimLeadingSpace = true + allRows, err := reader.ReadAll() if err != nil { return nil, fmt.Errorf("读取 CSV 文件失败: %v", err) } - - lines := strings.Split(string(content), "\n") - if len(lines) < 2 { + if len(allRows) < 2 { return nil, fmt.Errorf("CSV 文件至少需要标题行和一条数据") } - - var rows [][]string - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue + for i := range allRows { + for j := range allRows[i] { + allRows[i][j] = strings.TrimSpace(allRows[i][j]) } - fields := parseCSVLine(line) - rows = append(rows, fields) } - if len(rows) < 2 { - return nil, fmt.Errorf("CSV 文件无有效数据行") - } - return rows, nil + return allRows, nil } func (a *App) readExcelRaw(path string) ([][]string, error) { @@ -547,282 +550,24 @@ func getCell(row []string, idx int) string { return strings.TrimSpace(row[idx]) } -// parseCSVLine 简单 CSV 行解析(支持双引号包裹字段和转义双引号 "" → ") -func parseCSVLine(line string) []string { - var fields []string - var current strings.Builder - inQuotes := false - runes := []rune(line) - - for i := 0; i < len(runes); i++ { - ch := runes[i] - switch { - case ch == '"': - if inQuotes && i+1 < len(runes) && runes[i+1] == '"' { - current.WriteRune('"') - i++ - } else { - inQuotes = !inQuotes - } - case ch == ',' && !inQuotes: - fields = append(fields, strings.TrimSpace(current.String())) - current.Reset() - default: - current.WriteRune(ch) - } - } - fields = append(fields, strings.TrimSpace(current.String())) - return fields -} - -// ---------- 动态表头索引映射 ---------- - -// findColumnIndexExact 在表头行中查找与候选词【精确相等】的第一列索引,未找到返回 -1 -// 使用 strings.EqualFold 做大小写不敏感的精确匹配,两端 TrimSpace -func findColumnIndexExact(headers []string, candidates ...string) int { - for i, h := range headers { - trimmed := strings.TrimSpace(h) - for _, c := range candidates { - if strings.EqualFold(trimmed, strings.TrimSpace(c)) { - return i - } - } - } - return -1 -} - -// ---------- 读取月报 ---------- - -func (a *App) readMonthlyReport(path string) ([]MonthlyRecord, error) { - allRows, err := a.readRawRows(path) - if err != nil { - return nil, err - } - - // 第 1 行是表头 - headers := allRows[0] - fmt.Printf("[DEBUG] 月报全部表头 (共%d列): %v\n", len(headers), headers) - - // 动态找关键列的索引 - cellNameIdx := findColumnIndexExact(headers, "小区名称", "小区名", "cellname", "cell name", "小区") - timeIdx := findColumnIndexExact(headers, "告警发生时间", "发生时间", "告警时间", "时间", "occurtime", "occur time") - - // 回退策略:找不到时用前两列 - if cellNameIdx == -1 && len(headers) >= 1 { - cellNameIdx = 0 - } - if timeIdx == -1 && len(headers) >= 2 { - timeIdx = 1 - } - - fmt.Printf("[DEBUG] 月报列索引: 小区名称 idx=%d, 时间 idx=%d\n", cellNameIdx, timeIdx) - if cellNameIdx == -1 || timeIdx == -1 { - return nil, fmt.Errorf("无法识别月报表头,需要包含「小区名称」和「告警发生时间」列") - } - - var records []MonthlyRecord - for rowNum, row := range allRows[1:] { - name := getCell(row, cellNameIdx) - timeStr := getCell(row, timeIdx) - if name == "" || timeStr == "" { - continue - } - // 月报专用 Layout: 2006-01-02 15:04:05 - t, err := time.Parse("2006-01-02 15:04:05", timeStr) - if err != nil { - fmt.Printf("[DEBUG] 月报时间解析失败 L%d | 原始: '%s' | 错误: %v\n", rowNum+2, timeStr, err) - continue - } - records = append(records, MonthlyRecord{ - CellName: name, - OccurTime: t, - OccurTimeStr: t.Format("2006-01-02 15:04:05"), - }) - } - fmt.Printf("[DEBUG] 月报有效记录数: %d (共 %d 行数据)\n", len(records), len(allRows)-1) - return records, nil -} - -// ---------- 读取日报 ---------- - -func (a *App) readDailyReport(path string) ([]DailyRecord, error) { - allRows, err := a.readRawRows(path) - if err != nil { - return nil, err - } - - // 第 1 行是表头 - headers := allRows[0] - fmt.Printf("[DEBUG] 日报全部表头 (共%d列): %v\n", len(headers), headers) - - // 动态找关键列的索引 - cellIDIdx := findColumnIndexExact(headers, "小区号", "小区编号", "小区id", "cellid", "cell id", "小区") - timeIdx := findColumnIndexExact(headers, "发生时间", "时间", "occurtime", "occur time") - reasonIdx := findColumnIndexExact(headers, "中断原因", "原因", "reason", "中断", "中断原因", "failure reason") - - // 回退策略 - if cellIDIdx == -1 && len(headers) >= 1 { - cellIDIdx = 0 - } - if timeIdx == -1 && len(headers) >= 2 { - timeIdx = 1 - } - if reasonIdx == -1 && len(headers) >= 3 { - reasonIdx = 2 - } - - fmt.Printf("[DEBUG] 日报列索引: 小区号 idx=%d, 时间 idx=%d, 中断原因 idx=%d\n", cellIDIdx, timeIdx, reasonIdx) - if cellIDIdx == -1 || timeIdx == -1 || reasonIdx == -1 { - return nil, fmt.Errorf("无法识别日报表头,需要包含「小区号」「发生时间」「中断原因」列") - } - - var records []DailyRecord - for rowNum, row := range allRows[1:] { - cellID := getCell(row, cellIDIdx) - timeStr := getCell(row, timeIdx) - reason := getCell(row, reasonIdx) - if cellID == "" || timeStr == "" { - continue - } - // 日报专用 Layout: 2006/1/2 15:04(支持单双位数的月/日) - t, err := time.Parse("2006/1/2 15:04", timeStr) - if err != nil { - fmt.Printf("[DEBUG] 日报时间解析失败 L%d | 原始: '%s' | 错误: %v\n", rowNum+2, timeStr, err) - continue - } - records = append(records, DailyRecord{ - CellID: cellID, - OccurTime: t, - OccurTimeStr: t.Format("2006-01-02 15:04:05"), - InterruptReason: reason, - }) - } - fmt.Printf("[DEBUG] 日报有效记录数: %d (共 %d 行数据)\n", len(records), len(allRows)-1) - return records, nil -} - -// ---------- 核心匹配引擎 ---------- - -// StartMatching 执行多维智能匹配(带进度推送) -func (a *App) StartMatching(monthlyPath, dailyPath string) ([]MatchResult, error) { - a.emitProgress(0, 100, "正在读取月报文件...", "reading") - monthlyRecords, err := a.readMonthlyReport(monthlyPath) - if err != nil { - return nil, fmt.Errorf("读取月报失败: %v", err) - } - - a.emitProgress(0, 100, "正在读取日报文件...", "reading") - dailyRecords, err := a.readDailyReport(dailyPath) - if err != nil { - return nil, fmt.Errorf("读取日报失败: %v", err) - } - - if len(monthlyRecords) == 0 { - return nil, fmt.Errorf("月报中无有效记录(或时间解析失败)") - } - if len(dailyRecords) == 0 { - return nil, fmt.Errorf("日报中无有效记录(或时间解析失败)") - } - - twelveHours := 12 * time.Hour - totalMonthly := len(monthlyRecords) - var results []MatchResult - - for i, mr := range monthlyRecords { - if i%10 == 0 || i == totalMonthly-1 { - pct := (i + 1) * 100 / totalMonthly - a.emitProgress(i+1, totalMonthly, - fmt.Sprintf("正在匹配第 %d/%d 条月报记录 (%d%%)...", i+1, totalMonthly, pct), - "matching") - } - - var bestMatch *DailyRecord - bestSimilarity := 0.0 - bestTimeDiff := time.Duration(0) - cleanMonthly := a.CleanString(mr.CellName) - - for _, dr := range dailyRecords { - // 步骤一:时间剪枝 — 保留时间差在 ±12h 内的记录 - timeDiff := mr.OccurTime.Sub(dr.OccurTime) - if timeDiff < -twelveHours || timeDiff > twelveHours { - continue - } - - // 步骤二:计算清洗后中文名称的相似度 - cleanDaily := a.CleanString(dr.CellID) - if cleanMonthly == "" || cleanDaily == "" { - continue - } - - similarity := a.CalculateSimilarity(mr.CellName, dr.CellID) - - if i < 5 { - fmt.Printf("[DEBUG] 比对 | 月报='%s'→清洗='%s' | 日报='%s'→清洗='%s' | 时间差=%v | 相似度=%.4f\n", - mr.CellName, cleanMonthly, dr.CellID, cleanDaily, timeDiff, similarity) - } - - // 阈值 0.65:只保留高置信度匹配 - if similarity >= 0.65 && similarity > bestSimilarity { - bestSimilarity = similarity - bestMatch = &dr - bestTimeDiff = timeDiff - } - } - - if bestMatch != nil { - if i < 5 { - fmt.Printf("[DEBUG] ✓ 命中 | 月报='%s'→日报='%s' | 相似度=%.4f | 时间差=%v\n", - mr.CellName, bestMatch.CellID, bestSimilarity, bestTimeDiff) - } - results = append(results, MatchResult{ - MonthlyCellName: mr.CellName, - DailyCellID: bestMatch.CellID, - TimeDiff: formatTimeDiff(bestTimeDiff), - SimilarityScore: math.Round(bestSimilarity*10000) / 10000, - InterruptReason: bestMatch.InterruptReason, - AIMatched: false, - }) - } else { - if i < 5 { - fmt.Printf("[DEBUG] ✗ 未命中 | 月报='%s'(清洗='%s') | 未找到匹配\n", - mr.CellName, cleanMonthly) - } - } - } - - a.emitProgress(totalMonthly, totalMonthly, - fmt.Sprintf("匹配完成!共匹配成功 %d 条记录", len(results)), "done") - - return results, nil -} - -// ---------- 通用匹配引擎 ---------- // RunMatch 接收完整 MatchConfig,按列索引执行通用匹配 func (a *App) RunMatch(config MatchConfig) ([]MatchResult, error) { // 1. 编译正则 - var reg *regexp.Regexp - if config.RegexPattern != "" { - var err error - reg, err = regexp.Compile(config.RegexPattern) - if err != nil { - return nil, fmt.Errorf("正则表达式格式错误,请检查: %v", err) - } - fmt.Printf("[DEBUG] RunMatch 使用正则: '%s'\n", config.RegexPattern) - } else { - fmt.Printf("[DEBUG] RunMatch 跳过清洗(正则为空)\n") + reg, err := compileRegex(config.RegexPattern) + if err != nil { + return nil, err } // 2. 默认值兜底 timeWindow := config.TimeWindow if timeWindow <= 0 { - timeWindow = 12 + timeWindow = defaultTimeWindowH } threshold := config.Threshold if threshold <= 0 { - threshold = 0.65 + threshold = defaultThreshold } - useTime := config.ColATimeIndex >= 0 && config.ColBTimeIndex >= 0 // 3. 读取原始数据 a.emitProgress(0, 100, "正在读取 A 表...", "reading") @@ -842,18 +587,28 @@ func (a *App) RunMatch(config MatchConfig) ([]MatchResult, error) { return nil, fmt.Errorf("B 表无有效数据行") } - aHeaders := rowsA[0] - _ = aHeaders // 保留表头引用(将来导出时可能用到) + // 保存表头供导出使用 + a.headersA = rowsA[0] + a.headersB = rowsB[0] + a.lastConfig = config + dataA := rowsA[1:] dataB := rowsB[1:] windowDuration := time.Duration(timeWindow * float64(time.Hour)) + + return a.runMatchOnData(dataA, dataB, reg, config, timeWindow, threshold, windowDuration) +} + +// runMatchOnData 在已读取的数据上执行匹配(内部使用,避免重复 I/O) +func (a *App) runMatchOnData(dataA, dataB [][]string, reg *regexp.Regexp, config MatchConfig, _, threshold float64, windowDuration time.Duration) ([]MatchResult, error) { + useTime := config.ColATimeIndex >= 0 && config.ColBTimeIndex >= 0 totalA := len(dataA) var results []MatchResult useAllMatches := config.AllMatches maxPreview := config.MaxPreview if maxPreview <= 0 { - maxPreview = 3 + maxPreview = defaultMaxPreview } for i, rowA := range dataA { @@ -872,7 +627,10 @@ func (a *App) RunMatch(config MatchConfig) ([]MatchResult, error) { var hasTimeA bool if useTime { t, err := parseTimeFlexible(getCell(rowA, config.ColATimeIndex)) - if err == nil { timeA = t; hasTimeA = true } + if err == nil { + timeA = t + hasTimeA = true + } } cleanA := cleanWithRegex(matchStrA, reg) @@ -881,19 +639,27 @@ func (a *App) RunMatch(config MatchConfig) ([]MatchResult, error) { for _, rowB := range dataB { matchStrB := getCell(rowB, config.ColBMatchIndex) - if matchStrB == "" { continue } + if matchStrB == "" { + continue + } var timeDiff time.Duration if hasTimeA && useTime { tB, err := parseTimeFlexible(getCell(rowB, config.ColBTimeIndex)) - if err != nil { continue } + if err != nil { + continue + } td := timeA.Sub(tB) - if td < -windowDuration || td > windowDuration { continue } + if td < -windowDuration || td > windowDuration { + continue + } timeDiff = td } cleanB := cleanWithRegex(matchStrB, reg) - if cleanA == "" || cleanB == "" { continue } + if cleanA == "" || cleanB == "" { + continue + } similarity := calcSimilarity(matchStrA, matchStrB, reg, config.CaseSensitive) @@ -913,8 +679,14 @@ func (a *App) RunMatch(config MatchConfig) ([]MatchResult, error) { } if useAllMatches { candidates = append(candidates, mr) - } else if len(candidates) == 0 || similarity > candidates[0].SimilarityScore { - candidates = []MatchResult{mr} + } else { + if len(candidates) == 0 || similarity > candidates[0].SimilarityScore { + candidates = []MatchResult{mr} + } + // B7: 完美匹配时提前退出 + if similarity == 1.0 { + break + } } } } @@ -931,13 +703,15 @@ func (a *App) RunMatch(config MatchConfig) ([]MatchResult, error) { } // 结果排序 - if config.SortBy == "similarity" { + switch config.SortBy { + case "similarity": sort.Slice(results, func(i, j int) bool { return results[i].SimilarityScore > results[j].SimilarityScore }) - } else if config.SortBy == "timeDiff" { + case "timeDiff": + // B5: 使用原始数据重新解析时间差做数值排序 sort.Slice(results, func(i, j int) bool { - return results[i].TimeDiff < results[j].TimeDiff + return parseTimeDiffDuration(results[i].TimeDiff) < parseTimeDiffDuration(results[j].TimeDiff) }) } @@ -947,35 +721,89 @@ func (a *App) RunMatch(config MatchConfig) ([]MatchResult, error) { return results, nil } +// compileRegex 编译正则,nil 表示跳过清洗 +func compileRegex(pattern string) (*regexp.Regexp, error) { + if pattern == "" { + return nil, nil + } + reg, err := regexp.Compile(pattern) + if err != nil { + return nil, fmt.Errorf("正则表达式格式错误,请检查: %v", err) + } + fmt.Printf("[DEBUG] 使用正则: '%s'\n", pattern) + return reg, nil +} + +// parseTimeDiffDuration 将 TimeDiff 字符串(如 "1h30m")解析为 time.Duration(用于排序) +func parseTimeDiffDuration(s string) time.Duration { + if s == "" { + return 0 + } + sign := time.Duration(1) + if s[0] == '-' { + sign = -1 + s = s[1:] + } + d, err := time.ParseDuration(s) + if err != nil { + return 0 + } + return sign * d +} + // RunMatchWithAI 执行基础匹配 + Deepseek AI 增强匹配(配置驱动) func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) { if a.deepseekKey == "" { return nil, fmt.Errorf("请先设置 Deepseek API 密钥") } - // 1. 先执行基础匹配 - results, err := a.RunMatch(config) + // 1. 编译正则 + reg, err := compileRegex(config.RegexPattern) if err != nil { return nil, err } - // 2. 重新读取数据,找出未被基础匹配覆盖的 A 表行 + // 2. 默认值兜底 + timeWindow := config.TimeWindow + if timeWindow <= 0 { + timeWindow = defaultTimeWindowH + } + threshold := config.Threshold + if threshold <= 0 { + threshold = defaultThreshold + } + windowDuration := time.Duration(timeWindow * float64(time.Hour)) + + // 3. 一次读取数据,供匹配和 AI 使用 + a.emitProgress(0, 100, "正在读取 A 表...", "reading") rowsA, err := a.readRawRows(config.FileAPath) if err != nil { return nil, fmt.Errorf("读取 A 表失败: %v", err) } + a.emitProgress(0, 100, "正在读取 B 表...", "reading") rowsB, err := a.readRawRows(config.FileBPath) if err != nil { return nil, fmt.Errorf("读取 B 表失败: %v", err) } if len(rowsA) < 2 || len(rowsB) < 2 { - return results, nil + return nil, fmt.Errorf("数据表无有效数据行") } + // 保存表头供导出使用 + a.headersA = rowsA[0] + a.headersB = rowsB[0] + a.lastConfig = config + dataA := rowsA[1:] dataB := rowsB[1:] - // 用 RowAData 快速判断哪些 A 行已经被匹配 + // 4. 先执行基础匹配(使用已读取的数据,避免重复 I/O) + results, err := a.runMatchOnData(dataA, dataB, reg, config, timeWindow, threshold, windowDuration) + if err != nil { + return nil, err + } + + // 5. 找出未被基础匹配覆盖的 A 表行 matchedSet := make(map[string]bool) for _, r := range results { matchedSet[strings.Join(r.RowAData, "\x00")] = true @@ -993,30 +821,23 @@ func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) { return results, nil } - // 3. AI 增强匹配 - timeWindow := config.TimeWindow - if timeWindow <= 0 { - timeWindow = 12 - } - windowDuration := time.Duration(timeWindow * float64(time.Hour)) + // 6. AI 增强匹配 useTime := config.ColATimeIndex >= 0 && config.ColBTimeIndex >= 0 - totalUnmatched := len(unmatchedA) a.emitProgress(0, totalUnmatched, fmt.Sprintf("AI 增强匹配:还有 %d 条未匹配记录,正在调用 Deepseek...", totalUnmatched), "ai-enhancing") - batchSize := 8 aiMatched := 0 - for batchStart := 0; batchStart < totalUnmatched; batchStart += batchSize { - end := batchStart + batchSize + for batchStart := 0; batchStart < totalUnmatched; batchStart += defaultBatchSize { + end := batchStart + defaultBatchSize if end > totalUnmatched { end = totalUnmatched } a.emitProgress(batchStart+1, totalUnmatched, - fmt.Sprintf("AI 分析中 %d/%d (第 %d 批)...", end, totalUnmatched, (batchStart/batchSize)+1), + fmt.Sprintf("AI 分析中 %d/%d (第 %d 批)...", end, totalUnmatched, (batchStart/defaultBatchSize)+1), "ai-enhancing") batch := unmatchedA[batchStart:end] @@ -1047,7 +868,7 @@ func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) { // 过滤 B 表在时间窗口内的行(用户配置时间窗口 + 额外余量覆盖批次跨度) var relevantB [][]string if hasBatchTime && useTime { - padding := windowDuration + 3*time.Hour + padding := windowDuration + time.Duration(defaultAIWindowPadH)*time.Hour ws := minTime.Add(-padding) we := maxTime.Add(padding) for _, row := range dataB { @@ -1059,7 +880,7 @@ func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) { } } else { // 无时间列时限制 B 表条数以控制 token 消耗 - maxB := 200 + maxB := defaultMaxBNoTime if len(dataB) < maxB { maxB = len(dataB) } @@ -1090,7 +911,7 @@ func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) { } if parseErr != nil { fmt.Printf("[AI-WARN] 响应解析失败 (第 %d 批): %s\n 原始响应: %.200s\n", - (batchStart/batchSize)+1, parseErr.Error(), aiResp) + (batchStart/defaultBatchSize)+1, parseErr.Error(), aiResp) continue } @@ -1239,286 +1060,6 @@ func (a *App) callDeepseekAPI(messages []deepseekMessage) (string, error) { return result, nil } -// buildAIPrompt 构建 AI 匹配提示词(仅传入时间窗口内的日报记录以减少 token) -func buildAIPrompt(monthlyRecords []MonthlyRecord, dailyRecords []DailyRecord, batchStart, batchSize int) []deepseekMessage { - end := batchStart + batchSize - if end > len(monthlyRecords) { - end = len(monthlyRecords) - } - batch := monthlyRecords[batchStart:end] - - // 计算本批月报的时间范围 - var minTime, maxTime time.Time - hasTime := false - for _, mr := range batch { - if !hasTime { - minTime = mr.OccurTime - maxTime = mr.OccurTime - hasTime = true - } else { - if mr.OccurTime.Before(minTime) { - minTime = mr.OccurTime - } - if mr.OccurTime.After(maxTime) { - maxTime = mr.OccurTime - } - } - } - - // 时间窗口前后各扩展 3 小时,确保覆盖 AI 匹配所需的 ±2h - paddingHours := 3 * time.Hour - windowStart := minTime.Add(-paddingHours) - windowEnd := maxTime.Add(paddingHours) - - // 过滤日报:只保留在时间窗口内的记录,并使用 set 去重(按小区号+时间+原因) - seen := make(map[string]bool) - type indexedDaily struct { - idx int - rec DailyRecord - } - var relevant []indexedDaily - for i, dr := range dailyRecords { - if dr.OccurTime.Before(windowStart) || dr.OccurTime.After(windowEnd) { - continue - } - key := dr.CellID + "|" + dr.OccurTimeStr + "|" + dr.InterruptReason - if seen[key] { - continue - } - seen[key] = true - relevant = append(relevant, indexedDaily{idx: i, rec: dr}) - } - - var sb strings.Builder - sb.WriteString(`你是一个通信网络数据匹配专家。请根据以下月报记录,从日报数据中找出最匹配的「中断原因」。 - -匹配规则: -1. 首先根据「发生时间」匹配,时间差应在 ±2 小时内 -2. 然后根据「小区名称」匹配:小区名称可能包含字母数字前缀后缀(如 LTESF1_32、2100_2 等),请着重对比其中的中文字段 -3. 如果找到匹配项,返回中断原因;如果找不到,返回空字符串 - -请严格按照以下 JSON 格式返回结果: -{"matches":[{"index":0,"reason":"中断原因或空字符串"},...]} - -`) - - sb.WriteString("以下是需要匹配的月报记录列表(每条包含索引、小区名称、时间):\n") - for offset, mr := range batch { - sb.WriteString(fmt.Sprintf("- 索引 %d: 小区=", batchStart+offset)) - sb.WriteString(mr.CellName) - sb.WriteString(", 时间=") - sb.WriteString(mr.OccurTimeStr) - sb.WriteString("\n") - } - - // 输出时间窗口内的日报记录(去重后) - sb.WriteString(fmt.Sprintf("\n以下是与本批时间窗口匹配的日报记录列表(共 %d 条,供匹配参考):\n", len(relevant))) - for _, item := range relevant { - sb.WriteString(fmt.Sprintf(" D%d: 小区号=%s, 时间=%s, 中断原因=%s\n", - item.idx, item.rec.CellID, item.rec.OccurTimeStr, item.rec.InterruptReason)) - } - - // 如果没有相关日报记录,提示 AI - if len(relevant) == 0 { - sb.WriteString("\n注意:本批无时间窗口内的日报记录,请将所有 reason 设为空字符串。\n") - } - - sb.WriteString("\n请返回 JSON 格式的匹配结果,包含每个索引对应的中断原因。如果某条记录无法匹配,对应的 reason 设为空字符串。") - - return []deepseekMessage{ - {Role: "system", Content: "你是一个数据匹配专家。请严格按照 JSON 格式返回结果,不要添加额外说明。"}, - {Role: "user", Content: sb.String()}, - } -} - -// parseAIResponse 解析 Deepseek 返回的 JSON 结果 -type aiMatchItem struct { - Index int `json:"index"` - Reason string `json:"reason"` -} - -type aiMatchResponse struct { - Matches []aiMatchItem `json:"matches"` -} - -// DeepseekEnhanceMatching 使用 Deepseek AI 进行增强匹配(月报/日报专用,已弃用) -// Deprecated: 请使用 RunMatchWithAI(config MatchConfig),它基于通用列映射配置 -func (a *App) DeepseekEnhanceMatching(monthlyPath, dailyPath string) ([]MatchResult, error) { - if a.deepseekKey == "" { - return nil, fmt.Errorf("请先设置 Deepseek API 密钥(点击「配置 API」按钮)") - } - - // 先运行基础匹配获取结果 - a.emitProgress(0, 100, "正在读取数据文件...", "reading") - monthlyRecords, err := a.readMonthlyReport(monthlyPath) - if err != nil { - return nil, fmt.Errorf("读取月报失败: %v", err) - } - dailyRecords, err := a.readDailyReport(dailyPath) - if err != nil { - return nil, fmt.Errorf("读取日报失败: %v", err) - } - if len(monthlyRecords) == 0 || len(dailyRecords) == 0 { - return nil, fmt.Errorf("数据文件中无有效记录") - } - - twelveHours := 12 * time.Hour - totalMonthly := len(monthlyRecords) - - // 使用基础算法先跑一遍,收集结果和未匹配的记录 - a.emitProgress(0, totalMonthly, "正在运行基础匹配...", "matching") - var results []MatchResult - var unmatchedMonthly []MonthlyRecord - - for i, mr := range monthlyRecords { - if i%20 == 0 { - a.emitProgress(i+1, totalMonthly, - fmt.Sprintf("基础匹配中 %d/%d...", i+1, totalMonthly), "matching") - } - - var bestMatch *DailyRecord - bestSimilarity := 0.0 - bestTimeDiff := time.Duration(0) - cleanMonthly := a.CleanString(mr.CellName) - - for _, dr := range dailyRecords { - // 步骤一:时间剪枝 — 保留时间差在 ±12h 内的记录 - timeDiff := mr.OccurTime.Sub(dr.OccurTime) - if timeDiff < -twelveHours || timeDiff > twelveHours { - continue - } - - cleanDaily := a.CleanString(dr.CellID) - if cleanMonthly == "" || cleanDaily == "" { - continue - } - - similarity := a.CalculateSimilarity(mr.CellName, dr.CellID) - if similarity >= 0.65 && similarity > bestSimilarity { - bestSimilarity = similarity - bestMatch = &dr - bestTimeDiff = timeDiff - } - } - - if bestMatch != nil { - if i < 5 { - fmt.Printf("[DEBUG-AI] ✓ 基础命中 | 月报='%s'→日报='%s' | 相似度=%.4f | 时间差=%v\n", - mr.CellName, bestMatch.CellID, bestSimilarity, bestTimeDiff) - } - results = append(results, MatchResult{ - MonthlyCellName: mr.CellName, - DailyCellID: bestMatch.CellID, - TimeDiff: formatTimeDiff(bestTimeDiff), - SimilarityScore: math.Round(bestSimilarity*10000) / 10000, - InterruptReason: bestMatch.InterruptReason, - AIMatched: false, - }) - } else { - unmatchedMonthly = append(unmatchedMonthly, mr) - if i < 5 { - fmt.Printf("[DEBUG-AI] ✗ 基础未命中 | 月报='%s'(清洗='%s')\n", - mr.CellName, cleanMonthly) - } - } - } - - // 如果没有未匹配的记录,直接返回 - if len(unmatchedMonthly) == 0 { - a.emitProgress(totalMonthly, totalMonthly, "全部已匹配,无需 AI 增强", "done") - return results, nil - } - - a.emitProgress(0, len(unmatchedMonthly), - fmt.Sprintf("AI 增强匹配:还有 %d 条未匹配记录,正在调用 Deepseek...", len(unmatchedMonthly)), - "ai-enhancing") - - // 预建日报索引 map:按中断原因快速查找(仅构建一次) - reasonMap := make(map[string][]DailyRecord) - for _, dr := range dailyRecords { - reasonMap[dr.InterruptReason] = append(reasonMap[dr.InterruptReason], dr) - } - - // 分批调用 Deepseek API(每批 8 条) - batchSize := 8 - totalUnmatched := len(unmatchedMonthly) - aiMatched := 0 - - for batchStart := 0; batchStart < totalUnmatched; batchStart += batchSize { - end := batchStart + batchSize - if end > totalUnmatched { - end = totalUnmatched - } - - a.emitProgress(batchStart+1, totalUnmatched, - fmt.Sprintf("AI 分析中 %d/%d (第 %d 批)...", end, totalUnmatched, (batchStart/batchSize)+1), - "ai-enhancing") - - prompt := buildAIPrompt(unmatchedMonthly, dailyRecords, batchStart, batchSize) - aiResp, err := a.callDeepseekAPI(prompt) - if err != nil { - // AI 调用失败,跳过这批 - continue - } - - // 解析 AI 返回的 JSON - var matchResp aiMatchResponse - if err := json.Unmarshal([]byte(aiResp), &matchResp); err != nil { - // 尝试从 ```json 块中提取 - if idx := strings.Index(aiResp, "{"); idx >= 0 { - if endIdx := strings.LastIndex(aiResp, "}"); endIdx > idx { - json.Unmarshal([]byte(aiResp[idx:endIdx+1]), &matchResp) - } - } - } - - for _, item := range matchResp.Matches { - idx := item.Index - reason := strings.TrimSpace(item.Reason) - if idx < 0 || idx >= len(unmatchedMonthly) || reason == "" { - continue - } - - mr := unmatchedMonthly[idx] - // 优先精确匹配中断原因 - if matchedDRs, ok := reasonMap[reason]; ok { - // 取时间最接近的一条日报 - var bestDR *DailyRecord - var bestDiff time.Duration - for k := range matchedDRs { - diff := mr.OccurTime.Sub(matchedDRs[k].OccurTime) - absDiff := diff - if absDiff < 0 { - absDiff = -absDiff - } - if bestDR == nil || absDiff < bestDiff { - bestDR = &matchedDRs[k] - bestDiff = absDiff - } - } - if bestDR != nil { - timeDiff := mr.OccurTime.Sub(bestDR.OccurTime) - similarity := a.CalculateSimilarity(mr.CellName, bestDR.CellID) - results = append(results, MatchResult{ - MonthlyCellName: mr.CellName, - DailyCellID: bestDR.CellID, - TimeDiff: formatTimeDiff(timeDiff), - SimilarityScore: math.Round(similarity*10000) / 10000, - InterruptReason: reason, - AIMatched: true, - }) - aiMatched++ - } - } - } - } - - a.emitProgress(totalUnmatched, totalUnmatched, - fmt.Sprintf("AI 增强完成!基础匹配 %d 条 + AI 补充 %d 条 = 共 %d 条", - len(results)-aiMatched, aiMatched, len(results)), "done") - - return results, nil -} // formatTimeDiff 格式化时间差为可读字符串 func formatTimeDiff(d time.Duration) string { @@ -1544,17 +1085,27 @@ func formatTimeDiff(d time.Duration) string { // ---------- 导出结果 ---------- -// ExportResults 将匹配结果导出为 Excel 文件 +// ExportResults 将匹配结果导出为 Excel 或 CSV 文件 func (a *App) ExportResults(results []MatchResult) (string, error) { if len(results) == 0 { return "", fmt.Errorf("没有匹配结果可以导出") } + isCSV := a.lastConfig.ExportFormat == "csv" + ext := ".xlsx" + filterDisplay := "Excel 文件 (*.xlsx)" + filterPattern := "*.xlsx" + if isCSV { + ext = ".csv" + filterDisplay = "CSV 文件 (*.csv)" + filterPattern = "*.csv" + } + savePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{ Title: "导出匹配结果", - DefaultFilename: fmt.Sprintf("匹配结果_%s.xlsx", time.Now().Format("20060102_150405")), + DefaultFilename: fmt.Sprintf("匹配结果_%s%s", time.Now().Format("20060102_150405"), ext), Filters: []runtime.FileFilter{ - {DisplayName: "Excel 文件 (*.xlsx)", Pattern: "*.xlsx"}, + {DisplayName: filterDisplay, Pattern: filterPattern}, }, }) if err != nil { @@ -1563,78 +1114,75 @@ func (a *App) ExportResults(results []MatchResult) (string, error) { if savePath == "" { return "", nil } - if !strings.HasSuffix(strings.ToLower(savePath), ".xlsx") { - savePath += ".xlsx" + if !strings.HasSuffix(strings.ToLower(savePath), ext) { + savePath += ext } + if isCSV { + return a.exportResultsCSV(results, savePath) + } + return a.exportResultsXLSX(results, savePath) +} + +// exportHeaders 构建导出表头行(使用真实表头或回退默认) +func (a *App) exportHeaders(numACols int) []string { + headers := make([]string, 0, numACols+1) + if len(a.headersA) >= numACols { + for _, h := range a.headersA[:numACols] { + n := h + if n == "" { + n = fmt.Sprintf("Col%d", len(headers)+1) + } + headers = append(headers, n) + } + } else { + for i := 0; i < numACols; i++ { + headers = append(headers, fmt.Sprintf("A-Col%d", i+1)) + } + } + headers = append(headers, "匹配结果(由B表提取)") + return headers +} + +func (a *App) exportResultsXLSX(results []MatchResult, savePath string) (string, error) { f := excelize.NewFile() defer f.Close() sheetName := "匹配结果" f.SetSheetName("Sheet1", sheetName) - // 判断使用新格式还是旧格式 - if len(results) > 0 && len(results[0].RowAData) > 0 { - // 新格式:A 表所有原始列 + 最后追加「匹配结果(由B表提取)」 - numACols := len(results[0].RowAData) - colLetter := func(n int) string { c, _ := excelize.ColumnNumberToName(n + 1); return c } - colNums := make([]int, numACols+1) - for i := 0; i < numACols; i++ { - colNums[i] = i - f.SetCellValue(sheetName, fmt.Sprintf("%s1", colLetter(i)), fmt.Sprintf("A-Col%d", i+1)) - } - extractCol := numACols - colNums[numACols] = extractCol - f.SetCellValue(sheetName, fmt.Sprintf("%s1", colLetter(extractCol)), "匹配结果(由B表提取)") + numACols := len(results[0].RowAData) + colLetter := func(n int) string { c, _ := excelize.ColumnNumberToName(n + 1); return c } + headers := a.exportHeaders(numACols) + extractCol := numACols + + // 表头 + if a.lastConfig.IncludeHeader { + for i, h := range headers { + f.SetCellValue(sheetName, fmt.Sprintf("%s1", colLetter(i)), h) + } headerStyle, _ := f.NewStyle(&excelize.Style{ Font: &excelize.Font{Bold: true, Size: 12, Color: "FFFFFF"}, Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"4472C4"}}, }) f.SetCellStyle(sheetName, "A1", fmt.Sprintf("%s1", colLetter(extractCol)), headerStyle) + } - for i, r := range results { - rowNum := i + 2 - for _, ci := range colNums { - if ci < numACols { - f.SetCellValue(sheetName, fmt.Sprintf("%s%d", colLetter(ci), rowNum), r.RowAData[ci]) - } else { - f.SetCellValue(sheetName, fmt.Sprintf("%s%d", colLetter(ci), rowNum), r.ExtractValue) - } - } + // 数据行 + for i, r := range results { + rowNum := i + 2 + if !a.lastConfig.IncludeHeader { + rowNum = i + 1 } - for ci := range colNums { - f.SetColWidth(sheetName, colLetter(ci), colLetter(ci), 22) + for ci := 0; ci < numACols; ci++ { + f.SetCellValue(sheetName, fmt.Sprintf("%s%d", colLetter(ci), rowNum), r.RowAData[ci]) } - } else { - // 旧格式 向后兼容 - headers := []string{"月报小区名称", "日报小区号", "匹配时间差", "相似度得分", "统计到的中断原因", "AI辅助匹配"} - for i, h := range headers { - col, _ := excelize.ColumnNumberToName(i + 1) - f.SetCellValue(sheetName, fmt.Sprintf("%s1", col), h) - } - headerStyle, _ := f.NewStyle(&excelize.Style{ - Font: &excelize.Font{Bold: true, Size: 12, Color: "FFFFFF"}, - Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"4472C4"}}, - }) - lastCol, _ := excelize.ColumnNumberToName(len(headers)) - f.SetCellStyle(sheetName, "A1", fmt.Sprintf("%s1", lastCol), headerStyle) + f.SetCellValue(sheetName, fmt.Sprintf("%s%d", colLetter(extractCol), rowNum), r.ExtractValue) + } - for i, r := range results { - rowNum := i + 2 - f.SetCellValue(sheetName, fmt.Sprintf("A%d", rowNum), r.MonthlyCellName) - f.SetCellValue(sheetName, fmt.Sprintf("B%d", rowNum), r.DailyCellID) - f.SetCellValue(sheetName, fmt.Sprintf("C%d", rowNum), r.TimeDiff) - f.SetCellValue(sheetName, fmt.Sprintf("D%d", rowNum), r.SimilarityScore) - f.SetCellValue(sheetName, fmt.Sprintf("E%d", rowNum), r.InterruptReason) - aiLabel := "否" - if r.AIMatched { - aiLabel = "是" - } - f.SetCellValue(sheetName, fmt.Sprintf("F%d", rowNum), aiLabel) - } - for _, c := range []string{"A", "B", "C", "D", "E", "F"} { - f.SetColWidth(sheetName, c, c, 22) - } + // 列宽 + for ci := 0; ci <= numACols; ci++ { + f.SetColWidth(sheetName, colLetter(ci), colLetter(ci), 22) } if err := f.SaveAs(savePath); err != nil { @@ -1642,3 +1190,49 @@ func (a *App) ExportResults(results []MatchResult) (string, error) { } return savePath, nil } + +func (a *App) exportResultsCSV(results []MatchResult, savePath string) (string, error) { + var buf bytes.Buffer + // 使用 UTF-8 BOM 帮助 Excel 正确识别编码 + buf.Write([]byte{0xEF, 0xBB, 0xBF}) + + numACols := len(results[0].RowAData) + headers := a.exportHeaders(numACols) + + // 表头行 + if a.lastConfig.IncludeHeader { + for i, h := range headers { + if i > 0 { + buf.WriteByte(',') + } + buf.WriteString(csvEscape(h)) + } + buf.WriteByte('\n') + } + + // 数据行 + for _, r := range results { + for ci := 0; ci < numACols; ci++ { + if ci > 0 { + buf.WriteByte(',') + } + buf.WriteString(csvEscape(r.RowAData[ci])) + } + buf.WriteByte(',') + buf.WriteString(csvEscape(r.ExtractValue)) + buf.WriteByte('\n') + } + + if err := os.WriteFile(savePath, buf.Bytes(), 0600); err != nil { + return "", fmt.Errorf("保存 CSV 文件失败: %v", err) + } + return savePath, nil +} + +// csvEscape 对 CSV 字段进行转义(含逗号或引号时包裹双引号) +func csvEscape(s string) string { + if strings.ContainsAny(s, "\",\n\r") { + return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` + } + return s +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index dfeba93..2dd57a5 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -126,28 +126,32 @@ async function startMatching() { progress.value = { current: 0, total: 100, message: '准备中...', phase: 'reading' } try { - const config = { - fileAPath: fileAPath.value, fileBPath: fileBPath.value, - colAMatchIndex: colAMatchIdx.value, colATimeIndex: colATimeIdx.value, - colBMatchIndex: colBMatchIdx.value, colBTimeIndex: colBTimeIdx.value, - colBExtractIndex: colBExtractIdx.value, - regexPattern: matchConfig.value.regexPattern || '', - timeWindow: Number(matchConfig.value.timeWindow) || 12, - threshold: Number(matchConfig.value.threshold) || 0.65, - allMatches: matchConfig.value.allMatches || false, - caseSensitive: matchConfig.value.caseSensitive || false, - sortBy: matchConfig.value.sortBy || '', - maxPreview: Number(matchConfig.value.maxPreview) || 0, - exportFormat: matchConfig.value.exportFormat || 'xlsx', - includeHeader: matchConfig.value.includeHeader !== false - } - const data = await RunMatch(config) + const data = await RunMatch(buildMatchConfig()) results.value = data; stats.value.matched = data.length } catch (err) { errorMsg.value = typeof err === 'string' ? err : (err.message || '匹配失败') hideProgressNow() } finally { loading.value = false; if (!errorMsg.value) scheduleProgressDone() } } +// buildMatchConfig 从响应式状态构建 MatchConfig 对象(消除重复) +function buildMatchConfig() { + return { + fileAPath: fileAPath.value, fileBPath: fileBPath.value, + colAMatchIndex: colAMatchIdx.value, colATimeIndex: colATimeIdx.value, + colBMatchIndex: colBMatchIdx.value, colBTimeIndex: colBTimeIdx.value, + colBExtractIndex: colBExtractIdx.value, + regexPattern: matchConfig.value.regexPattern || '', + timeWindow: Number(matchConfig.value.timeWindow) || 12, + threshold: Number(matchConfig.value.threshold) || 0.65, + allMatches: matchConfig.value.allMatches || false, + caseSensitive: matchConfig.value.caseSensitive || false, + sortBy: matchConfig.value.sortBy || '', + maxPreview: Number(matchConfig.value.maxPreview) || 0, + exportFormat: matchConfig.value.exportFormat || 'xlsx', + includeHeader: matchConfig.value.includeHeader !== false + } +} + // ----------- Deepseek AI 增强匹配 ----------- async function startAIEnhance() { if (!fileAPath.value || !fileBPath.value) { @@ -179,22 +183,7 @@ async function startAIEnhance() { progress.value = { current: 0, total: 100, message: '正在启动 AI 增强匹配...', phase: 'reading' } try { - const config = { - fileAPath: fileAPath.value, fileBPath: fileBPath.value, - colAMatchIndex: colAMatchIdx.value, colATimeIndex: colATimeIdx.value, - colBMatchIndex: colBMatchIdx.value, colBTimeIndex: colBTimeIdx.value, - colBExtractIndex: colBExtractIdx.value, - regexPattern: matchConfig.value.regexPattern || '', - timeWindow: Number(matchConfig.value.timeWindow) || 12, - threshold: Number(matchConfig.value.threshold) || 0.65, - allMatches: matchConfig.value.allMatches || false, - caseSensitive: matchConfig.value.caseSensitive || false, - sortBy: matchConfig.value.sortBy || '', - maxPreview: Number(matchConfig.value.maxPreview) || 0, - exportFormat: matchConfig.value.exportFormat || 'xlsx', - includeHeader: matchConfig.value.includeHeader !== false - } - const data = await RunMatchWithAI(config) + const data = await RunMatchWithAI(buildMatchConfig()) results.value = data stats.value.matched = data.length } catch (err) { diff --git a/main.go b/main.go index 5d683b4..3508e97 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "embed" + "log" "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/options" @@ -41,6 +42,6 @@ func main() { }) if err != nil { - println("Error:", err.Error()) + log.Fatalf("应用启动失败: %v", err) } } From b5a91cb115e2e3d9ddf06e4a64ca1d3656b63f8e Mon Sep 17 00:00:00 2001 From: RainySY Date: Wed, 20 May 2026 14:16:13 +0800 Subject: [PATCH 2/6] perf: optimize hot path & reorganize project config Backend (app.go): - AICache: replace linear scan with map-based O(1) lookup (get/getRow/put/putRow) - runMatchOnData: pre-compute B-column cleaned values, parsed times, extract values to eliminate O(n*m) regex/time-parse from inner loop - calcSimilarity: eliminate double rune conversion (levenshteinDistance now takes []rune) - Add similarityFromCleaned to skip redundant regex step in hot path - Fix corrupted bare 'n' literal causing build failure - Move saveToFile out of inner match loop (was called per item) - dataMu: Mutex -> RWMutex (exportHeaders/ExportResults use RLock) - buildGenericAIPrompt: fix truncation check order (check after write) Project: - .gitignore: deduplicate & tighten rules; track package-lock.json and .vscode/* - Clean up stale root binary (data-matcher.exe) --- .gitignore | 37 +- .vscode/settings.json | 21 + .vscode/tasks.json | 13 + app.go | 543 ++++++++++++------ frontend/package-lock.json | 884 ++++++++++++++++++++++++++++++ frontend/wailsjs/go/main/App.d.ts | 6 +- frontend/wailsjs/go/main/App.js | 8 - frontend/wailsjs/go/models.ts | 20 +- 8 files changed, 1325 insertions(+), 207 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 .vscode/tasks.json create mode 100644 frontend/package-lock.json diff --git a/.gitignore b/.gitignore index be14f9c..2be89a1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,32 +1,27 @@ -# --- Build output --- -build/bin -build/*.exe -build/*.app +# === 构建产物 === +build/ -# --- Frontend --- -node_modules/ -frontend/dist -frontend/node_modules/ -frontend/package-lock.json - -# --- Go --- -*.exe -*.exe~ -*.dll -*.so -*.dylib +# === Go === +vendor/ *.test *.out -vendor/ -# --- IDE --- +# === IDE / 编辑器 === .idea/ *.swp *.swo *~ + +# === 操作系统 === .DS_Store Thumbs.db -# VS Code(保留 .vscode/extensions.json 以便共享推荐) -.vscode/* -!.vscode/extensions.json +# === 前端 === +frontend/node_modules/ +frontend/dist/ + +# === Wails 运行时(开发模式临时文件)=== +# 注意:frontend/wailsjs/ 是 Wails 自动生成的绑定代码,必须提交 + +# === AI 缓存(本地临时文件,不提交)=== +data-matcher-ai-cache.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5ef6ae1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,21 @@ +{ + "editor.formatOnSave": true, + "editor.tabSize": 2, + "files.eol": "\n", + "files.insertFinalNewline": true, + "files.trimTrailingWhitespace": true, + "[go]": { + "editor.tabSize": 4, + "editor.insertSpaces": false, + "editor.formatOnSave": true + }, + "[vue]": { + "editor.tabSize": 2, + "editor.formatOnSave": true + }, + "search.exclude": { + "frontend/dist": true, + "build": true, + "node_modules": true + } +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..7365e38 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,13 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "dev (wails dev)", + "type": "shell", + "command": "wails dev", + "isBackground": true, + "problemMatcher": [], + "group": "build" + } + ] +} \ No newline at end of file diff --git a/app.go b/app.go index 2174bba..8918f7c 100644 --- a/app.go +++ b/app.go @@ -27,20 +27,12 @@ import ( // MatchResult 匹配结果 type MatchResult struct { - // 新字段(通用化) - RowAData []string `json:"rowAData"` // A 表原始所有列(新) - RowBKey string `json:"rowBKey"` // B 表匹配列的值(新) - ExtractValue string `json:"extractValue"` // 从 B 表提取的目标列值(新) - - // 旧字段(向后兼容) - MonthlyCellName string `json:"monthlyCellName"` - DailyCellID string `json:"dailyCellId"` - InterruptReason string `json:"interruptReason"` - - // 公共字段 - TimeDiff string `json:"timeDiff"` + RowAData []string `json:"rowAData"` // A 表原始所有列 + RowBKey string `json:"rowBKey"` // B 表匹配列的值 + ExtractValue string `json:"extractValue"` // 从 B 表提取的目标列值 + TimeDiff string `json:"timeDiff"` SimilarityScore float64 `json:"similarityScore"` - AIMatched bool `json:"aiMatched"` + AIMatched bool `json:"aiMatched"` } // ProgressPayload 进度信息 @@ -113,19 +105,30 @@ type AICacheInfo struct { FilePath string `json:"filePath"` } -// AICacheEntry 单条缓存记录 +// AICacheEntry 单条缓存记录(批量 prompt → 响应) type AICacheEntry struct { PromptHash string `json:"promptHash"` Response string `json:"response"` CreatedAt int64 `json:"createdAt"` } +// AIRowCacheEntry 单行 AI 匹配缓存(跨批次复用) +type AIRowCacheEntry struct { + Key string `json:"key"` + Value string `json:"value"` // AI 匹配到的 extractValue + CreatedAt int64 `json:"createdAt"` +} + // AICache AI 响应缓存(持久化到临时文件) type AICache struct { - Entries []AICacheEntry `json:"entries"` - mu sync.RWMutex // 小写,必须保持非导出以兼容 JSON 序列化 - filePath string - maxSize int // 最大缓存条目数 + Entries []AICacheEntry `json:"entries"` + RowEntries []AIRowCacheEntry `json:"rowEntries"` + entriesIdx map[string]int // promptHash → index in Entries (O(1) lookup) + rowEntriesIdx map[string]int // key → index in RowEntries (O(1) lookup) + mu sync.RWMutex + filePath string + maxSize int // 批量缓存最大条目数 + maxRowSize int // 行级缓存最大条目数 } // cacheFileName 缓存文件名 @@ -139,13 +142,26 @@ const ( defaultMaxPreview = 3 defaultMaxBNoTime = 200 defaultAIWindowPadH = 3.0 + maxPromptBChars = 80000 // B 表数据在 prompt 中的最大字符数 ) +// rebuildIndexes 从切片重建索引 map(反序列化或裁剪后调用) +func (c *AICache) rebuildIndexes() { + c.entriesIdx = make(map[string]int, len(c.Entries)) + for i := range c.Entries { + c.entriesIdx[c.Entries[i].PromptHash] = i + } + c.rowEntriesIdx = make(map[string]int, len(c.RowEntries)) + for i := range c.RowEntries { + c.rowEntriesIdx[c.RowEntries[i].Key] = i + } +} // newAICache 创建缓存实例并加载已有数据 func newAICache() *AICache { c := &AICache{ - filePath: filepath.Join(os.TempDir(), cacheFileName), - maxSize: 500, + filePath: filepath.Join(os.TempDir(), cacheFileName), + maxSize: 500, + maxRowSize: 5000, } c.loadFromFile() return c @@ -155,11 +171,20 @@ func newAICache() *AICache { func (c *AICache) loadFromFile() { data, err := os.ReadFile(c.filePath) if err != nil { - return // 文件不存在或无法读取,从空缓存开始 + // 文件不存在或无法读取,从空缓存开始 + if !os.IsNotExist(err) { + fmt.Printf("[CACHE] 读取缓存文件失败: %v\n", err) + } + return } c.mu.Lock() defer c.mu.Unlock() - _ = json.Unmarshal(data, c) // 忽略解析错误,重置为 entries + if err := json.Unmarshal(data, c); err != nil { + fmt.Printf("[CACHE] 解析缓存文件失败,重置为空: %v\n", err) + c.Entries = nil + c.RowEntries = nil + } + c.rebuildIndexes() } // saveToFile 将缓存写入磁盘 @@ -168,19 +193,20 @@ func (c *AICache) saveToFile() { data, err := json.Marshal(c) c.mu.RUnlock() if err != nil { + fmt.Printf("[CACHE] 序列化缓存失败: %v\n", err) return } - _ = os.WriteFile(c.filePath, data, 0600) + if err := os.WriteFile(c.filePath, data, 0600); err != nil { + fmt.Printf("[CACHE] 写入缓存文件失败: %v\n", err) + } } // get 根据 hash 查找缓存,命中返回响应,否则返回空 func (c *AICache) get(hash string) (string, bool) { c.mu.RLock() defer c.mu.RUnlock() - for i := range c.Entries { - if c.Entries[i].PromptHash == hash { - return c.Entries[i].Response, true - } + if idx, ok := c.entriesIdx[hash]; ok && idx < len(c.Entries) && c.Entries[idx].PromptHash == hash { + return c.Entries[idx].Response, true } return "", false } @@ -191,27 +217,28 @@ func (c *AICache) put(hash, response string) { defer c.mu.Unlock() // 去重:如果已存在则覆盖 - for i := range c.Entries { - if c.Entries[i].PromptHash == hash { - c.Entries[i].Response = response - c.Entries[i].CreatedAt = time.Now().Unix() - return - } + if idx, ok := c.entriesIdx[hash]; ok && idx < len(c.Entries) && c.Entries[idx].PromptHash == hash { + c.Entries[idx].Response = response + c.Entries[idx].CreatedAt = time.Now().Unix() + return } + // 新增条目 + idx := len(c.Entries) c.Entries = append(c.Entries, AICacheEntry{ PromptHash: hash, Response: response, CreatedAt: time.Now().Unix(), }) + c.entriesIdx[hash] = idx // 超过上限则删除最旧的条目 if len(c.Entries) > c.maxSize { - // 按 CreatedAt 排序保留最新的 sort.Slice(c.Entries, func(i, j int) bool { return c.Entries[i].CreatedAt > c.Entries[j].CreatedAt }) c.Entries = c.Entries[:c.maxSize] + c.rebuildIndexes() } } @@ -220,6 +247,9 @@ func (c *AICache) clear() { c.mu.Lock() defer c.mu.Unlock() c.Entries = nil + c.RowEntries = nil + c.entriesIdx = nil + c.rowEntriesIdx = nil _ = os.Remove(c.filePath) } @@ -227,7 +257,66 @@ func (c *AICache) clear() { func (c *AICache) stat() (count int, path string) { c.mu.RLock() defer c.mu.RUnlock() - return len(c.Entries), c.filePath + return len(c.Entries) + len(c.RowEntries), c.filePath +} + +// rowKey 为单行匹配构建缓存键 +func (a *App) buildRowCacheKey(matchValue, timeStr string, config MatchConfig) string { + parts := fmt.Sprintf("%s|%s|%s|%.1f|%s", + matchValue, timeStr, config.RegexPattern, config.TimeWindow, + filepath.Base(config.FileBPath)) + h := sha256.Sum256([]byte(parts)) + return hex.EncodeToString(h[:]) +} + +// getRow 查找行级缓存 +func (c *AICache) getRow(key string) (string, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + if idx, ok := c.rowEntriesIdx[key]; ok && idx < len(c.RowEntries) && c.RowEntries[idx].Key == key { + return c.RowEntries[idx].Value, true + } + return "", false +} + +// putRow 存入行级缓存(线程安全 + 自动裁剪) +func (c *AICache) putRow(key, value string) { + c.mu.Lock() + defer c.mu.Unlock() + + // 去重:更新已存在的条目 + if idx, ok := c.rowEntriesIdx[key]; ok && idx < len(c.RowEntries) && c.RowEntries[idx].Key == key { + c.RowEntries[idx].Value = value + c.RowEntries[idx].CreatedAt = time.Now().Unix() + return + } + + // 新增条目 + idx := len(c.RowEntries) + c.RowEntries = append(c.RowEntries, AIRowCacheEntry{ + Key: key, + Value: value, + CreatedAt: time.Now().Unix(), + }) + c.rowEntriesIdx[key] = idx + + // 超过上限则删除最旧的条目 + if len(c.RowEntries) > c.maxRowSize { + sort.Slice(c.RowEntries, func(i, j int) bool { + return c.RowEntries[i].CreatedAt > c.RowEntries[j].CreatedAt + }) + c.RowEntries = c.RowEntries[:c.maxRowSize] + c.rebuildIndexes() + } +} + +// matchPrep 匹配准备的中间结果 +type matchPrep struct { + dataA, dataB [][]string + reg *regexp.Regexp + timeWindow float64 + threshold float64 + windowDuration time.Duration } // ---------- App 结构体 ---------- @@ -238,6 +327,7 @@ type App struct { aiCache *AICache // 最近一次匹配的配置和表头(供导出使用) + dataMu sync.RWMutex lastConfig MatchConfig headersA []string headersB []string @@ -411,9 +501,7 @@ func parseTimeFlexible(timeStr string) (time.Time, error) { // ---------- Levenshtein 距离算法 ---------- -func levenshteinDistance(s1, s2 string) int { - runes1 := []rune(s1) - runes2 := []rune(s2) +func levenshteinDistance(runes1, runes2 []rune) int { m, n := len(runes1), len(runes2) // 使用一维数组优化空间复杂度 @@ -438,12 +526,6 @@ func levenshteinDistance(s1, s2 string) int { return dp[n] } -func min(a, b int) int { - if a < b { - return a - } - return b -} // CalculateSimilarity 计算清洗后中文名称的相似度(基于 Levenshtein 距离归一化) func (a *App) CalculateSimilarity(s1, s2 string) float64 { @@ -473,7 +555,7 @@ func calcSimilarity(s1, s2 string, reg *regexp.Regexp, caseSensitive bool) float return 0.0 } - dist := levenshteinDistance(clean1, clean2) + dist := levenshteinDistance(r1, r2) maxLen := math.Max(float64(len(r1)), float64(len(r2))) return 1.0 - float64(dist)/maxLen } @@ -485,6 +567,24 @@ func cleanWithRegex(input string, reg *regexp.Regexp) string { } return reg.ReplaceAllString(input, "") } +// similarityFromCleaned 基于已清洗字符串计算相似度(跳过 regex 步骤,避免重复清洗) +func similarityFromCleaned(clean1, clean2 string, caseSensitive bool) float64 { + if !caseSensitive { + clean1 = strings.ToLower(clean1) + clean2 = strings.ToLower(clean2) + } + r1 := []rune(clean1) + r2 := []rune(clean2) + if len(r1) == 0 && len(r2) == 0 { + return 1.0 + } + if len(r1) == 0 || len(r2) == 0 { + return 0.0 + } + dist := levenshteinDistance(r1, r2) + maxLen := math.Max(float64(len(r1)), float64(len(r2))) + return 1.0 - float64(dist)/maxLen +} // ---------- 文件读取(通用)---------- @@ -551,25 +651,22 @@ func getCell(row []string, idx int) string { } -// RunMatch 接收完整 MatchConfig,按列索引执行通用匹配 -func (a *App) RunMatch(config MatchConfig) ([]MatchResult, error) { - // 1. 编译正则 +// prepareMatch 编译正则、读取文件、初始化默认值(RunMatch / RunMatchWithAI 共用) +func (a *App) prepareMatch(config MatchConfig) (*matchPrep, error) { reg, err := compileRegex(config.RegexPattern) if err != nil { return nil, err } - // 2. 默认值兜底 - timeWindow := config.TimeWindow - if timeWindow <= 0 { - timeWindow = defaultTimeWindowH + tw := config.TimeWindow + if tw <= 0 { + tw = defaultTimeWindowH } - threshold := config.Threshold - if threshold <= 0 { - threshold = defaultThreshold + th := config.Threshold + if th <= 0 { + th = defaultThreshold } - // 3. 读取原始数据 a.emitProgress(0, 100, "正在读取 A 表...", "reading") rowsA, err := a.readRawRows(config.FileAPath) if err != nil { @@ -587,22 +684,35 @@ func (a *App) RunMatch(config MatchConfig) ([]MatchResult, error) { return nil, fmt.Errorf("B 表无有效数据行") } - // 保存表头供导出使用 + a.dataMu.Lock() a.headersA = rowsA[0] a.headersB = rowsB[0] a.lastConfig = config + a.dataMu.Unlock() - dataA := rowsA[1:] - dataB := rowsB[1:] - windowDuration := time.Duration(timeWindow * float64(time.Hour)) - - return a.runMatchOnData(dataA, dataB, reg, config, timeWindow, threshold, windowDuration) + return &matchPrep{ + dataA: rowsA[1:], + dataB: rowsB[1:], + reg: reg, + timeWindow: tw, + threshold: th, + windowDuration: time.Duration(tw * float64(time.Hour)), + }, nil } -// runMatchOnData 在已读取的数据上执行匹配(内部使用,避免重复 I/O) -func (a *App) runMatchOnData(dataA, dataB [][]string, reg *regexp.Regexp, config MatchConfig, _, threshold float64, windowDuration time.Duration) ([]MatchResult, error) { +// RunMatch 接收完整 MatchConfig,按列索引执行通用匹配 +func (a *App) RunMatch(config MatchConfig) ([]MatchResult, error) { + prep, err := a.prepareMatch(config) + if err != nil { + return nil, err + } + return a.runMatchOnData(prep, config) +} + +// runMatchOnData 在已读取的数据上执行匹配 +func (a *App) runMatchOnData(prep *matchPrep, config MatchConfig) ([]MatchResult, error) { useTime := config.ColATimeIndex >= 0 && config.ColBTimeIndex >= 0 - totalA := len(dataA) + totalA := len(prep.dataA) var results []MatchResult useAllMatches := config.AllMatches @@ -611,7 +721,40 @@ func (a *App) runMatchOnData(dataA, dataB [][]string, reg *regexp.Regexp, config maxPreview = defaultMaxPreview } - for i, rowA := range dataA { + // 预计算 B 表清洗后的匹配值,避免内层循环中重复 regex 替换(O(n*m)→O(n+m)) + totalB := len(prep.dataB) + cleanedBMatch := make([]string, totalB) + origBMatch := make([]string, totalB) + parsedBTime := make([]time.Time, totalB) + hasBTime := make([]bool, totalB) + bExtractVal := make([]string, totalB) + for bIdx, rowB := range prep.dataB { + matchStrB := getCell(rowB, config.ColBMatchIndex) + origBMatch[bIdx] = matchStrB + if matchStrB == "" { + cleanedBMatch[bIdx] = "" + } else { + cleanedBMatch[bIdx] = cleanWithRegex(matchStrB, prep.reg) + } + if useTime { + t, err := parseTimeFlexible(getCell(rowB, config.ColBTimeIndex)) + if err == nil { + parsedBTime[bIdx] = t + hasBTime[bIdx] = true + } + } + bExtractVal[bIdx] = getCell(rowB, config.ColBExtractIndex) + } + for i, rowA := range prep.dataA { + // 定期检查是否取消 + if a.ctx != nil { + select { + case <-a.ctx.Done(): + return results, a.ctx.Err() + default: + } + } + if i%10 == 0 || i == totalA-1 { pct := (i + 1) * 100 / totalA a.emitProgress(i+1, totalA, @@ -633,46 +776,46 @@ func (a *App) runMatchOnData(dataA, dataB [][]string, reg *regexp.Regexp, config } } - cleanA := cleanWithRegex(matchStrA, reg) + cleanA := cleanWithRegex(matchStrA, prep.reg) // 收集该 A 行的所有候选匹配 var candidates []MatchResult - for _, rowB := range dataB { - matchStrB := getCell(rowB, config.ColBMatchIndex) - if matchStrB == "" { - continue - } + for bIdx := range prep.dataB { + matchStrB := origBMatch[bIdx] + if matchStrB == "" { + continue + } - var timeDiff time.Duration - if hasTimeA && useTime { - tB, err := parseTimeFlexible(getCell(rowB, config.ColBTimeIndex)) - if err != nil { - continue - } - td := timeA.Sub(tB) - if td < -windowDuration || td > windowDuration { - continue - } - timeDiff = td - } + var timeDiff time.Duration + if hasTimeA && useTime { + if !hasBTime[bIdx] { + continue + } + tB := parsedBTime[bIdx] + td := timeA.Sub(tB) + if td < -prep.windowDuration || td > prep.windowDuration { + continue + } + timeDiff = td + } - cleanB := cleanWithRegex(matchStrB, reg) - if cleanA == "" || cleanB == "" { - continue - } + cleanB := cleanedBMatch[bIdx] + if cleanA == "" || cleanB == "" { + continue + } - similarity := calcSimilarity(matchStrA, matchStrB, reg, config.CaseSensitive) + similarity := similarityFromCleaned(cleanA, cleanB, config.CaseSensitive) if i < maxPreview { fmt.Printf("[DEBUG] | A[%d]='%s'→'%s' | B='%s'→'%s' | 相似度=%.4f\n", i, matchStrA, cleanA, matchStrB, cleanB, similarity) } - if similarity >= threshold { + if similarity >= prep.threshold { mr := MatchResult{ RowAData: rowA, RowBKey: matchStrB, - ExtractValue: getCell(rowB, config.ColBExtractIndex), + ExtractValue: bExtractVal[bIdx], TimeDiff: formatTimeDiff(timeDiff), SimilarityScore: math.Round(similarity*10000) / 10000, AIMatched: false, @@ -757,60 +900,25 @@ func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) { return nil, fmt.Errorf("请先设置 Deepseek API 密钥") } - // 1. 编译正则 - reg, err := compileRegex(config.RegexPattern) + prep, err := a.prepareMatch(config) if err != nil { return nil, err } - // 2. 默认值兜底 - timeWindow := config.TimeWindow - if timeWindow <= 0 { - timeWindow = defaultTimeWindowH - } - threshold := config.Threshold - if threshold <= 0 { - threshold = defaultThreshold - } - windowDuration := time.Duration(timeWindow * float64(time.Hour)) - - // 3. 一次读取数据,供匹配和 AI 使用 - a.emitProgress(0, 100, "正在读取 A 表...", "reading") - rowsA, err := a.readRawRows(config.FileAPath) - if err != nil { - return nil, fmt.Errorf("读取 A 表失败: %v", err) - } - a.emitProgress(0, 100, "正在读取 B 表...", "reading") - rowsB, err := a.readRawRows(config.FileBPath) - if err != nil { - return nil, fmt.Errorf("读取 B 表失败: %v", err) - } - if len(rowsA) < 2 || len(rowsB) < 2 { - return nil, fmt.Errorf("数据表无有效数据行") - } - - // 保存表头供导出使用 - a.headersA = rowsA[0] - a.headersB = rowsB[0] - a.lastConfig = config - - dataA := rowsA[1:] - dataB := rowsB[1:] - - // 4. 先执行基础匹配(使用已读取的数据,避免重复 I/O) - results, err := a.runMatchOnData(dataA, dataB, reg, config, timeWindow, threshold, windowDuration) + // 1. 先执行基础匹配 + results, err := a.runMatchOnData(prep, config) if err != nil { return nil, err } - // 5. 找出未被基础匹配覆盖的 A 表行 + // 2. 找出未被基础匹配覆盖的 A 表行 matchedSet := make(map[string]bool) for _, r := range results { matchedSet[strings.Join(r.RowAData, "\x00")] = true } var unmatchedA [][]string - for _, row := range dataA { + for _, row := range prep.dataA { if !matchedSet[strings.Join(row, "\x00")] { unmatchedA = append(unmatchedA, row) } @@ -821,26 +929,61 @@ func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) { return results, nil } - // 6. AI 增强匹配 + // 3. AI 增强匹配(先查行级缓存,减少 API 调用) useTime := config.ColATimeIndex >= 0 && config.ColBTimeIndex >= 0 - totalUnmatched := len(unmatchedA) - a.emitProgress(0, totalUnmatched, - fmt.Sprintf("AI 增强匹配:还有 %d 条未匹配记录,正在调用 Deepseek...", totalUnmatched), - "ai-enhancing") aiMatched := 0 + var failedBatches []int + + // 3a. 检查行级缓存,命中则直接加入结果 + var uncachedA [][]string + cacheHits := 0 + for _, row := range unmatchedA { + matchVal := getCell(row, config.ColAMatchIndex) + timeStr := "" + if useTime { + timeStr = getCell(row, config.ColATimeIndex) + } + cacheKey := a.buildRowCacheKey(matchVal, timeStr, config) + if cachedVal, ok := a.aiCache.getRow(cacheKey); ok { + results = append(results, MatchResult{ + RowAData: row, + RowBKey: "", + ExtractValue: cachedVal, + SimilarityScore: 0, + AIMatched: true, + }) + aiMatched++ + cacheHits++ + } else { + uncachedA = append(uncachedA, row) + } + } + + if cacheHits > 0 { + fmt.Printf("[CACHE] ✓ 行级缓存命中 %d 条,剩余 %d 条需 AI 处理\n", cacheHits, len(uncachedA)) + } + + if len(uncachedA) == 0 { + a.emitProgress(1, 1, + fmt.Sprintf("AI 增强完成!全部 %d 条命中缓存", cacheHits), "done") + return results, nil + } + + totalUnmatched := len(uncachedA) + a.emitProgress(0, totalUnmatched, + fmt.Sprintf("AI 增强匹配:%d 条命中缓存,%d 条需调用 Deepseek...", cacheHits, totalUnmatched), + "ai-enhancing") for batchStart := 0; batchStart < totalUnmatched; batchStart += defaultBatchSize { - end := batchStart + defaultBatchSize - if end > totalUnmatched { - end = totalUnmatched - } + end := min(batchStart+defaultBatchSize, totalUnmatched) + batchNum := (batchStart / defaultBatchSize) + 1 a.emitProgress(batchStart+1, totalUnmatched, - fmt.Sprintf("AI 分析中 %d/%d (第 %d 批)...", end, totalUnmatched, (batchStart/defaultBatchSize)+1), + fmt.Sprintf("AI 分析中 %d/%d (第 %d 批)...", end, totalUnmatched, batchNum), "ai-enhancing") - batch := unmatchedA[batchStart:end] + batch := uncachedA[batchStart:end] // 计算本批 A 表的时间范围 var minTime, maxTime time.Time @@ -868,10 +1011,10 @@ func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) { // 过滤 B 表在时间窗口内的行(用户配置时间窗口 + 额外余量覆盖批次跨度) var relevantB [][]string if hasBatchTime && useTime { - padding := windowDuration + time.Duration(defaultAIWindowPadH)*time.Hour + padding := prep.windowDuration + time.Duration(defaultAIWindowPadH)*time.Hour ws := minTime.Add(-padding) we := maxTime.Add(padding) - for _, row := range dataB { + for _, row := range prep.dataB { t, err := parseTimeFlexible(getCell(row, config.ColBTimeIndex)) if err != nil || t.Before(ws) || t.After(we) { continue @@ -880,17 +1023,16 @@ func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) { } } else { // 无时间列时限制 B 表条数以控制 token 消耗 - maxB := defaultMaxBNoTime - if len(dataB) < maxB { - maxB = len(dataB) - } - relevantB = dataB[:maxB] + maxB := min(defaultMaxBNoTime, len(prep.dataB)) + relevantB = prep.dataB[:maxB] } // 构建 AI 提示 - prompt := a.buildGenericAIPrompt(batch, relevantB, config, windowDuration, hasBatchTime) + prompt := a.buildGenericAIPrompt(batch, relevantB, config, prep.windowDuration, hasBatchTime) aiResp, err := a.callDeepseekAPI(prompt) if err != nil { + fmt.Printf("[AI-WARN] 第 %d 批 API 调用失败: %v\n", batchNum, err) + failedBatches = append(failedBatches, batchNum) continue } @@ -911,7 +1053,8 @@ func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) { } if parseErr != nil { fmt.Printf("[AI-WARN] 响应解析失败 (第 %d 批): %s\n 原始响应: %.200s\n", - (batchStart/defaultBatchSize)+1, parseErr.Error(), aiResp) + batchNum, parseErr.Error(), aiResp) + failedBatches = append(failedBatches, batchNum) continue } @@ -930,13 +1073,27 @@ func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) { AIMatched: true, } results = append(results, mr) - aiMatched++ + aiMatched++ + + // 写入行级缓存 + matchVal := getCell(rowA, config.ColAMatchIndex) + timeStr := "" + if useTime { + timeStr = getCell(rowA, config.ColATimeIndex) + } + cacheKey := a.buildRowCacheKey(matchVal, timeStr, config) + a.aiCache.putRow(cacheKey, val) } } + a.aiCache.saveToFile() - a.emitProgress(totalUnmatched, totalUnmatched, - fmt.Sprintf("AI 增强完成!基础匹配 %d 条 + AI 补充 %d 条 = 共 %d 条", - len(results)-aiMatched, aiMatched, len(results)), "done") + // 构建完成消息 + msg := fmt.Sprintf("AI 增强完成!基础匹配 %d 条 + AI 补充 %d 条 = 共 %d 条", + len(results)-aiMatched, aiMatched, len(results)) + if len(failedBatches) > 0 { + msg += fmt.Sprintf("(警告:第 %v 批失败)", failedBatches) + } + a.emitProgress(totalUnmatched, totalUnmatched, msg, "done") return results, nil } @@ -967,7 +1124,8 @@ func (a *App) buildGenericAIPrompt(unmatched, bRows [][]string, config MatchConf } sb.WriteString(fmt.Sprintf("\nB 表参考数据(共 %d 条):\n", len(bRows))) - for _, row := range bRows { + truncated := false + for i, row := range bRows { matchVal := getCell(row, config.ColBMatchIndex) extractVal := getCell(row, config.ColBExtractIndex) sb.WriteString(fmt.Sprintf(" 「%s」 → 目标列值: 「%s」", matchVal, extractVal)) @@ -975,6 +1133,15 @@ func (a *App) buildGenericAIPrompt(unmatched, bRows [][]string, config MatchConf sb.WriteString(fmt.Sprintf(", 时间=%s", getCell(row, config.ColBTimeIndex))) } sb.WriteString("\n") + // 限制 B 表部分总字符数,防止 prompt 超出 token 限制 + if sb.Len() > maxPromptBChars { + fmt.Printf("[AI-WARN] Prompt B 表数据超长 (%d 条,%d 字符),截断于第 %d 条\n", len(bRows), sb.Len(), i) + truncated = true + } + if truncated { + sb.WriteString(fmt.Sprintf(" ... 已截断,省略 %d 条\n", len(bRows)-i-1)) + break + } } sb.WriteString("\n请返回 JSON 格式的匹配结果。") @@ -1037,7 +1204,10 @@ func (a *App) callDeepseekAPI(messages []deepseekMessage) (string, error) { } defer resp.Body.Close() - respBytes, _ := io.ReadAll(resp.Body) + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("读取 Deepseek 响应失败: %v", err) + } var dr deepseekResponse if err := json.Unmarshal(respBytes, &dr); err != nil { return "", fmt.Errorf("解析 Deepseek 响应失败: %v", err) @@ -1091,7 +1261,12 @@ func (a *App) ExportResults(results []MatchResult) (string, error) { return "", fmt.Errorf("没有匹配结果可以导出") } - isCSV := a.lastConfig.ExportFormat == "csv" + a.dataMu.RLock() + useCSV := a.lastConfig.ExportFormat == "csv" + includeHdr := a.lastConfig.IncludeHeader + a.dataMu.RUnlock() + + isCSV := useCSV ext := ".xlsx" filterDisplay := "Excel 文件 (*.xlsx)" filterPattern := "*.xlsx" @@ -1119,16 +1294,21 @@ func (a *App) ExportResults(results []MatchResult) (string, error) { } if isCSV { - return a.exportResultsCSV(results, savePath) + return a.exportResultsCSV(results, savePath, includeHdr) } - return a.exportResultsXLSX(results, savePath) + return a.exportResultsXLSX(results, savePath, includeHdr) } // exportHeaders 构建导出表头行(使用真实表头或回退默认) func (a *App) exportHeaders(numACols int) []string { + a.dataMu.RLock() + hdrA := make([]string, len(a.headersA)) + copy(hdrA, a.headersA) + a.dataMu.RUnlock() + headers := make([]string, 0, numACols+1) - if len(a.headersA) >= numACols { - for _, h := range a.headersA[:numACols] { + if len(hdrA) >= numACols { + for _, h := range hdrA[:numACols] { n := h if n == "" { n = fmt.Sprintf("Col%d", len(headers)+1) @@ -1144,7 +1324,7 @@ func (a *App) exportHeaders(numACols int) []string { return headers } -func (a *App) exportResultsXLSX(results []MatchResult, savePath string) (string, error) { +func (a *App) exportResultsXLSX(results []MatchResult, savePath string, includeHeader bool) (string, error) { f := excelize.NewFile() defer f.Close() sheetName := "匹配结果" @@ -1157,7 +1337,7 @@ func (a *App) exportResultsXLSX(results []MatchResult, savePath string) (string, extractCol := numACols // 表头 - if a.lastConfig.IncludeHeader { + if includeHeader { for i, h := range headers { f.SetCellValue(sheetName, fmt.Sprintf("%s1", colLetter(i)), h) } @@ -1166,12 +1346,41 @@ func (a *App) exportResultsXLSX(results []MatchResult, savePath string) (string, Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"4472C4"}}, }) f.SetCellStyle(sheetName, "A1", fmt.Sprintf("%s1", colLetter(extractCol)), headerStyle) + // 数据行样式(带边框和行号字体) + dataStyle, _ := f.NewStyle(&excelize.Style{ + Font: &excelize.Font{Size: 11}, + Border: []excelize.Border{ + {Type: "bottom", Color: "D9D9D9", Style: 1}, + }, + }) + firstDataRow := 2 + lastDataRow := len(results) + 1 + for ci := 0; ci <= numACols; ci++ { + f.SetCellStyle(sheetName, + fmt.Sprintf("%s%d", colLetter(ci), firstDataRow), + fmt.Sprintf("%s%d", colLetter(ci), lastDataRow), + dataStyle) + } + } else { + dataStyle, _ := f.NewStyle(&excelize.Style{ + Font: &excelize.Font{Size: 11}, + Border: []excelize.Border{ + {Type: "bottom", Color: "D9D9D9", Style: 1}, + }, + }) + lastDataRow := len(results) + for ci := 0; ci <= numACols; ci++ { + f.SetCellStyle(sheetName, + fmt.Sprintf("%s%d", colLetter(ci), 1), + fmt.Sprintf("%s%d", colLetter(ci), lastDataRow), + dataStyle) + } } // 数据行 for i, r := range results { rowNum := i + 2 - if !a.lastConfig.IncludeHeader { + if !includeHeader { rowNum = i + 1 } for ci := 0; ci < numACols; ci++ { @@ -1191,7 +1400,7 @@ func (a *App) exportResultsXLSX(results []MatchResult, savePath string) (string, return savePath, nil } -func (a *App) exportResultsCSV(results []MatchResult, savePath string) (string, error) { +func (a *App) exportResultsCSV(results []MatchResult, savePath string, includeHeader bool) (string, error) { var buf bytes.Buffer // 使用 UTF-8 BOM 帮助 Excel 正确识别编码 buf.Write([]byte{0xEF, 0xBB, 0xBF}) @@ -1200,7 +1409,7 @@ func (a *App) exportResultsCSV(results []MatchResult, savePath string) (string, headers := a.exportHeaders(numACols) // 表头行 - if a.lastConfig.IncludeHeader { + if includeHeader { for i, h := range headers { if i > 0 { buf.WriteByte(',') diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..dc95d08 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,884 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "vue": "^3.2.37" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^3.0.3", + "vite": "^3.0.7" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.15.18.tgz", + "integrity": "sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.15.18.tgz", + "integrity": "sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-3.2.0.tgz", + "integrity": "sha512-E0tnaL4fr+qkdCNxJ+Xd0yM31UwMkQje76fsDVBBUCoGOUPexu2VDUYHL8P4CwV+zMvWw6nlRw19OnRKmYAJpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^3.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz", + "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/shared": "3.5.34", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz", + "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz", + "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/compiler-core": "3.5.34", + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.14", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz", + "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz", + "integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.34.tgz", + "integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz", + "integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/runtime-core": "3.5.34", + "@vue/shared": "3.5.34", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz", + "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "vue": "3.5.34" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz", + "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.15.18.tgz", + "integrity": "sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.15.18", + "@esbuild/linux-loong64": "0.15.18", + "esbuild-android-64": "0.15.18", + "esbuild-android-arm64": "0.15.18", + "esbuild-darwin-64": "0.15.18", + "esbuild-darwin-arm64": "0.15.18", + "esbuild-freebsd-64": "0.15.18", + "esbuild-freebsd-arm64": "0.15.18", + "esbuild-linux-32": "0.15.18", + "esbuild-linux-64": "0.15.18", + "esbuild-linux-arm": "0.15.18", + "esbuild-linux-arm64": "0.15.18", + "esbuild-linux-mips64le": "0.15.18", + "esbuild-linux-ppc64le": "0.15.18", + "esbuild-linux-riscv64": "0.15.18", + "esbuild-linux-s390x": "0.15.18", + "esbuild-netbsd-64": "0.15.18", + "esbuild-openbsd-64": "0.15.18", + "esbuild-sunos-64": "0.15.18", + "esbuild-windows-32": "0.15.18", + "esbuild-windows-64": "0.15.18", + "esbuild-windows-arm64": "0.15.18" + } + }, + "node_modules/esbuild-android-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.15.18.tgz", + "integrity": "sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-android-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.15.18.tgz", + "integrity": "sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-darwin-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.15.18.tgz", + "integrity": "sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-darwin-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.18.tgz", + "integrity": "sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-freebsd-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.18.tgz", + "integrity": "sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-freebsd-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.18.tgz", + "integrity": "sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-32": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.15.18.tgz", + "integrity": "sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.15.18.tgz", + "integrity": "sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-arm": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.15.18.tgz", + "integrity": "sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.18.tgz", + "integrity": "sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-mips64le": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.18.tgz", + "integrity": "sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-ppc64le": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.18.tgz", + "integrity": "sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-riscv64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.18.tgz", + "integrity": "sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-s390x": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.18.tgz", + "integrity": "sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-netbsd-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.18.tgz", + "integrity": "sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-openbsd-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.18.tgz", + "integrity": "sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-sunos-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.15.18.tgz", + "integrity": "sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-32": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.15.18.tgz", + "integrity": "sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.15.18.tgz", + "integrity": "sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.18.tgz", + "integrity": "sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "2.80.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", + "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/vite": { + "version": "3.2.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.11.tgz", + "integrity": "sha512-K/jGKL/PgbIgKCiJo5QbASQhFiV02X9Jh+Qq0AKCRCRKZtOTVi4t6wh75FDpGf2N9rYOnzH87OEFQNaFy6pdxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.15.9", + "postcss": "^8.4.18", + "resolve": "^1.22.1", + "rollup": "^2.79.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@types/node": ">= 14", + "less": "*", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz", + "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-sfc": "3.5.34", + "@vue/runtime-dom": "3.5.34", + "@vue/server-renderer": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + } + } +} diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index dfa9de4..45e71a1 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -8,11 +8,9 @@ export function CleanString(arg1:string):Promise; export function ClearAICache():Promise; -export function DeepseekEnhanceMatching(arg1:string,arg2:string):Promise>; - export function ExportResults(arg1:Array):Promise; -export function GetAICacheInfo():Promise>; +export function GetAICacheInfo():Promise; export function GetDeepseekStatus():Promise; @@ -31,5 +29,3 @@ export function RunMatch(arg1:main.MatchConfig):Promise> export function RunMatchWithAI(arg1:main.MatchConfig):Promise>; export function SetDeepseekAPIKey(arg1:string):Promise; - -export function StartMatching(arg1:string,arg2:string):Promise>; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 641d8ab..f93a406 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -14,10 +14,6 @@ export function ClearAICache() { return window['go']['main']['App']['ClearAICache'](); } -export function DeepseekEnhanceMatching(arg1, arg2) { - return window['go']['main']['App']['DeepseekEnhanceMatching'](arg1, arg2); -} - export function ExportResults(arg1) { return window['go']['main']['App']['ExportResults'](arg1); } @@ -61,7 +57,3 @@ export function RunMatchWithAI(arg1) { export function SetDeepseekAPIKey(arg1) { return window['go']['main']['App']['SetDeepseekAPIKey'](arg1); } - -export function StartMatching(arg1, arg2) { - return window['go']['main']['App']['StartMatching'](arg1, arg2); -} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index f2504b0..1d0ce96 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1,5 +1,19 @@ export namespace main { + export class AICacheInfo { + count: number; + filePath: string; + + static createFrom(source: any = {}) { + return new AICacheInfo(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.count = source["count"]; + this.filePath = source["filePath"]; + } + } export class MatchConfig { fileAPath: string; fileBPath: string; @@ -46,9 +60,6 @@ export namespace main { rowAData: string[]; rowBKey: string; extractValue: string; - monthlyCellName: string; - dailyCellId: string; - interruptReason: string; timeDiff: string; similarityScore: number; aiMatched: boolean; @@ -62,9 +73,6 @@ export namespace main { this.rowAData = source["rowAData"]; this.rowBKey = source["rowBKey"]; this.extractValue = source["extractValue"]; - this.monthlyCellName = source["monthlyCellName"]; - this.dailyCellId = source["dailyCellId"]; - this.interruptReason = source["interruptReason"]; this.timeDiff = source["timeDiff"]; this.similarityScore = source["similarityScore"]; this.aiMatched = source["aiMatched"]; From 6f9ffc59f31932e1f7b6bc1d4e2d52a31c843daf Mon Sep 17 00:00:00 2001 From: RainySY Date: Wed, 20 May 2026 14:33:10 +0800 Subject: [PATCH 3/6] feat: support any OpenAI-compatible AI API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Add apiEndpoint / apiModel fields to App struct (configurable, with Deepseek defaults) - New SetAIConfig(endpoint, model, key) unified config method - New SetAPIKey(key) for key-only updates - GetAIStatus() returns { ready, endpoint, model } - callDeepseekAPI → callAIAPI with configurable endpoint and model - Remove hardcoded Deepseek URL/model; defaults remain as fallback Frontend: - Replace Deepseek-specific state with generic AI config (endpoint / model / key) - Add endpoint URL and model name input fields - saveApiKey → saveApiConfig using SetAIConfig - onMounted restores full AI config via GetAIStatus - Rename all user-facing 'Deepseek' labels to 'AI / AI API' Now supports: Deepseek, OpenAI, Ollama, vLLM, or any OpenAI-compatible endpoint. --- app.go | 95 ++++++++++++++++++++----------- frontend/src/App.vue | 90 ++++++++++++++++++----------- frontend/wailsjs/go/main/App.d.ts | 6 +- frontend/wailsjs/go/main/App.js | 12 ++-- 4 files changed, 132 insertions(+), 71 deletions(-) diff --git a/app.go b/app.go index 8918f7c..8f3b86c 100644 --- a/app.go +++ b/app.go @@ -72,7 +72,7 @@ type MatchConfig struct { IncludeHeader bool `json:"includeHeader"` // 导出时是否包含表头行 } -// ---------- Deepseek API 类型 ---------- +// ---------- OpenAI 兼容 API 类型 ---------- type deepseekMessage struct { Role string `json:"role"` @@ -322,9 +322,11 @@ type matchPrep struct { // ---------- App 结构体 ---------- type App struct { - ctx context.Context - deepseekKey string - aiCache *AICache + ctx context.Context + apiKey string // AI API 密钥(兼容 OpenAI/Deepseek/本地模型) + apiEndpoint string // API 端点(默认 https://api.deepseek.com/v1/chat/completions) + apiModel string // 模型名称(默认 deepseek-chat) + aiCache *AICache // 最近一次匹配的配置和表头(供导出使用) dataMu sync.RWMutex @@ -360,18 +362,36 @@ func (a *App) emitProgress(current, total int, message, phase string) { }) } -// SetDeepseekAPIKey 设置 Deepseek API 密钥(仅保存在内存中) -func (a *App) SetDeepseekAPIKey(key string) string { - a.deepseekKey = strings.TrimSpace(key) - if a.deepseekKey == "" { - return "已清除 Deepseek API 密钥" +// SetAIConfig 统一设置 AI API 配置(端点、模型、密钥) +func (a *App) SetAIConfig(endpoint, model, key string) string { + if endpoint != "" { + a.apiEndpoint = strings.TrimSpace(endpoint) } - return "Deepseek API 密钥已设置" + if model != "" { + a.apiModel = strings.TrimSpace(model) + } + if key != "" { + a.apiKey = strings.TrimSpace(key) + } + return fmt.Sprintf("AI 配置已更新 (端点=%s, 模型=%s)", a.apiEndpoint, a.apiModel) } -// GetDeepseekStatus 返回是否已配置 Deepseek API 密钥 -func (a *App) GetDeepseekStatus() bool { - return a.deepseekKey != "" +// SetAPIKey 设置 AI API 密钥(仅保存在内存中) +func (a *App) SetAPIKey(key string) string { + a.apiKey = strings.TrimSpace(key) + if a.apiKey == "" { + return "已清除 AI API 密钥" + } + return "AI API 密钥已设置" +} + +// GetAIStatus 返回 AI API 配置状态 +func (a *App) GetAIStatus() map[string]string { + return map[string]string{ + "ready": fmt.Sprintf("%v", a.apiKey != ""), + "endpoint": a.apiEndpoint, + "model": a.apiModel, + } } // ClearAICache 清除所有 AI 缓存 @@ -894,10 +914,10 @@ func parseTimeDiffDuration(s string) time.Duration { return sign * d } -// RunMatchWithAI 执行基础匹配 + Deepseek AI 增强匹配(配置驱动) +// RunMatchWithAI 执行基础匹配 + AI 增强匹配(配置驱动) func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) { - if a.deepseekKey == "" { - return nil, fmt.Errorf("请先设置 Deepseek API 密钥") + if a.apiKey == "" { + return nil, fmt.Errorf("请先设置 AI API 密钥") } prep, err := a.prepareMatch(config) @@ -972,7 +992,7 @@ func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) { totalUnmatched := len(uncachedA) a.emitProgress(0, totalUnmatched, - fmt.Sprintf("AI 增强匹配:%d 条命中缓存,%d 条需调用 Deepseek...", cacheHits, totalUnmatched), + fmt.Sprintf("AI 增强匹配:%d 条命中缓存,%d 条需调用 AI...", cacheHits, totalUnmatched), "ai-enhancing") for batchStart := 0; batchStart < totalUnmatched; batchStart += defaultBatchSize { @@ -1029,7 +1049,7 @@ func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) { // 构建 AI 提示 prompt := a.buildGenericAIPrompt(batch, relevantB, config, prep.windowDuration, hasBatchTime) - aiResp, err := a.callDeepseekAPI(prompt) + aiResp, err := a.callAIAPI(prompt) if err != nil { fmt.Printf("[AI-WARN] 第 %d 批 API 调用失败: %v\n", batchNum, err) failedBatches = append(failedBatches, batchNum) @@ -1152,7 +1172,7 @@ func (a *App) buildGenericAIPrompt(unmatched, bRows [][]string, config MatchConf } } -// ---------- Deepseek AI 增强匹配 ---------- +// ---------- AI API 调用 ---------- // hashPrompt 对 prompt 消息计算 SHA256(用于缓存键) func hashPrompt(messages []deepseekMessage) string { @@ -1166,10 +1186,20 @@ func hashPrompt(messages []deepseekMessage) string { 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 密钥") +// callAIAPI 调用 OpenAI 兼容 API(Deepseek / OpenAI / 本地模型 等) +func (a *App) callAIAPI(messages []deepseekMessage) (string, error) { + if a.apiKey == "" { + return "", fmt.Errorf("请先设置 AI API 密钥") + } + + // 默认值 + endpoint := a.apiEndpoint + if endpoint == "" { + endpoint = "https://api.deepseek.com/v1/chat/completions" + } + model := a.apiModel + if model == "" { + model = "deepseek-chat" } hash := hashPrompt(messages) @@ -1179,46 +1209,45 @@ func (a *App) callDeepseekAPI(messages []deepseekMessage) (string, error) { fmt.Printf("[CACHE] ✓ 命中 AI 缓存 (hash=%s)\n", hash[:12]) return cached, nil } - fmt.Printf("[CACHE] ✗ 缓存未命中 (hash=%s),调用 API...\n", hash[:12]) + fmt.Printf("[CACHE] ✗ 缓存未命中 (hash=%s),调用 %s...\n", hash[:12], endpoint) reqBody := deepseekRequest{ - Model: "deepseek-chat", + Model: model, Messages: messages, Temperature: 0.05, MaxTokens: 2048, } bodyBytes, _ := json.Marshal(reqBody) - httpReq, err := http.NewRequest("POST", "https://api.deepseek.com/v1/chat/completions", - bytes.NewReader(bodyBytes)) + httpReq, err := http.NewRequest("POST", endpoint, 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) + httpReq.Header.Set("Authorization", "Bearer "+a.apiKey) client := &http.Client{Timeout: 60 * time.Second} resp, err := client.Do(httpReq) if err != nil { - return "", fmt.Errorf("调用 Deepseek API 失败: %v", err) + return "", fmt.Errorf("调用 AI API 失败: %v", err) } defer resp.Body.Close() respBytes, err := io.ReadAll(resp.Body) if err != nil { - return "", fmt.Errorf("读取 Deepseek 响应失败: %v", err) + return "", fmt.Errorf("读取 AI 响应失败: %v", err) } var dr deepseekResponse if err := json.Unmarshal(respBytes, &dr); err != nil { - return "", fmt.Errorf("解析 Deepseek 响应失败: %v", err) + return "", fmt.Errorf("解析 AI 响应失败: %v", err) } if dr.Error != nil { - return "", fmt.Errorf("Deepseek API 错误: %s", dr.Error.Message) + return "", fmt.Errorf("AI API 错误: %s", dr.Error.Message) } if len(dr.Choices) == 0 { - return "", fmt.Errorf("Deepseek 未返回有效结果") + return "", fmt.Errorf("AI 未返回有效结果") } result := strings.TrimSpace(dr.Choices[0].Message.Content) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 2dd57a5..97afab2 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,8 +1,8 @@ -