Files
olons_server/internal/ai/service.go
T

388 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}