561 lines
18 KiB
Go
561 lines
18 KiB
Go
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
|
||
}
|
||
usage := usageFromResponse(resp)
|
||
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 fallbackDocument(sourceText, titleHint, documentType, usage, "AI 输出格式异常,已使用兜底文档。"), nil
|
||
}
|
||
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 fallbackDocument(sourceText, titleHint, documentType, usage, "AI 输出缺少文档正文,已使用兜底文档。"), nil
|
||
}
|
||
return DocumentResult{
|
||
Title: out.Title,
|
||
ContentMarkdown: out.ContentMarkdown,
|
||
ContentPlain: strings.TrimSpace(out.ContentPlain),
|
||
Sections: out.Sections,
|
||
Summary: strings.TrimSpace(out.Summary),
|
||
Usage: usage,
|
||
}, nil
|
||
}
|
||
|
||
func fallbackDocument(sourceText, titleHint, documentType string, usage Usage, reason string) DocumentResult {
|
||
title := firstNonEmpty(titleHint, documentTypeTitle(documentType), excerptPlain(sourceText, 30), "智能文档")
|
||
source := strings.TrimSpace(sourceText)
|
||
if source == "" {
|
||
source = "用户未提供有效原文。"
|
||
}
|
||
sections := []DocumentSection{
|
||
{Heading: "处理说明", Content: reason},
|
||
{Heading: "用户输入", Content: source},
|
||
{Heading: "后续建议", Content: "建议检查原始输入是否完整,或重新发起生成以获取更完整的智能文档。"},
|
||
}
|
||
contentMarkdown := markdownFromSections(sections)
|
||
return DocumentResult{
|
||
Title: title,
|
||
ContentMarkdown: contentMarkdown,
|
||
ContentPlain: plainFromSections(sections),
|
||
Sections: sections,
|
||
Summary: excerptPlain(source, 120),
|
||
Usage: usage,
|
||
}
|
||
}
|
||
|
||
func documentTypeTitle(documentType string) string {
|
||
switch strings.TrimSpace(documentType) {
|
||
case "meeting_minutes":
|
||
return "会议纪要"
|
||
case "speech":
|
||
return "演讲稿"
|
||
case "proposal":
|
||
return "方案文档"
|
||
case "summary":
|
||
return "内容总结"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
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")
|
||
}
|