package ai import ( "context" "encoding/json" "fmt" "strings" ) type Service struct { client *Client } type Usage struct { ProviderRequestID string Model string PromptTokens int CompletionTokens int TotalTokens int LatencyMS int64 } type PolishResult struct { Text string Usage Usage } type DocumentResult struct { Title string ContentMarkdown string ContentPlain string Sections []DocumentSection Summary string Usage Usage } type DocumentSection struct { Heading string `json:"heading"` Content string `json:"content"` } type TableAnalysisInput struct { Topic string `json:"topic"` FileID string `json:"file_id"` FileName string `json:"file_name"` FileType string `json:"file_type"` FileSize int64 `json:"file_size"` Profile TableProfile `json:"profile"` } type TableAnalysisResult struct { Title string Metrics []TableMetric Preview TablePreview Alerts []TableAlert Interpretation string Summary string Usage Usage } type TableMetric struct { Name string `json:"name"` Value string `json:"value"` ChangeText string `json:"change_text"` Trend string `json:"trend"` } type TablePreview struct { Columns []string `json:"columns"` Rows [][]string `json:"rows"` TotalRows int `json:"total_rows"` } type TableAlert struct { Level string `json:"level"` Title string `json:"title"` Description string `json:"description"` } type TableProfile struct { Columns any `json:"columns,omitempty"` SampleRows any `json:"sample_rows,omitempty"` RowCount int `json:"row_count,omitempty"` Stats any `json:"stats,omitempty"` Outliers any `json:"outliers,omitempty"` Parsed bool `json:"parsed"` Message string `json:"message,omitempty"` } type MeetingSummaryInput struct { MeetingTitle string StartAt string EndAt string Participants []string Files []string } type MeetingSummaryResult struct { Conclusions []string Decisions []string ActionItems []MeetingActionItem Usage Usage } type MeetingActionItem struct { OwnerName string `json:"owner_name"` Content string `json:"content"` DueText string `json:"due_text"` Status string `json:"status"` } func NewService(client *Client) *Service { return &Service{client: client} } func (s *Service) Configured() bool { return s != nil && s.client != nil && s.client.Configured() } func (s *Service) Polish(ctx context.Context, sourceText, scene, tone string, maxOutputChars int) (PolishResult, error) { if s == nil || s.client == nil { return PolishResult{}, ErrNotConfigured } if maxOutputChars <= 0 { maxOutputChars = 300 } system := "你是 IM 工作台的企业办公文案助手。请只输出 JSON,不要输出 Markdown,不要输出额外解释。" user := fmt.Sprintf(`请润色下面的中文工作文案。 要求: 1. 语气:%s。 2. 场景:%s。 3. 输出不超过 %d 个中文字符。 4. 保持事实不变,不编造数据、人名、时间和结论。 5. 只输出 JSON:{"polished_text":"..."}。 原文: %s`, firstNonEmpty(tone, "professional"), firstNonEmpty(scene, "general"), maxOutputChars, sourceText) resp, err := s.client.Generate(ctx, GenerateRequest{ Feature: "text_polish", Model: s.client.DefaultModel(), Messages: []Message{ {Role: "system", Content: system}, {Role: "user", Content: user}, }, MaxTokens: maxOutputChars * 3, }) if err != nil { return PolishResult{}, err } var out struct { PolishedText string `json:"polished_text"` } if err := json.Unmarshal([]byte(ExtractJSONObject(resp.Content)), &out); err != nil { return PolishResult{}, fmt.Errorf("%w: %v", ErrBadOutput, err) } out.PolishedText = strings.TrimSpace(out.PolishedText) if out.PolishedText == "" { return PolishResult{}, ErrBadOutput } return PolishResult{Text: out.PolishedText, Usage: usageFromResponse(resp)}, nil } func (s *Service) GenerateDocument(ctx context.Context, sourceText, titleHint, documentType, language string) (DocumentResult, error) { if s == nil || s.client == nil { return DocumentResult{}, ErrNotConfigured } system := "你是 IM 工作台的企业办公文档助手。请严格输出 JSON,不要输出 Markdown code fence,不要输出额外解释。" user := fmt.Sprintf(`请根据用户输入生成一份结构化办公文档。 要求: 1. 输出语言:%s。 2. 文档类型:%s。 3. 标题提示:%s。 4. 内容要忠于原文,不编造事实。 5. sections 建议 3 到 6 个,每个 section 包含 heading 和 content。 6. content_markdown 要适合直接展示,使用二级标题和列表。 7. 只输出 JSON,格式: { "title": "string", "summary": "string", "content_markdown": "string", "content_plain": "string", "sections": [{"heading":"string","content":"string"}] } 用户输入: %s`, firstNonEmpty(language, "zh-CN"), firstNonEmpty(documentType, "general"), strings.TrimSpace(titleHint), sourceText) resp, err := s.client.Generate(ctx, GenerateRequest{ Feature: "smart_document", Model: s.client.DefaultModel(), Messages: []Message{ {Role: "system", Content: system}, {Role: "user", Content: user}, }, MaxTokens: 2400, }) if err != nil { return DocumentResult{}, err } var out struct { Title string `json:"title"` Summary string `json:"summary"` ContentMarkdown string `json:"content_markdown"` ContentPlain string `json:"content_plain"` Sections []DocumentSection `json:"sections"` } if err := json.Unmarshal([]byte(ExtractJSONObject(resp.Content)), &out); err != nil { return DocumentResult{}, fmt.Errorf("%w: %v", ErrBadOutput, err) } out.Title = strings.TrimSpace(firstNonEmpty(out.Title, titleHint, "智能文档")) out.ContentMarkdown = strings.TrimSpace(out.ContentMarkdown) if out.ContentMarkdown == "" && len(out.Sections) > 0 { out.ContentMarkdown = markdownFromSections(out.Sections) } if strings.TrimSpace(out.ContentPlain) == "" { out.ContentPlain = plainFromSections(out.Sections) } if out.ContentMarkdown == "" { return DocumentResult{}, ErrBadOutput } return DocumentResult{ Title: out.Title, ContentMarkdown: out.ContentMarkdown, ContentPlain: strings.TrimSpace(out.ContentPlain), Sections: out.Sections, Summary: strings.TrimSpace(out.Summary), Usage: usageFromResponse(resp), }, nil } func (s *Service) AnalyzeTable(ctx context.Context, input TableAnalysisInput) (TableAnalysisResult, error) { if s == nil || s.client == nil { return TableAnalysisResult{}, ErrNotConfigured } system := "你是 IM 工作台的数据分析助手。请严格输出 JSON,不要输出 Markdown code fence,不要输出额外解释。" profileJSON, _ := json.Marshal(input.Profile) user := fmt.Sprintf(`请根据报表信息生成一份简洁的数据解读报告。 注意: 1. 若 profile.parsed=true,请优先依据 columns、sample_rows、stats、outliers 做分析。 2. 若 profile.parsed=false,说明当前没有可用表格内容,只能基于文件元信息给出分析方向,不要编造具体真实数值。 报表主题:%s 文件名:%s 文件类型:%s 文件大小:%d bytes 表格画像:%s 只输出 JSON: { "title": "string", "summary": "string", "metrics": [{"name":"string","value":"string","change_text":"string","trend":"up|down|flat"}], "preview": {"columns":["string"],"rows":[["string"]],"total_rows":0}, "alerts": [{"level":"info|warning|danger","title":"string","description":"string"}], "interpretation": "string" }`, input.Topic, input.FileName, input.FileType, input.FileSize, string(profileJSON)) resp, err := s.client.Generate(ctx, GenerateRequest{ Feature: "smart_table", Model: s.client.ComplexModel(), Messages: []Message{ {Role: "system", Content: system}, {Role: "user", Content: user}, }, MaxTokens: 1800, }) if err != nil { return TableAnalysisResult{}, err } usage := usageFromResponse(resp) var out struct { Title string `json:"title"` Summary string `json:"summary"` Metrics []TableMetric `json:"metrics"` Preview TablePreview `json:"preview"` Alerts []TableAlert `json:"alerts"` Interpretation string `json:"interpretation"` } if err := json.Unmarshal([]byte(ExtractJSONObject(resp.Content)), &out); err != nil { return fallbackTableAnalysis(input, usage, "AI 输出格式异常,已使用兜底解读。"), nil } out.Title = firstNonEmpty(out.Title, input.Topic, input.FileName, "智能解读报告") if strings.TrimSpace(out.Interpretation) == "" { 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: 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 } system := "你是 IM 工作台的会议纪要助手。请严格输出 JSON,不要输出 Markdown code fence,不要输出额外解释。" payload, _ := json.Marshal(input) user := fmt.Sprintf(`请根据会议上下文生成会议 AI 总结。 要求: 1. 不编造会议中没有出现的事实。 2. 若附件内容不足,请基于标题和上下文给出谨慎总结。 3. action_items 的 status 固定为 pending。 4. 只输出 JSON: { "conclusions": ["string"], "decisions": ["string"], "action_items": [{"owner_name":"string","content":"string","due_text":"string","status":"pending"}] } 会议上下文: %s`, string(payload)) resp, err := s.client.Generate(ctx, GenerateRequest{ Feature: "meeting_summary", Model: s.client.ComplexModel(), Messages: []Message{ {Role: "system", Content: system}, {Role: "user", Content: user}, }, MaxTokens: 1800, }) if err != nil { return MeetingSummaryResult{}, err } var out struct { Conclusions []string `json:"conclusions"` Decisions []string `json:"decisions"` ActionItems []MeetingActionItem `json:"action_items"` } if err := json.Unmarshal([]byte(ExtractJSONObject(resp.Content)), &out); err != nil { return MeetingSummaryResult{}, fmt.Errorf("%w: %v", ErrBadOutput, err) } if len(out.Conclusions) == 0 && len(out.Decisions) == 0 && len(out.ActionItems) == 0 { return MeetingSummaryResult{}, ErrBadOutput } for i := range out.ActionItems { if strings.TrimSpace(out.ActionItems[i].Status) == "" { out.ActionItems[i].Status = "pending" } } return MeetingSummaryResult{Conclusions: out.Conclusions, Decisions: out.Decisions, ActionItems: out.ActionItems, Usage: usageFromResponse(resp)}, nil } func usageFromResponse(resp GenerateResponse) Usage { return Usage{ ProviderRequestID: resp.ProviderRequestID, Model: resp.Model, PromptTokens: resp.PromptTokens, CompletionTokens: resp.CompletionTokens, TotalTokens: resp.TotalTokens, LatencyMS: resp.LatencyMS, } } func markdownFromSections(sections []DocumentSection) string { var b strings.Builder for _, section := range sections { heading := strings.TrimSpace(section.Heading) content := strings.TrimSpace(section.Content) if heading == "" && content == "" { continue } if heading != "" { b.WriteString("## ") b.WriteString(heading) b.WriteString("\n") } if content != "" { b.WriteString(content) b.WriteString("\n\n") } } return strings.TrimSpace(b.String()) } func plainFromSections(sections []DocumentSection) string { parts := make([]string, 0, len(sections)*2) for _, section := range sections { if heading := strings.TrimSpace(section.Heading); heading != "" { parts = append(parts, heading) } if content := strings.TrimSpace(section.Content); content != "" { parts = append(parts, content) } } return strings.Join(parts, "\n") }