Jobmaker-LdPost/editor/main.go
Ricardo Carneiro ea532659b0 feat: pipeline inicial ldpost-squad (6 agentes)
Pipeline completo de publicação no LinkedIn:
evaluator → redator → editor → art → director → publisher

- Seed com 37 posts em _sugestoes.md
- Sorteio de formato com N=3 bloqueados (format-history)
- Reciclagem mensal de posts com rotação de formato
- Revisão via Telegram com chat livre (Gemini 2.5 Flash)
- Publicação via LinkedIn API (OAuth2)
- Makefile com targets para Windows/Linux/ARM64

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 18:55:39 -03:00

362 lines
13 KiB
Go
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"bufio"
"fmt"
"log"
"os"
"strings"
"time"
"unicode/utf8"
"github.com/spf13/cobra"
"ldpost/shared/config"
"ldpost/shared/groq"
"ldpost/shared/state"
"ldpost/shared/workspace"
)
// ─── System prompt ────────────────────────────────────────────────────────────
const systemPromptEditor = `Você é um editor especializado em conteúdo técnico para LinkedIn. Seu trabalho é transformar um rascunho técnico cru em um post LinkedIn que seja compartilhável, legível e que preserve a voz do autor (Ricardo, Tech Lead brasileiro direto e técnico).
VOZ DO AUTOR:
Ricardo está construindo um curso sobre uso de IA como ferramenta produtiva para profissionais de tecnologia. Ele acredita e pratica que a IA potencializa humanos, não os substitui. Quando o post tocar em produtividade, automação, ferramentas ou processos, deixe essa perspectiva aparecer de forma natural — pelo exemplo, pelo resultado concreto, pela experiência vivida. Nunca use a frase literal. Nunca force em posts onde o tema não permite.
REGRAS ABSOLUTAS:
- Nunca usar: "mergulho profundo", "no cenário atual", "é importante destacar", "como profissional", "apaixonado por", "robusto", "abrangente"
- Parágrafos máximo de 3 linhas
- Linha em branco entre cada parágrafo/bloco
- Primeira linha deve parar o scroll (número, contradição, afirmação forte ou pergunta provocativa)
- Última linha: CTA de baixa fricção (pergunta, ou "salva pra depois se você trabalha com X")
- 3-5 hashtags no final, específicas do conteúdo
- Preservar os fatos e números do rascunho — não inventar nada
FORMATO DE RETORNO:
Retorne APENAS o post formatado. Sem explicação, sem "aqui está o post", sem markdown de cabeçalho.
Depois do post, em uma linha separada com "---", adicione:
CHECKLIST:
✅ ou ❌ Gancho para scroll
✅ ou ❌ Parágrafos curtos
✅ ou ❌ Frase salvável
✅ ou ❌ CTA de baixa fricção
✅ ou ❌ 3-5 hashtags específicas
✅ ou ❌ Sem frases robóticas`
// ─── Helpers ──────────────────────────────────────────────────────────────────
func isTerminal() bool {
fi, err := os.Stdin.Stat()
if err != nil {
return false
}
return (fi.Mode() & os.ModeCharDevice) != 0
}
func wordCount(s string) int {
return len(strings.Fields(s))
}
// parseOutput splits LLM response into post body and checklist section.
func parseOutput(raw string) (post, checklist string) {
raw = strings.TrimSpace(raw)
idx := strings.Index(raw, "\n---")
if idx < 0 {
return raw, ""
}
post = strings.TrimSpace(raw[:idx])
rest := strings.TrimSpace(raw[idx+4:])
rest = strings.TrimPrefix(rest, "---")
checklist = strings.TrimSpace(rest)
return
}
func printSeparatorDouble() {
fmt.Println(strings.Repeat("═", 51))
}
func printSeparatorSingle() {
fmt.Println(strings.Repeat("─", 51))
}
func printPost(post, checklist, slug string, version, ciclos int) {
wc := wordCount(post)
printSeparatorDouble()
fmt.Printf("ldpost-editor | Post: %s | Versão: %d | ~%d palavras\n", slug, version, wc)
printSeparatorDouble()
fmt.Println()
fmt.Println(post)
fmt.Println()
if checklist != "" {
printSeparatorSingle()
fmt.Println("Checklist automático:")
for _, line := range strings.Split(checklist, "\n") {
line = strings.TrimSpace(line)
if line != "" && line != "CHECKLIST:" {
fmt.Println(" ", line)
}
}
}
printSeparatorSingle()
if ciclos > 0 {
fmt.Printf("Ciclos de revisão: %d\n", ciclos)
printSeparatorSingle()
}
}
func printMenu() {
fmt.Println()
fmt.Println("O que fazemos?")
fmt.Println(" [A] Aprovar — salvar e avançar para arte")
fmt.Println(" [R] Reprovar — nova versão (abordagem diferente)")
fmt.Println(" [A1] Ajustar gancho")
fmt.Println(" [A2] Ajustar CTA")
fmt.Println(" [A3] Ajustar hashtags")
fmt.Println(" [A4] Digitar instrução livre")
fmt.Println(" [V] Ver rascunho original do redator")
fmt.Print("\nEscolha: ")
}
// buildRefinementPrompt builds the user message for a refinement call.
func buildRefinementPrompt(instruction, previousPost string) string {
return fmt.Sprintf("%s\n\nPost atual para referência:\n%s", instruction, previousPost)
}
// ─── Main ─────────────────────────────────────────────────────────────────────
func main() {
log.SetFlags(log.Ltime)
var (
flagPost string
flagWorkspace string
flagDryRun bool
flagMaxCiclos int
flagNoInteractive bool
)
root := &cobra.Command{
Use: "ldpost-editor --post <slug>",
Short: "Formata rascunho para LinkedIn com loop interativo no terminal",
RunE: func(cmd *cobra.Command, args []string) error {
if flagPost == "" {
return fmt.Errorf("--post é obrigatório")
}
cfg := config.Load()
if flagWorkspace != "" {
cfg.Workspace = flagWorkspace
}
// Non-TTY → dry-run automatically (unless --no-interactive bypasses this)
if !isTerminal() && !flagDryRun && !flagNoInteractive {
log.Printf("[INFO] não-TTY detectado — modo dry-run automático")
flagDryRun = true
}
if !flagDryRun {
if err := cfg.Validate("groq"); err != nil {
return err
}
}
// ── 1. Encontrar post e validar state ─────────────────────────
postPath, err := workspace.FindPostBySlug(cfg.Workspace, flagPost)
if err != nil {
return fmt.Errorf("post %q: %w", flagPost, err)
}
s, err := state.LoadState(postPath)
if err != nil {
return fmt.Errorf("ler state: %w", err)
}
if !s.IsStatus(state.StatusWaitingEditor) {
log.Printf("[ERROR] status atual: %q — esperado: %q", s.Status, state.StatusWaitingEditor)
return fmt.Errorf("estado incorreto — rode o agente correto para o status atual")
}
// ── 2. Identificar versão mais recente para usar como base ────
basePath, _ := workspace.LatestVersionFile(postPath, "editor")
if basePath == "" {
basePath, _ = workspace.LatestVersionFile(postPath, "redator")
}
if basePath == "" {
return fmt.Errorf("nenhum rascunho em %s — rode ldpost-redator primeiro", workspace.WorkPath(postPath))
}
baseData, err := os.ReadFile(basePath)
if err != nil {
return fmt.Errorf("ler base: %w", err)
}
baseDraft := string(baseData)
// Strip YAML front-matter if present
if strings.HasPrefix(baseDraft, "---") {
if end := strings.Index(baseDraft[3:], "---"); end >= 0 {
baseDraft = strings.TrimSpace(baseDraft[end+6:])
}
}
log.Printf("[INFO] base: %s (%d chars)", basePath, utf8.RuneCountInString(baseDraft))
// ── 3. Dry-run ────────────────────────────────────────────────
if flagDryRun {
fmt.Printf("=== DRY-RUN — ldpost-editor ===\n")
fmt.Printf("post: %s\n", flagPost)
fmt.Printf("base: %s\n", basePath)
fmt.Printf("formato: %s\n", s.Formato)
fmt.Printf("\n--- SYSTEM ---\n%s\n", systemPromptEditor)
fmt.Printf("\n--- USER ---\nFormate este rascunho para LinkedIn:\n\n%s\n", baseDraft)
return nil
}
gc := groq.NewGroqClient(cfg.GroqAPIKey)
scanner := bufio.NewScanner(os.Stdin)
_, lastEditorN := workspace.LatestVersionFile(postPath, "editor")
editorN := lastEditorN + 1
ciclos := 0
currentPost := ""
currentChecklist := ""
// redatorPath for [V] command
redatorPath, _ := workspace.LatestVersionFile(postPath, "redator")
// ── 4. First generation ───────────────────────────────────────
userMsg := "Formate este rascunho para LinkedIn:\n\n" + baseDraft
log.Printf("[INFO] gerando versão %d (temp=0.7)...", editorN)
raw, err := gc.Chat(groq.TextModel, groq.TextMessages(systemPromptEditor, userMsg), 0.7, 0)
if err != nil {
return fmt.Errorf("groq: %w", err)
}
currentPost, currentChecklist = parseOutput(raw)
outPath := workspace.VersionedFile(postPath, "editor", editorN)
if err := os.WriteFile(outPath, []byte(raw), 0644); err != nil {
return fmt.Errorf("salvar editor-v%d: %w", editorN, err)
}
// ── 5. Loop interativo (skip if --no-interactive) ─────────────
if flagNoInteractive {
printPost(currentPost, currentChecklist, flagPost, editorN, 0)
log.Printf("[INFO] --no-interactive: aprovando automaticamente versão %d", editorN)
} else {
loop:
for {
printPost(currentPost, currentChecklist, flagPost, editorN, ciclos)
if ciclos >= flagMaxCiclos {
fmt.Printf("\n⚠ %d ciclos de revisão — considere aprovar ou rejeitar.\n", ciclos)
}
printMenu()
if !scanner.Scan() {
break loop
}
choice := strings.ToUpper(strings.TrimSpace(scanner.Text()))
var instruction string
temp := 0.7
switch choice {
case "A":
break loop
case "V":
if redatorPath != "" {
data, err := os.ReadFile(redatorPath)
if err == nil {
printSeparatorDouble()
fmt.Printf("RASCUNHO ORIGINAL — %s\n", redatorPath)
printSeparatorDouble()
fmt.Println(string(data))
}
} else {
fmt.Println("(rascunho do redator não encontrado)")
}
continue loop
case "R":
instruction = fmt.Sprintf(
"Gere uma versão completamente diferente do post anterior. Mude o gancho, a estrutura e o tom, mas preserve os fatos. Versão anterior para referência:\n%s",
currentPost)
temp = 0.9
case "A1":
instruction = "Reescreva apenas o gancho (primeiras 1-2 linhas) tornando-o mais impactante — use número, contradição ou afirmação forte. Mantenha o resto do post exatamente igual."
case "A2":
instruction = "Reescreva apenas o CTA final (última linha antes das hashtags) com pergunta aberta ou convite a salvar. Mantenha o resto do post exatamente igual."
case "A3":
instruction = "Substitua as hashtags por 3-5 opções mais específicas e relevantes ao conteúdo técnico. Mantenha o resto do post exatamente igual."
case "A4":
fmt.Print("Instrução: ")
if !scanner.Scan() {
continue loop
}
instruction = strings.TrimSpace(scanner.Text())
if instruction == "" {
fmt.Println("(instrução vazia — ignorada)")
continue loop
}
default:
fmt.Printf("Opção inválida: %q\n", choice)
continue loop
}
// Generate new version
ciclos++
editorN++
refinedMsg := buildRefinementPrompt(instruction, currentPost)
log.Printf("[INFO] ciclo %d — gerando versão %d (temp=%.1f)...", ciclos, editorN, temp)
raw, err := gc.Chat(groq.TextModel, groq.TextMessages(systemPromptEditor, refinedMsg), temp, 0)
if err != nil {
log.Printf("[ERROR] groq ciclo %d: %v", ciclos, err)
fmt.Printf("Erro ao chamar Groq: %v\n", err)
continue loop
}
currentPost, currentChecklist = parseOutput(raw)
vPath := workspace.VersionedFile(postPath, "editor", editorN)
if err := os.WriteFile(vPath, []byte(raw), 0644); err != nil {
log.Printf("[WARN] salvar editor-v%d: %v", editorN, err)
}
}
}
// ── 6. Salvar aprovação ───────────────────────────────────────
finalPath := workspace.WorkPath(postPath) + "/editor-final.md"
if err := os.WriteFile(finalPath, []byte(currentPost), 0644); err != nil {
return fmt.Errorf("salvar editor-final.md: %w", err)
}
if err := os.WriteFile(workspace.OutputPostPath(postPath), []byte(currentPost), 0644); err != nil {
return fmt.Errorf("salvar output/post.md: %w", err)
}
now := time.Now()
s.Status = state.StatusWaitingArt
s.SetEtapa("editor", state.EtapaDone)
s.SetEtapa("art", state.EtapaWaiting)
s.Aprovacoes.Texto.Aprovado = true
s.Aprovacoes.Texto.Ciclos = ciclos
s.Aprovacoes.Texto.Timestamp = now
if err := state.SaveState(postPath, s); err != nil {
return fmt.Errorf("state: %w", err)
}
fmt.Printf("\n✅ Post aprovado após %d ciclo(s).\n", ciclos)
fmt.Printf(" Salvo: %s\n", finalPath)
fmt.Printf(" Próximo: ldpost-art --post %s\n", flagPost)
return nil
},
}
root.Flags().StringVar(&flagPost, "post", "", "Slug do post (obrigatório)")
root.Flags().StringVar(&flagWorkspace, "workspace", "", "Override de LDPOST_WORKSPACE")
root.Flags().BoolVar(&flagDryRun, "dry-run", false, "Mostra prompt sem chamar Groq")
root.Flags().IntVar(&flagMaxCiclos, "max-ciclos", 5, "Máximo de ciclos antes de alertar")
root.Flags().BoolVar(&flagNoInteractive, "no-interactive", false, "Gera e aprova automaticamente sem loop interativo")
if err := root.Execute(); err != nil {
os.Exit(1)
}
}