diff --git a/eai_ap_app/backend-go/go.mod b/eai_ap_app/backend-go/go.mod index 8d3121b..8a9971b 100644 --- a/eai_ap_app/backend-go/go.mod +++ b/eai_ap_app/backend-go/go.mod @@ -28,6 +28,7 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/jung-kurt/gofpdf/v2 v2.17.3 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/eai_ap_app/backend-go/go.sum b/eai_ap_app/backend-go/go.sum index 65ce1f0..2883d8f 100644 --- a/eai_ap_app/backend-go/go.sum +++ b/eai_ap_app/backend-go/go.sum @@ -48,6 +48,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jung-kurt/gofpdf/v2 v2.17.3 h1:otZXZby2gXJ7uU6pzprXHq/R57lsHLi0WtH79VabWxY= +github.com/jung-kurt/gofpdf/v2 v2.17.3/go.mod h1:Qx8ZNg4cNsO5i6uLDiBngnm+ii/FjtAqjRNO6drsoYU= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= diff --git a/eai_ap_app/backend-go/internal/api/report_gen.go b/eai_ap_app/backend-go/internal/api/report_gen.go new file mode 100644 index 0000000..eb73eec --- /dev/null +++ b/eai_ap_app/backend-go/internal/api/report_gen.go @@ -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"), + } +} diff --git a/eai_ap_app/backend-go/internal/api/report_pdf.go b/eai_ap_app/backend-go/internal/api/report_pdf.go new file mode 100644 index 0000000..21971a6 --- /dev/null +++ b/eai_ap_app/backend-go/internal/api/report_pdf.go @@ -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 +} diff --git a/eai_ap_app/backend-go/internal/api/router.go b/eai_ap_app/backend-go/internal/api/router.go index 64c7ae4..2ab85df 100644 --- a/eai_ap_app/backend-go/internal/api/router.go +++ b/eai_ap_app/backend-go/internal/api/router.go @@ -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()) diff --git a/eai_ap_app/frontend/src/api/report.js b/eai_ap_app/frontend/src/api/report.js new file mode 100644 index 0000000..196ac35 --- /dev/null +++ b/eai_ap_app/frontend/src/api/report.js @@ -0,0 +1,13 @@ +import http from './http' + +export function generateReport(data) { + return http.post('/report/generate', data) +} + +export function exportReportPDF(data) { + return http.post('/report/export/pdf', data, { responseType: 'blob' }) +} + +export function previewReport(data) { + return http.post('/report/preview', data) +} diff --git a/eai_ap_app/frontend/src/router/index.js b/eai_ap_app/frontend/src/router/index.js index 5c0d6d9..657b686 100644 --- a/eai_ap_app/frontend/src/router/index.js +++ b/eai_ap_app/frontend/src/router/index.js @@ -23,6 +23,7 @@ const routes = [ { path: 'apps/knowledge-operations', name: 'KnowledgeOperationsApp', meta: { workspaceKey: 'knowledge-operations' }, component: () => import('@/views/workbench/BusinessAppPage.vue') }, { path: 'apps/process-coordination', name: 'ProcessCoordinationApp', meta: { workspaceKey: 'process-coordination' }, component: () => import('@/views/workbench/BusinessAppPage.vue') }, { path: 'apps/report-generation', name: 'ReportGenerationApp', meta: { workspaceKey: 'report-generation' }, component: () => import('@/views/workbench/BusinessAppPage.vue') }, + { path: 'report-gen', name: 'ReportGeneration', meta: { requiresAdmin: true }, component: () => import('@/views/report/ReportGenerationPage.vue') }, { path: 'apps/wechat-official-account', name: 'WechatOfficialAccountApp', meta: { workspaceKey: 'wechat-official-account' }, component: () => import('@/views/workbench/OfficialAccountWorkflowPage.vue') }, { path: 'apps/logistics-fulfillment', name: 'LogisticsFulfillmentApp', meta: { workspaceKey: 'logistics-fulfillment' }, component: () => import('@/views/workbench/BusinessAppPage.vue') }, { path: 'apps/hr-email-sorter', name: 'HrEmailSorter', meta: { workspaceKey: 'hr-email-sorter' }, component: () => import('@/views/workbench/BusinessAppPage.vue') }, diff --git a/eai_ap_app/frontend/src/views/report/ReportGenerationPage.vue b/eai_ap_app/frontend/src/views/report/ReportGenerationPage.vue new file mode 100644 index 0000000..812b547 --- /dev/null +++ b/eai_ap_app/frontend/src/views/report/ReportGenerationPage.vue @@ -0,0 +1,472 @@ + + + + + diff --git a/eai_ap_app/frontend/src/views/workbench/StudioPage.vue b/eai_ap_app/frontend/src/views/workbench/StudioPage.vue index c04e161..2d37ba6 100644 --- a/eai_ap_app/frontend/src/views/workbench/StudioPage.vue +++ b/eai_ap_app/frontend/src/views/workbench/StudioPage.vue @@ -547,6 +547,14 @@ const templateCards = [ actions: ['归并进展', '提炼风险', '生成周报'], results: ['周报文档'], }, + { + key: 'report-generation', + label: '报告生成', + summary: 'AI 驱动正式报告生成,自动规划章节,支持 PDF 导出。', + inputs: ['报告主题', '背景资料'], + actions: ['章节规划', 'LLM 撰写', 'PDF 导出'], + results: ['PDF 报告'], + }, ] const toolboxItems = [