From 1bcce3cddedbbe4ade103a2953ea9b64fefc0ade Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Tue, 29 Sep 2026 17:37:57 +0800 Subject: [PATCH] fix: fallback on invalid ai table output --- internal/ai/service.go | 141 +++++++++++++++++++++++++++++++++++- internal/ai/service_test.go | 29 ++++++++ 2 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 internal/ai/service_test.go diff --git a/internal/ai/service.go b/internal/ai/service.go index f8d7d47..4e94108 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -268,6 +268,7 @@ func (s *Service) AnalyzeTable(ctx context.Context, input TableAnalysisInput) (T if err != nil { return TableAnalysisResult{}, err } + usage := usageFromResponse(resp) var out struct { Title string `json:"title"` Summary string `json:"summary"` @@ -277,18 +278,152 @@ func (s *Service) AnalyzeTable(ctx context.Context, input TableAnalysisInput) (T Interpretation string `json:"interpretation"` } if err := json.Unmarshal([]byte(ExtractJSONObject(resp.Content)), &out); err != nil { - return TableAnalysisResult{}, fmt.Errorf("%w: %v", ErrBadOutput, err) + return fallbackTableAnalysis(input, usage, "AI 输出格式异常,已使用兜底解读。"), nil } out.Title = firstNonEmpty(out.Title, input.Topic, input.FileName, "智能解读报告") if strings.TrimSpace(out.Interpretation) == "" { - return TableAnalysisResult{}, ErrBadOutput + return fallbackTableAnalysis(input, usage, "AI 输出缺少核心解读,已使用兜底解读。"), nil } return TableAnalysisResult{ Title: out.Title, Metrics: out.Metrics, Preview: out.Preview, Alerts: out.Alerts, - Interpretation: strings.TrimSpace(out.Interpretation), Summary: strings.TrimSpace(out.Summary), Usage: usageFromResponse(resp), + Interpretation: strings.TrimSpace(out.Interpretation), Summary: strings.TrimSpace(out.Summary), Usage: usage, }, nil } +func fallbackTableAnalysis(input TableAnalysisInput, usage Usage, reason string) TableAnalysisResult { + title := firstNonEmpty(input.Topic, input.FileName, "智能表格解读报告") + preview := fallbackTablePreview(input.Profile) + metrics := fallbackTableMetrics(input, preview) + alerts := []TableAlert{{ + Level: "warning", + Title: "已启用兜底解读", + Description: reason, + }} + if !input.Profile.Parsed { + alerts = append(alerts, TableAlert{ + Level: "warning", + Title: "表格内容未解析", + Description: firstNonEmpty(input.Profile.Message, "当前无法读取表格内容,仅基于文件元信息生成解读。"), + }) + } + interpretation := fallbackTableInterpretation(input, preview, reason) + return TableAnalysisResult{ + Title: title, + Summary: excerptPlain(interpretation, 120), + Metrics: metrics, + Preview: preview, + Alerts: alerts, + Interpretation: interpretation, + Usage: usage, + } +} + +func fallbackTablePreview(profile TableProfile) TablePreview { + var columns []struct { + Name string `json:"name"` + Type string `json:"type"` + } + _ = decodeProfileAny(profile.Columns, &columns) + names := make([]string, 0, len(columns)) + for _, column := range columns { + if name := strings.TrimSpace(column.Name); name != "" { + names = append(names, name) + } + } + var rows [][]string + _ = decodeProfileAny(profile.SampleRows, &rows) + if len(rows) > 5 { + rows = rows[:5] + } + return TablePreview{Columns: names, Rows: rows, TotalRows: profile.RowCount} +} + +func fallbackTableMetrics(input TableAnalysisInput, preview TablePreview) []TableMetric { + metrics := []TableMetric{ + {Name: "文件类型", Value: strings.ToUpper(input.FileType), ChangeText: "来自上传元信息", Trend: "flat"}, + {Name: "文件大小", Value: fmt.Sprintf("%d bytes", input.FileSize), ChangeText: "来自上传元信息", Trend: "flat"}, + } + if input.Profile.Parsed { + metrics = append(metrics, + TableMetric{Name: "数据行数", Value: fmt.Sprintf("%d", preview.TotalRows), ChangeText: "来自表格解析", Trend: "flat"}, + TableMetric{Name: "字段数", Value: fmt.Sprintf("%d", len(preview.Columns)), ChangeText: "来自表格解析", Trend: "flat"}, + ) + } else { + metrics = append(metrics, TableMetric{Name: "解析状态", Value: "未解析", ChangeText: firstNonEmpty(input.Profile.Message, "source file is not available locally"), Trend: "flat"}) + } + var stats map[string]struct { + Count int `json:"count"` + Sum float64 `json:"sum"` + Avg float64 `json:"avg"` + Min float64 `json:"min"` + Max float64 `json:"max"` + } + if err := decodeProfileAny(input.Profile.Stats, &stats); err == nil { + for name, stat := range stats { + if len(metrics) >= 6 { + break + } + metrics = append(metrics, TableMetric{ + Name: name, + Value: fmt.Sprintf("平均 %.2f", stat.Avg), + ChangeText: fmt.Sprintf("样本 %d,范围 %.2f-%.2f", stat.Count, stat.Min, stat.Max), + Trend: "flat", + }) + } + } + return metrics +} + +func fallbackTableInterpretation(input TableAnalysisInput, preview TablePreview, reason string) string { + var b strings.Builder + b.WriteString(reason) + b.WriteString("\n\n") + b.WriteString("文件《") + b.WriteString(firstNonEmpty(input.FileName, input.FileID, "未命名文件")) + b.WriteString("》已进入智能表格解读流程。") + if input.Profile.Parsed { + b.WriteString(fmt.Sprintf("当前已解析到 %d 行数据、%d 个字段。", preview.TotalRows, len(preview.Columns))) + if len(preview.Columns) > 0 { + b.WriteString("字段包括:") + b.WriteString(strings.Join(preview.Columns, "、")) + b.WriteString("。") + } + b.WriteString("建议结合字段含义继续查看数值分布、异常值和业务口径。") + } else { + b.WriteString("当前未能读取表格内容,报告仅基于文件名、类型和大小生成。") + if msg := strings.TrimSpace(input.Profile.Message); msg != "" { + b.WriteString("未解析原因:") + b.WriteString(msg) + b.WriteString("。") + } + b.WriteString("建议确认文件已上传完成、格式正确后重新发起解读。") + } + return b.String() +} + +func decodeProfileAny(value any, target any) error { + if value == nil { + return nil + } + data, err := json.Marshal(value) + if err != nil { + return err + } + return json.Unmarshal(data, target) +} + +func excerptPlain(value string, maxRunes int) string { + value = strings.TrimSpace(value) + if maxRunes <= 0 { + return "" + } + runes := []rune(value) + if len(runes) <= maxRunes { + return value + } + return string(runes[:maxRunes]) +} + func (s *Service) SummarizeMeeting(ctx context.Context, input MeetingSummaryInput) (MeetingSummaryResult, error) { if s == nil || s.client == nil { return MeetingSummaryResult{}, ErrNotConfigured diff --git a/internal/ai/service_test.go b/internal/ai/service_test.go new file mode 100644 index 0000000..188c7e5 --- /dev/null +++ b/internal/ai/service_test.go @@ -0,0 +1,29 @@ +package ai + +import ( + "strings" + "testing" +) + +func TestFallbackTableAnalysisUnparsedProfile(t *testing.T) { + result := fallbackTableAnalysis(TableAnalysisInput{ + Topic: "销售分析", + FileID: "file1", + FileName: "sales.xlsx", + FileType: "xlsx", + FileSize: 5149, + Profile: TableProfile{Parsed: false, Message: "source file is not available locally"}, + }, Usage{Model: "deepseek-flash"}, "AI 输出格式异常,已使用兜底解读。") + if result.Title != "销售分析" { + t.Fatalf("Title=%q, want 销售分析", result.Title) + } + if strings.TrimSpace(result.Interpretation) == "" { + t.Fatal("Interpretation is empty") + } + if len(result.Alerts) < 2 { + t.Fatalf("Alerts len=%d, want at least 2", len(result.Alerts)) + } + if got := result.Metrics[len(result.Metrics)-1].Value; got != "未解析" { + t.Fatalf("last metric value=%q, want 未解析", got) + } +}