388 lines
12 KiB
Go
388 lines
12 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
|
||
}
|
||
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
|
||
}
|
||
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 TableAnalysisResult{}, fmt.Errorf("%w: %v", ErrBadOutput, err)
|
||
}
|
||
out.Title = firstNonEmpty(out.Title, input.Topic, input.FileName, "智能解读报告")
|
||
if strings.TrimSpace(out.Interpretation) == "" {
|
||
return TableAnalysisResult{}, ErrBadOutput
|
||
}
|
||
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),
|
||
}, nil
|
||
}
|
||
|
||
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")
|
||
}
|