Jobmaker-LdPost/redator/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

210 lines
9.7 KiB
Go

package main
import (
"fmt"
"log"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"ldpost/shared/config"
"ldpost/shared/groq"
"ldpost/shared/state"
"ldpost/shared/workspace"
)
// ─── System prompts ───────────────────────────────────────────────────────────
const systemBase = `Você é o ghostwriter técnico de Ricardo, um Tech Lead brasileiro com 22+ anos de experiência em C#/.NET, especialista em Clean Architecture, IA local, agentes de IA e SAP BTP. Ricardo tem um livro publicado sobre C# e 6 anos de experiência ensinando.
PERFIL E VOZ DO AUTOR:
Ricardo escreve de forma direta, sem rodeios, com linguagem técnica mas acessível. Ele usa exemplos concretos, cita números reais quando tem, e não tem medo de opiniões fortes. Ele não escreve "como profissional apaixonado" nem usa frases corporativas vazias.
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, templates, ferramentas ou processos de trabalho, reflita essa perspectiva de forma natural e indireta. Não force em todo post. Nunca use a frase literal "a IA não substitui os humanos" — mostre isso pelo exemplo, pelo tom, pela experiência concreta que o Ricardo viveu.
Seu trabalho é escrever um RASCUNHO CRU do post — sem preocupação com hashtags, formatação LinkedIn ou CTA ainda. Foque em:
1. Extrair o máximo de valor técnico do resumo fornecido
2. Expandir com contexto que um leitor dev precisaria para entender
3. Ser específico: nomes de tecnologias, versões, números, trade-offs reais
4. NÃO inventar fatos — se o resumo não menciona, não coloque
5. Escrever em português brasileiro, tom informal-técnico
6. Tamanho: 300-500 palavras (o editor vai ajustar depois)
Retorne APENAS o rascunho em markdown simples. Sem cabeçalho, sem rodapé, sem explicação.`
var formatBlocks = map[string]string{
"como": `Estruture como: problema/contexto (1 parágrafo) → solução passo a passo (3-5 passos numerados, cada um com detalhe técnico) → resultado/benefício (1 parágrafo). Seja didático mas não condescendente.`,
"porque": `Estruture como: afirmação contraintuitiva forte (1 frase impactante) → o que a maioria pensa (1 parágrafo) → por que está errado/incompleto (2-3 argumentos com evidência do resumo) → sua perspectiva (1 parágrafo conclusivo). Seja direto e não se desculpe pela opinião.`,
"erro": `Estruture como: o erro específico que cometeu (concreto, não genérico) → por que aconteceu (contexto/pressão) → o impacto real (quanto tempo/dinheiro/frustração custou) → a solução que funcionou (específica, testada) → o que aprendeu. Use primeira pessoa, seja vulnerável mas técnico.`,
"comparacao": `Estruture como: o que estava comparando e por quê (contexto) → critérios de comparação (3-5 critérios objetivos) → resultado de cada critério (específico, com números se tiver) → conclusão com recomendação clara para cada cenário. Evite "depende" como resposta final — dê uma recomendação.`,
"checklist": `Estruture como: por que essa checklist existe (o problema que ela resolve) → os itens (5-7 itens, cada um com 1-2 frases de contexto explicando por que importa) → como usar na prática. Cada item deve ser acionável, não abstrato.`,
"bastidor": `Estruture como: o que está construindo e por que agora (contexto) → o estado atual (o que funciona, o que não funciona, o que está decidindo) → a próxima decisão ou desafio aberto → o que vem por aí. Seja honesto sobre incertezas — build in public funciona pela autenticidade.`,
}
func buildSystemPrompt(formato string) string {
block, ok := formatBlocks[formato]
if !ok {
block = formatBlocks["como"] // fallback
}
return systemBase + "\n\nESTRUTURA DO FORMATO '" + strings.ToUpper(formato) + "':\n" + block
}
func buildUserMessage(s *state.PostState, textoBody string) string {
var sb strings.Builder
fmt.Fprintf(&sb, "FORMATO: %s\n", s.Formato)
fmt.Fprintf(&sb, "TEMA: %s\n", s.TemaEscolhido)
if s.TrendReferencia != "" {
fmt.Fprintf(&sb, "TREND: %s\n", s.TrendReferencia)
}
if s.Categoria != "" {
fmt.Fprintf(&sb, "CATEGORIA: %s\n", s.Categoria)
}
sb.WriteString("\nRESUMO TÉCNICO DO AUTOR:\n")
sb.WriteString(textoBody)
return sb.String()
}
func wordCount(s string) int {
return len(strings.Fields(s))
}
// ─── Main ─────────────────────────────────────────────────────────────────────
func main() {
log.SetFlags(log.Ltime)
var (
flagPost string
flagWorkspace string
flagDryRun bool
flagTemp float64
)
root := &cobra.Command{
Use: "ldpost-redator --post <slug>",
Short: "Gera rascunho cru do post via Groq",
RunE: func(cmd *cobra.Command, args []string) error {
if flagPost == "" {
return fmt.Errorf("--post é obrigatório")
}
cfg := config.Load()
if flagWorkspace != "" {
cfg.Workspace = flagWorkspace
}
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 — rode ldpost-evaluator primeiro", flagPost, err)
}
s, err := state.LoadState(postPath)
if err != nil {
return fmt.Errorf("ler state: %w", err)
}
if !s.IsStatus(state.StatusWaitingRedator) {
log.Printf("[ERROR] status atual: %q — esperado: %q", s.Status, state.StatusWaitingRedator)
return fmt.Errorf("estado incorreto — rode o agente correto para o status atual")
}
log.Printf("[INFO] post=%s formato=%s", s.Slug, s.Formato)
// ── 2. Ler input/texto.md ─────────────────────────────────────
textoRaw, err := os.ReadFile(workspace.InputTextoPath(postPath))
if err != nil {
return fmt.Errorf("input/texto.md não encontrado: %w — rode ldpost-evaluator primeiro", err)
}
// Strip YAML front-matter
body := string(textoRaw)
if strings.HasPrefix(body, "---") {
if end := strings.Index(body[3:], "---"); end >= 0 {
body = body[end+6:]
}
}
textoBody := strings.TrimSpace(body)
wc := wordCount(textoBody)
log.Printf("[INFO] texto.md: %d chars, ~%d palavras", len(textoBody), wc)
if wc < 100 {
log.Printf("[WARN] resumo curto (%d palavras) — LLM fará o que pode", wc)
}
// ── 3. Build prompts ──────────────────────────────────────────
systemPrompt := buildSystemPrompt(s.Formato)
userMsg := buildUserMessage(s, textoBody)
// ── 4. Dry-run ────────────────────────────────────────────────
if flagDryRun {
fmt.Printf("=== DRY-RUN — ldpost-redator ===\n")
fmt.Printf("post: %s\n", flagPost)
fmt.Printf("formato: %s\n", s.Formato)
fmt.Printf("temp: %.1f\n", flagTemp)
fmt.Printf("\n--- SYSTEM ---\n%s\n", systemPrompt)
fmt.Printf("\n--- USER ---\n%s\n", userMsg)
return nil
}
// ── 5. Chamar Groq ────────────────────────────────────────────
log.Printf("[INFO] chamando Groq (temp=%.1f)...", flagTemp)
gc := groq.NewGroqClient(cfg.GroqAPIKey)
draft, err := gc.Chat(groq.TextModel, groq.TextMessages(systemPrompt, userMsg), flagTemp, 1000)
if err != nil {
return fmt.Errorf("groq: %w", err)
}
draftWC := wordCount(draft)
if draftWC < 100 {
log.Printf("[ERROR] output do LLM muito curto (%d palavras) — provável falha de API", draftWC)
log.Printf("[ERROR] use --dry-run para inspecionar o prompt")
return fmt.Errorf("rascunho inválido: %d palavras (mínimo 100)", draftWC)
}
// ── 6. Salvar work/redator-v1.md ─────────────────────────────
_, lastN := workspace.LatestVersionFile(postPath, "redator")
outPath := workspace.VersionedFile(postPath, "redator", lastN+1)
header := fmt.Sprintf("---\nversao: %d\nformato: %s\ngerado_em: %s\n---\n\n",
lastN+1, s.Formato, time.Now().Format(time.RFC3339))
content := header + draft
if err := os.WriteFile(outPath, []byte(content), 0644); err != nil {
return fmt.Errorf("salvar rascunho: %w", err)
}
// ── 7. Atualizar state ────────────────────────────────────────
s.Status = state.StatusWaitingEditor
s.SetEtapa("redator", state.EtapaDone)
s.SetEtapa("editor", state.EtapaWaiting)
if err := state.SaveState(postPath, s); err != nil {
return fmt.Errorf("atualizar state: %w", err)
}
fmt.Printf("✅ Rascunho gerado: %s\n", outPath)
fmt.Printf(" Formato: %s | Palavras: ~%d\n", s.Formato, draftWC)
fmt.Printf(" Próximo: ldpost-editor --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().Float64Var(&flagTemp, "temp", 0.7, "Temperatura do LLM (0.0-1.0)")
if err := root.Execute(); err != nil {
os.Exit(1)
}
}