diff --git a/eai_ap_app/backend-go/cmd/inspect_oa_debug/main.go b/eai_ap_app/backend-go/cmd/inspect_oa_debug/main.go new file mode 100644 index 0000000..ca5ae1c --- /dev/null +++ b/eai_ap_app/backend-go/cmd/inspect_oa_debug/main.go @@ -0,0 +1,214 @@ +package main + +import ( + "database/sql" + "encoding/json" + "flag" + "fmt" + "log" + "os" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +type taskRow struct { + ID int64 `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + UpdatedAt string `json:"updated_at"` +} + +type articleRow struct { + TaskID int64 `json:"task_id"` + Keyword string `json:"keyword"` + SelectedTopic string `json:"selected_topic"` + TopicHeat string `json:"topic_heat"` + OutlineStyle string `json:"outline_style"` + OutlineWords int `json:"outline_words"` + ContentTargetWords int `json:"content_target_words"` + HotspotsJSON string `json:"hotspots_json"` + TopicCandidatesJSON string `json:"topic_candidates_json"` +} + +type runRow struct { + ID int64 `json:"id"` + TaskID int64 `json:"task_id"` + ActionKey string `json:"action_key"` + ActionTitle string `json:"action_title"` + OutputJSON string `json:"output_json"` + LogsJSON string `json:"logs_json"` + StartedAt string `json:"started_at"` +} + +func main() { + taskID := flag.Int64("task", 0, "official account task id") + resetStuck := flag.Bool("reset-stuck", false, "reset stuck running state for the given task") + flag.Parse() + + dbPath := `d:\traecode\pj0235-eai_agentplatform\eai_ap_app\backend-go\data\eaisalestrain.db` + db, err := sql.Open("sqlite", dbPath) + if err != nil { + log.Fatal(err) + } + defer db.Close() + + if *resetStuck { + if *taskID <= 0 { + log.Fatal("please pass -task when using -reset-stuck") + } + if err := resetOfficialAccountTaskState(db, *taskID); err != nil { + log.Fatal(err) + } + fmt.Printf("reset stuck running state for task %d\n", *taskID) + return + } + + tasks := make([]taskRow, 0, 5) + taskRows, err := db.Query(` + select id, title, status, updated_at + from worker_task + where specialist_key = 'wechat-official-account' + order by id desc + limit 5 + `) + if err != nil { + log.Fatal(err) + } + for taskRows.Next() { + var row taskRow + if err := taskRows.Scan(&row.ID, &row.Title, &row.Status, &row.UpdatedAt); err != nil { + log.Fatal(err) + } + tasks = append(tasks, row) + } + _ = taskRows.Close() + + var article articleRow + _ = db.QueryRow(` + select task_id, keyword, selected_topic, topic_heat, outline_style, outline_words, content_target_words, hotspots_json, topic_candidates_json + from official_account_article + order by id desc + limit 1 + `).Scan( + &article.TaskID, + &article.Keyword, + &article.SelectedTopic, + &article.TopicHeat, + &article.OutlineStyle, + &article.OutlineWords, + &article.ContentTargetWords, + &article.HotspotsJSON, + &article.TopicCandidatesJSON, + ) + + runs := make([]runRow, 0, 5) + runRows, err := db.Query(` + select id, task_id, action_key, action_title, output_json, logs_json, started_at + from worker_run + where task_id = ? + order by id desc + limit 5 + `, article.TaskID) + if err != nil { + log.Fatal(err) + } + for runRows.Next() { + var row runRow + if err := runRows.Scan(&row.ID, &row.TaskID, &row.ActionKey, &row.ActionTitle, &row.OutputJSON, &row.LogsJSON, &row.StartedAt); err != nil { + log.Fatal(err) + } + runs = append(runs, row) + } + _ = runRows.Close() + + var hotspotCount int + _ = db.QueryRow(`select count(1) from official_account_hotspot`).Scan(&hotspotCount) + + result := map[string]any{ + "tasks": tasks, + "latest_article": article, + "latest_runs": runs, + "hotspot_count": hotspotCount, + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(result); err != nil { + fmt.Println(err) + } +} + +func resetOfficialAccountTaskState(db *sql.DB, taskID int64) error { + tx, err := db.Begin() + if err != nil { + return err + } + defer func() { + if err != nil { + _ = tx.Rollback() + } + }() + + var contextJSON string + if err = tx.QueryRow(`select context_json from worker_task where id = ?`, taskID).Scan(&contextJSON); err != nil { + return err + } + + type stepState struct { + Status string `json:"status"` + Progress int `json:"progress"` + ProgressText string `json:"progress_text"` + StartedAt string `json:"started_at"` + FinishedAt string `json:"finished_at"` + Logs []string `json:"logs"` + Output map[string]interface{} `json:"output"` + } + type workflowState struct { + CurrentStep string `json:"current_step"` + Steps map[string]*stepState `json:"steps"` + } + + var workflow workflowState + if strings.TrimSpace(contextJSON) != "" { + if err = json.Unmarshal([]byte(contextJSON), &workflow); err != nil { + return err + } + } + if workflow.Steps == nil { + workflow.Steps = map[string]*stepState{} + } + for _, key := range []string{"topic_recommendation", "title_generation", "outline_generation", "content_creation"} { + step := workflow.Steps[key] + if step == nil || step.Status != "running" { + continue + } + step.Status = "error" + step.ProgressText = "上次执行已中断,请重新执行" + step.FinishedAt = time.Now().Format(time.RFC3339) + step.Logs = append(step.Logs, time.Now().Format("15:04:05")+" 执行已中断,已自动停止") + } + workflow.CurrentStep = "topic_recommendation" + newContext, err := json.Marshal(workflow) + if err != nil { + return err + } + + if _, err = tx.Exec(` + update worker_task + set status = ?, current_result = ?, context_json = ?, updated_at = CURRENT_TIMESTAMP + where id = ? + `, "待处理", "", string(newContext), taskID); err != nil { + return err + } + + if _, err = tx.Exec(` + update worker_run + set status = ?, output_json = ?, logs_json = ?, finished_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + where task_id = ? and status = ? + `, "error", `{"summary":"上次执行已中断,请重新执行","error":"interrupted"}`, `["执行已中断,已自动停止"]`, taskID, "running"); err != nil { + return err + } + + return tx.Commit() +} diff --git a/eai_ap_app/backend-go/config/ai_config.json b/eai_ap_app/backend-go/config/ai_config.json index 12485be..25d494f 100644 --- a/eai_ap_app/backend-go/config/ai_config.json +++ b/eai_ap_app/backend-go/config/ai_config.json @@ -1 +1 @@ -{"agent_routes": {"embed_gen": "embed_route_openrouter_text_v3", "image_gen": "image_route_openrouter_gpt_image_2", "path_coach": "chat_route_openrouter_deepseek_v4_flash", "title_gen": "chat_route_openrouter_deepseek_v4_flash"}, "chat_routes": {"chat_route_ollama_qwen": {"description": "本地 Ollama Qwen2.5:7b", "endpoint": "/chat/completions", "max_tokens": 2048, "model": "qwen2.5:7b", "provider": "ollama", "temperature": 0.7}, "chat_route_ollama_qwen_fast": {"description": "本地快速对话 (Ollama Qwen2.5:7b 轻量)", "endpoint": "/chat/completions", "max_tokens": 512, "model": "qwen2.5:7b", "provider": "ollama", "temperature": 0.3}, "chat_route_openrouter_deepseek_v3": {"description": "DeepSeek V3.2 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "deepseek/deepseek-v3.2", "provider": "openrouter", "temperature": 0.6}, "chat_route_openrouter_deepseek_v4_flash": {"description": "DeepSeek V4 Flash (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "deepseek/deepseek-v4-flash", "provider": "openrouter", "temperature": 0.7}, "chat_route_openrouter_gpt_5": {"description": "GPT 5.4 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 8192, "model": "openai/gpt-5.4", "provider": "openrouter", "temperature": 0.7}, "chat_route_openrouter_grok_4": {"description": "Grok 4.20 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "x-ai/grok-4.20", "provider": "openrouter", "temperature": 0.6}}, "default_embed_route": "embed_route_openrouter_text_v3", "default_route": "chat_route_openrouter_deepseek_v4_flash", "description": "eaisalestrain AI 路由配置 — 按场景分类定义多条路由,支持 provider/模型切换", "embed_routes": {"embed_route_ollama_bge_m3": {"description": "本地 Ollama bge-m3", "endpoint": "/embeddings", "model": "bge-m3", "provider": "ollama"}, "embed_route_openrouter_text_v3": {"description": "OpenAI text-embedding-3-small (OpenRouter)", "endpoint": "/embeddings", "model": "openai/text-embedding-3-small", "provider": "openrouter"}}, "fallback_routes": {"chat_route_openrouter_deepseek_v3": ["chat_route_openrouter_grok_4", "chat_route_ollama_qwen"], "chat_route_openrouter_deepseek_v4_flash": ["chat_route_openrouter_deepseek_v3", "chat_route_openrouter_grok_4", "chat_route_ollama_qwen"], "chat_route_openrouter_grok_4": ["chat_route_ollama_qwen"], "embed_route_ollama_bge_m3": ["embed_route_openrouter_text_v3"], "embed_route_openrouter_text_v3": ["embed_route_ollama_bge_m3"]}, "image_routes": {"image_route_ollama_llava": {"description": "本地 Ollama LLaVA", "endpoint": "/chat/completions", "max_tokens": 2048, "model": "llava", "provider": "ollama", "temperature": 0.7}, "image_route_openrouter_flux": {"description": "FLUX 1.1 Pro (OpenRouter)", "endpoint": "/images/generations", "max_tokens": 1024, "model": "black-forest-labs/flux-1.1-pro", "provider": "openrouter", "temperature": 1}, "image_route_openrouter_gpt_image_2": {"description": "GPT 5.4 Image 2 (OpenRouter)", "endpoint": "/images/generations", "max_tokens": 4096, "model": "openai/gpt-5.4-image-2", "provider": "openrouter", "temperature": 1}}, "version": "2.1.0"} \ No newline at end of file +{"agent_routes": {"embed_gen": "embed_route_openrouter_text_v3", "image_gen": "image_route_openrouter_gpt_image_2", "path_coach": "chat_route_openrouter_deepseek_v4_flash", "title_gen": "chat_route_openrouter_deepseek_v4_flash"}, "chat_routes": {"chat_route_ollama_qwen": {"description": "本地 Ollama Qwen2.5:7b", "endpoint": "/chat/completions", "max_tokens": 2048, "model": "qwen2.5:7b", "provider": "ollama", "temperature": 0.7}, "chat_route_ollama_qwen_fast": {"description": "本地快速对话 (Ollama Qwen2.5:7b 轻量)", "endpoint": "/chat/completions", "max_tokens": 512, "model": "qwen2.5:7b", "provider": "ollama", "temperature": 0.3}, "chat_route_openrouter_deepseek_v3": {"description": "DeepSeek V3.2 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "deepseek/deepseek-v3.2", "provider": "openrouter", "temperature": 0.6}, "chat_route_openrouter_deepseek_v4_flash": {"description": "DeepSeek V4 Flash (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "deepseek/deepseek-v4-flash", "provider": "openrouter", "temperature": 0.7}, "chat_route_openrouter_gpt_5": {"description": "GPT 5.4 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 8192, "model": "openai/gpt-5.4", "provider": "openrouter", "temperature": 0.7}, "chat_route_openrouter_grok_4": {"description": "Grok 4.20 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "x-ai/grok-4.20", "provider": "openrouter", "temperature": 0.6}}, "default_embed_route": "embed_route_openrouter_text_v3", "default_route": "chat_route_openrouter_deepseek_v4_flash", "description": "eaisalestrain AI 路由配置 — 按场景分类定义多条路由,支持 provider/模型切换", "embed_routes": {"embed_route_ollama_bge_m3": {"description": "本地 Ollama bge-m3", "endpoint": "/embeddings", "model": "bge-m3", "provider": "ollama"}, "embed_route_openrouter_text_v3": {"description": "OpenAI text-embedding-3-small (OpenRouter)", "endpoint": "/embeddings", "model": "openai/text-embedding-3-small", "provider": "openrouter"}}, "fallback_routes": {"chat_route_openrouter_deepseek_v3": ["chat_route_openrouter_grok_4", "chat_route_ollama_qwen"], "chat_route_openrouter_deepseek_v4_flash": ["chat_route_openrouter_deepseek_v3", "chat_route_openrouter_grok_4", "chat_route_ollama_qwen"], "chat_route_openrouter_grok_4": ["chat_route_ollama_qwen"], "embed_route_ollama_bge_m3": ["embed_route_openrouter_text_v3"], "embed_route_openrouter_text_v3": ["embed_route_ollama_bge_m3"]}, "image_routes": {"image_route_ollama_llava": {"description": "本地 Ollama LLaVA", "endpoint": "/chat/completions", "max_tokens": 2048, "model": "llava", "provider": "ollama", "temperature": 0.7}, "image_route_openrouter_flux": {"description": "FLUX 1.1 Pro (OpenRouter)", "endpoint": "/images/generations", "max_tokens": 1024, "model": "black-forest-labs/flux-1.1-pro", "provider": "openrouter", "temperature": 1}, "image_route_openrouter_gpt_image_2": {"description": "GPT 5.4 Image 2 (OpenRouter)", "endpoint": "/images/generations", "max_tokens": 4096, "model": "openai/gpt-5.4-image-2", "provider": "openrouter", "temperature": 1}}, "version": "2.1.0"} diff --git a/eai_ap_app/backend-go/internal/api/official_account_article_service.go b/eai_ap_app/backend-go/internal/api/official_account_article_service.go new file mode 100644 index 0000000..8d80db0 --- /dev/null +++ b/eai_ap_app/backend-go/internal/api/official_account_article_service.go @@ -0,0 +1,297 @@ +package api + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "eaisalestrain/backend/internal/model" + "eaisalestrain/backend/internal/store" +) + +func loadOfficialAccountArticle(taskID uint) (*model.OfficialAccountArticle, error) { + var article model.OfficialAccountArticle + if err := store.DB.Where("task_id = ?", taskID).First(&article).Error; err != nil { + return nil, err + } + return &article, nil +} + +func ensureOfficialAccountArticle(task model.WorkerTask, workflow officialAccountWorkflowState) (*model.OfficialAccountArticle, error) { + article, err := loadOfficialAccountArticle(task.ID) + if err == nil { + return article, nil + } + row := &model.OfficialAccountArticle{ + TaskID: task.ID, + SpecialistKey: task.SpecialistKey, + BusinessDomain: resolveOfficialAccountBusinessDomain(workflow.Form.BusinessDomain, workflow.Form.Keyword), + Keyword: firstNonEmpty(workflow.Form.Keyword, getOfficialAccountBusinessDomainConfig(workflow.Form.BusinessDomain).DefaultKeyword), + Audience: firstNonEmpty(workflow.Form.Audience, "公众号读者"), + Goal: firstNonEmpty(workflow.Form.Goal, "输出一篇可发布的公众号文章"), + Tone: firstNonEmpty(workflow.Form.Tone, "专业但好懂"), + Requirements: workflow.Form.Requirements, + OutlineStyle: normalizeOfficialAccountOutlineStyle(workflow.Form.OutlineStyle), + OutlineWords: normalizeOfficialAccountOutlineWords(workflow.Form.OutlineWords), + ContentTargetWords: normalizeOfficialAccountContentTargetWords(workflow.Form.ContentTargetWords), + Status: "draft", + } + if err := store.DB.Create(row).Error; err != nil { + return nil, err + } + return row, nil +} + +func syncWorkflowFromOfficialAccountArticle(workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle) { + if workflow == nil || article == nil { + return + } + workflow.Form.Keyword = firstNonEmpty(article.Keyword, workflow.Form.Keyword) + workflow.Form.BusinessDomain = resolveOfficialAccountBusinessDomain(article.BusinessDomain, workflow.Form.Keyword) + workflow.Form.Audience = firstNonEmpty(article.Audience, workflow.Form.Audience) + workflow.Form.Goal = firstNonEmpty(article.Goal, workflow.Form.Goal) + workflow.Form.Tone = firstNonEmpty(article.Tone, workflow.Form.Tone) + workflow.Form.Requirements = article.Requirements + workflow.Form.OutlineStyle = normalizeOfficialAccountOutlineStyle(article.OutlineStyle) + workflow.Form.OutlineWords = normalizeOfficialAccountOutlineWords(article.OutlineWords) + workflow.Form.ContentTargetWords = normalizeOfficialAccountContentTargetWords(article.ContentTargetWords) + + if strings.TrimSpace(article.TopicCandidatesJSON) != "" { + var items []officialAccountTopicCandidate + if json.Unmarshal([]byte(article.TopicCandidatesJSON), &items) == nil { + workflow.Shared.TopicCandidates = items + } + } + if strings.TrimSpace(article.TitleCandidatesJSON) != "" { + var items []string + if json.Unmarshal([]byte(article.TitleCandidatesJSON), &items) == nil { + workflow.Shared.TitleCandidates = items + } + } + workflow.Shared.SelectedTopic = article.SelectedTopic + workflow.Shared.SelectedTitle = article.SelectedTitle + workflow.Shared.Outline = article.Outline + workflow.Shared.Content = article.Content +} + +func syncOfficialAccountArticleFromWorkflow(article *model.OfficialAccountArticle, workflow officialAccountWorkflowState) { + if article == nil { + return + } + article.BusinessDomain = resolveOfficialAccountBusinessDomain(workflow.Form.BusinessDomain, workflow.Form.Keyword) + article.Keyword = firstNonEmpty(workflow.Form.Keyword, article.Keyword) + article.Audience = firstNonEmpty(workflow.Form.Audience, article.Audience) + article.Goal = firstNonEmpty(workflow.Form.Goal, article.Goal) + article.Tone = firstNonEmpty(workflow.Form.Tone, article.Tone) + article.Requirements = workflow.Form.Requirements + article.OutlineStyle = normalizeOfficialAccountOutlineStyle(workflow.Form.OutlineStyle) + article.OutlineWords = normalizeOfficialAccountOutlineWords(workflow.Form.OutlineWords) + article.ContentTargetWords = normalizeOfficialAccountContentTargetWords(workflow.Form.ContentTargetWords) + article.SelectedTopic = workflow.Shared.SelectedTopic + article.SelectedTitle = workflow.Shared.SelectedTitle + article.Outline = workflow.Shared.Outline + article.Content = workflow.Shared.Content + if data, err := json.Marshal(workflow.Shared.TopicCandidates); err == nil { + article.TopicCandidatesJSON = string(data) + } + if data, err := json.Marshal(workflow.Shared.TitleCandidates); err == nil { + article.TitleCandidatesJSON = string(data) + } + if workflow.Shared.Content != "" { + article.Status = "content_ready" + } else if workflow.Shared.Outline != "" { + article.Status = "outline_ready" + } else if workflow.Shared.SelectedTitle != "" { + article.Status = "title_ready" + } else if workflow.Shared.SelectedTopic != "" { + article.Status = "topic_ready" + } else { + article.Status = "draft" + } +} + +func resetOfficialAccountArticle(article *model.OfficialAccountArticle, req officialAccountTaskUpdateReq) { + if article == nil { + return + } + article.BusinessDomain = resolveOfficialAccountBusinessDomain(req.BusinessDomain, req.Keyword) + article.Keyword = req.Keyword + article.Audience = firstNonEmpty(req.Audience, "公众号读者") + article.Goal = firstNonEmpty(req.Goal, "输出一篇可发布的公众号文章") + article.Tone = firstNonEmpty(req.Tone, "专业但好懂") + article.Requirements = req.Requirements + article.TopicCandidatesJSON = "[]" + article.HotspotsJSON = "[]" + article.SelectedTopic = "" + article.TopicHeat = "" + article.TitleCandidatesJSON = "[]" + article.SelectedTitle = "" + article.OutlineStyle = normalizeOfficialAccountOutlineStyle(req.OutlineStyle) + article.OutlineWords = normalizeOfficialAccountOutlineWords(req.OutlineWords) + article.Outline = "" + article.ContentTargetWords = normalizeOfficialAccountContentTargetWords(req.ContentTargetWords) + article.Content = "" + article.Status = "draft" +} + +func buildOfficialAccountTopicCandidatesFromHotspots(form officialAccountForm, hotspots []model.OfficialAccountHotspot) []officialAccountTopicCandidate { + domainLabel := officialAccountBusinessDomainLabel(form.BusinessDomain) + candidates := make([]officialAccountTopicCandidate, 0, 4) + for _, item := range hotspots { + topic := deriveOfficialAccountTopic(form.Keyword, item.Title) + if topic == "" { + continue + } + candidates = append(candidates, officialAccountTopicCandidate{ + Topic: topic, + Angle: deriveOfficialAccountAngle(item.Title), + Reason: firstNonEmpty(item.Summary, "来自公开"+domainLabel+"资讯源,已按业务域相关性筛选。"), + Heat: firstNonEmpty(item.HeatLabel, "中高"), + }) + if len(candidates) >= 4 { + break + } + } + if len(candidates) == 0 { + return buildOfficialAccountTopicCandidates(form) + } + return dedupeOfficialAccountTopicCandidates(candidates) +} + +func dedupeOfficialAccountTopicCandidates(items []officialAccountTopicCandidate) []officialAccountTopicCandidate { + seen := map[string]struct{}{} + result := make([]officialAccountTopicCandidate, 0, len(items)) + for _, item := range items { + key := strings.TrimSpace(item.Topic) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + result = append(result, item) + } + return result +} + +func deriveOfficialAccountTopic(keyword, sourceTitle string) string { + keyword = strings.TrimSpace(keyword) + sourceTitle = strings.TrimSpace(sourceTitle) + switch { + case keyword == "": + return sourceTitle + case strings.Contains(strings.ToLower(sourceTitle), strings.ToLower(keyword)): + return sourceTitle + default: + return fmt.Sprintf("%s:%s", keyword, sourceTitle) + } +} + +func deriveOfficialAccountAngle(sourceTitle string) string { + switch { + case strings.Contains(sourceTitle, "融资") || strings.Contains(sourceTitle, "估值"): + return "产业动向" + case strings.Contains(sourceTitle, "发布") || strings.Contains(sourceTitle, "上线"): + return "产品更新" + case strings.Contains(sourceTitle, "落地") || strings.Contains(sourceTitle, "案例"): + return "场景落地" + default: + return "热点追踪" + } +} + +func marshalOfficialAccountHotspots(hotspots []model.OfficialAccountHotspot) string { + if len(hotspots) == 0 { + return "[]" + } + data, err := json.Marshal(hotspots) + if err != nil { + return "[]" + } + return string(data) +} + +func buildOfficialAccountHotspotPrompt(form officialAccountForm, hotspots []model.OfficialAccountHotspot) string { + cfg := getOfficialAccountBusinessDomainConfig(form.BusinessDomain) + lines := []string{ + fmt.Sprintf("你是公众号选题策划编辑,请基于下面的%s热点,为公众号生成 4 个值得写的热点选题。", cfg.PromptLabel), + "要求:", + fmt.Sprintf("1. 主题必须适合%s方向的公众号写作;", cfg.PromptLabel), + "2. 要能写成公众号文章,不是简单复述新闻;", + "3. 每个候选返回 topic、angle、reason、heat 四个字段;", + "4. 输出 JSON 数组,不要 markdown。", + fmt.Sprintf("业务域:%s", cfg.Label), + fmt.Sprintf("关键词:%s", firstNonEmpty(form.Keyword, cfg.DefaultKeyword)), + fmt.Sprintf("面向人群:%s", firstNonEmpty(form.Audience, "公众号读者")), + fmt.Sprintf("内容目标:%s", firstNonEmpty(form.Goal, "输出一篇可发布的公众号文章")), + fmt.Sprintf("语气:%s", firstNonEmpty(form.Tone, "专业但好懂")), + "热点列表:", + } + for idx, item := range hotspots { + lines = append(lines, fmt.Sprintf("%d. 标题:%s | 热度:%s | 摘要:%s", idx+1, item.Title, item.HeatLabel, firstNonEmpty(item.Summary, "无摘要"))) + if idx >= 7 { + break + } + } + return strings.Join(lines, "\n") +} + +func buildOfficialAccountTopicEvalPrompt(form officialAccountForm, topic string, hotspots []model.OfficialAccountHotspot) string { + cfg := getOfficialAccountBusinessDomainConfig(form.BusinessDomain) + lines := []string{ + fmt.Sprintf("请判断这个公众号选题在当前%s热点里的热度和可写性,返回一个 JSON 对象,字段固定为 heat、reason。", cfg.PromptLabel), + fmt.Sprintf("选题:%s", topic), + fmt.Sprintf("业务域:%s", cfg.Label), + fmt.Sprintf("关键词:%s", firstNonEmpty(form.Keyword, cfg.DefaultKeyword)), + "参考热点:", + } + for idx, item := range hotspots { + lines = append(lines, fmt.Sprintf("%d. %s(%s)", idx+1, item.Title, item.HeatLabel)) + if idx >= 5 { + break + } + } + return strings.Join(lines, "\n") +} + +func buildOfficialAccountTopicBatchEvalPrompt(form officialAccountForm, candidates []officialAccountTopicCandidate, hotspots []model.OfficialAccountHotspot) string { + cfg := getOfficialAccountBusinessDomainConfig(form.BusinessDomain) + lines := []string{ + fmt.Sprintf("你是公众号选题评审助手,请基于当前%s热点,对下面多个公众号选题候选做一次批量评判。", cfg.PromptLabel), + "要求:", + "1. 结合热点相关性、传播性、公众号可写性综合判断;", + "2. 每个候选都要返回 topic、heat、reason、score 四个字段;", + "3. heat 只能是 爆热 / 高 / 中高 / 中 其中之一;", + "4. score 为 0-100 的整数;", + "5. 只返回 JSON 数组,不要 markdown,不要额外解释。", + fmt.Sprintf("业务域:%s", cfg.Label), + fmt.Sprintf("关键词:%s", firstNonEmpty(form.Keyword, cfg.DefaultKeyword)), + fmt.Sprintf("面向人群:%s", firstNonEmpty(form.Audience, "公众号读者")), + fmt.Sprintf("内容目标:%s", firstNonEmpty(form.Goal, "输出一篇可发布的公众号文章")), + "待评估选题:", + } + for idx, item := range candidates { + lines = append(lines, fmt.Sprintf("%d. 选题:%s | 角度:%s | 当前理由:%s | 当前热度:%s", idx+1, item.Topic, firstNonEmpty(item.Angle, "热点追踪"), firstNonEmpty(item.Reason, "待评估"), firstNonEmpty(item.Heat, "中高"))) + } + lines = append(lines, "参考热点:") + for idx, item := range hotspots { + lines = append(lines, fmt.Sprintf("%d. %s(%s)| 摘要:%s", idx+1, item.Title, firstNonEmpty(item.HeatLabel, "中高"), firstNonEmpty(item.Summary, "无摘要"))) + if idx >= 5 { + break + } + } + return strings.Join(lines, "\n") +} + +func sortOfficialAccountHotspots(items []model.OfficialAccountHotspot) []model.OfficialAccountHotspot { + cloned := append([]model.OfficialAccountHotspot(nil), items...) + sort.Slice(cloned, func(i, j int) bool { + if cloned[i].DomainScore == cloned[j].DomainScore { + return cloned[i].FetchedAt.After(cloned[j].FetchedAt) + } + return cloned[i].DomainScore > cloned[j].DomainScore + }) + return cloned +} diff --git a/eai_ap_app/backend-go/internal/api/official_account_domain_config.go b/eai_ap_app/backend-go/internal/api/official_account_domain_config.go new file mode 100644 index 0000000..b727337 --- /dev/null +++ b/eai_ap_app/backend-go/internal/api/official_account_domain_config.go @@ -0,0 +1,239 @@ +package api + +import "strings" + +const ( + officialAccountBusinessDomainAuto = "auto" + officialAccountBusinessDomainAI = "ai" + officialAccountBusinessDomainMedical = "medical" + officialAccountBusinessDomainInsurance = "insurance" + officialAccountBusinessDomainHR = "hr" + officialAccountBusinessDomainLegal = "legal" + officialAccountBusinessDomainGeneric = "generic" +) + +type officialAccountHotspotSource struct { + Key string + Label string + Type string + URL string +} + +type officialAccountBusinessDomainConfig struct { + Key string + Label string + PromptLabel string + DefaultKeyword string + DomainKeywords []string + BusinessKeywords []string + BoostKeywords []string + FallbackSearchHints []string + Sources []officialAccountHotspotSource +} + +var officialAccountBusinessDomainConfigs = map[string]officialAccountBusinessDomainConfig{ + officialAccountBusinessDomainAI: { + Key: officialAccountBusinessDomainAI, + Label: "AI / 智能体", + PromptLabel: "AI 业务域", + DefaultKeyword: "AI 落地", + DomainKeywords: []string{ + "ai", "aigc", "大模型", "人工智能", "智能体", "agent", "生成式", "机器学习", "多模态", "工作流", "知识库", + "rag", "copilot", "自动化", "机器人", "推理", "模型", "算力", "数据智能", "数字员工", "workbuddy", + }, + BusinessKeywords: []string{ + "企业", "落地", "场景", "产品", "平台", "工作台", "办公", "培训", "实施", "知识库", "销售", "客户", + "提效", "运营", "私有化", "部署", "公司", "团队", "组织", "流程", "项目", "交付", + }, + BoostKeywords: []string{"工作流", "智能体", "企业ai"}, + FallbackSearchHints: []string{"AI", "大模型", "智能体", "企业AI"}, + Sources: []officialAccountHotspotSource{ + {Key: "36kr_feed", Label: "36氪订阅", Type: "rss", URL: "https://www.36kr.com/feed"}, + {Key: "ithome_rss", Label: "IT之家订阅", Type: "rss", URL: "https://www.ithome.com/rss/"}, + {Key: "36kr_ai_web", Label: "36氪AI专题", Type: "web", URL: "https://www.36kr.com/information/AI/"}, + {Key: "36kr_aigc_web", Label: "36氪AIGC专题", Type: "web", URL: "https://www.36kr.com/information/AIGC/"}, + }, + }, + officialAccountBusinessDomainMedical: { + Key: officialAccountBusinessDomainMedical, + Label: "医疗 / 健康", + PromptLabel: "医疗健康业务域", + DefaultKeyword: "医疗健康", + DomainKeywords: []string{ + "医疗", "医保", "医院", "医生", "患者", "医药", "药企", "药品", "诊疗", "健康", "护理", "手术", "疾病", "控费", + "集采", "互联网医疗", "医疗器械", "生物医药", "健康险", "临床", "门诊", "住院", "卫健", "医保局", "药监", "药审", + "创新药", "医改", "药械", "医用耗材", "公共卫生", "分级诊疗", + }, + BusinessKeywords: []string{ + "政策", "支付", "落地", "案例", "服务", "行业", "机构", "改革", "商业模式", "运营", "平台", "场景", "解决方案", + "通知", "公告", "指南", "征求意见", "审评", "审批", "基金", "目录", "招采", "挂网", "集采", + }, + BoostKeywords: []string{"医保", "医院", "医疗器械", "互联网医疗", "创新药", "药审", "医改", "分级诊疗"}, + FallbackSearchHints: []string{"医疗", "医保", "医院", "健康险", "创新药", "医疗器械"}, + Sources: []officialAccountHotspotSource{ + {Key: "nhsa_policy_web", Label: "国家医保局政策栏目", Type: "web", URL: "https://www.nhsa.gov.cn/col/col14/index.html"}, + {Key: "nhsa_news_web", Label: "国家医保局工作动态", Type: "web", URL: "https://www.nhsa.gov.cn/col/col104/index.html"}, + {Key: "cde_home_web", Label: "药审中心", Type: "web", URL: "https://www.cde.org.cn/"}, + }, + }, + officialAccountBusinessDomainInsurance: { + Key: officialAccountBusinessDomainInsurance, + Label: "保险 / 金融", + PromptLabel: "保险金融业务域", + DefaultKeyword: "保险行业", + DomainKeywords: []string{ + "保险", "险资", "寿险", "财险", "健康险", "车险", "养老保险", "理赔", "赔付", "保单", "代理人", "年金", "再保险", + "偿付能力", "分红险", "重疾险", "医疗险", "保险公司", "保险经纪", "保险中介", + }, + BusinessKeywords: []string{ + "监管", "政策", "行业", "产品", "销售", "转型", "增长", "客户", "渠道", "经营", "服务", "投资", "市场", "赔付", + }, + BoostKeywords: []string{"理赔", "偿付能力", "代理人", "保险公司"}, + FallbackSearchHints: []string{"保险", "理赔", "代理人", "养老保险"}, + Sources: []officialAccountHotspotSource{ + {Key: "iachina_web", Label: "中国保险行业协会", Type: "web", URL: "https://www.iachina.cn/col/col3/index.html"}, + {Key: "cnstock_insurance_web", Label: "上海证券报保险频道", Type: "web", URL: "http://www.cnstock.com/channel/10120"}, + {Key: "china_finance_insurance_web", Label: "中国网财经保险", Type: "web", URL: "http://finance.china.com.cn/money/insurance/index.shtml"}, + }, + }, + officialAccountBusinessDomainHR: { + Key: officialAccountBusinessDomainHR, + Label: "HR / 招聘", + PromptLabel: "人力资源业务域", + DefaultKeyword: "人力资源", + DomainKeywords: []string{ + "hr", "人力", "招聘", "人才", "雇主", "员工", "绩效", "薪酬", "福利", "组织", "用工", "劳务", "社保", + "培训", "面试", "招聘流程", "入职", "离职", "考勤", "激励", + }, + BusinessKeywords: []string{ + "管理", "平台", "系统", "政策", "趋势", "案例", "企业", "数字化", "转型", "场景", "效率", "合规", + }, + BoostKeywords: []string{"招聘", "社保", "薪酬", "劳动"}, + FallbackSearchHints: []string{"招聘", "人力资源", "薪酬", "社保"}, + Sources: []officialAccountHotspotSource{ + {Key: "hrac_web", Label: "中国人力资源协会", Type: "web", URL: "http://www.hrac.net.cn/CN/HR/"}, + {Key: "hrtechweekly_rss", Label: "HR Tech Weekly", Type: "rss", URL: "https://hrtechweekly.com/feed/"}, + {Key: "techrseries_rss", Label: "HRTech Series", Type: "rss", URL: "https://techrseries.com/feed/"}, + }, + }, + officialAccountBusinessDomainLegal: { + Key: officialAccountBusinessDomainLegal, + Label: "法务 / 合规", + PromptLabel: "法务合规业务域", + DefaultKeyword: "法务合规", + DomainKeywords: []string{ + "法务", "法律", "合规", "合同", "诉讼", "仲裁", "监管", "公司法", "劳动法", "数据合规", "知产", "知识产权", + "审查", "风控", "条款", "争议", "律师", "执法", "规范", + }, + BusinessKeywords: []string{ + "企业", "实务", "案例", "解读", "风控", "业务", "制度", "管理", "要求", "落地", "操作", "要点", + }, + BoostKeywords: []string{"合同", "合规", "数据", "劳动法"}, + FallbackSearchHints: []string{"合同", "合规", "劳动法", "数据合规"}, + Sources: []officialAccountHotspotSource{ + {Key: "law_asia_cn_web", Label: "商法中国", Type: "web", URL: "https://law.asia/zh-hans/china/"}, + {Key: "law_asia_en_web", Label: "China Business Law Journal", Type: "web", URL: "https://law.asia/china/"}, + {Key: "jurist_rss", Label: "Jurist", Type: "rss", URL: "https://www.jurist.org/news/feed/"}, + }, + }, + officialAccountBusinessDomainGeneric: { + Key: officialAccountBusinessDomainGeneric, + Label: "通用业务域", + PromptLabel: "通用业务域", + DefaultKeyword: "业务热点", + DomainKeywords: []string{ + "行业", "企业", "客户", "增长", "经营", "产品", "服务", "转型", "市场", "政策", "平台", "解决方案", + }, + BusinessKeywords: []string{ + "热点", "趋势", "案例", "落地", "复盘", "方法", "实践", "问题", "经验", "机会", "风险", + }, + BoostKeywords: []string{"行业", "企业", "趋势"}, + FallbackSearchHints: []string{"行业", "企业", "政策", "趋势"}, + Sources: []officialAccountHotspotSource{ + {Key: "36kr_feed", Label: "36氪订阅", Type: "rss", URL: "https://www.36kr.com/feed"}, + {Key: "cnstock_business_web", Label: "上海证券报", Type: "web", URL: "http://www.cnstock.com/"}, + {Key: "ithome_rss", Label: "IT之家订阅", Type: "rss", URL: "https://www.ithome.com/rss/"}, + }, + }, +} + +var officialAccountBusinessDomainDetectionHints = map[string][]string{ + officialAccountBusinessDomainAI: officialAccountBusinessDomainConfigs[officialAccountBusinessDomainAI].DomainKeywords, + officialAccountBusinessDomainMedical: officialAccountBusinessDomainConfigs[officialAccountBusinessDomainMedical].DomainKeywords, + officialAccountBusinessDomainInsurance: officialAccountBusinessDomainConfigs[officialAccountBusinessDomainInsurance].DomainKeywords, + officialAccountBusinessDomainHR: officialAccountBusinessDomainConfigs[officialAccountBusinessDomainHR].DomainKeywords, + officialAccountBusinessDomainLegal: officialAccountBusinessDomainConfigs[officialAccountBusinessDomainLegal].DomainKeywords, +} + +func normalizeOfficialAccountBusinessDomain(value string) string { + value = strings.TrimSpace(strings.ToLower(value)) + switch value { + case "", officialAccountBusinessDomainAuto, "自动识别", "自动": + return officialAccountBusinessDomainAuto + case "ai", "aigc", "智能体", "人工智能": + return officialAccountBusinessDomainAI + case "medical", "health", "医疗", "健康": + return officialAccountBusinessDomainMedical + case "insurance", "finance", "保险", "金融": + return officialAccountBusinessDomainInsurance + case "hr", "recruiting", "招聘", "人力", "人力资源": + return officialAccountBusinessDomainHR + case "legal", "compliance", "法务", "合规", "法律": + return officialAccountBusinessDomainLegal + case "generic", "other", "其他", "通用": + return officialAccountBusinessDomainGeneric + default: + if _, ok := officialAccountBusinessDomainConfigs[value]; ok { + return value + } + return officialAccountBusinessDomainGeneric + } +} + +func detectOfficialAccountBusinessDomain(keyword string) string { + text := strings.ToLower(strings.TrimSpace(keyword)) + if text == "" { + return officialAccountBusinessDomainAI + } + bestKey := officialAccountBusinessDomainAI + bestScore := 0 + for key, hints := range officialAccountBusinessDomainDetectionHints { + score := 0 + for _, hint := range hints { + if strings.Contains(text, strings.ToLower(hint)) { + score++ + } + } + if score > bestScore { + bestScore = score + bestKey = key + } + } + if bestScore == 0 { + return officialAccountBusinessDomainGeneric + } + return bestKey +} + +func resolveOfficialAccountBusinessDomain(domain, keyword string) string { + normalized := normalizeOfficialAccountBusinessDomain(domain) + if normalized == officialAccountBusinessDomainAuto { + return detectOfficialAccountBusinessDomain(keyword) + } + return normalized +} + +func getOfficialAccountBusinessDomainConfig(domain string) officialAccountBusinessDomainConfig { + key := normalizeOfficialAccountBusinessDomain(domain) + if key == officialAccountBusinessDomainAuto { + key = officialAccountBusinessDomainAI + } + if cfg, ok := officialAccountBusinessDomainConfigs[key]; ok { + return cfg + } + return officialAccountBusinessDomainConfigs[officialAccountBusinessDomainGeneric] +} + +func officialAccountBusinessDomainLabel(domain string) string { + return getOfficialAccountBusinessDomainConfig(domain).Label +} diff --git a/eai_ap_app/backend-go/internal/api/official_account_hotspot_service.go b/eai_ap_app/backend-go/internal/api/official_account_hotspot_service.go new file mode 100644 index 0000000..ebc043e --- /dev/null +++ b/eai_ap_app/backend-go/internal/api/official_account_hotspot_service.go @@ -0,0 +1,520 @@ +package api + +import ( + "bytes" + "encoding/json" + "encoding/xml" + "html" + "io" + "net/http" + neturl "net/url" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "golang.org/x/net/html/charset" + "gorm.io/gorm/clause" + + "eaisalestrain/backend/internal/model" + "eaisalestrain/backend/internal/store" +) + +type officialAccountFetchedHotspot struct { + Title string + URL string + Summary string + PublishedAt *time.Time + SourceKey string + SourceLabel string + SourceType string +} + +var ( + officialAccountAnchorRE = regexp.MustCompile(`(?is)]+href=["']([^"'#]+)["'][^>]*>(.*?)`) + officialAccountStripTagRE = regexp.MustCompile(`(?is)<[^>]+>`) + officialAccountSpaceRE = regexp.MustCompile(`\s+`) +) + +type officialAccountRSS struct { + Channel struct { + Items []struct { + Title string `xml:"title"` + Link string `xml:"link"` + Description string `xml:"description"` + PubDate string `xml:"pubDate"` + } `xml:"item"` + } `xml:"channel"` + Entries []struct { + Title string `xml:"title"` + Summary string `xml:"summary"` + Content string `xml:"content"` + Updated string `xml:"updated"` + Published string `xml:"published"` + ID string `xml:"id"` + Links []struct { + Href string `xml:"href,attr"` + } `xml:"link"` + } `xml:"entry"` +} + +func ensureOfficialAccountHotspots(form officialAccountForm, force bool) ([]model.OfficialAccountHotspot, []string, error) { + domainKey := resolveOfficialAccountBusinessDomain(form.BusinessDomain, form.Keyword) + cfg := getOfficialAccountBusinessDomainConfig(domainKey) + logs := []string{} + if !force { + cached, err := loadFreshOfficialAccountHotspots(domainKey, form.Keyword, 90*time.Minute) + if err == nil && len(cached) >= 8 { + logs = append(logs, "命中热点缓存,直接复用近 90 分钟"+cfg.Label+"热点。") + return cached, logs, nil + } + } + + client := &http.Client{Timeout: 12 * time.Second} + totalUpserts := 0 + sourceKeywords := buildOfficialAccountSourceKeywords(domainKey, form.Keyword, cfg.FallbackSearchHints) + for _, source := range cfg.Sources { + items, err := fetchOfficialAccountHotspotsFromSource(client, source, sourceKeywords) + if err != nil { + logs = append(logs, source.Label+" 拉取失败:"+err.Error()) + continue + } + upserts, sourceLogs := upsertOfficialAccountHotspots(domainKey, form.Keyword, items) + totalUpserts += upserts + logs = append(logs, sourceLogs...) + } + if totalUpserts == 0 { + logs = append(logs, "本轮未写入新热点,回退读取最近缓存。") + } + latest, err := loadFreshOfficialAccountHotspots(domainKey, form.Keyword, 48*time.Hour) + if err == nil && len(latest) == 0 && domainKey != officialAccountBusinessDomainGeneric { + logs = append(logs, "当前业务域命中热点较少,已自动降级到通用热点检索。") + genericCfg := getOfficialAccountBusinessDomainConfig(officialAccountBusinessDomainGeneric) + genericKeywords := buildOfficialAccountSourceKeywords( + officialAccountBusinessDomainGeneric, + form.Keyword, + append(cfg.FallbackSearchHints, genericCfg.FallbackSearchHints...), + ) + for _, source := range genericCfg.Sources { + items, fetchErr := fetchOfficialAccountHotspotsFromSource(client, source, genericKeywords) + if fetchErr != nil { + logs = append(logs, source.Label+" 泛检索失败:"+fetchErr.Error()) + continue + } + upserts, sourceLogs := upsertOfficialAccountHotspots(officialAccountBusinessDomainGeneric, form.Keyword, items) + totalUpserts += upserts + logs = append(logs, sourceLogs...) + } + latest, err = loadFreshOfficialAccountHotspots(officialAccountBusinessDomainGeneric, form.Keyword, 48*time.Hour) + } + if err != nil { + return nil, logs, err + } + return latest, logs, nil +} + +func loadFreshOfficialAccountHotspots(domainKey, keyword string, maxAge time.Duration) ([]model.OfficialAccountHotspot, error) { + var items []model.OfficialAccountHotspot + cutoff := time.Now().Add(-maxAge) + err := store.DB. + Where("business_domain = ? AND fetched_at >= ? AND domain_score > 0", domainKey, cutoff). + Order("domain_score DESC, published_at DESC, id DESC"). + Limit(40). + Find(&items).Error + if err != nil { + return nil, err + } + filtered := filterOfficialAccountHotspotsByKeyword(items, keyword) + if len(filtered) > 0 { + return filtered, nil + } + if len(items) > 20 { + return items[:20], nil + } + return items, nil +} + +func fetchOfficialAccountHotspotsFromSource(client *http.Client, source officialAccountHotspotSource, keywords []string) ([]officialAccountFetchedHotspot, error) { + switch source.Type { + case "rss": + return fetchOfficialAccountRSSHotspots(client, source) + default: + return fetchOfficialAccountWebHotspots(client, source, keywords) + } +} + +func fetchOfficialAccountRSSHotspots(client *http.Client, source officialAccountHotspotSource) ([]officialAccountFetchedHotspot, error) { + body, err := fetchOfficialAccountSourceBody(client, source.URL) + if err != nil { + return nil, err + } + var feed officialAccountRSS + if err := xml.Unmarshal(body, &feed); err != nil { + return nil, err + } + items := make([]officialAccountFetchedHotspot, 0, 24) + for _, item := range feed.Channel.Items { + title := normalizeOfficialAccountText(item.Title) + link := strings.TrimSpace(item.Link) + summary := normalizeOfficialAccountText(item.Description) + if title == "" || link == "" { + continue + } + items = append(items, officialAccountFetchedHotspot{ + Title: title, + URL: link, + Summary: summary, + PublishedAt: parseOfficialAccountTime(item.PubDate), + SourceKey: source.Key, + SourceLabel: source.Label, + SourceType: source.Type, + }) + } + for _, entry := range feed.Entries { + title := normalizeOfficialAccountText(entry.Title) + link := strings.TrimSpace(entry.ID) + if len(entry.Links) > 0 && strings.TrimSpace(entry.Links[0].Href) != "" { + link = strings.TrimSpace(entry.Links[0].Href) + } + summary := normalizeOfficialAccountText(firstNonEmpty(entry.Summary, entry.Content)) + if title == "" || link == "" { + continue + } + items = append(items, officialAccountFetchedHotspot{ + Title: title, + URL: link, + Summary: summary, + PublishedAt: parseOfficialAccountTime(firstNonEmpty(entry.Published, entry.Updated)), + SourceKey: source.Key, + SourceLabel: source.Label, + SourceType: source.Type, + }) + } + return dedupeOfficialAccountFetched(items), nil +} + +func fetchOfficialAccountWebHotspots(client *http.Client, source officialAccountHotspotSource, keywords []string) ([]officialAccountFetchedHotspot, error) { + body, err := fetchOfficialAccountSourceBody(client, source.URL) + if err != nil { + return nil, err + } + base, _ := neturl.Parse(source.URL) + matches := officialAccountAnchorRE.FindAllStringSubmatch(string(body), -1) + items := make([]officialAccountFetchedHotspot, 0, 24) + for _, match := range matches { + if len(match) < 3 { + continue + } + href := strings.TrimSpace(match[1]) + text := normalizeOfficialAccountText(match[2]) + if text == "" || len([]rune(text)) < 8 || len([]rune(text)) > 90 { + continue + } + if len(keywords) > 0 && !containsOfficialAccountKeywords(text, keywords) { + continue + } + if base != nil { + if ref, err := neturl.Parse(href); err == nil { + href = base.ResolveReference(ref).String() + } + } + items = append(items, officialAccountFetchedHotspot{ + Title: text, + URL: href, + Summary: "", + PublishedAt: nil, + SourceKey: source.Key, + SourceLabel: source.Label, + SourceType: source.Type, + }) + if len(items) >= 24 { + break + } + } + return dedupeOfficialAccountFetched(items), nil +} + +func fetchOfficialAccountSourceBody(client *http.Client, rawURL string) ([]byte, error) { + req, err := http.NewRequest(http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "Mozilla/5.0 official-account-assistant/1.0") + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + rawBody, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) + if err != nil { + return nil, err + } + decodedReader, err := charset.NewReader(bytes.NewReader(rawBody), resp.Header.Get("Content-Type")) + if err != nil { + return rawBody, nil + } + decodedBody, err := io.ReadAll(decodedReader) + if err != nil { + return rawBody, nil + } + return decodedBody, nil +} + +func upsertOfficialAccountHotspots(domainKey, keyword string, items []officialAccountFetchedHotspot) (int, []string) { + logs := []string{} + if len(items) == 0 { + return 0, logs + } + cfg := getOfficialAccountBusinessDomainConfig(domainKey) + now := time.Now() + upserts := make([]model.OfficialAccountHotspot, 0, len(items)) + for _, item := range items { + rawScore, domainScore, tags := scoreOfficialAccountHotspot(domainKey, keyword, item.Title, item.Summary) + if domainScore <= 0 { + continue + } + tagsJSON, _ := json.Marshal(tags) + upserts = append(upserts, model.OfficialAccountHotspot{ + BusinessDomain: domainKey, + SourceKey: item.SourceKey, + SourceLabel: item.SourceLabel, + SourceType: item.SourceType, + Title: item.Title, + URL: item.URL, + Summary: item.Summary, + PublishedAt: item.PublishedAt, + RawScore: rawScore, + DomainScore: domainScore, + HeatLabel: officialAccountHeatLabel(domainScore), + TagsJSON: string(tagsJSON), + FetchedAt: now, + }) + } + if len(upserts) == 0 { + return 0, logs + } + if err := store.DB.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "source_key"}, {Name: "url"}}, + DoUpdates: clause.AssignmentColumns([]string{"title", "summary", "published_at", "raw_score", "domain_score", "heat_label", "tags_json", "fetched_at", "updated_at"}), + }).Create(&upserts).Error; err != nil { + logs = append(logs, "热点入库失败:"+err.Error()) + return 0, logs + } + logs = append(logs, "已从公开 RSS / 网页源更新 "+itoa(len(upserts))+" 条"+cfg.Label+"热点。") + return len(upserts), logs +} + +func scoreOfficialAccountHotspot(domainKey, keyword, title, summary string) (float64, float64, []string) { + cfg := getOfficialAccountBusinessDomainConfig(domainKey) + text := strings.ToLower(title + "\n" + summary) + rawScore := 0.0 + domainScore := 0.0 + tags := make([]string, 0, 8) + for _, item := range cfg.DomainKeywords { + if strings.Contains(text, strings.ToLower(item)) { + rawScore += 1 + domainScore += 1.5 + tags = append(tags, item) + } + } + for _, item := range cfg.BusinessKeywords { + if strings.Contains(text, strings.ToLower(item)) { + rawScore += 1 + domainScore += 2 + tags = append(tags, item) + } + } + for _, item := range cfg.BoostKeywords { + if strings.Contains(text, strings.ToLower(item)) { + domainScore += 2 + tags = append(tags, item) + } + } + for _, item := range buildOfficialAccountKeywordTerms(keyword) { + if strings.Contains(text, strings.ToLower(item)) { + domainScore += 2.5 + tags = append(tags, item) + } + } + if domainKey == officialAccountBusinessDomainGeneric { + domainScore += 2 + } + return rawScore, domainScore, uniqueOfficialAccountStrings(tags) +} + +func dedupeOfficialAccountFetched(items []officialAccountFetchedHotspot) []officialAccountFetchedHotspot { + seen := map[string]struct{}{} + result := make([]officialAccountFetchedHotspot, 0, len(items)) + for _, item := range items { + key := strings.TrimSpace(item.URL) + if key == "" { + key = item.SourceKey + "::" + item.Title + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + result = append(result, item) + } + return result +} + +func normalizeOfficialAccountText(value string) string { + value = html.UnescapeString(value) + value = officialAccountStripTagRE.ReplaceAllString(value, " ") + value = officialAccountSpaceRE.ReplaceAllString(value, " ") + return strings.TrimSpace(value) +} + +func parseOfficialAccountTime(value string) *time.Time { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + layouts := []string{ + time.RFC1123Z, + time.RFC1123, + time.RFC3339, + "2006-01-02 15:04:05", + "2006-01-02", + } + for _, layout := range layouts { + if t, err := time.Parse(layout, value); err == nil { + return &t + } + } + return nil +} + +func containsOfficialAccountKeywords(text string, keywords []string) bool { + text = strings.ToLower(text) + for _, keyword := range keywords { + if strings.Contains(text, strings.ToLower(keyword)) { + return true + } + } + return false +} + +func filterOfficialAccountHotspotsByKeyword(items []model.OfficialAccountHotspot, keyword string) []model.OfficialAccountHotspot { + terms := buildOfficialAccountKeywordTerms(keyword) + if len(terms) == 0 { + return items + } + type scoredHotspot struct { + item model.OfficialAccountHotspot + score int + } + scored := make([]scoredHotspot, 0, len(items)) + for _, item := range items { + text := strings.ToLower(item.Title + "\n" + item.Summary) + score := 0 + for _, term := range terms { + if strings.Contains(text, strings.ToLower(term)) { + score++ + } + } + if score > 0 { + scored = append(scored, scoredHotspot{item: item, score: score}) + } + } + sort.SliceStable(scored, func(i, j int) bool { + if scored[i].score == scored[j].score { + if scored[i].item.DomainScore == scored[j].item.DomainScore { + return scored[i].item.ID > scored[j].item.ID + } + return scored[i].item.DomainScore > scored[j].item.DomainScore + } + return scored[i].score > scored[j].score + }) + result := make([]model.OfficialAccountHotspot, 0, len(scored)) + for _, item := range scored { + result = append(result, item.item) + if len(result) >= 20 { + break + } + } + return result +} + +func buildOfficialAccountKeywordTerms(keyword string) []string { + raw := strings.TrimSpace(strings.ToLower(keyword)) + if raw == "" { + return nil + } + splitter := func(r rune) bool { + switch r { + case ' ', '\t', '\n', '\r', ',', ',', '、', '/', '|', ';', ';', ':', ':', '(', ')', '(', ')', '+', '-', '_': + return true + default: + return false + } + } + parts := strings.FieldsFunc(raw, splitter) + terms := make([]string, 0, len(parts)+1) + if utf8RuneCount(raw) >= 2 { + terms = append(terms, raw) + } + for _, part := range parts { + part = strings.TrimSpace(part) + if utf8RuneCount(part) < 2 { + continue + } + terms = append(terms, part) + } + return uniqueOfficialAccountStrings(terms) +} + +func buildOfficialAccountSourceKeywords(domainKey, keyword string, extras []string) []string { + cfg := getOfficialAccountBusinessDomainConfig(domainKey) + keywords := append([]string{}, cfg.DomainKeywords...) + keywords = append(keywords, buildOfficialAccountKeywordTerms(keyword)...) + keywords = append(keywords, extras...) + if domainKey == officialAccountBusinessDomainGeneric && len(buildOfficialAccountKeywordTerms(keyword)) == 0 { + keywords = append(keywords, cfg.FallbackSearchHints...) + } + return uniqueOfficialAccountStrings(keywords) +} + +func utf8RuneCount(value string) int { + return len([]rune(value)) +} + +func officialAccountHeatLabel(score float64) string { + switch { + case score >= 12: + return "爆热" + case score >= 8: + return "高" + case score >= 5: + return "中高" + default: + return "中" + } +} + +func uniqueOfficialAccountStrings(items []string) []string { + if len(items) == 0 { + return nil + } + seen := map[string]struct{}{} + result := make([]string, 0, len(items)) + for _, item := range items { + item = strings.TrimSpace(item) + if item == "" { + continue + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + result = append(result, item) + } + sort.Strings(result) + return result +} + +func itoa(v int) string { + return strconv.Itoa(v) +} diff --git a/eai_ap_app/backend-go/internal/api/official_account_workflow.go b/eai_ap_app/backend-go/internal/api/official_account_workflow.go new file mode 100644 index 0000000..b346bb8 --- /dev/null +++ b/eai_ap_app/backend-go/internal/api/official_account_workflow.go @@ -0,0 +1,1228 @@ +package api + +import ( + "encoding/json" + "fmt" + "sort" + "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" +) + +const ( + officialAccountSpecialistKey = "wechat-official-account" + + officialAccountStepKeyConfig = "task_configuration" + officialAccountStepKeyTopic = "topic_recommendation" + officialAccountStepKeyTitle = "title_generation" + officialAccountStepKeyOutline = "outline_generation" + officialAccountStepKeyContent = "content_creation" + + officialAccountStepStatusPending = "pending" + officialAccountStepStatusRunning = "running" + officialAccountStepStatusCompleted = "completed" + officialAccountStepStatusError = "error" +) + +type officialAccountTaskCreateReq struct { + Keyword string `json:"keyword"` + Audience string `json:"audience"` + Goal string `json:"goal"` + Tone string `json:"tone"` + Requirements string `json:"requirements"` + OutlineStyle string `json:"outline_style"` + OutlineWords int `json:"outline_words"` + ContentTargetWords int `json:"content_target_words"` +} + +type officialAccountStepReq struct { + Topic string `json:"topic"` + Title string `json:"title"` + Outline string `json:"outline"` + Requirements string `json:"requirements"` +} + +type officialAccountTaskUpdateReq struct { + Keyword string `json:"keyword"` + Audience string `json:"audience"` + Goal string `json:"goal"` + Tone string `json:"tone"` + Requirements string `json:"requirements"` + OutlineStyle string `json:"outline_style"` + OutlineWords int `json:"outline_words"` + ContentTargetWords int `json:"content_target_words"` +} + +type officialAccountWorkflowState struct { + WorkflowType string `json:"workflow_type"` + Form officialAccountForm `json:"form"` + CurrentStep string `json:"current_step"` + Steps map[string]*officialAccountStepState `json:"steps"` + Shared officialAccountSharedState `json:"shared"` +} + +type officialAccountForm struct { + Keyword string `json:"keyword"` + Audience string `json:"audience"` + Goal string `json:"goal"` + Tone string `json:"tone"` + Requirements string `json:"requirements"` + OutlineStyle string `json:"outline_style"` + OutlineWords int `json:"outline_words"` + ContentTargetWords int `json:"content_target_words"` +} + +type officialAccountSharedState struct { + TopicCandidates []officialAccountTopicCandidate `json:"topic_candidates"` + SelectedTopic string `json:"selected_topic"` + TitleCandidates []string `json:"title_candidates"` + SelectedTitle string `json:"selected_title"` + Outline string `json:"outline"` + Content string `json:"content"` +} + +type officialAccountStepState struct { + Key string `json:"key"` + Title string `json:"title"` + Status string `json:"status"` + Progress int `json:"progress"` + ProgressText string `json:"progress_text"` + StartedAt string `json:"started_at"` + FinishedAt string `json:"finished_at"` + Logs []string `json:"logs"` + Output map[string]any `json:"output"` +} + +type officialAccountTopicCandidate struct { + Topic string `json:"topic"` + Angle string `json:"angle"` + Reason string `json:"reason"` + Heat string `json:"heat"` +} + +type officialAccountStepExecution struct { + Summary string + TaskStatus string + ArtifactTitle string + ArtifactType string + ArtifactStatus string + ArtifactContent string + Output map[string]any + Logs []string +} + +func CreateOfficialAccountTask(c *gin.Context) { + user := middleware.CurrentUser(c) + if user == nil { + web.Fail(c, web.NewAuthError("未登录")) + return + } + + var req officialAccountTaskCreateReq + if err := c.ShouldBindJSON(&req); err != nil { + web.Fail(c, web.NewBadRequest("请求参数错误")) + return + } + req.Keyword = strings.TrimSpace(req.Keyword) + req.Audience = strings.TrimSpace(req.Audience) + req.Goal = strings.TrimSpace(req.Goal) + req.Tone = strings.TrimSpace(req.Tone) + req.Requirements = strings.TrimSpace(req.Requirements) + req.OutlineStyle = strings.TrimSpace(req.OutlineStyle) + if req.Keyword == "" { + web.Fail(c, web.NewBadRequest("请先填写选题关键词")) + return + } + + var specialist model.Specialist + if err := store.DB.Where("key = ?", officialAccountSpecialistKey).First(&specialist).Error; err != nil { + web.Fail(c, web.NewNotFoundError("公众号助手尚未配置")) + return + } + + workflow := newOfficialAccountWorkflow(req) + contextJSON, _ := json.Marshal(workflow) + dueAt := time.Now().Add(24 * time.Hour) + task := model.WorkerTask{ + SpecialistKey: specialist.Key, + Title: buildOfficialAccountTaskTitle(req.Keyword), + Summary: buildOfficialAccountTaskSummary(req), + Owner: bootstrapOwner(user), + Priority: "P2", + Status: workerTaskStatusPending, + ContextJSON: string(contextJSON), + DueAt: &dueAt, + } + if user != nil { + task.CreatedBy = &user.ID + } + if err := store.DB.Create(&task).Error; err != nil { + web.Fail(c, web.NewBadRequest("创建公众号任务失败")) + return + } + if _, err := ensureOfficialAccountArticle(task, workflow); err != nil { + _ = store.DB.Delete(&task).Error + web.Fail(c, web.NewBadRequest("初始化公众号文章状态失败")) + return + } + + web.OK(c, gin.H{ + "task": task, + "workflow": workflow, + }) +} + +func GetOfficialAccountWorkflow(c *gin.Context) { + user := middleware.CurrentUser(c) + task, ok := loadAccessibleWorkerTask(c, user) + if !ok { + return + } + if task.SpecialistKey != officialAccountSpecialistKey { + web.Fail(c, web.NewNotFoundError("当前事项不属于公众号助手")) + return + } + respondOfficialAccountWorkflow(c, task) +} + +func UpdateOfficialAccountTask(c *gin.Context) { + user := middleware.CurrentUser(c) + task, ok := loadAccessibleWorkerTask(c, user) + if !ok { + return + } + if task.SpecialistKey != officialAccountSpecialistKey { + web.Fail(c, web.NewNotFoundError("当前事项不属于公众号助手")) + return + } + + var req officialAccountTaskUpdateReq + if err := c.ShouldBindJSON(&req); err != nil { + web.Fail(c, web.NewBadRequest("请求参数错误")) + return + } + + req.Keyword = strings.TrimSpace(req.Keyword) + req.Audience = strings.TrimSpace(req.Audience) + req.Goal = strings.TrimSpace(req.Goal) + req.Tone = strings.TrimSpace(req.Tone) + req.Requirements = strings.TrimSpace(req.Requirements) + req.OutlineStyle = strings.TrimSpace(req.OutlineStyle) + if req.Keyword == "" { + web.Fail(c, web.NewBadRequest("关键词不能为空")) + return + } + + oldWorkflow := parseOfficialAccountWorkflow(task.ContextJSON) + article, err := ensureOfficialAccountArticle(task, oldWorkflow) + if err != nil { + web.Fail(c, web.NewBadRequest("读取公众号文章状态失败")) + return + } + syncWorkflowFromOfficialAccountArticle(&oldWorkflow, article) + changed := oldWorkflow.Form.Keyword != req.Keyword || + oldWorkflow.Form.Audience != firstNonEmpty(req.Audience, "公众号读者") || + oldWorkflow.Form.Goal != firstNonEmpty(req.Goal, "输出一篇可发布的公众号文章") || + oldWorkflow.Form.Tone != firstNonEmpty(req.Tone, "专业但好懂") || + oldWorkflow.Form.Requirements != req.Requirements || + oldWorkflow.Form.OutlineStyle != normalizeOfficialAccountOutlineStyle(req.OutlineStyle) || + oldWorkflow.Form.OutlineWords != normalizeOfficialAccountOutlineWords(req.OutlineWords) || + oldWorkflow.Form.ContentTargetWords != normalizeOfficialAccountContentTargetWords(req.ContentTargetWords) + + workflow := oldWorkflow + if changed { + workflow = newOfficialAccountWorkflow(officialAccountTaskCreateReq{ + Keyword: req.Keyword, + Audience: req.Audience, + Goal: req.Goal, + Tone: req.Tone, + Requirements: req.Requirements, + OutlineStyle: req.OutlineStyle, + OutlineWords: req.OutlineWords, + ContentTargetWords: req.ContentTargetWords, + }) + } else { + workflow.Form.Keyword = req.Keyword + workflow.Form.Audience = firstNonEmpty(req.Audience, "公众号读者") + workflow.Form.Goal = firstNonEmpty(req.Goal, "输出一篇可发布的公众号文章") + workflow.Form.Tone = firstNonEmpty(req.Tone, "专业但好懂") + workflow.Form.Requirements = req.Requirements + workflow.Form.OutlineStyle = normalizeOfficialAccountOutlineStyle(req.OutlineStyle) + workflow.Form.OutlineWords = normalizeOfficialAccountOutlineWords(req.OutlineWords) + workflow.Form.ContentTargetWords = normalizeOfficialAccountContentTargetWords(req.ContentTargetWords) + } + + if changed { + task.Title = buildOfficialAccountTaskTitle(req.Keyword) + task.Summary = buildOfficialAccountTaskSummary(officialAccountTaskCreateReq{ + Keyword: req.Keyword, + Audience: req.Audience, + }) + task.Status = workerTaskStatusPending + task.CurrentResult = "" + task.CurrentRunID = nil + task.LastTriggeredAt = nil + if err := store.DB.Where("task_id = ?", task.ID).Delete(&model.WorkerRun{}).Error; err != nil { + web.Fail(c, web.NewBadRequest("清理旧运行记录失败")) + return + } + if err := store.DB.Where("task_id = ?", task.ID).Delete(&model.WorkerArtifact{}).Error; err != nil { + web.Fail(c, web.NewBadRequest("清理旧产物失败")) + return + } + resetOfficialAccountArticle(article, req) + } else { + task.Title = buildOfficialAccountTaskTitleFromWorkflow(workflow) + task.Summary = buildOfficialAccountTaskSummaryFromWorkflow(workflow) + syncOfficialAccountArticleFromWorkflow(article, workflow) + } + task.ContextJSON = marshalOfficialAccountWorkflow(workflow) + + if err := store.DB.Save(&task).Error; err != nil { + web.Fail(c, web.NewBadRequest("更新任务配置失败")) + return + } + if err := store.DB.Save(article).Error; err != nil { + web.Fail(c, web.NewBadRequest("保存公众号文章状态失败")) + return + } + + if changed { + now := time.Now() + configSummary := fmt.Sprintf("任务配置已更新为「%s」,流程已重置,等待重新执行。", req.Keyword) + outputJSON, _ := json.Marshal(gin.H{ + "summary": configSummary, + "keyword": req.Keyword, + "audience": firstNonEmpty(req.Audience, "公众号读者"), + "goal": firstNonEmpty(req.Goal, "输出一篇可发布的公众号文章"), + "tone": firstNonEmpty(req.Tone, "专业但好懂"), + "requirements": req.Requirements, + "outline_style": normalizeOfficialAccountOutlineStyle(req.OutlineStyle), + "outline_words": normalizeOfficialAccountOutlineWords(req.OutlineWords), + "content_target_words": normalizeOfficialAccountContentTargetWords(req.ContentTargetWords), + }) + logsJSON, _ := json.Marshal([]string{ + fmt.Sprintf("%s 已切换主题并重置流程", now.Format("15:04:05")), + }) + run := model.WorkerRun{ + TaskID: task.ID, + SpecialistKey: task.SpecialistKey, + ActionKey: officialAccountStepKeyConfig, + ActionTitle: "更新任务配置", + ActionType: "config_update", + Status: "done", + OutputJSON: string(outputJSON), + LogsJSON: string(logsJSON), + StartedAt: now, + FinishedAt: &now, + } + if err := store.DB.Create(&run).Error; err == nil { + task.CurrentRunID = &run.ID + task.LastTriggeredAt = &now + _ = store.DB.Save(&task).Error + } + } + + respondOfficialAccountWorkflow(c, task) +} + +func ExecuteOfficialAccountWorkflowStep(c *gin.Context) { + user := middleware.CurrentUser(c) + task, ok := loadAccessibleWorkerTask(c, user) + if !ok { + return + } + if task.SpecialistKey != officialAccountSpecialistKey { + web.Fail(c, web.NewNotFoundError("当前事项不属于公众号助手")) + return + } + + stepKey := strings.TrimSpace(c.Param("stepKey")) + if !isOfficialAccountStep(stepKey) { + web.Fail(c, web.NewBadRequest("工作流步骤不存在")) + return + } + + var req officialAccountStepReq + if err := c.ShouldBindJSON(&req); err != nil { + web.Fail(c, web.NewBadRequest("请求参数错误")) + return + } + req.Topic = strings.TrimSpace(req.Topic) + req.Title = strings.TrimSpace(req.Title) + req.Outline = strings.TrimSpace(req.Outline) + req.Requirements = strings.TrimSpace(req.Requirements) + + workflow := parseOfficialAccountWorkflow(task.ContextJSON) + article, err := ensureOfficialAccountArticle(task, workflow) + if err != nil { + web.Fail(c, web.NewBadRequest("读取公众号文章状态失败")) + return + } + syncWorkflowFromOfficialAccountArticle(&workflow, article) + if req.Requirements != "" { + workflow.Form.Requirements = req.Requirements + } + now := time.Now() + setOfficialAccountStepRunning(&workflow, stepKey, now) + + execution, appErr := runOfficialAccountStep(stepKey, &workflow, article, req, user, now) + if appErr != nil { + setOfficialAccountStepError(&workflow, stepKey, appErr.Message, now) + task.ContextJSON = marshalOfficialAccountWorkflow(workflow) + _ = store.DB.Save(&task).Error + web.Fail(c, appErr) + return + } + + setOfficialAccountStepCompleted(&workflow, stepKey, execution.Output, execution.Logs, now) + task.Status = execution.TaskStatus + task.Summary = buildOfficialAccountTaskSummaryFromWorkflow(workflow) + task.Title = buildOfficialAccountTaskTitleFromWorkflow(workflow) + task.ContextJSON = marshalOfficialAccountWorkflow(workflow) + task.CurrentResult = execution.Summary + task.LastTriggeredAt = &now + syncOfficialAccountArticleFromWorkflow(article, workflow) + + inputJSON, _ := json.Marshal(gin.H{ + "step_key": stepKey, + "step_title": officialAccountStepLabel(stepKey), + "topic": req.Topic, + "title": req.Title, + "outline": req.Outline, + "requirements": req.Requirements, + }) + outputPayload := cloneOutputMap(execution.Output) + outputPayload["summary"] = execution.Summary + outputPayload["keyword"] = workflow.Form.Keyword + outputJSON, _ := json.Marshal(outputPayload) + logsJSON, _ := json.Marshal(execution.Logs) + run := model.WorkerRun{ + TaskID: task.ID, + SpecialistKey: task.SpecialistKey, + ActionKey: stepKey, + ActionTitle: officialAccountStepLabel(stepKey), + ActionType: "workflow_step", + Status: "done", + InputJSON: string(inputJSON), + OutputJSON: string(outputJSON), + LogsJSON: string(logsJSON), + StartedAt: now, + FinishedAt: &now, + } + if err := store.DB.Create(&run).Error; err != nil { + web.Fail(c, web.NewBadRequest("保存步骤运行记录失败")) + return + } + + task.CurrentRunID = &run.ID + var artifact *model.WorkerArtifact + if execution.ArtifactTitle != "" { + sourceRefsJSON, _ := json.Marshal(buildOfficialAccountSourceRefs(workflow, stepKey)) + artifact = &model.WorkerArtifact{ + TaskID: task.ID, + SpecialistKey: task.SpecialistKey, + Title: execution.ArtifactTitle, + ArtifactType: execution.ArtifactType, + Status: execution.ArtifactStatus, + ContentText: execution.ArtifactContent, + ContentJSON: string(outputJSON), + SourceRefsJSON: string(sourceRefsJSON), + CreatedByRunID: &run.ID, + } + if err := store.DB.Create(artifact).Error; err != nil { + web.Fail(c, web.NewBadRequest("保存步骤产物失败")) + return + } + } + + if err := store.DB.Save(article).Error; err != nil { + web.Fail(c, web.NewBadRequest("保存公众号文章状态失败")) + return + } + if err := store.DB.Save(&task).Error; err != nil { + web.Fail(c, web.NewBadRequest("更新公众号任务失败")) + return + } + + web.OK(c, gin.H{ + "task": task, + "workflow": workflow, + "run": run, + "artifact": artifact, + }) +} + +func respondOfficialAccountWorkflow(c *gin.Context, task model.WorkerTask) { + workflow := parseOfficialAccountWorkflow(task.ContextJSON) + article, _ := ensureOfficialAccountArticle(task, workflow) + syncWorkflowFromOfficialAccountArticle(&workflow, article) + + var artifacts []model.WorkerArtifact + if err := store.DB.Where("task_id = ?", task.ID).Order("created_at DESC, id DESC").Find(&artifacts).Error; err != nil { + web.Fail(c, web.NewBadRequest("查询公众号产物失败")) + return + } + + var runs []model.WorkerRun + if err := store.DB.Where("task_id = ?", task.ID).Order("started_at DESC, id DESC").Find(&runs).Error; err != nil { + web.Fail(c, web.NewBadRequest("查询公众号运行记录失败")) + return + } + + web.OK(c, gin.H{ + "task": task, + "workflow": workflow, + "article": article, + "artifacts": artifacts, + "runs": runs, + "bottom_panel": buildBottomPanel(runs), + }) +} + +func newOfficialAccountWorkflow(req officialAccountTaskCreateReq) officialAccountWorkflowState { + workflow := officialAccountWorkflowState{ + WorkflowType: "wechat_official_account", + CurrentStep: officialAccountStepKeyTopic, + Form: officialAccountForm{ + Keyword: req.Keyword, + Audience: firstNonEmpty(req.Audience, "公众号读者"), + Goal: firstNonEmpty(req.Goal, "输出一篇可发布的公众号文章"), + Tone: firstNonEmpty(req.Tone, "专业但好懂"), + Requirements: req.Requirements, + OutlineStyle: normalizeOfficialAccountOutlineStyle(req.OutlineStyle), + OutlineWords: normalizeOfficialAccountOutlineWords(req.OutlineWords), + ContentTargetWords: normalizeOfficialAccountContentTargetWords(req.ContentTargetWords), + }, + Steps: map[string]*officialAccountStepState{}, + Shared: officialAccountSharedState{ + TopicCandidates: []officialAccountTopicCandidate{}, + TitleCandidates: []string{}, + }, + } + for _, item := range []struct { + key string + title string + text string + }{ + {officialAccountStepKeyTopic, officialAccountStepLabel(officialAccountStepKeyTopic), "等待推荐热点选题"}, + {officialAccountStepKeyTitle, officialAccountStepLabel(officialAccountStepKeyTitle), "等待生成标题"}, + {officialAccountStepKeyOutline, officialAccountStepLabel(officialAccountStepKeyOutline), "等待生成提纲"}, + {officialAccountStepKeyContent, officialAccountStepLabel(officialAccountStepKeyContent), "等待创作正文"}, + } { + workflow.Steps[item.key] = &officialAccountStepState{ + Key: item.key, + Title: item.title, + Status: officialAccountStepStatusPending, + Progress: 0, + ProgressText: item.text, + Logs: []string{}, + Output: map[string]any{}, + } + } + return workflow +} + +func normalizeOfficialAccountOutlineStyle(value string) string { + return firstNonEmpty(strings.TrimSpace(value), "问题拆解型") +} + +func normalizeOfficialAccountOutlineWords(value int) int { + if value <= 0 { + return 600 + } + return value +} + +func normalizeOfficialAccountContentTargetWords(value int) int { + if value <= 0 { + return 1400 + } + return value +} + +func parseOfficialAccountWorkflow(value string) officialAccountWorkflowState { + if strings.TrimSpace(value) == "" { + return newOfficialAccountWorkflow(officialAccountTaskCreateReq{}) + } + var workflow officialAccountWorkflowState + if err := json.Unmarshal([]byte(value), &workflow); err != nil { + return newOfficialAccountWorkflow(officialAccountTaskCreateReq{}) + } + if workflow.WorkflowType == "" { + workflow.WorkflowType = "wechat_official_account" + } + if workflow.Form.Audience == "" { + workflow.Form.Audience = "公众号读者" + } + if workflow.Form.Goal == "" { + workflow.Form.Goal = "输出一篇可发布的公众号文章" + } + if workflow.Form.Tone == "" { + workflow.Form.Tone = "专业但好懂" + } + if workflow.Steps == nil { + workflow.Steps = map[string]*officialAccountStepState{} + } + base := newOfficialAccountWorkflow(officialAccountTaskCreateReq{}) + for key, state := range base.Steps { + if workflow.Steps[key] == nil { + workflow.Steps[key] = state + continue + } + if workflow.Steps[key].Title == "" { + workflow.Steps[key].Title = state.Title + } + if workflow.Steps[key].Status == "" { + workflow.Steps[key].Status = state.Status + } + if workflow.Steps[key].ProgressText == "" { + workflow.Steps[key].ProgressText = state.ProgressText + } + if workflow.Steps[key].Logs == nil { + workflow.Steps[key].Logs = []string{} + } + if workflow.Steps[key].Output == nil { + workflow.Steps[key].Output = map[string]any{} + } + } + if workflow.Shared.TopicCandidates == nil { + workflow.Shared.TopicCandidates = []officialAccountTopicCandidate{} + } + if workflow.Shared.TitleCandidates == nil { + workflow.Shared.TitleCandidates = []string{} + } + if workflow.CurrentStep == "" { + workflow.CurrentStep = officialAccountStepKeyTopic + } + return workflow +} + +func marshalOfficialAccountWorkflow(workflow officialAccountWorkflowState) string { + data, err := json.Marshal(workflow) + if err != nil { + return "{}" + } + return string(data) +} + +func isOfficialAccountStep(stepKey string) bool { + switch stepKey { + case officialAccountStepKeyTopic, officialAccountStepKeyTitle, officialAccountStepKeyOutline, officialAccountStepKeyContent: + return true + default: + return false + } +} + +func officialAccountStepLabel(stepKey string) string { + switch stepKey { + case officialAccountStepKeyTopic: + return "推荐热点选题" + case officialAccountStepKeyTitle: + return "标题生成" + case officialAccountStepKeyOutline: + return "提纲生成" + case officialAccountStepKeyContent: + return "正文创作" + default: + return "工作流步骤" + } +} + +func officialAccountNextStep(stepKey string) string { + switch stepKey { + case officialAccountStepKeyTopic: + return officialAccountStepKeyTitle + case officialAccountStepKeyTitle: + return officialAccountStepKeyOutline + case officialAccountStepKeyOutline: + return officialAccountStepKeyContent + default: + return officialAccountStepKeyContent + } +} + +func setOfficialAccountStepRunning(workflow *officialAccountWorkflowState, stepKey string, now time.Time) { + step := workflow.Steps[stepKey] + if step == nil { + return + } + step.Status = officialAccountStepStatusRunning + step.Progress = 35 + step.ProgressText = "正在执行" + step.StartedAt = now.Format(time.RFC3339) + step.Logs = append(step.Logs, fmt.Sprintf("%s 开始执行", now.Format("15:04:05"))) + workflow.CurrentStep = stepKey +} + +func setOfficialAccountStepCompleted(workflow *officialAccountWorkflowState, stepKey string, output map[string]any, logs []string, now time.Time) { + step := workflow.Steps[stepKey] + if step == nil { + return + } + step.Status = officialAccountStepStatusCompleted + step.Progress = 100 + step.ProgressText = "已完成" + step.FinishedAt = now.Format(time.RFC3339) + step.Output = output + step.Logs = append(step.Logs, logs...) + workflow.CurrentStep = officialAccountNextStep(stepKey) +} + +func setOfficialAccountStepError(workflow *officialAccountWorkflowState, stepKey, reason string, now time.Time) { + step := workflow.Steps[stepKey] + if step == nil { + return + } + step.Status = officialAccountStepStatusError + step.ProgressText = reason + step.FinishedAt = now.Format(time.RFC3339) + step.Logs = append(step.Logs, fmt.Sprintf("%s 执行失败:%s", now.Format("15:04:05"), reason)) + workflow.CurrentStep = stepKey +} + +func runOfficialAccountStep(stepKey string, workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle, req officialAccountStepReq, user *model.User, now time.Time) (officialAccountStepExecution, *web.AppError) { + switch stepKey { + case officialAccountStepKeyTopic: + return executeOfficialAccountTopicStep(workflow, article, user, now), nil + case officialAccountStepKeyTitle: + if req.Topic != "" { + workflow.Shared.SelectedTopic = req.Topic + } + if workflow.Shared.SelectedTopic == "" { + return officialAccountStepExecution{}, web.NewBadRequest("请先完成选题推荐,或手动选择一个选题") + } + if article != nil { + article.SelectedTopic = workflow.Shared.SelectedTopic + } + return executeOfficialAccountTitleStep(workflow, article, user, now), nil + case officialAccountStepKeyOutline: + if req.Title != "" { + workflow.Shared.SelectedTitle = req.Title + } + if workflow.Shared.SelectedTitle == "" { + return officialAccountStepExecution{}, web.NewBadRequest("请先生成标题,或手动输入标题") + } + if article != nil { + article.SelectedTitle = workflow.Shared.SelectedTitle + } + return executeOfficialAccountOutlineStep(workflow, article, user, now), nil + case officialAccountStepKeyContent: + if req.Outline != "" { + workflow.Shared.Outline = req.Outline + } + if workflow.Shared.Outline == "" { + return officialAccountStepExecution{}, web.NewBadRequest("请先生成提纲,或手动输入提纲") + } + return executeOfficialAccountContentStep(workflow, article, user, now), nil + default: + return officialAccountStepExecution{}, web.NewBadRequest("工作流步骤不存在") + } +} + +func executeOfficialAccountTopicStep(workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution { + hotspots, hotspotLogs, _ := ensureOfficialAccountHotspots(false) + hotspots = sortOfficialAccountHotspots(hotspots) + candidates := buildOfficialAccountTopicCandidatesFromHotspots(workflow.Form, hotspots) + _ = tryFillOfficialAccountTopicsWithAI(workflow.Form, hotspots, user, &candidates) + evaluatedCount := evaluateOfficialAccountTopics(workflow.Form, hotspots, user, &candidates) + if workflow.Shared.SelectedTopic == "" && len(candidates) > 0 { + workflow.Shared.SelectedTopic = candidates[0].Topic + } + workflow.Shared.TopicCandidates = candidates + if article != nil { + article.HotspotsJSON = marshalOfficialAccountHotspots(hotspots) + article.TopicHeat = findOfficialAccountTopicHeat(candidates, workflow.Shared.SelectedTopic) + } + + output := map[string]any{ + "candidates": candidates, + "selected_topic": workflow.Shared.SelectedTopic, + "hotspots": hotspots, + } + return officialAccountStepExecution{ + Summary: fmt.Sprintf("已完成热点选题推荐,当前推荐题目为「%s」。", workflow.Shared.SelectedTopic), + TaskStatus: workerTaskStatusInProgress, + ArtifactTitle: "公众号选题建议", + ArtifactType: "topics", + ArtifactStatus: workerArtifactStatusDraft, + ArtifactContent: buildTopicArtifactContent(candidates, workflow.Shared.SelectedTopic), + Output: output, + Logs: append([]string{ + fmt.Sprintf("%s 已基于关键词「%s」生成候选选题", now.Format("15:04:05"), workflow.Form.Keyword), + fmt.Sprintf("%s 已完成 %d 个选题的热度与可写性评判", now.Format("15:04:05"), evaluatedCount), + fmt.Sprintf("%s 默认选中「%s」进入下一步", now.Format("15:04:05"), workflow.Shared.SelectedTopic), + }, hotspotLogs...), + } +} + +func executeOfficialAccountTitleStep(workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution { + titles := buildOfficialAccountTitles(workflow.Form, workflow.Shared.SelectedTopic) + _ = tryFillOfficialAccountTitlesWithAI(workflow.Form, workflow.Shared.SelectedTopic, user, &titles) + if workflow.Shared.SelectedTitle == "" && len(titles) > 0 { + workflow.Shared.SelectedTitle = titles[0] + } + workflow.Shared.TitleCandidates = titles + if article != nil && workflow.Shared.SelectedTitle != article.SelectedTitle { + workflow.Shared.Outline = "" + workflow.Shared.Content = "" + article.Outline = "" + article.Content = "" + } + + output := map[string]any{ + "titles": titles, + "selected_title": workflow.Shared.SelectedTitle, + "selected_topic": workflow.Shared.SelectedTopic, + } + return officialAccountStepExecution{ + Summary: fmt.Sprintf("已完成标题生成,当前推荐标题为「%s」。", workflow.Shared.SelectedTitle), + TaskStatus: workerTaskStatusInProgress, + ArtifactTitle: "公众号标题建议", + ArtifactType: "titles", + ArtifactStatus: workerArtifactStatusDraft, + ArtifactContent: buildTitleArtifactContent(titles, workflow.Shared.SelectedTitle), + Output: output, + Logs: []string{ + fmt.Sprintf("%s 已基于选题「%s」生成标题候选", now.Format("15:04:05"), workflow.Shared.SelectedTopic), + fmt.Sprintf("%s 默认选中「%s」进入下一步", now.Format("15:04:05"), workflow.Shared.SelectedTitle), + fmt.Sprintf("%s 若标题发生变化,将自动清空旧提纲和旧正文。", now.Format("15:04:05")), + }, + } +} + +func executeOfficialAccountOutlineStep(workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution { + outline := buildOfficialAccountOutline(workflow.Form, workflow.Shared.SelectedTitle) + aiOutline, ok := tryGenerateOfficialAccountOutlineWithAI(workflow.Form, workflow.Shared.SelectedTitle, user) + if ok { + outline = aiOutline + } + workflow.Shared.Outline = outline + workflow.Shared.Content = "" + if article != nil { + article.Content = "" + } + + output := map[string]any{ + "outline": outline, + "selected_title": workflow.Shared.SelectedTitle, + } + return officialAccountStepExecution{ + Summary: "提纲已生成,可继续进入正文创作。", + TaskStatus: workerTaskStatusInProgress, + ArtifactTitle: "公众号文章提纲", + ArtifactType: "outline", + ArtifactStatus: workerArtifactStatusDraft, + ArtifactContent: outline, + Output: output, + Logs: []string{ + fmt.Sprintf("%s 已基于标题「%s」生成文章提纲", now.Format("15:04:05"), workflow.Shared.SelectedTitle), + fmt.Sprintf("%s 提纲风格:%s,目标字数:%d 字。", now.Format("15:04:05"), normalizeOfficialAccountOutlineStyle(workflow.Form.OutlineStyle), normalizeOfficialAccountOutlineWords(workflow.Form.OutlineWords)), + fmt.Sprintf("%s 提纲更新后,旧正文已自动失效,需要重新创作。", now.Format("15:04:05")), + }, + } +} + +func executeOfficialAccountContentStep(workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution { + content := buildOfficialAccountContent(workflow.Form, workflow.Shared.SelectedTitle, workflow.Shared.Outline) + aiContent, ok := tryGenerateOfficialAccountContentWithAI(workflow.Form, workflow.Shared.SelectedTitle, workflow.Shared.Outline, user) + if ok { + content = aiContent + } + workflow.Shared.Content = content + if article != nil { + article.Content = content + } + + output := map[string]any{ + "content": content, + "selected_title": workflow.Shared.SelectedTitle, + "outline": workflow.Shared.Outline, + } + return officialAccountStepExecution{ + Summary: "正文草稿已生成,可进入人工校对和发布环节。", + TaskStatus: workerTaskStatusDraft, + ArtifactTitle: firstNonEmpty(workflow.Shared.SelectedTitle, "公众号文章草稿"), + ArtifactType: "article", + ArtifactStatus: workerArtifactStatusDraft, + ArtifactContent: content, + Output: output, + Logs: []string{ + fmt.Sprintf("%s 已完成正文创作草稿", now.Format("15:04:05")), + fmt.Sprintf("%s 正文目标字数:%d 字。", now.Format("15:04:05"), normalizeOfficialAccountContentTargetWords(workflow.Form.ContentTargetWords)), + }, + } +} + +func buildOfficialAccountTopicCandidates(form officialAccountForm) []officialAccountTopicCandidate { + keyword := firstNonEmpty(form.Keyword, "公众号运营") + audience := firstNonEmpty(form.Audience, "公众号读者") + return []officialAccountTopicCandidate{ + { + Topic: fmt.Sprintf("%s最近为什么又被拿出来讨论?", keyword), + Angle: "热点追踪", + Reason: fmt.Sprintf("适合%s快速理解为什么这个话题又热了。", audience), + Heat: "高", + }, + { + Topic: fmt.Sprintf("%s最容易踩的 3 个坑", keyword), + Angle: "避坑拆解", + Reason: "容易形成强点击标题,也便于后续展开案例。", + Heat: "高", + }, + { + Topic: fmt.Sprintf("普通人怎么把%s真正用起来?", keyword), + Angle: "实操指南", + Reason: "更贴近业务落地,适合输出步骤化内容。", + Heat: "中高", + }, + { + Topic: fmt.Sprintf("%s这件事,外行和内行到底差在哪?", keyword), + Angle: "认知对比", + Reason: "利于写出观点型文章,适合做公众号风格表达。", + Heat: "中", + }, + } +} + +func buildOfficialAccountTitles(form officialAccountForm, topic string) []string { + keyword := firstNonEmpty(form.Keyword, "这个话题") + return []string{ + fmt.Sprintf("%s最近又火了,但大多数人根本没看懂", topic), + fmt.Sprintf("别急着谈%s,先把这 3 件事看明白", keyword), + fmt.Sprintf("%s最容易踩的坑,我一次讲透", keyword), + fmt.Sprintf("为什么说%s不是不能做,而是你做错了顺序", keyword), + fmt.Sprintf("关于%s,外行最爱问的 5 个问题", keyword), + } +} + +func buildOfficialAccountOutline(form officialAccountForm, title string) string { + keyword := firstNonEmpty(form.Keyword, "这个主题") + outlineStyle := normalizeOfficialAccountOutlineStyle(form.OutlineStyle) + outlineWords := normalizeOfficialAccountOutlineWords(form.OutlineWords) + return strings.TrimSpace(fmt.Sprintf( + "# 标题\n%s\n\n> 提纲风格:%s | 目标字数:约 %d 字\n\n## 一、为什么现在值得聊%s\n- 当前背景和触发原因\n- 为什么读者现在会关心\n\n## 二、大多数人最容易误解的地方\n- 常见认知偏差 1\n- 常见认知偏差 2\n- 常见认知偏差 3\n\n## 三、真正可落地的做法\n- 第一步:先明确目标\n- 第二步:先从小范围验证\n- 第三步:沉淀方法和复用模板\n\n## 四、最后给读者的建议\n- 什么情况应该立刻开始\n- 什么情况应该先补前置条件\n- 一句话总结\n", + title, + outlineStyle, + outlineWords, + keyword, + )) +} + +func buildOfficialAccountContent(form officialAccountForm, title, outline string) string { + keyword := firstNonEmpty(form.Keyword, "这个主题") + audience := firstNonEmpty(form.Audience, "公众号读者") + tone := firstNonEmpty(form.Tone, "专业但好懂") + targetWords := normalizeOfficialAccountContentTargetWords(form.ContentTargetWords) + return strings.TrimSpace(fmt.Sprintf( + "# %s\n\n> 正文字数目标:约 %d 字\n\n很多%s最近都在关注“%s”,但真正把这件事讲清楚的人并不多。今天这篇文章,我想用尽量%s的方式,把这个问题讲透。\n\n## 为什么现在值得聊\n\n第一,是这个话题本身已经进入更多人的工作和决策场景;第二,是不少人已经开始行动,但做法还比较碎;第三,大家更需要的不是空泛观点,而是能拿去用的方法。\n\n## 最容易踩的坑\n\n第一个坑,是一上来就追求大而全,结果什么都想做,最后什么都落不下去。\n\n第二个坑,是只看表面热度,不看自己真实业务是否适配。\n\n第三个坑,是做完一次就结束,没有形成可复用的内容资产和方法模板。\n\n## 真正可落地的做法\n\n先明确目标:你是要做传播、做转化,还是做认知教育?目标不同,内容结构完全不同。\n\n再做小范围验证:不要一开始就上最重的版本,先验证选题、标题和读者反馈。\n\n最后沉淀模板:把好用的标题结构、提纲结构和正文风格保留下来,后面才能越做越快。\n\n## 最后想说\n\n如果你也在做%s,别急着一次把所有事情做完。先抓住一个明确问题,把它讲明白、讲透、讲到读者愿意继续往下看,这篇文章就已经成功了一半。\n\n这篇内容可以继续细化成你自己的案例版、行业版、客户版。后面只要沿着这个骨架迭代,文章生产就会越来越稳。\n", + firstNonEmpty(title, keyword), + targetWords, + audience, + keyword, + tone, + keyword, + )) +} + +func tryFillOfficialAccountTopicsWithAI(form officialAccountForm, hotspots []model.OfficialAccountHotspot, user *model.User, target *[]officialAccountTopicCandidate) bool { + var items []officialAccountTopicCandidate + prompt := buildOfficialAccountHotspotPrompt(form, hotspots) + if !generateOfficialAccountJSON(prompt, user, &items) || len(items) == 0 { + return false + } + *target = items + return true +} + +func tryFillOfficialAccountTitlesWithAI(form officialAccountForm, topic string, user *model.User, target *[]string) bool { + var items []string + prompt := fmt.Sprintf( + "请基于选题“%s”为公众号生成 5 个标题候选,关键词是“%s”,面向%s,语气%s。只返回 JSON 数组字符串,不要输出 markdown。", + topic, + form.Keyword, + form.Audience, + form.Tone, + ) + if !generateOfficialAccountJSON(prompt, user, &items) || len(items) == 0 { + return false + } + *target = items + return true +} + +func tryGenerateOfficialAccountOutlineWithAI(form officialAccountForm, title string, user *model.User) (string, bool) { + prompt := fmt.Sprintf( + "请基于公众号标题“%s”生成一份适合中文公众号的文章提纲,输出 markdown。要求有 4-6 个二级部分,每部分带 2-4 个要点,语气%s,受众%s,提纲风格%s,目标提纲字数约%d字。", + title, + form.Tone, + form.Audience, + normalizeOfficialAccountOutlineStyle(form.OutlineStyle), + normalizeOfficialAccountOutlineWords(form.OutlineWords), + ) + return generateOfficialAccountText(prompt, user) +} + +func tryGenerateOfficialAccountContentWithAI(form officialAccountForm, title, outline string, user *model.User) (string, bool) { + prompt := fmt.Sprintf( + "请根据以下公众号文章信息输出一篇可发布的中文正文草稿。\n标题:%s\n受众:%s\n目标:%s\n语气:%s\n提纲:\n%s\n要求:约%d字,语言自然,不要编造成数据,不要输出多余解释。", + title, + form.Audience, + form.Goal, + form.Tone, + outline, + normalizeOfficialAccountContentTargetWords(form.ContentTargetWords), + ) + return generateOfficialAccountText(prompt, user) +} + +type officialAccountTopicEvalResult struct { + Heat string `json:"heat"` + Reason string `json:"reason"` + Score int `json:"score"` +} + +func evaluateOfficialAccountTopics(form officialAccountForm, hotspots []model.OfficialAccountHotspot, user *model.User, target *[]officialAccountTopicCandidate) int { + if target == nil || len(*target) == 0 { + return 0 + } + evaluated := 0 + items := append([]officialAccountTopicCandidate(nil), (*target)...) + for idx := range items { + result, ok := tryEvaluateOfficialAccountTopicWithAI(form, items[idx].Topic, hotspots, user) + if !ok { + continue + } + if strings.TrimSpace(result.Heat) != "" { + items[idx].Heat = result.Heat + } + if strings.TrimSpace(result.Reason) != "" { + items[idx].Reason = result.Reason + } + evaluated++ + } + sortOfficialAccountTopicCandidates(items) + *target = dedupeOfficialAccountTopicCandidates(items) + return evaluated +} + +func tryEvaluateOfficialAccountTopicWithAI(form officialAccountForm, topic string, hotspots []model.OfficialAccountHotspot, user *model.User) (officialAccountTopicEvalResult, bool) { + var result officialAccountTopicEvalResult + prompt := buildOfficialAccountTopicEvalPrompt(form, topic, hotspots) + if !generateOfficialAccountJSON(prompt, user, &result) { + return officialAccountTopicEvalResult{}, false + } + return result, true +} + +func sortOfficialAccountTopicCandidates(items []officialAccountTopicCandidate) { + order := map[string]int{ + "爆热": 4, + "高": 3, + "中高": 2, + "中": 1, + } + sort.SliceStable(items, func(i, j int) bool { + left := order[strings.TrimSpace(items[i].Heat)] + right := order[strings.TrimSpace(items[j].Heat)] + if left == right { + return len([]rune(items[i].Reason)) > len([]rune(items[j].Reason)) + } + return left > right + }) +} + +func findOfficialAccountTopicHeat(items []officialAccountTopicCandidate, selected string) string { + for _, item := range items { + if strings.TrimSpace(item.Topic) == strings.TrimSpace(selected) { + return item.Heat + } + } + if len(items) > 0 { + return items[0].Heat + } + return "" +} + +func generateOfficialAccountText(prompt string, user *model.User) (string, bool) { + if user == nil || user.AiPoints <= 0 { + return "", false + } + route, err := config.GetRoute("title_gen") + if err != nil { + return "", false + } + start := time.Now() + result, usedRoute, err := ai.GenerateFullWithFallback(route, []ai.Message{ + {Role: "system", Content: "你是一个擅长公众号内容策划与创作的中文编辑助手,请直接给出结果,不要输出多余说明。"}, + {Role: "user", Content: prompt}, + }) + if err != nil || result == nil || strings.TrimSpace(result.Content) == "" { + ai.LogCall(ai.LogEntry{ + UserID: user.ID, + Capability: ai.CapabilityTextGen, + Provider: route.Provider, + RouteID: route.RouteID, + Model: route.Model, + Success: false, + ErrorMessage: firstNonEmpty(strings.TrimSpace(fmt.Sprint(err)), "empty_result"), + LatencyMs: int(time.Since(start).Milliseconds()), + }) + return "", false + } + finalRoute := route + if usedRoute != nil { + finalRoute = usedRoute + } + ai.LogCall(ai.LogEntry{ + UserID: user.ID, + Capability: ai.CapabilityTextGen, + Provider: finalRoute.Provider, + RouteID: finalRoute.RouteID, + Model: finalRoute.Model, + TokensInput: result.Usage.PromptTokens, + TokensOutput: result.Usage.CompletionTokens, + Success: true, + LatencyMs: int(time.Since(start).Milliseconds()), + }) + return strings.TrimSpace(stripJSONCodeFence(result.Content)), true +} + +func generateOfficialAccountJSON(prompt string, user *model.User, target any) bool { + content, ok := generateOfficialAccountText(prompt, user) + if !ok { + return false + } + content = stripJSONCodeFence(content) + return json.Unmarshal([]byte(content), target) == nil +} + +func stripJSONCodeFence(value string) string { + value = strings.TrimSpace(value) + value = strings.TrimPrefix(value, "```json") + value = strings.TrimPrefix(value, "```") + value = strings.TrimSuffix(value, "```") + return strings.TrimSpace(value) +} + +func buildOfficialAccountTaskTitle(keyword string) string { + return fmt.Sprintf("公众号文章:%s", firstNonEmpty(keyword, "待定主题")) +} + +func buildOfficialAccountTaskSummary(req officialAccountTaskCreateReq) string { + return strings.TrimSpace(fmt.Sprintf("围绕「%s」面向「%s」产出公众号文章草稿。", firstNonEmpty(req.Keyword, "待定主题"), firstNonEmpty(req.Audience, "公众号读者"))) +} + +func buildOfficialAccountTaskSummaryFromWorkflow(workflow officialAccountWorkflowState) string { + if workflow.Shared.Content != "" { + return "正文草稿已生成,等待人工校对。" + } + if workflow.Shared.Outline != "" { + return "提纲已生成,可继续创作正文。" + } + if workflow.Shared.SelectedTitle != "" { + return fmt.Sprintf("标题已选定为「%s」,等待生成提纲。", workflow.Shared.SelectedTitle) + } + if workflow.Shared.SelectedTopic != "" { + return fmt.Sprintf("当前选题为「%s」,等待生成标题。", workflow.Shared.SelectedTopic) + } + return buildOfficialAccountTaskSummary(officialAccountTaskCreateReq{Keyword: workflow.Form.Keyword, Audience: workflow.Form.Audience}) +} + +func buildOfficialAccountTaskTitleFromWorkflow(workflow officialAccountWorkflowState) string { + if workflow.Shared.SelectedTitle != "" { + return workflow.Shared.SelectedTitle + } + if workflow.Shared.SelectedTopic != "" { + return buildOfficialAccountTaskTitle(workflow.Shared.SelectedTopic) + } + return buildOfficialAccountTaskTitle(workflow.Form.Keyword) +} + +func buildTopicArtifactContent(candidates []officialAccountTopicCandidate, selected string) string { + lines := []string{"# 选题建议", ""} + for idx, item := range candidates { + lines = append(lines, + fmt.Sprintf("%d. %s", idx+1, item.Topic), + fmt.Sprintf("- 角度:%s", item.Angle), + fmt.Sprintf("- 推荐理由:%s", item.Reason), + fmt.Sprintf("- 热度:%s", item.Heat), + "", + ) + } + lines = append(lines, fmt.Sprintf("当前默认选题:%s", selected)) + return strings.Join(lines, "\n") +} + +func buildTitleArtifactContent(titles []string, selected string) string { + lines := []string{"# 标题候选", ""} + for idx, item := range titles { + lines = append(lines, fmt.Sprintf("%d. %s", idx+1, item)) + } + lines = append(lines, "", fmt.Sprintf("当前默认标题:%s", selected)) + return strings.Join(lines, "\n") +} + +func buildOfficialAccountSourceRefs(workflow officialAccountWorkflowState, stepKey string) []string { + refs := []string{ + "关键词:" + firstNonEmpty(workflow.Form.Keyword, "待补充"), + "受众:" + firstNonEmpty(workflow.Form.Audience, "公众号读者"), + } + if workflow.Shared.SelectedTopic != "" { + refs = append(refs, "选题:"+workflow.Shared.SelectedTopic) + } + if workflow.Shared.SelectedTitle != "" { + refs = append(refs, "标题:"+workflow.Shared.SelectedTitle) + } + if stepKey == officialAccountStepKeyContent && workflow.Shared.Outline != "" { + refs = append(refs, "提纲:已生成") + } + return refs +} + +func cloneOutputMap(source map[string]any) map[string]any { + result := map[string]any{} + for key, value := range source { + result[key] = value + } + return result +} + +func loadAccessibleWorkerTask(c *gin.Context, user *model.User) (model.WorkerTask, bool) { + id, ok := parseID(c, "id") + if !ok { + return model.WorkerTask{}, false + } + + query := store.DB.Model(&model.WorkerTask{}).Where("id = ?", id) + if user != nil && user.Role != "admin" { + query = query.Where("owner IN ?", []string{user.FullName, user.Username}) + } + + var task model.WorkerTask + if err := query.First(&task).Error; err != nil { + web.Fail(c, web.NewNotFoundError("事项不存在")) + return model.WorkerTask{}, false + } + return task, true +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + return value + } + } + return "" +} + diff --git a/eai_ap_app/backend-go/internal/api/router.go b/eai_ap_app/backend-go/internal/api/router.go index 3be2be5..e52b48e 100644 --- a/eai_ap_app/backend-go/internal/api/router.go +++ b/eai_ap_app/backend-go/internal/api/router.go @@ -47,6 +47,10 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) { r.GET("/api/worker/tasks", middleware.Auth(cfg), ListWorkerTasks) r.GET("/api/worker/tasks/:id", middleware.Auth(cfg), GetWorkerTaskDetail) r.GET("/api/worker/artifacts/:id", middleware.Auth(cfg), GetWorkerArtifactDetail) + r.POST("/api/official-account/tasks", middleware.Auth(cfg), CreateOfficialAccountTask) + r.GET("/api/official-account/tasks/:id/workflow", middleware.Auth(cfg), GetOfficialAccountWorkflow) + r.PUT("/api/official-account/tasks/:id", middleware.Auth(cfg), UpdateOfficialAccountTask) + r.POST("/api/official-account/tasks/:id/steps/:stepKey", middleware.Auth(cfg), ExecuteOfficialAccountWorkflowStep) r.GET("/api/connectors", middleware.Auth(cfg), ListConnectors) r.GET("/api/connectors/:key", middleware.Auth(cfg), GetConnector) r.POST("/api/connectors/:key/query", middleware.Auth(cfg), QueryConnector) diff --git a/eai_ap_app/backend-go/internal/model/official_account_article.go b/eai_ap_app/backend-go/internal/model/official_account_article.go new file mode 100644 index 0000000..5e95322 --- /dev/null +++ b/eai_ap_app/backend-go/internal/model/official_account_article.go @@ -0,0 +1,32 @@ +package model + +import "time" + +// OfficialAccountArticle 公众号文章状态表,对齐工作流中的主题/标题/提纲/正文显式传递。 +type OfficialAccountArticle struct { + ID uint `gorm:"primaryKey" json:"id"` + TaskID uint `gorm:"not null;uniqueIndex" json:"task_id"` + SpecialistKey string `gorm:"size:64;not null;index" json:"specialist_key"` + BusinessDomain string `gorm:"size:32;not null;default:ai;index" json:"business_domain"` + Keyword string `gorm:"size:256;not null" json:"keyword"` + Audience string `gorm:"size:128;not null" json:"audience"` + Goal string `gorm:"size:128;not null" json:"goal"` + Tone string `gorm:"size:128;not null" json:"tone"` + Requirements string `gorm:"type:text" json:"requirements"` + TopicCandidatesJSON string `gorm:"type:text" json:"topic_candidates_json"` + HotspotsJSON string `gorm:"type:text" json:"hotspots_json"` + SelectedTopic string `gorm:"size:256" json:"selected_topic"` + TopicHeat string `gorm:"size:64" json:"topic_heat"` + TitleCandidatesJSON string `gorm:"type:text" json:"title_candidates_json"` + SelectedTitle string `gorm:"size:256" json:"selected_title"` + OutlineStyle string `gorm:"size:64;not null;default:问题拆解型" json:"outline_style"` + OutlineWords int `gorm:"not null;default:600" json:"outline_words"` + Outline string `gorm:"type:text" json:"outline"` + ContentTargetWords int `gorm:"not null;default:1400" json:"content_target_words"` + Content string `gorm:"type:text" json:"content"` + Status string `gorm:"size:32;not null;default:draft;index" json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (OfficialAccountArticle) TableName() string { return "official_account_article" } diff --git a/eai_ap_app/backend-go/internal/model/official_account_hotspot.go b/eai_ap_app/backend-go/internal/model/official_account_hotspot.go new file mode 100644 index 0000000..16a8c8b --- /dev/null +++ b/eai_ap_app/backend-go/internal/model/official_account_hotspot.go @@ -0,0 +1,25 @@ +package model + +import "time" + +// OfficialAccountHotspot 业务域热点缓存表,承接 RSS/网页抓取结果及领域评分。 +type OfficialAccountHotspot struct { + ID uint `gorm:"primaryKey" json:"id"` + BusinessDomain string `gorm:"size:32;not null;default:ai;index" json:"business_domain"` + SourceKey string `gorm:"size:64;not null;index;uniqueIndex:idx_hotspot_source_url" json:"source_key"` + SourceLabel string `gorm:"size:128;not null" json:"source_label"` + SourceType string `gorm:"size:16;not null" json:"source_type"` + Title string `gorm:"size:512;not null" json:"title"` + URL string `gorm:"size:1024;not null;uniqueIndex:idx_hotspot_source_url" json:"url"` + Summary string `gorm:"type:text" json:"summary"` + PublishedAt *time.Time `json:"published_at"` + RawScore float64 `gorm:"not null;default:0" json:"raw_score"` + DomainScore float64 `gorm:"not null;default:0" json:"domain_score"` + HeatLabel string `gorm:"size:32;default:''" json:"heat_label"` + TagsJSON string `gorm:"type:text" json:"tags_json"` + FetchedAt time.Time `gorm:"index" json:"fetched_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (OfficialAccountHotspot) TableName() string { return "official_account_hotspot" } diff --git a/eai_ap_app/backend-go/internal/store/db.go b/eai_ap_app/backend-go/internal/store/db.go index 2acf063..e36e058 100644 --- a/eai_ap_app/backend-go/internal/store/db.go +++ b/eai_ap_app/backend-go/internal/store/db.go @@ -58,6 +58,8 @@ func Init(dbPath string) (*gorm.DB, error) { &model.WorkerTask{}, &model.WorkerArtifact{}, &model.WorkerRun{}, + &model.OfficialAccountArticle{}, + &model.OfficialAccountHotspot{}, ); err != nil { return nil, err } diff --git a/eai_ap_app/backend-go/internal/store/seed.go b/eai_ap_app/backend-go/internal/store/seed.go index 4d90e4c..4f8a630 100644 --- a/eai_ap_app/backend-go/internal/store/seed.go +++ b/eai_ap_app/backend-go/internal/store/seed.go @@ -1,13 +1,13 @@ package store import ( - "encoding/json" - "io" - "log" - "os" - "path/filepath" - "runtime" - "strings" + "encoding/json" + "io" + "log" + "os" + "path/filepath" + "runtime" + "strings" "eaisalestrain/backend/internal/auth" "eaisalestrain/backend/internal/config" @@ -381,6 +381,30 @@ func seedSpecialists() error { State: "active", SortOrder: 40, }, + { + Key: "wechat-official-account", + Label: "公众号助手", + Tier: "generic", + WorkerType: "dw", + Route: "/apps/wechat-official-account", + Summary: "热点选题、标题、提纲、正文四步创作工作流", + WorkStatus: "4 个节点可执行", + RiskLabel: "0 个异常", + Color: "#2563eb", + Stage: "正文创作", + Progress: 0, + MarketTag: "已安装", + Version: "v1.0", + ConnectorScope: "连接知识库 / 公众号工作区上下文", + PermissionScope: "文章任务创建、步骤执行、草稿保存、人工确认", + ResourceBindings: "公众号工作流、知识库、事项运行记录、文章草稿", + InfoSources: "选题关键词、目标受众、文章目标、风格要求", + BaseSkills: "推荐热点选题、标题生成、提纲生成、正文创作", + AIAssistance: "按步骤生成中间结果,并展示进度与可追溯输出", + GeneratedSkills: "公众号选题建议、标题建议、文章提纲、正文草稿", + State: "active", + SortOrder: 45, + }, { Key: "contract-review", Label: "合同审查专员", @@ -635,40 +659,40 @@ func ensureMediaLink(src, dst string) error { if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } - if err := os.Symlink(absSrc, dst); err == nil { - return nil - } else if shouldCopySeedMedia(err) { - return copySeedMedia(absSrc, dst) - } else { - return err - } + if err := os.Symlink(absSrc, dst); err == nil { + return nil + } else if shouldCopySeedMedia(err) { + return copySeedMedia(absSrc, dst) + } else { + return err + } } func shouldCopySeedMedia(err error) bool { - if err == nil { - return false - } - // Windows 普通终端默认没有创建符号链接权限,开发环境回退为复制文件即可。 - return runtime.GOOS == "windows" || strings.Contains(strings.ToLower(err.Error()), "privilege") + if err == nil { + return false + } + // Windows 普通终端默认没有创建符号链接权限,开发环境回退为复制文件即可。 + return runtime.GOOS == "windows" || strings.Contains(strings.ToLower(err.Error()), "privilege") } func copySeedMedia(src, dst string) error { - in, err := os.Open(src) - if err != nil { - return err - } - defer in.Close() + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() - out, err := os.Create(dst) - if err != nil { - return err - } - defer out.Close() + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() - if _, err := io.Copy(out, in); err != nil { - return err - } - return out.Close() + if _, err := io.Copy(out, in); err != nil { + return err + } + return out.Close() } func firstExistingPath(paths ...string) string { diff --git a/eai_ap_app/frontend/src/api/officialAccount.js b/eai_ap_app/frontend/src/api/officialAccount.js new file mode 100644 index 0000000..bfbad1b --- /dev/null +++ b/eai_ap_app/frontend/src/api/officialAccount.js @@ -0,0 +1,19 @@ +import http from './http' + +export function createOfficialAccountTask(data) { + return http.post('/official-account/tasks', data) +} + +export function getOfficialAccountWorkflow(taskId) { + return http.get(`/official-account/tasks/${taskId}/workflow`) +} + +export function updateOfficialAccountTask(taskId, data) { + return http.put(`/official-account/tasks/${taskId}`, data) +} + +export function executeOfficialAccountWorkflowStep(taskId, stepKey, data) { + return http.post(`/official-account/tasks/${taskId}/steps/${stepKey}`, data, { + timeout: 0, + }) +} diff --git a/eai_ap_app/frontend/src/config/workbench.js b/eai_ap_app/frontend/src/config/workbench.js index 5d418c1..1baeb80 100644 --- a/eai_ap_app/frontend/src/config/workbench.js +++ b/eai_ap_app/frontend/src/config/workbench.js @@ -92,6 +92,20 @@ export const businessApps = [ stage: '报告汇总', progress: 76, }, + { + key: 'wechat-official-account', + label: '公众号助手', + tier: 'generic', + workerType: 'dw', + marketTag: '已安装', + route: '/apps/wechat-official-account', + summary: '热点选题、标题、提纲、正文四步创作工作流', + status: '4 个节点可执行', + risk: '0 个异常', + color: '#2563eb', + stage: '正文创作', + progress: 0, + }, { key: 'logistics-fulfillment', label: '履约跟单专员', diff --git a/eai_ap_app/frontend/src/layout/MainLayout.vue b/eai_ap_app/frontend/src/layout/MainLayout.vue index d745887..c19fbd9 100644 --- a/eai_ap_app/frontend/src/layout/MainLayout.vue +++ b/eai_ap_app/frontend/src/layout/MainLayout.vue @@ -49,16 +49,54 @@