feat: 报告生成模块(LLM 生成 + PDF 导出 + 前端页面)

- 新增报告生成 API(/api/report/generate, /api/report/export/pdf, /api/report/preview)
- 新增 PDF 导出模块(gofpdf,支持系统中文字体自动检测)
- 新增前端报告生成页面(report/ReportGenerationPage.vue)
- 新增前端 API(report.js)
- 路由注册(后端 router.go + 前端 router/index.js)
- Studio 页面新增「报告生成」应用入口

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
eaiadmin
2026-09-13 19:38:44 +08:00
co-authored by Claude Code
parent bd7fe63b70
commit c8781bfe90
9 changed files with 993 additions and 0 deletions
@@ -0,0 +1,395 @@
package api
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/ai"
"eaisalestrain/backend/internal/config"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// ReportGenRequest POST /api/report/generate 报告生成请求
type ReportGenRequest struct {
Topic string `json:"topic"` // 报告主题,必填
Summary string `json:"summary"` // 报告摘要/背景
Context map[string]any `json:"context"` // 附加上下文
Sections []string `json:"sections"` // 期望包含的章节(可选)
Knowledge string `json:"knowledge_key"` // 关联知识库 key
}
// reportChapter 报告章节结构
type reportChapter struct {
Title string `json:"title"`
Body string `json:"body"`
}
// reportContent 完整报告结构
type reportContent struct {
Topic string `json:"topic"`
Summary string `json:"summary"`
Chapters []reportChapter `json:"chapters"`
CreatedAt string `json:"created_at"`
}
// ReportGenerate POST /api/report/generate —— 调用 LLM 生成报告正文
func ReportGenerate(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var req ReportGenRequest
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Topic) == "" {
web.Fail(c, web.NewBadRequest("topic 必填"))
return
}
// 1. 检索知识库片段
spaceKey := ""
if req.Knowledge != "" {
spaceKey = req.Knowledge
}
knowledgeContent := retrieveReportKnowledge(req.Topic, spaceKey)
// 2. 构建 LLM 提示词
chapters := make([]reportChapter, 0)
if len(req.Sections) > 0 {
// 使用用户指定的章节
chapters = promptReportWithSections(req, knowledgeContent)
} else {
// LLM 自动规划章节
chapters = promptReportAutoChapters(req, knowledgeContent)
}
// 3. 组装报告
report := reportContent{
Topic: req.Topic,
Summary: strings.TrimSpace(req.Summary),
Chapters: chapters,
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
}
web.OK(c, gin.H{
"report": report,
})
}
// retrieveReportKnowledge 为报告生成检索知识库片段
func retrieveReportKnowledge(topic string, spaceKey string) string {
if spaceKey != "" {
// 按空间检索
var space model.KnowledgeSpace
if err := store.DB.Where("key = ?", spaceKey).First(&space).Error; err != nil {
return ""
}
var chunks []model.KnowledgeChunk
store.DB.Where("space_id = ? AND approved = ?", space.ID, true).
Order("importance DESC").
Limit(10).
Find(&chunks)
parts := make([]string, 0, len(chunks))
for _, ch := range chunks {
parts = append(parts, ch.Content)
}
return strings.Join(parts, "\n\n")
}
// 全局检索(按 title 匹配)
var chunks []model.KnowledgeChunk
store.DB.Where("approved = ?", true).
Order("importance DESC").
Limit(15).
Find(&chunks)
parts := make([]string, 0, len(chunks))
for _, ch := range chunks {
parts = append(parts, fmt.Sprintf("【%s】%s", ch.Content))
}
return strings.Join(parts, "\n\n")
}
// promptReportWithSections 使用用户指定章节生成报告
func promptReportWithSections(req ReportGenRequest, knowledge string) []reportChapter {
sectionsStr := strings.Join(req.Sections, "、")
systemPrompt := `你是博昇内部培训平台的报告生成引擎。你的任务是输出一份结构化的正式报告,语言为中文。`
userPrompt := fmt.Sprintf(`请围绕以下主题撰写一份正式报告。
主题:%s
报告要求:
1. 内容完整、逻辑清晰、避免空话
2. 使用正式的书面语言,适合内部汇报或培训
3. 报告必须包含以下章节(按顺序):%s
4. 每个章节下至少 3-5 个要点
5. 如有知识库资料可引用,请融入内容中
当前报告摘要/背景:%s
知识库相关资料(如有):
%s`,
req.Topic,
sectionsStr,
strings.TrimSpace(req.Summary),
knowledge,
)
route, _ := config.GetRoute("title_gen")
content, err := ai.GenerateWithFallback(route, []ai.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
})
if err != nil {
return fallbackReport(req.Topic)
}
return parseReportMarkdown(content, req.Sections)
}
// promptReportAutoChapters 让 LLM 自动规划报告章节
func promptReportAutoChapters(req ReportGenRequest, knowledge string) []reportChapter {
systemPrompt := `你是博昇内部培训平台的报告生成引擎。先规划报告章节,再撰写内容。输出必须严格为 JSON 数组,不要输出其他内容。`
userPrompt := fmt.Sprintf(`请围绕以下主题撰写一份正式报告。
主题:%s
报告要求:
1. 先规划 5-8 个合适的章节
2. 每个章节包含:标题(title)和正文(body)
3. 正文不少于 300 字
4. 使用正式的书面语言
当前报告摘要/背景:%s
知识库相关资料(如有):
%s
请输出 JSON 格式,每个条目包含 "title" 和 "body" 两个字段。`,
req.Topic,
strings.TrimSpace(req.Summary),
knowledge,
)
route, _ := config.GetRoute("title_gen")
content, err := ai.GenerateWithFallback(route, []ai.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
})
if err != nil {
return fallbackReport(req.Topic)
}
return parseReportJSON(content)
}
// parseReportMarkdown 从 Markdown 格式解析报告为章节数组
func parseReportMarkdown(content string, defaultSections []string) []reportChapter {
if len(defaultSections) == 0 {
defaultSections = []string{"背景概述", "核心内容", "关键数据", "实施方案", "总结建议"}
}
lines := strings.Split(content, "\n")
chapters := make([]reportChapter, 0, len(defaultSections))
currentTitle := ""
currentBody := ""
isChapterTitle := func(line string) bool {
if strings.HasPrefix(line, "# ") || strings.HasPrefix(line, "## ") {
return true
}
runes := []rune(line)
if len(runes) >= 2 {
// 数字序号:1、 1. 一、
if (runes[0] >= '0' && runes[0] <= '9') && (runes[1] == '、' || runes[1] == '.') {
return true
}
if runes[0] == '一' && runes[1] == '、' {
return true
}
}
// 短标题带冒号(排除 URL)
if len(runes) < 30 && len(runes) > 3 && strings.Contains(line, ":") && !strings.Contains(line, "https") {
// 排除 markdown 行内代码
if !strings.Contains(line, "```") {
return true
}
}
return false
}
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "---") {
continue
}
if len(chapters) < 10 && isChapterTitle(line) {
if currentTitle != "" && currentBody != "" {
chapters = append(chapters, reportChapter{
Title: strings.TrimSpace(currentTitle),
Body: strings.TrimSpace(currentBody),
})
}
currentTitle = strings.TrimLeft(line, "# \t")
currentTitle = strings.TrimRight(currentTitle, " ")
currentBody = ""
continue
}
if currentTitle != "" {
currentBody += line + "\n"
}
}
if currentTitle != "" && currentBody != "" {
chapters = append(chapters, reportChapter{
Title: strings.TrimSpace(currentTitle),
Body: strings.TrimSpace(currentBody),
})
}
if len(chapters) == 0 {
for _, title := range defaultSections {
chapters = append(chapters, reportChapter{
Title: title,
Body: content,
})
}
}
if len(chapters) > 15 {
chapters = chapters[:15]
}
return chapters
}
// parseReportJSON 从 LLM 返回的 JSON 解析报告
func parseReportJSON(content string) []reportChapter {
// 提取 JSON 块
jsonStr := content
if idx := strings.Index(content, "["); idx >= 0 {
jsonStr = content[idx:]
}
if idx := strings.LastIndex(jsonStr, "]"); idx >= 0 {
jsonStr = jsonStr[:idx+1]
}
var chapters []reportChapter
if err := json.Unmarshal([]byte(jsonStr), &chapters); err == nil && len(chapters) > 0 {
return chapters
}
// 提取数组对象块
start := strings.Index(content, "{")
end := strings.LastIndex(content, "}")
if start >= 0 && end > start {
jsonStr2 := content[start : end+1]
if err := json.Unmarshal([]byte(jsonStr2), &chapters); err == nil && len(chapters) > 0 {
return chapters
}
}
// 回退:生成默认章节
return fallbackReport("")
}
// fallbackReport 无 LLM 时的默认报告模板
func fallbackReport(topic string) []reportChapter {
title := topic
if title == "" {
title = "分析报告"
}
return []reportChapter{
{
Title: "一、背景概述",
Body: fmt.Sprintf("本报告围绕「%s」主题展开,旨在梳理相关背景信息与核心要点,为内部学习与汇报提供参考。", title),
},
{
Title: "二、核心内容",
Body: fmt.Sprintf("在「%s」领域,当前主流实践聚焦于以下几个维度:知识体系构建、流程标准化、工具赋能与效果评估。通过系统化的培训与考核机制,帮助团队提升专业能力。", title),
},
{
Title: "三、关键数据",
Body: "关键指标包括:产品覆盖率、培训完成率、考试通过率、知识入库率、AI 使用频次等。具体数据需根据实际运营情况定期更新。",
},
{
Title: "四、实施方案",
Body: "建议分阶段推进:第一阶段建立知识体系与培训框架;第二阶段完善考核与评估机制;第三阶段引入 AI 辅助工具提升效率;第四阶段持续优化与迭代。",
},
{
Title: "五、总结建议",
Body: "整体而言,「%s」是当前业务发展的核心方向之一。建议持续投入资源,建立系统化培训体系,结合 AI 工具提升效率,确保团队能力与业务发展同步。" + title,
},
}
}
// ReportExportPDF POST /api/report/export/pdf —— 导出报告为 PDF 文件
func ReportExportPDF(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var req struct {
Report reportContent `json:"report"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Report.Topic == "" {
web.Fail(c, web.NewBadRequest("report 不能为空"))
return
}
data, err := ExportReportPDF(req.Report)
if err != nil {
web.Fail(c, web.NewBadRequest("PDF 生成失败: "+err.Error()))
return
}
c.Header("Content-Type", "application/pdf")
c.Header("Content-Disposition",
"attachment; filename=\""+strings.ReplaceAll(req.Report.Topic, " ", "_")+".pdf\"")
c.Data(200, "application/pdf", data)
}
// ReportPreview GET /api/report/preview —— 预览报告(JSON,用于前端预览)
func ReportPreview(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var req ReportGenRequest
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Topic) == "" {
web.Fail(c, web.NewBadRequest("topic 必填"))
return
}
// 使用 LLM 生成报告内容
report := promptReportPreview(req)
web.OK(c, gin.H{
"report": report,
})
}
// promptReportPreview 预览模式:返回精简报告
func promptReportPreview(req ReportGenRequest) reportContent {
chapters := []reportChapter{}
if len(req.Sections) > 0 {
chapters = promptReportWithSections(req, retrieveReportKnowledge(req.Topic, req.Knowledge))
} else {
chapters = promptReportAutoChapters(req, retrieveReportKnowledge(req.Topic, req.Knowledge))
}
return reportContent{
Topic: req.Topic,
Summary: strings.TrimSpace(req.Summary),
Chapters: chapters,
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
}
}
@@ -0,0 +1,96 @@
package api
import (
"bytes"
"os"
"strings"
"github.com/jung-kurt/gofpdf/v2"
)
// ExportReportPDF 导出报告为 PDF 字节
func ExportReportPDF(report reportContent) ([]byte, error) {
pdf := gofpdf.New("P", "mm", "A4", "")
fontLoaded := false
for _, candidate := range []string{
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
"/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
} {
if _, err := os.Stat(candidate); err == nil {
pdf.AddUTF8Font("czk", "", candidate)
fontLoaded = true
break
}
}
pdf.AddPage()
// 标题
if fontLoaded {
pdf.SetFont("czk", "B", 20)
} else {
pdf.SetFont("Helvetica", "B", 20)
}
pdf.CellFormat(170, 12, report.Topic, "", 1, "C", false, 0, "")
pdf.Ln(8)
// 摘要
if strings.TrimSpace(report.Summary) != "" {
if fontLoaded {
pdf.SetFont("czk", "", 10)
} else {
pdf.SetFont("Helvetica", "", 10)
}
pdf.MultiCell(170, 5, report.Summary, "", "L", false)
pdf.Ln(6)
}
// 目录
if fontLoaded {
pdf.SetFont("czk", "B", 14)
} else {
pdf.SetFont("Helvetica", "B", 14)
}
pdf.Cell(0, 8, "目录")
pdf.Ln(4)
for _, ch := range report.Chapters {
if fontLoaded {
pdf.SetFont("czk", "", 10)
} else {
pdf.SetFont("Helvetica", "", 10)
}
pdf.Cell(0, 6, " " + ch.Title)
pdf.Ln(5)
}
pdf.Ln(6)
// 章节
for _, ch := range report.Chapters {
pdf.AddPage()
if fontLoaded {
pdf.SetFont("czk", "B", 14)
} else {
pdf.SetFont("Helvetica", "B", 14)
}
pdf.Cell(0, 8, ch.Title)
pdf.Ln(2)
if fontLoaded {
pdf.SetFont("czk", "", 10)
} else {
pdf.SetFont("Helvetica", "", 10)
}
pdf.MultiCell(170, 5, ch.Body, "", "L", false)
pdf.Ln(6)
}
var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
@@ -118,6 +118,11 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
r.PUT("/api/notes/:id", middleware.Auth(cfg), UpdateNote)
r.DELETE("/api/notes/:id", middleware.Auth(cfg), DeleteNote)
// ── 报告生成(普通员工可访问)──
r.POST("/api/report/generate", middleware.Auth(cfg), ReportGenerate)
r.POST("/api/report/export/pdf", middleware.Auth(cfg), ReportExportPDF)
r.POST("/api/report/preview", middleware.Auth(cfg), ReportPreview)
// 管理员维护
admin := r.Group("/api")
admin.Use(middleware.Auth(cfg), middleware.RequireAdmin())