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>
362 lines
13 KiB
Go
362 lines
13 KiB
Go
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)
|
||
}
|
||
}
|