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>
210 lines
9.7 KiB
Go
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)
|
|
}
|
|
}
|