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 ", 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) } }