Expand outreach filtering options

This commit is contained in:
alexandrev-tibco
2026-02-20 18:08:06 +01:00
parent 3a66d687e1
commit 7ab65adb11
5 changed files with 297 additions and 24 deletions
+37 -1
View File
@@ -92,6 +92,7 @@ func main() {
fmt.Fprintln(os.Stderr, " ideas draft <idea_id>")
fmt.Fprintln(os.Stderr, " ideas publish <idea_id>")
fmt.Fprintln(os.Stderr, " ideas delete <idea_id>")
fmt.Fprintln(os.Stderr, " article [en|es] <idea text> [--approach <approach text>]")
fmt.Fprintln(os.Stderr, " outreach [status]")
fmt.Fprintln(os.Stderr, " outreach generate")
fmt.Fprintln(os.Stderr, " outreach delete <id>")
@@ -108,6 +109,7 @@ func main() {
fmt.Fprintln(os.Stderr, "Examples:")
fmt.Fprintln(os.Stderr, " wp-sk-cli pending")
fmt.Fprintln(os.Stderr, " wp-sk-cli changes 123")
fmt.Fprintln(os.Stderr, " wp-sk-cli article en Build a practical Kubernetes backup guide --approach Include a decision matrix and step-by-step rollout")
fmt.Fprintln(os.Stderr, " WPSK_API_URL=http://server:8080 wp-sk-cli pending")
fmt.Fprintln(os.Stderr, " wp-sk-cli approve 456 --api http://server:8080")
fmt.Fprintln(os.Stderr, " wp-sk-cli --site padre pending")
@@ -315,6 +317,40 @@ func executeCommand(c *client, cmd string, args []string) error {
status := args[0]
handleSimpleGet(c, "/api/v1/ideas?status="+status)
}
case "article":
if len(args) == 0 {
return fmt.Errorf("article requires text input: article [en|es] <idea text> [--approach <approach text>]")
}
lang := "en"
tokens := args
if args[0] == "en" || args[0] == "es" {
lang = args[0]
tokens = args[1:]
}
approachIdx := -1
for i, t := range tokens {
if t == "--approach" {
approachIdx = i
break
}
}
ideaTokens := tokens
approach := ""
if approachIdx >= 0 {
ideaTokens = tokens[:approachIdx]
if approachIdx+1 < len(tokens) {
approach = strings.TrimSpace(strings.Join(tokens[approachIdx+1:], " "))
}
}
idea := strings.TrimSpace(strings.Join(ideaTokens, " "))
if idea == "" {
return fmt.Errorf("article requires non-empty idea text")
}
handleSimplePost(c, "/api/v1/article/generate", map[string]interface{}{
"idea": idea,
"approach": approach,
"language": lang,
})
case "outreach":
if len(args) == 0 {
handleSimpleGet(c, "/api/v1/outreach")
@@ -400,7 +436,7 @@ func runInteractive(c *client) {
return
}
if line == "help" {
fmt.Println("Commands: health, version, pending [summary|text <draft_id>], optimize <id> [lang], status <job_id>, changes <post_id>, approve <draft_id>, reject <draft_id>, ideas [status|generate|draft <id>|publish <id>|delete <id>], outreach [status|generate|delete <id>], newsletter [status|generate|delete <id>], orphans, translate <post_id> [lang], audit [limit], exit")
fmt.Println("Commands: health, version, pending [summary|text <draft_id>], optimize <id> [lang], status <job_id>, changes <post_id>, approve <draft_id>, reject <draft_id>, ideas [status|generate|draft <id>|publish <id>|delete <id>], article [en|es] <idea text> [--approach <approach text>], outreach [status|generate|delete <id>], newsletter [status|generate|delete <id>], orphans, translate <post_id> [lang], audit [limit], exit")
continue
}
tokens := strings.Fields(line)
+41
View File
@@ -179,6 +179,7 @@ func (s *Server) Start() error {
router.HandleFunc("/api/v1/ideas/{id}/draft", s.handleGenerateIdeaDraft).Methods("POST")
router.HandleFunc("/api/v1/ideas/{id}/publish", s.handlePublishIdeaDraft).Methods("POST")
router.HandleFunc("/api/v1/ideas/{id}/delete", s.handleDeleteIdea).Methods("POST")
router.HandleFunc("/api/v1/article/generate", s.handleGenerateArticleFromInput).Methods("POST")
router.HandleFunc("/api/v1/drafts/{id}", s.handleGetDraft).Methods("GET")
router.HandleFunc("/api/v1/outreach", s.handleListOutreach).Methods("GET")
router.HandleFunc("/api/v1/outreach/generate", s.handleGenerateOutreach).Methods("POST")
@@ -1039,6 +1040,46 @@ func (s *Server) handleDeleteIdea(w http.ResponseWriter, r *http.Request) {
})
}
// handleGenerateArticleFromInput handles POST /api/v1/article/generate
func (s *Server) handleGenerateArticleFromInput(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
if site.IdeaSvc == nil {
http.Error(w, "idea service not configured", http.StatusNotImplemented)
return
}
var req struct {
Idea string `json:"idea"`
Approach string `json:"approach"`
Language string `json:"language"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request payload", http.StatusBadRequest)
return
}
draft, draftID, err := site.IdeaSvc.GenerateArticleFromInput(r.Context(), req.Idea, req.Approach, req.Language)
if err != nil {
http.Error(w, fmt.Sprintf("failed to generate article draft: %v", err), http.StatusBadRequest)
return
}
s.recordAudit(site, r, &storage.AuditRecord{
Action: "article_generated_from_input",
Summary: "Generated article draft from free-form command input",
DraftPostID: draftID,
Language: draft.Language,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "generated",
"draft_post_id": draftID,
"article": draft,
})
}
// handleListOutreach handles GET /api/v1/outreach?status=new
func (s *Server) handleListOutreach(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
+39 -1
View File
@@ -1534,7 +1534,7 @@ const refreshAll = async () => {
const commandHandlers = {
help: async () =>
"Commands: health, version, pending [summary|text <id>], approve <id>, reject <id>, ideas [status|generate|draft <id>|publish <id>|delete <id>], outreach [status|generate|delete <id>], newsletter [status|generate|delete <id>], broken-links [scan], image-audit [scan|fix <post_id>|sync <post_id> [lang]], seo-audit [scan], search <text> [--case] [--exact] [--regex] [--block] [--max=N] [--posts=N], orphans, translate <post_id> [lang], optimize <post_id> [lang], changes <post_id>, status <job_id>, audit [limit]",
"Commands: health, version, pending [summary|text <id>], approve <id>, reject <id>, ideas [status|generate|draft <id>|publish <id>|delete <id>], article [en|es] <idea text> [--approach <approach text>], outreach [status|generate|delete <id>], newsletter [status|generate|delete <id>], broken-links [scan], image-audit [scan|fix <post_id>|sync <post_id> [lang]], seo-audit [scan], search <text> [--case] [--exact] [--regex] [--block] [--max=N] [--posts=N], orphans, translate <post_id> [lang], optimize <post_id> [lang], changes <post_id>, status <job_id>, audit [limit]",
health: async () => JSON.stringify(await apiFetch("/api/v1/health"), null, 2),
version: async () => JSON.stringify(await apiFetch("/api/v1/health"), null, 2),
pending: async (args) => {
@@ -1576,6 +1576,43 @@ const commandHandlers = {
const status = args[0] || "new";
return JSON.stringify(await apiFetch(`/api/v1/ideas?status=${status}`), null, 2);
},
article: async (args) => {
if (!args.length) {
return "article [en|es] <idea text> [--approach <approach text>]";
}
let lang = "en";
let tokens = [...args];
if (tokens[0] === "en" || tokens[0] === "es") {
lang = tokens[0];
tokens = tokens.slice(1);
}
const approachIdx = tokens.indexOf("--approach");
let ideaTokens = tokens;
let approachText = "";
if (approachIdx >= 0) {
ideaTokens = tokens.slice(0, approachIdx);
approachText = tokens.slice(approachIdx + 1).join(" ").trim();
}
const ideaText = ideaTokens.join(" ").trim();
if (!ideaText) {
return "article [en|es] <idea text> [--approach <approach text>]";
}
return JSON.stringify(
await apiFetch("/api/v1/article/generate", {
method: "POST",
body: JSON.stringify({
idea: ideaText,
approach: approachText,
language: lang,
}),
}),
null,
2
);
},
outreach: async (args) => {
if (args[0] === "generate") {
return JSON.stringify(await apiFetch("/api/v1/outreach/generate", { method: "POST" }), null, 2);
@@ -1692,6 +1729,7 @@ const commandHandlers = {
const commandSuggestions = [
{ label: "Pending drafts", command: "pending" },
{ label: "Generate article from text", command: "article en your article idea --approach practical step-by-step angle" },
{ label: "Run SEO audit", command: "seo-audit scan" },
{ label: "Broken links scan", command: "broken-links scan" },
{ label: "Image audit scan", command: "image-audit scan" },
+90 -22
View File
@@ -361,6 +361,69 @@ func (s *Service) DeleteIdea(ctx context.Context, id string) error {
return s.store.Delete(id)
}
// GenerateArticleFromInput creates one article draft from free-form idea/approach text
// using the same embedded article prompt template used by idea drafts.
func (s *Service) GenerateArticleFromInput(ctx context.Context, ideaText, approachText, lang string) (*models.ArticleDraft, int, error) {
ideaText = strings.TrimSpace(ideaText)
approachText = strings.TrimSpace(approachText)
if ideaText == "" {
return nil, 0, fmt.Errorf("idea text is required")
}
lang = strings.ToLower(strings.TrimSpace(lang))
if lang == "" {
lang = "en"
}
if lang != "en" && lang != "es" {
return nil, 0, fmt.Errorf("unsupported language %q (use en or es)", lang)
}
styleSamples, err := s.fetchStyleSamples(ctx)
if err != nil {
s.logger.Warn("Failed to fetch style samples", "error", err)
}
summary := ideaText
if approachText != "" {
summary = summary + "\nApproach: " + approachText
}
prompt, err := BuildArticlePrompt(ArticlePromptData{
Language: languageLabel(lang),
IdeaTitle: deriveIdeaTitle(ideaText),
IdeaSummary: summary,
PrimaryKW: "",
Sources: nil,
StyleSamples: styleSamples,
SiteName: s.profile.Name,
SiteURL: s.profile.URL,
SiteTopics: strings.Join(s.profile.Topics, ", "),
SiteAudience: s.profile.Audience,
SiteVoice: s.profile.Voice,
ContentGuidelines: s.profile.ContentGuidelines,
})
if err != nil {
return nil, 0, fmt.Errorf("failed to build article prompt: %w", err)
}
draft, err := s.llm.GenerateArticleDraft(ctx, prompt)
if err != nil {
return nil, 0, err
}
draft.Language = lang
if s.dryRun {
s.logger.Info("Dry-run enabled, skipping WordPress draft creation for free-form article generation")
return draft, 0, nil
}
draftID, err := s.createWordPressDraft(ctx, draft, lang)
if err != nil {
return nil, 0, err
}
return draft, draftID, nil
}
// GenerateDrafts creates draft posts in WordPress for the idea.
func (s *Service) GenerateDrafts(ctx context.Context, id string) (*models.PostIdea, error) {
idea, err := s.store.Get(id)
@@ -422,17 +485,7 @@ func (s *Service) GenerateDrafts(ctx context.Context, id string) (*models.PostId
} else {
if draft, ok := drafts["en"]; ok {
var err error
enID, err = s.wpClient.CreateDraftPost(ctx, wordpress.PostCreate{
Status: "draft",
Title: draft.Title,
Content: draft.ContentHTML,
Excerpt: chooseExcerpt(draft),
Lang: "en",
Meta: map[string]interface{}{
wordpress.MetaRankMathTitle: draft.SEOTitle,
wordpress.MetaRankMathDescription: draft.MetaDescription,
},
})
enID, err = s.createWordPressDraft(ctx, draft, "en")
if err != nil {
return nil, fmt.Errorf("failed to create EN draft: %w", err)
}
@@ -440,17 +493,7 @@ func (s *Service) GenerateDrafts(ctx context.Context, id string) (*models.PostId
if draft, ok := drafts["es"]; ok {
var err error
esID, err = s.wpClient.CreateDraftPost(ctx, wordpress.PostCreate{
Status: "draft",
Title: draft.Title,
Content: draft.ContentHTML,
Excerpt: chooseExcerpt(draft),
Lang: "es",
Meta: map[string]interface{}{
wordpress.MetaRankMathTitle: draft.SEOTitle,
wordpress.MetaRankMathDescription: draft.MetaDescription,
},
})
esID, err = s.createWordPressDraft(ctx, draft, "es")
if err != nil {
return nil, fmt.Errorf("failed to create ES draft: %w", err)
}
@@ -614,6 +657,31 @@ func languageLabel(code string) string {
}
}
func (s *Service) createWordPressDraft(ctx context.Context, draft *models.ArticleDraft, lang string) (int, error) {
return s.wpClient.CreateDraftPost(ctx, wordpress.PostCreate{
Status: "draft",
Title: draft.Title,
Content: draft.ContentHTML,
Excerpt: chooseExcerpt(draft),
Lang: lang,
Meta: map[string]interface{}{
wordpress.MetaRankMathTitle: draft.SEOTitle,
wordpress.MetaRankMathDescription: draft.MetaDescription,
},
})
}
func deriveIdeaTitle(text string) string {
normalized := strings.TrimSpace(strings.ReplaceAll(text, "\n", " "))
if normalized == "" {
return "New article idea"
}
if len(normalized) <= 90 {
return normalized
}
return strings.TrimSpace(normalized[:90]) + "..."
}
func chooseExcerpt(draft *models.ArticleDraft) string {
if draft.Excerpt != "" {
return draft.Excerpt
+90
View File
@@ -329,3 +329,93 @@ func TestGenerateOutreachSuggestionsDeduplicatesAndSkipsExistingLinks(t *testing
t.Fatalf("expected 2 total outreach suggestions (seed + new), got %d", len(all))
}
}
func TestGenerateArticleFromInputCreatesDraftWithEmbeddedPromptStyle(t *testing.T) {
created := 0
wpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/wp-json/wp/v2/posts":
posts := []map[string]interface{}{
{
"id": 21,
"title": map[string]interface{}{"rendered": "Existing Style Sample"},
"excerpt": map[string]interface{}{"rendered": "Style sample excerpt"},
"content": map[string]interface{}{"rendered": "Style sample content"},
"status": "publish",
},
}
_ = json.NewEncoder(w).Encode(posts)
return
case r.Method == http.MethodPost && r.URL.Path == "/wp-json/wp/v2/posts":
created++
var payload map[string]interface{}
_ = json.NewDecoder(r.Body).Decode(&payload)
if payload["status"] != "draft" {
t.Fatalf("expected draft status, got %v", payload["status"])
}
resp := map[string]interface{}{
"id": 909,
"status": "draft",
"title": map[string]interface{}{"rendered": "Draft"},
}
_ = json.NewEncoder(w).Encode(resp)
return
default:
w.WriteHeader(http.StatusNotFound)
return
}
}))
defer wpServer.Close()
redditClient := reddit.NewClient("http://unused.local", nil)
wpClient := wordpress.NewClient(wpServer.URL, "user", "pass", 0, nil)
ideasStore := storage.NewIdeaStorage(t.TempDir(), nil)
if err := ideasStore.Initialize(); err != nil {
t.Fatalf("init ideas storage: %v", err)
}
outreachStore := storage.NewOutreachStorage(t.TempDir(), nil)
if err := outreachStore.Initialize(); err != nil {
t.Fatalf("init outreach storage: %v", err)
}
cfg := config.IdeasConfig{
Enabled: true,
IntervalHours: 24,
Subreddit: "kubernetes",
Listing: "top",
TimeRange: "day",
MaxRedditPosts: 5,
MaxIdeas: 1,
StyleSamplePosts: 1,
MinScore: 1,
MinComments: 1,
BasePath: t.TempDir(),
}
outreachCfg := config.OutreachConfig{
Enabled: false,
}
profile := config.SiteProfile{
Name: "Test Site",
URL: "https://example.com",
Topics: []string{"Kubernetes"},
Audience: "Engineers",
Voice: "Technical",
ContentGuidelines: []string{"Keep it practical."},
}
svc := NewService(wpClient, &fakeLLM{}, redditClient, ideasStore, cfg, outreachStore, outreachCfg, profile, []string{"en"}, false, nil)
draft, draftID, err := svc.GenerateArticleFromInput(context.Background(), "How to run Kubernetes backups safely", "Use a practical rollout and risk checklist", "en")
if err != nil {
t.Fatalf("GenerateArticleFromInput failed: %v", err)
}
if draft == nil {
t.Fatalf("expected draft")
}
if draftID != 909 {
t.Fatalf("expected draft id 909, got %d", draftID)
}
if created != 1 {
t.Fatalf("expected one WP draft creation, got %d", created)
}
}