fix: fallback on invalid ai table output

This commit is contained in:
richjohnrambo
2026-09-29 17:37:57 +08:00
parent 192ab342f6
commit 1bcce3cdde
2 changed files with 167 additions and 3 deletions
+138 -3
View File
@@ -268,6 +268,7 @@ func (s *Service) AnalyzeTable(ctx context.Context, input TableAnalysisInput) (T
if err != nil { if err != nil {
return TableAnalysisResult{}, err return TableAnalysisResult{}, err
} }
usage := usageFromResponse(resp)
var out struct { var out struct {
Title string `json:"title"` Title string `json:"title"`
Summary string `json:"summary"` Summary string `json:"summary"`
@@ -277,18 +278,152 @@ func (s *Service) AnalyzeTable(ctx context.Context, input TableAnalysisInput) (T
Interpretation string `json:"interpretation"` Interpretation string `json:"interpretation"`
} }
if err := json.Unmarshal([]byte(ExtractJSONObject(resp.Content)), &out); err != nil { 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, "智能解读报告") out.Title = firstNonEmpty(out.Title, input.Topic, input.FileName, "智能解读报告")
if strings.TrimSpace(out.Interpretation) == "" { if strings.TrimSpace(out.Interpretation) == "" {
return TableAnalysisResult{}, ErrBadOutput return fallbackTableAnalysis(input, usage, "AI 输出缺少核心解读,已使用兜底解读。"), nil
} }
return TableAnalysisResult{ return TableAnalysisResult{
Title: out.Title, Metrics: out.Metrics, Preview: out.Preview, Alerts: out.Alerts, 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 }, 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) { func (s *Service) SummarizeMeeting(ctx context.Context, input MeetingSummaryInput) (MeetingSummaryResult, error) {
if s == nil || s.client == nil { if s == nil || s.client == nil {
return MeetingSummaryResult{}, ErrNotConfigured return MeetingSummaryResult{}, ErrNotConfigured
+29
View File
@@ -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)
}
}