Expand outreach filtering options
This commit is contained in:
+37
-1
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user