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>
This commit is contained in:
Ricardo Carneiro 2026-05-03 18:55:39 -03:00
commit ea532659b0
40 changed files with 6017 additions and 0 deletions

21
.env.example Normal file
View File

@ -0,0 +1,21 @@
# Groq — https://console.groq.com
GROQ_API_KEY=
# Gemini — https://aistudio.google.com/app/apikey
GEMINI_API_KEY=
# Telegram — @BotFather → /newbot
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
# LinkedIn — gerado via: ldpost-publisher --auth
LINKEDIN_ACCESS_TOKEN=
# Workspace — pasta raiz onde os posts ficam
# Windows: C:\Textos-Linkedin
# Linux/Termux: /data/data/com.termux/files/home/workspace
LDPOST_WORKSPACE=
# Crop do watermark Gemini (padrão 48px — ajuste se necessário)
# LDPOST_CROP_BOTTOM=48
# LDPOST_CROP_RIGHT=48

20
.gitignore vendored Normal file
View File

@ -0,0 +1,20 @@
# Secrets
.env
.env.*
!.env.example
# Compiled binaries
bin/
*.exe
# Workspace — conteúdo pessoal dos posts
workspace/
workspace-test/
# Go build cache
*.test
*.out
# OS
.DS_Store
Thumbs.db

179
INSTRUCOES.md Normal file
View File

@ -0,0 +1,179 @@
# ldpost-squad — Instruções Gerais
---
## Princípio Central: Testabilidade Independente
**Cada CLI deve rodar de forma isolada, sem depender que outro CLI tenha rodado antes.**
Regras:
- Toda CLI aceita `--post <slug>` como entrada principal
- Toda CLI aceita `--dry-run` para operar sem side effects (sem salvar arquivo, sem chamar API externa)
- Toda CLI pode ser testada com um fixture manual (arquivo criado à mão no workspace)
- Nenhuma CLI assume estado global — lê e escreve apenas no workspace do post
---
## Workspace Layout
```
$LDPOST_WORKSPACE/
<slug>/
post.json ← metadados: tema, formato, imagens desc, status
draft.md ← rascunho cru (gerado pelo redator)
final.md ← versão formatada LinkedIn (gerado pelo editor)
img/
cover.png ← imagem principal
slide-*.png ← slides do carrossel (se aplicável)
status.json ← pipeline state: pending|approved|published + URL
```
**Como testar qualquer CLI isoladamente:**
```bash
# Crie o fixture mínimo manualmente
mkdir -p "$LDPOST_WORKSPACE/meu-slug"
echo '{"slug":"meu-slug","topic":"IA no RH","format":"lista"}' \
> "$LDPOST_WORKSPACE/meu-slug/post.json"
# Agora rode qualquer CLI contra esse slug
ldpost-redator --post meu-slug --dry-run
ldpost-editor --post meu-slug --no-interactive
ldpost-art --post meu-slug --dry-run
ldpost-director --post meu-slug --skip-telegram
ldpost-publisher --post meu-slug --dry-run
```
---
## Variáveis de Ambiente
Arquivo `.env` na raiz do workspace **ou** exportadas no shell.
```env
# Obrigatórias
GROQ_API_KEY= # modelo rápido (redator, evaluator)
GEMINI_API_KEY= # imagens e análise (art, editor)
LDPOST_WORKSPACE=C:\Textos-LinkedIn
# Telegram (director)
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
# LinkedIn (publisher) — opcional, fallback manual
LINKEDIN_ACCESS_TOKEN=
LINKEDIN_CLIENT_ID=
LINKEDIN_CLIENT_SECRET=
```
Cada CLI deve chamar `godotenv.Load()` no init para carregar `.env` automaticamente se presente.
---
## Estrutura de Módulo Go
Cada CLI é um módulo Go **independente** dentro de `C:\gocode\jobmaker-ldpost\`:
```
jobmaker-ldpost/
shared/ ← módulo compartilhado (go.mod: ldpost/shared)
evaluator/ ← go.mod: ldpost/evaluator
redator/ ← go.mod: ldpost/redator
editor/ ← go.mod: ldpost/editor
art/ ← go.mod: ldpost/art
director/ ← go.mod: ldpost/director
publisher/ ← go.mod: ldpost/publisher
```
CLIs dependem de `ldpost/shared` via `replace` no `go.mod`:
```
require ldpost/shared v0.0.0
replace ldpost/shared => ../shared
```
---
## Convenções de Código
### Flags padrão (toda CLI implementa)
| Flag | Tipo | Descrição |
|------|------|-----------|
| `--post` | string | Slug do post (obrigatória na maioria) |
| `--dry-run` | bool | Opera sem salvar/publicar |
| `--model` | string | Override do modelo LLM |
| `--workspace` | string | Override de `LDPOST_WORKSPACE` |
| `--verbose` | bool | Log detalhado |
### Saída padrão
- Sucesso: imprime caminho do arquivo gerado ou URL publicada
- Erro: `fmt.Fprintf(os.Stderr, "erro: %v\n", err)` + `os.Exit(1)`
- Dry-run: imprime conteúdo que seria gerado/enviado
### Arquivo `post.json` (schema mínimo)
```json
{
"slug": "ia-no-rh-2026",
"topic": "IA no recrutamento em 2026",
"format": "lista",
"images": [
{"index": 1, "prompt": "robô entrevistando humano, estilo flat design"},
{"index": 2, "prompt": "dashboard de triagem de currículos com IA"}
],
"created_at": "2026-05-02T11:00:00Z"
}
```
### Arquivo `status.json`
```json
{
"slug": "ia-no-rh-2026",
"pipeline_status": "pending",
"steps_completed": ["evaluated", "drafted", "edited"],
"approved_at": null,
"published_url": null
}
```
---
## Padrões do jobmaker-squad (reutilizar)
Referência: `C:\gocode\jobmaker-squad\`
| Padrão | Onde está | O que reusar |
|--------|-----------|-------------|
| Gemini API calls | `jobmaker-redator/main.go` | Stream + retry pattern |
| Imagen (geração de imagem) | `jobmaker-art/main.go` | Gemini Imagen 3.0 call |
| Telegram bot | `jobmaker-editor/internal/telegram/` | Keyboard inline, await response |
| Config loader | `jobmaker-editor/internal/config/` | `.env` + flags merge |
| Vector store local | `jobmaker-editor/internal/vectorstore/` | chromem-go pattern |
---
## Como Rodar Testes
```bash
# Cada CLI
cd evaluator && go test ./... -v
# Shared
cd shared && go test ./... -v
# Smoke test completo (requer .env configurado)
./scripts/smoke-test.sh meu-slug
```
---
## Ordem de Implementação
Seguir essa ordem — cada passo desbloqueia o próximo:
1. `shared/` — structs + workspace utils
2. `evaluator/` — sem este, nenhum slug existe
3. `redator/` — depende de `post.json` do evaluator
4. `editor/` — depende de `draft.md` do redator
5. `art/` — depende de `post.json` (descriptions de imagem)
6. `director/` — depende de `final.md` + imagens
7. `publisher/` — depende de `status.json` aprovado
Mas cada um pode ser desenvolvido/testado **isoladamente** com fixtures manuais.

60
Makefile Normal file
View File

@ -0,0 +1,60 @@
AGENTS := evaluator redator editor art director publisher
BIN := ./bin
# ── Detecta OS para extensão dos binários ─────────────────────────────────────
ifeq ($(OS),Windows_NT)
EXT := .exe
else
EXT :=
endif
.PHONY: all build build-windows build-linux build-arm64 clean seed-inbox
# Build para o OS atual
all: build
build:
@mkdir -p $(BIN)
@$(foreach agent,$(AGENTS), \
echo " build $(agent)..." && \
go build -o $(BIN)/ldpost-$(agent)$(EXT) ./$(agent)/ ; \
)
@echo "Done → $(BIN)/"
# Cross-compile explícito
build-windows:
@mkdir -p $(BIN)
@$(foreach agent,$(AGENTS), \
echo " [windows/amd64] $(agent)" && \
GOOS=windows GOARCH=amd64 go build -o $(BIN)/ldpost-$(agent).exe ./$(agent)/ ; \
)
build-linux:
@mkdir -p $(BIN)
@$(foreach agent,$(AGENTS), \
echo " [linux/amd64] $(agent)" && \
GOOS=linux GOARCH=amd64 go build -o $(BIN)/ldpost-$(agent) ./$(agent)/ ; \
)
build-arm64:
@mkdir -p $(BIN)
@$(foreach agent,$(AGENTS), \
echo " [linux/arm64] $(agent)" && \
GOOS=linux GOARCH=arm64 go build -o $(BIN)/ldpost-$(agent) ./$(agent)/ ; \
)
# Testes
test:
go test ./shared/...
# Copia _sugestoes.md do seed para o workspace
seed-inbox:
@if [ -z "$(LDPOST_WORKSPACE)" ]; then \
echo "LDPOST_WORKSPACE não definido — exporte ou copie .env.example para .env"; exit 1; \
fi
@mkdir -p "$(LDPOST_WORKSPACE)/_inbox"
@cp seed/_sugestoes.md "$(LDPOST_WORKSPACE)/_inbox/_sugestoes.md"
@echo "Seed copiado para $(LDPOST_WORKSPACE)/_inbox/_sugestoes.md"
clean:
rm -f $(BIN)/ldpost-*

154
STATUS.md Normal file
View File

@ -0,0 +1,154 @@
# ldpost-squad — Status do Projeto
> Atualizado: 2026-05-02
> Todos os itens devem ser testáveis de forma independente via CLI.
---
## Fase 0 — Spec & Arquitetura
| Item | Status |
|------|--------|
| `00-arquitetura-mestre.md` — contratos, convenções, estrutura | [ ] |
| `00b-shared-module.md` — módulo compartilhado | [ ] |
| `.env.example` com todas as variáveis necessárias | [ ] |
| `seed/_sugestoes.md` — 30 posts com resumo + 2 imagens cada | [ ] |
---
## Módulo Compartilhado (`shared/`)
| Item | Status |
|------|--------|
| Diretório + `go.mod` | [ ] |
| Estruturas comuns (`Post`, `Slug`, `WorkspaceLayout`) | [ ] |
| Utilitários de workspace (resolver paths, criar dirs) | [ ] |
| Helper `.env` / config loader | [ ] |
| Testável: `go test ./shared/...` | [ ] |
---
## CLI 1 — `ldpost-evaluator`
Busca trends, sorteia formato, cria post stub no workspace.
| Item | Status |
|------|--------|
| Diretório + `go.mod` | [ ] |
| Integração Groq/Gemini para buscar trends | [ ] |
| Sorteio de formato (carrossel / artigo / lista / storytelling) | [ ] |
| Cria `$LDPOST_WORKSPACE/<slug>/post.json` com metadados | [ ] |
| Flag `--topic` para forçar tema manual | [ ] |
| Flag `--format` para forçar formato | [ ] |
| Flag `--dry-run` imprime sem salvar | [ ] |
| Testável: `ldpost-evaluator --topic "IA no RH" --dry-run` | [ ] |
| Testes escritos | [ ] |
---
## CLI 2 — `ldpost-redator`
Gera rascunho do post a partir do stub criado pelo evaluator.
| Item | Status |
|------|--------|
| Diretório + `go.mod` | [ ] |
| Lê `$LDPOST_WORKSPACE/<slug>/post.json` | [ ] |
| Gera rascunho via Groq/Gemini | [ ] |
| Salva `$LDPOST_WORKSPACE/<slug>/draft.md` | [ ] |
| Flag `--post <slug>` (obrigatória) | [ ] |
| Flag `--model` para escolher modelo LLM | [ ] |
| Flag `--dry-run` imprime sem salvar | [ ] |
| Testável: `ldpost-redator --post meu-slug --dry-run` | [ ] |
| Testes escritos | [ ] |
---
## CLI 3 — `ldpost-editor`
Formata para LinkedIn + loop de revisão interativo.
| Item | Status |
|------|--------|
| Diretório + `go.mod` | [ ] |
| Lê `draft.md` do workspace do post | [ ] |
| Aplica formatação LinkedIn (emojis, quebras, hashtags) | [ ] |
| Loop de revisão: mostra diff, aceita/rejeita/refina | [ ] |
| Salva `$LDPOST_WORKSPACE/<slug>/final.md` | [ ] |
| Flag `--post <slug>` (obrigatória) | [ ] |
| Flag `--no-interactive` para modo batch | [ ] |
| Testável: `ldpost-editor --post meu-slug --no-interactive` | [ ] |
| Testes escritos | [ ] |
---
## CLI 4 — `ldpost-art`
Gera e cropa imagens para o post.
| Item | Status |
|------|--------|
| Diretório + `go.mod` | [ ] |
| Lê descrições de imagem do `post.json` | [ ] |
| Gera imagens via Gemini Imagen (ou Groq) | [ ] |
| Cropa para formato LinkedIn (1200x627 ou 1080x1080) | [ ] |
| Salva `$LDPOST_WORKSPACE/<slug>/img/*.png` | [ ] |
| Flag `--post <slug>` (obrigatória) | [ ] |
| Flag `--format square|landscape` (default: landscape) | [ ] |
| Flag `--dry-run` gera prompt sem chamar API | [ ] |
| Testável: `ldpost-art --post meu-slug --dry-run` | [ ] |
| Testes escritos | [ ] |
---
## CLI 5 — `ldpost-director`
Aprovação final via Telegram.
| Item | Status |
|------|--------|
| Diretório + `go.mod` | [ ] |
| Lê `final.md` + imagens do workspace | [ ] |
| Envia preview completo via Telegram | [ ] |
| Aguarda resposta: aprovar / rejeitar / editar | [ ] |
| Salva status em `$LDPOST_WORKSPACE/<slug>/status.json` | [ ] |
| Flag `--post <slug>` (obrigatória) | [ ] |
| Flag `--skip-telegram` aprova localmente sem bot | [ ] |
| Testável: `ldpost-director --post meu-slug --skip-telegram` | [ ] |
| Testes escritos | [ ] |
---
## CLI 6 — `ldpost-publisher`
Publica no LinkedIn.
| Item | Status |
|------|--------|
| Diretório + `go.mod` | [ ] |
| Lê `final.md` + imagens + `status.json` (deve estar approved) | [ ] |
| Publica via LinkedIn API (ou imprime instruções manuais) | [ ] |
| Registra URL do post publicado em `status.json` | [ ] |
| Flag `--post <slug>` (obrigatória) | [ ] |
| Flag `--manual` imprime conteúdo formatado sem publicar | [ ] |
| Flag `--dry-run` valida sem publicar | [ ] |
| Testável: `ldpost-publisher --post meu-slug --dry-run` | [ ] |
| Testes escritos | [ ] |
---
## Integração End-to-End
| Item | Status |
|------|--------|
| Script de pipeline completo (`run-all.sh` / `.ps1`) | [ ] |
| Testado com post real do zero ao publicado | [ ] |
| Documentação de troubleshooting | [ ] |
---
## Legenda
- `[ ]` Não iniciado
- `[~]` Em progresso
- `[x]` Concluído

18
art/go.mod Normal file
View File

@ -0,0 +1,18 @@
module ldpost/art
go 1.22
require (
github.com/disintegration/imaging v1.6.2
github.com/spf13/cobra v1.8.0
ldpost/shared v0.0.0
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
)
replace ldpost/shared => ../shared

17
art/go.sum Normal file
View File

@ -0,0 +1,17 @@
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

504
art/main.go Normal file
View File

@ -0,0 +1,504 @@
package main
import (
"context"
"encoding/json"
"fmt"
"image"
"log"
"os"
"path/filepath"
"strings"
"github.com/disintegration/imaging"
"github.com/spf13/cobra"
"ldpost/shared/config"
"ldpost/shared/gemini"
"ldpost/shared/groq"
"ldpost/shared/state"
"ldpost/shared/telegram"
"ldpost/shared/workspace"
)
// ─── Types ────────────────────────────────────────────────────────────────────
type slidePrompts struct {
DescricaoOriginal string `json:"descricao_original"`
PromptGemini string `json:"prompt_gemini"`
}
type artPromptsFile struct {
Slide1 slidePrompts `json:"slide1"`
Slide2 slidePrompts `json:"slide2"`
}
type artChoices struct {
Slide1 string // "bottom" or "right"
Slide2 string
}
// ─── Text parsing ─────────────────────────────────────────────────────────────
// parseImageSection extracts content under "## Imagem N" heading.
func parseImageSection(text, heading string) string {
lines := strings.Split(text, "\n")
inSection := false
var sb strings.Builder
for _, line := range lines {
if strings.HasPrefix(line, "## ") {
title := strings.TrimSpace(strings.TrimPrefix(line, "## "))
if strings.EqualFold(title, heading) {
inSection = true
continue
} else if inSection {
break
}
}
if inSection {
sb.WriteString(line + "\n")
}
}
return strings.TrimSpace(sb.String())
}
// firstNWords returns first n words from a string.
func firstNWords(s string, n int) string {
words := strings.Fields(s)
if len(words) <= n {
return s
}
return strings.Join(words[:n], " ")
}
// ─── Groq prompt builder ──────────────────────────────────────────────────────
const systemPromptImgTranslate = `Você converte descrições de imagens em prompts otimizados para geração com Gemini Image Generation.
Regras:
- Prompt em inglês
- Estilo: clean, professional, tech-focused, suitable for LinkedIn carousel
- Incluir: composição, estilo visual, paleta de cores sugerida (azul/branco/cinza para tech)
- Evitar: rostos humanos, texto em destaque (o Gemini erra texto), logos de marcas reais
- Máximo 100 palavras
- Retornar APENAS o prompt, sem explicação`
func buildGeminiPrompt(gc *groq.GroqClient, postContext, desc string) (string, error) {
system := systemPromptImgTranslate + "\n\nContexto do post: " + firstNWords(postContext, 50)
user := "Descrição original: " + desc
prompt, err := gc.Chat(groq.TextModel, groq.TextMessages(system, user), 0.4, 150)
if err != nil {
return "", fmt.Errorf("traduzir prompt: %w", err)
}
return strings.TrimSpace(prompt), nil
}
// ─── Image helpers ────────────────────────────────────────────────────────────
// findInputImage looks for input/imagemN in common formats.
func findInputImage(inputPath, base string) string {
for _, ext := range []string{".png", ".jpg", ".jpeg", ".webp"} {
p := filepath.Join(inputPath, base+ext)
if _, err := os.Stat(p); err == nil {
return p
}
}
return ""
}
func cropBottomVariant(img image.Image, px int) image.Image {
b := img.Bounds()
newH := b.Max.Y - b.Min.Y - px
if newH <= 0 {
return img
}
return imaging.Crop(img, image.Rect(b.Min.X, b.Min.Y, b.Max.X, b.Min.Y+newH))
}
func cropRightVariant(img image.Image, px int) image.Image {
b := img.Bounds()
newW := b.Max.X - b.Min.X - px
if newW <= 0 {
return img
}
return imaging.Crop(img, image.Rect(b.Min.X, b.Min.Y, b.Min.X+newW, b.Max.Y))
}
// saveVariants crops bottom and right variants, returns (bottomPath, rightPath).
func saveVariants(rawPath, artRawDir, name string, cropBottom, cropRight int) (string, string, error) {
img, err := imaging.Open(rawPath)
if err != nil {
return "", "", fmt.Errorf("abrir %s: %w", rawPath, err)
}
bottomPath := filepath.Join(artRawDir, name+"-crop-bottom.png")
rightPath := filepath.Join(artRawDir, name+"-crop-right.png")
if err := imaging.Save(cropBottomVariant(img, cropBottom), bottomPath); err != nil {
return "", "", fmt.Errorf("salvar crop-bottom: %w", err)
}
if err := imaging.Save(cropRightVariant(img, cropRight), rightPath); err != nil {
return "", "", fmt.Errorf("salvar crop-right: %w", err)
}
return bottomPath, rightPath, nil
}
// ─── Telegram flow ────────────────────────────────────────────────────────────
func sendArtPreview(bot *telegram.Bot, slug string,
s1bottom, s1right, s2bottom, s2right string) error {
if _, err := bot.SendMessage(fmt.Sprintf(
"🎨 <b>ldpost-art</b> | <code>%s</code>\n\nGerei 2 imagens para o carrossel.\nEscolha a variante de cada uma:",
slug)); err != nil {
return fmt.Errorf("msg intro: %w", err)
}
if err := bot.SendMediaGroup([]telegram.MediaFile{
{Path: s1bottom, Caption: "Slide 1 — A) Corte de baixo"},
{Path: s1right, Caption: "Slide 1 — B) Corte da direita"},
}); err != nil {
log.Printf("[WARN] media group slide1: %v", err)
}
if err := bot.SendMediaGroup([]telegram.MediaFile{
{Path: s2bottom, Caption: "Slide 2 — A) Corte de baixo"},
{Path: s2right, Caption: "Slide 2 — B) Corte da direita"},
}); err != nil {
log.Printf("[WARN] media group slide2: %v", err)
}
buttons := [][]telegram.InlineButton{
{
{Text: "Slide 1: Corte de baixo", CallbackData: "s1_bottom"},
{Text: "Slide 1: Corte da direita", CallbackData: "s1_right"},
},
{
{Text: "Slide 2: Corte de baixo", CallbackData: "s2_bottom"},
{Text: "Slide 2: Corte da direita", CallbackData: "s2_right"},
},
{
{Text: "🔄 Regenerar tudo", CallbackData: "regenerar"},
{Text: "✅ Confirmar escolhas", CallbackData: "confirmar"},
},
}
if _, err := bot.SendMessageWithKeyboard("Selecione as variantes preferidas:", buttons); err != nil {
return fmt.Errorf("botões: %w", err)
}
return nil
}
// waitChoices polls Telegram until both slides are chosen and confirmed.
// Returns ("regenerar", {}) if user requests full regeneration.
func waitChoices(bot *telegram.Bot) (action string, choices artChoices) {
all := []string{"s1_bottom", "s1_right", "s2_bottom", "s2_right", "regenerar", "confirmar"}
for {
cb, err := bot.WaitForCallback(all, 0)
if err != nil {
log.Printf("[WARN] WaitForCallback: %v", err)
continue
}
switch cb {
case "s1_bottom":
choices.Slide1 = "bottom"
bot.SendMessage("✓ Slide 1: corte de baixo selecionado")
case "s1_right":
choices.Slide1 = "right"
bot.SendMessage("✓ Slide 1: corte da direita selecionado")
case "s2_bottom":
choices.Slide2 = "bottom"
bot.SendMessage("✓ Slide 2: corte de baixo selecionado")
case "s2_right":
choices.Slide2 = "right"
bot.SendMessage("✓ Slide 2: corte da direita selecionado")
case "regenerar":
return "regenerar", artChoices{}
case "confirmar":
if choices.Slide1 == "" || choices.Slide2 == "" {
bot.SendMessage("⚠️ Selecione variante para ambos os slides antes de confirmar.")
continue
}
return "confirmar", choices
}
}
}
// ─── Main ─────────────────────────────────────────────────────────────────────
func main() {
log.SetFlags(log.Ltime)
var (
flagPost string
flagWorkspace string
flagDryRun bool
flagSkipGeneration bool
flagCropBottom int
flagCropRight int
)
root := &cobra.Command{
Use: "ldpost-art --post <slug>",
Short: "Gera imagens do carrossel via Gemini e envia para aprovação no Telegram",
RunE: func(cmd *cobra.Command, args []string) error {
if flagPost == "" {
return fmt.Errorf("--post é obrigatório")
}
cfg := config.Load()
if flagWorkspace != "" {
cfg.Workspace = flagWorkspace
}
// CLI flags override .env
if !cmd.Flags().Changed("crop-bottom") {
flagCropBottom = cfg.CropBottomPx
}
if !cmd.Flags().Changed("crop-right") {
flagCropRight = cfg.CropRightPx
}
if !flagDryRun && !flagSkipGeneration {
if err := cfg.Validate("groq", "gemini"); err != nil {
return err
}
} else 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.StatusWaitingArt) {
log.Printf("[ERROR] status atual: %q — esperado: %q", s.Status, state.StatusWaitingArt)
return fmt.Errorf("estado incorreto — rode o agente correto para o status atual")
}
inputPath := workspace.InputPath(postPath)
artRawPath := workspace.ArtRawPath(postPath)
outputPath := workspace.OutputPath(postPath)
// ── 2. Ler input/texto.md ─────────────────────────────────────
textoRaw, err := os.ReadFile(workspace.InputTextoPath(postPath))
if err != nil {
return fmt.Errorf("input/texto.md: %w", err)
}
textoStr := string(textoRaw)
desc1 := parseImageSection(textoStr, "Imagem 1")
desc2 := parseImageSection(textoStr, "Imagem 2")
if desc1 == "" {
desc1 = "Abstract tech illustration for LinkedIn post slide 1"
log.Printf("[WARN] seção 'Imagem 1' não encontrada em texto.md — usando fallback")
}
if desc2 == "" {
desc2 = "Abstract tech illustration for LinkedIn post slide 2"
log.Printf("[WARN] seção 'Imagem 2' não encontrada em texto.md — usando fallback")
}
// ── 3. Ler post final para contexto ──────────────────────────
postContext := ""
if data, err := os.ReadFile(workspace.OutputPostPath(postPath)); err == nil {
postContext = strings.TrimSpace(string(data))
} else if finalPath := workspace.WorkPath(postPath) + "/editor-final.md"; func() bool {
_, e := os.Stat(finalPath)
return e == nil
}() {
if data, err := os.ReadFile(workspace.WorkPath(postPath) + "/editor-final.md"); err == nil {
postContext = strings.TrimSpace(string(data))
}
}
gc := groq.NewGroqClient(cfg.GroqAPIKey)
// ── 4. Loop principal (suporta regeneração) ───────────────────
for {
// ── 4a. Checar imagens de input direto ────────────────────
var raw1, raw2 string
if flagSkipGeneration {
raw1 = findInputImage(inputPath, "imagem1")
raw2 = findInputImage(inputPath, "imagem2")
if raw1 == "" || raw2 == "" {
return fmt.Errorf("--skip-generation requer input/imagem1.* e input/imagem2.*")
}
log.Printf("[INFO] usando imagens de input: %s, %s", filepath.Base(raw1), filepath.Base(raw2))
} else {
// ── 4b. Construir prompts via Groq ────────────────────
log.Printf("[INFO] gerando prompts Gemini via Groq...")
prompt1, err := buildGeminiPrompt(gc, postContext, desc1)
if err != nil {
log.Printf("[WARN] prompt slide1: %v — usando descrição original", err)
prompt1 = desc1
}
prompt2, err := buildGeminiPrompt(gc, postContext, desc2)
if err != nil {
log.Printf("[WARN] prompt slide2: %v — usando descrição original", err)
prompt2 = desc2
}
apf := artPromptsFile{
Slide1: slidePrompts{DescricaoOriginal: desc1, PromptGemini: prompt1},
Slide2: slidePrompts{DescricaoOriginal: desc2, PromptGemini: prompt2},
}
apfData, _ := json.MarshalIndent(apf, "", " ")
if err := os.WriteFile(workspace.ArtPromptsPath(postPath), apfData, 0644); err != nil {
log.Printf("[WARN] salvar art-prompts.json: %v", err)
}
log.Printf("[INFO] art-prompts.json salvo")
if flagDryRun {
fmt.Printf("=== DRY-RUN — ldpost-art ===\n")
fmt.Printf("post: %s\n", flagPost)
fmt.Printf("modelo: %s\n", gemini.ImageModelV2)
fmt.Printf("crop: bottom=%dpx right=%dpx\n", flagCropBottom, flagCropRight)
fmt.Printf("\nSlide 1 — descrição: %s\n", desc1)
fmt.Printf("Slide 1 — prompt: %s\n\n", prompt1)
fmt.Printf("Slide 2 — descrição: %s\n", desc2)
fmt.Printf("Slide 2 — prompt: %s\n", prompt2)
return nil
}
// ── 4c. Gerar imagens via Gemini ──────────────────────
gm := gemini.New(cfg.GeminiAPIKey)
imageModel := gemini.ImageModelV2
log.Printf("[INFO] gerando slide1 via Gemini...")
img1Bytes, err := gm.GenerateImageSquare(context.Background(), imageModel, prompt1)
if err != nil {
log.Printf("[WARN] gemini slide1: %v", err)
// fallback para input/imagem1.*
raw1 = findInputImage(inputPath, "imagem1")
if raw1 == "" {
return fmt.Errorf("falha ao gerar slide1 e sem fallback em input/imagem1.*: %w", err)
}
log.Printf("[INFO] usando fallback: %s", filepath.Base(raw1))
} else {
raw1 = filepath.Join(artRawPath, "slide1-raw.png")
if err := os.WriteFile(raw1, img1Bytes, 0644); err != nil {
return fmt.Errorf("salvar slide1-raw.png: %w", err)
}
}
log.Printf("[INFO] gerando slide2 via Gemini...")
img2Bytes, err := gm.GenerateImageSquare(context.Background(), imageModel, prompt2)
if err != nil {
log.Printf("[WARN] gemini slide2: %v", err)
raw2 = findInputImage(inputPath, "imagem2")
if raw2 == "" {
return fmt.Errorf("falha ao gerar slide2 e sem fallback em input/imagem2.*: %w", err)
}
log.Printf("[INFO] usando fallback: %s", filepath.Base(raw2))
} else {
raw2 = filepath.Join(artRawPath, "slide2-raw.png")
if err := os.WriteFile(raw2, img2Bytes, 0644); err != nil {
return fmt.Errorf("salvar slide2-raw.png: %w", err)
}
}
}
// ── 4d. Crop 2 variantes por imagem ──────────────────────
log.Printf("[INFO] gerando variantes de crop (bottom=%dpx, right=%dpx)...", flagCropBottom, flagCropRight)
s1bottom, s1right, err := saveVariants(raw1, artRawPath, "slide1", flagCropBottom, flagCropRight)
if err != nil {
return fmt.Errorf("crop slide1: %w", err)
}
s2bottom, s2right, err := saveVariants(raw2, artRawPath, "slide2", flagCropBottom, flagCropRight)
if err != nil {
return fmt.Errorf("crop slide2: %w", err)
}
log.Printf("[INFO] variantes salvas em %s", artRawPath)
// ── 4e. Telegram: enviar preview e aguardar escolha ───────
if cfg.TelegramBotToken == "" || cfg.TelegramChatID == "" {
// Auto-approve first variant
log.Printf("[INFO] Telegram não configurado — aprovando variante 'bottom' automaticamente")
if err := copyFile(s1bottom, filepath.Join(outputPath, "slide1.png")); err != nil {
return err
}
if err := copyFile(s2bottom, filepath.Join(outputPath, "slide2.png")); err != nil {
return err
}
break
}
bot := telegram.NewBot(cfg.TelegramBotToken, cfg.TelegramChatID)
if err := sendArtPreview(bot, flagPost, s1bottom, s1right, s2bottom, s2right); err != nil {
log.Printf("[WARN] enviar preview: %v", err)
}
action, choices := waitChoices(bot)
if action == "regenerar" {
if flagSkipGeneration {
return fmt.Errorf("--skip-generation ativo: não é possível regenerar")
}
log.Printf("[INFO] regenerando imagens...")
bot.SendMessage("🔄 Regenerando imagens...")
continue // restart loop
}
// Copiar variantes escolhidas para output/
slide1src := s1bottom
if choices.Slide1 == "right" {
slide1src = s1right
}
slide2src := s2bottom
if choices.Slide2 == "right" {
slide2src = s2right
}
if err := copyFile(slide1src, filepath.Join(outputPath, "slide1.png")); err != nil {
return err
}
if err := copyFile(slide2src, filepath.Join(outputPath, "slide2.png")); err != nil {
return err
}
bot.SendMessage(fmt.Sprintf(
"✅ Imagens salvas em output/\n\nPróximo: <code>ldpost-director --post %s</code>",
flagPost))
break
}
// ── 5. Atualizar state ────────────────────────────────────────
s.Status = state.StatusWaitingDirector
s.SetEtapa("art", state.EtapaDone)
s.SetEtapa("director", state.EtapaWaiting)
if err := state.SaveState(postPath, s); err != nil {
return fmt.Errorf("state: %w", err)
}
fmt.Printf("✅ Arte concluída.\n")
fmt.Printf(" output/slide1.png e output/slide2.png\n")
fmt.Printf(" Próximo: ldpost-director --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 prompts sem chamar Gemini")
root.Flags().BoolVar(&flagSkipGeneration, "skip-generation", false, "Usa input/imagem1.* diretamente")
root.Flags().IntVar(&flagCropBottom, "crop-bottom", 48, "Pixels a cortar de baixo (override LDPOST_CROP_BOTTOM)")
root.Flags().IntVar(&flagCropRight, "crop-right", 48, "Pixels a cortar da direita (override LDPOST_CROP_RIGHT)")
if err := root.Execute(); err != nil {
os.Exit(1)
}
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
func copyFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return fmt.Errorf("ler %s: %w", src, err)
}
if err := os.WriteFile(dst, data, 0644); err != nil {
return fmt.Errorf("escrever %s: %w", dst, err)
}
return nil
}

16
director/go.mod Normal file
View File

@ -0,0 +1,16 @@
module ldpost/director
go 1.22
require (
github.com/spf13/cobra v1.8.0
ldpost/shared v0.0.0
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
)
replace ldpost/shared => ../shared

12
director/go.sum Normal file
View File

@ -0,0 +1,12 @@
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

666
director/main.go Normal file
View File

@ -0,0 +1,666 @@
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"context"
"github.com/spf13/cobra"
"ldpost/shared/config"
"ldpost/shared/gemini"
"ldpost/shared/groq"
"ldpost/shared/state"
"ldpost/shared/telegram"
"ldpost/shared/workspace"
)
// ─── Validation ───────────────────────────────────────────────────────────────
type requiredFile struct {
label string
path string
}
func collectRequiredFiles(postPath string) []requiredFile {
return []requiredFile{
{"input/texto.md", workspace.InputTextoPath(postPath)},
{"work/editor-final.md", filepath.Join(workspace.WorkPath(postPath), "editor-final.md")},
{"output/slide1.png", filepath.Join(workspace.OutputPath(postPath), "slide1.png")},
{"output/slide2.png", filepath.Join(workspace.OutputPath(postPath), "slide2.png")},
}
}
func validateFiles(files []requiredFile) []string {
var missing []string
for _, f := range files {
if _, err := os.Stat(f.path); err != nil {
missing = append(missing, f.label)
}
}
return missing
}
// ─── Text helpers ─────────────────────────────────────────────────────────────
func wordCount(s string) int { return len(strings.Fields(s)) }
func truncate(s string, maxChars int) string {
if len(s) <= maxChars {
return s
}
return s[:maxChars] + "\n... (truncado — ver arquivo completo)"
}
// ─── Groq helpers ─────────────────────────────────────────────────────────────
const systemSugestoes = `Você é um consultor de conteúdo LinkedIn especializado em tech. Analise o post abaixo e sugira 3 melhorias específicas e acionáveis. Cada sugestão deve:
- Ser específica (não "melhore o gancho" diga exatamente o que mudar e para o quê)
- Ter no máximo 2 linhas
- Focar em impacto no engagement (compartilhamento, comentário, salvamento)
Retorne APENAS as 3 sugestões numeradas. Sem introdução.`
func getSugestoes(gc *groq.GroqClient, postText string) (string, error) {
return gc.Chat(groq.TextModel, groq.TextMessages(systemSugestoes, postText), 0.7, 400)
}
func applySugestao(gc *gemini.Client, postText, sugestao string) (string, error) {
system := `Você é editor especializado em conteúdo LinkedIn para Ricardo, Tech Lead brasileiro direto e técnico.
O input do usuário pode ser de dois tipos identifique e aja conforme:
TIPO A Instrução de edição direta:
Exemplos: "mude o gancho", "remova hashtags", "encurta o terceiro parágrafo", "torna mais direto"
Ação: aplique cirurgicamente, mantendo TUDO o mais intacto possível.
TIPO B Informação/contexto adicional do autor:
Exemplos: parágrafos descritivos, números reais, detalhes da experiência, correções de fato
Ação: reescreva as partes relevantes do post integrando essas informações de forma fluida.
CRÍTICO para Tipo B:
- NÃO cole o texto bruto do usuário integre com a voz do Ricardo
- Preserve TODAS as distinções que o usuário fez (ex: "ainda faço X, eliminei Y")
- Preserve TODOS os números exatos (percentuais, dias, minutos)
- Preserve TODOS os tipos/termos específicos mencionados (ex: "tarefas, risco, protótipo")
- Se o usuário corrigiu um detalhe, use a versão correta não resuma
REGRAS SEMPRE ATIVAS:
- Parágrafos máximo 3 linhas, linha em branco entre blocos
- Mantenha as hashtags do post original a menos que o usuário peça para mudar
- Mantenha a estrutura geral do formato (gancho problema solução resultado CTA)
- Nunca invente fatos além do que foi fornecido
- Nunca use: "mergulho profundo", "no cenário atual", "é importante destacar", "robusto", "abrangente"
- Retorne APENAS o post modificado. Sem explicação, sem cabeçalho, sem "aqui está".`
user := fmt.Sprintf("Input do usuário:\n%s\n\nPost atual:\n%s", sugestao, postText)
return gc.Chat(context.Background(), gemini.TextModel, system, user)
}
// parseSugestoes extracts numbered lines from LLM suggestion output.
func parseSugestoes(raw string) [3]string {
var result [3]string
lines := strings.Split(strings.TrimSpace(raw), "\n")
idx := 0
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Strip leading "1. " "2. " etc.
for _, prefix := range []string{"1. ", "2. ", "3. ", "1) ", "2) ", "3) "} {
if strings.HasPrefix(line, prefix) {
line = strings.TrimPrefix(line, prefix)
break
}
}
if idx < 3 {
result[idx] = line
idx++
}
}
return result
}
// ─── Telegram senders ─────────────────────────────────────────────────────────
func sendPackage(bot *telegram.Bot, s *state.PostState, slug, postText, originalText string,
inputImgs, outputImgs []string) (msgID int, err error) {
// Msg 1 — header
header := fmt.Sprintf(
"📋 <b>ldpost-director</b> | Aprovação Final\n📅 %s\n📝 Post: <code>%s</code>\n🎯 Formato: %s | Funil: %s\n🔄 Ciclos de revisão: %d",
time.Now().Format("02/01/2006"),
slug, s.Formato, s.FunilTag,
s.Aprovacoes.Texto.Ciclos,
)
if _, err := bot.SendMessage(header); err != nil {
log.Printf("[WARN] header: %v", err)
}
// Msg 2 — original text
orig := "📄 <b>TEXTO ORIGINAL (seu resumo)</b>\n─────────────────────────────\n" +
truncate(originalText, 800)
if _, err := bot.SendMessage(orig); err != nil {
log.Printf("[WARN] texto original: %v", err)
}
// Msg 3 — final post (split if needed)
finalHeader := "✍️ <b>TEXTO FINAL (post LinkedIn)</b>\n─────────────────────────────\n"
if len(finalHeader)+len(postText) > 3000 {
mid := len(postText) / 2
// Split at nearest newline
if idx := strings.LastIndex(postText[:mid], "\n"); idx > 0 {
mid = idx
}
if _, err := bot.SendMessage(finalHeader + postText[:mid]); err != nil {
log.Printf("[WARN] texto final 1: %v", err)
}
if _, err := bot.SendMessage(postText[mid:]); err != nil {
log.Printf("[WARN] texto final 2: %v", err)
}
} else {
if _, err := bot.SendMessage(finalHeader + postText); err != nil {
log.Printf("[WARN] texto final: %v", err)
}
}
// Msg 4 — input images (optional)
if len(inputImgs) > 0 {
var mf []telegram.MediaFile
for i, p := range inputImgs {
cap := ""
if i == 0 {
cap = "🖼️ Imagens originais (seu input)"
}
mf = append(mf, telegram.MediaFile{Path: p, Caption: cap})
}
if err := bot.SendMediaGroup(mf); err != nil {
log.Printf("[WARN] imagens input: %v", err)
}
}
// Msg 5 — output images
if len(outputImgs) > 0 {
var mf []telegram.MediaFile
for i, p := range outputImgs {
cap := ""
if i == 0 {
cap = "🎨 Imagens finais (geradas pelo squad)"
}
mf = append(mf, telegram.MediaFile{Path: p, Caption: cap})
}
if err := bot.SendMediaGroup(mf); err != nil {
log.Printf("[WARN] imagens output: %v", err)
}
}
// Msg 6 — decision buttons
buttons := [][]telegram.InlineButton{
{{Text: "✅ Aprovar e publicar", CallbackData: "aprovar"}},
{{Text: "❌ Reprovar tudo", CallbackData: "reprovar"}},
{{Text: "🔄 Revisar texto", CallbackData: "revisar_texto"},
{Text: "🎨 Revisar imagens", CallbackData: "revisar_arte"}},
{{Text: "✏️ Editar via chat", CallbackData: "editar_chat"},
{Text: "💡 Sugerir alternativas", CallbackData: "sugestoes"}},
}
msgID, err = bot.SendMessageWithKeyboard("⬇️ <b>Decisão final:</b>", buttons)
return
}
func sendSugestoes(bot *telegram.Bot, sug [3]string) error {
text := fmt.Sprintf("💡 <b>Sugestões do squad:</b>\n\n1. %s\n\n2. %s\n\n3. %s",
sug[0], sug[1], sug[2])
buttons := [][]telegram.InlineButton{
{{Text: "Aplicar sugestão 1", CallbackData: "aplicar_1"}},
{{Text: "Aplicar sugestão 2", CallbackData: "aplicar_2"}},
{{Text: "Aplicar sugestão 3", CallbackData: "aplicar_3"}},
{{Text: "Ignorar e aprovar mesmo assim", CallbackData: "aprovar"},
{Text: "Ignorar e reprovar", CallbackData: "reprovar"}},
}
_, err := bot.SendMessageWithKeyboard(text, buttons)
return err
}
// ─── Decision loop ────────────────────────────────────────────────────────────
type dirResult struct {
action string // "aprovar", "reprovar", "revisar_texto", "revisar_arte", "sugestao_aplicada"
newText string // filled when sugestão applied
}
var mainCallbacks = []string{"aprovar", "reprovar", "revisar_texto", "revisar_arte", "sugestoes", "editar_chat"}
var sugCallbacks = []string{"aplicar_1", "aplicar_2", "aplicar_3", "aprovar", "reprovar"}
var chatCallbacks = []string{"chat_aprovar", "chat_editar_mais", "chat_cancelar"}
func waitDecision(bot *telegram.Bot, gc *groq.GroqClient, gem *gemini.Client, postText string) dirResult {
for {
cb, err := bot.WaitForCallback(mainCallbacks, 24*time.Hour)
if err != nil {
log.Printf("[WARN] timeout aguardando decisão — retomando polling")
continue
}
switch cb {
case "aprovar", "reprovar", "revisar_texto", "revisar_arte":
return dirResult{action: cb}
case "editar_chat":
result := runChatEdit(bot, gem, postText)
if result.action == "continuar" {
// User cancelled chat — stay in main loop with (possibly) updated text
postText = result.newText
continue
}
return result
case "sugestoes":
log.Printf("[INFO] gerando sugestões via Groq...")
raw, err := getSugestoes(gc, postText)
if err != nil {
bot.SendMessage(fmt.Sprintf("⚠️ Erro ao gerar sugestões: %v", err))
continue
}
sug := parseSugestoes(raw)
if err := sendSugestoes(bot, sug); err != nil {
log.Printf("[WARN] enviar sugestões: %v", err)
}
cb2, err := bot.WaitForCallback(sugCallbacks, 24*time.Hour)
if err != nil {
log.Printf("[WARN] timeout aguardando sugestão — retomando")
continue
}
switch cb2 {
case "aprovar", "reprovar":
return dirResult{action: cb2}
case "aplicar_1", "aplicar_2", "aplicar_3":
idx := int(cb2[len(cb2)-1] - '1')
chosen := sug[idx]
log.Printf("[INFO] aplicando sugestão %d: %s", idx+1, chosen)
updated, err := applySugestao(gem, postText, chosen)
if err != nil {
bot.SendMessage(fmt.Sprintf("⚠️ Erro ao aplicar sugestão: %v", err))
continue
}
return dirResult{action: "sugestao_aplicada", newText: strings.TrimSpace(updated)}
}
}
}
}
// runChatEdit runs a free-text editing loop with the user via Telegram.
// The user types instructions in plain text; the LLM applies them to the post.
// Returns:
// - action="sugestao_aplicada" + newText → user approved a version
// - action="reprovar" → user rejected in chat
// - action="continuar" + newText → user cancelled chat (back to main menu)
func runChatEdit(bot *telegram.Bot, gem *gemini.Client, postText string) dirResult {
original := postText
current := postText
bot.SendMessage("✏️ <b>Modo edição via chat</b>\n\nDigite o que quer mudar no texto.\n<i>Exemplos: \"gancho mais direto\", \"remove hashtags de vendas\", \"encurta o terceiro parágrafo\"</i>")
for {
// Wait for free text from user
event, err := bot.WaitForAny(24 * time.Hour)
if err != nil {
log.Printf("[WARN] chat edit timeout: %v", err)
return dirResult{action: "continuar", newText: current}
}
// If user sent a callback (tapped an old button), ignore
if event.IsCallback {
bot.SendMessage("💬 Estamos no modo chat. Digite sua instrução de edição como texto, ou use os botões abaixo quando estiver pronto.")
continue
}
instrucao := strings.TrimSpace(event.Text)
if instrucao == "" {
continue
}
log.Printf("[INFO] chat edit: %q", instrucao)
bot.SendMessage("⏳ Aplicando sua instrução...")
updated, err := applySugestao(gem, current, instrucao)
if err != nil {
bot.SendMessage(fmt.Sprintf("⚠️ Erro ao aplicar edição: %v\n\nTente novamente.", err))
continue
}
current = strings.TrimSpace(updated)
// Show updated post
preview := "✍️ <b>Texto atualizado:</b>\n─────────────────────────────\n" + truncate(current, 2500)
bot.SendMessage(preview)
// Show chat action buttons
buttons := [][]telegram.InlineButton{
{{Text: "✅ Aprovar esta versão", CallbackData: "chat_aprovar"}},
{{Text: "✏️ Editar mais", CallbackData: "chat_editar_mais"}},
{{Text: "↩️ Cancelar e voltar ao menu", CallbackData: "chat_cancelar"}},
}
if _, err := bot.SendMessageWithKeyboard("O que fazemos?", buttons); err != nil {
log.Printf("[WARN] botões chat: %v", err)
}
// Wait for button response
cb, err := bot.WaitForCallback(chatCallbacks, 24*time.Hour)
if err != nil {
log.Printf("[WARN] chat button timeout: %v", err)
return dirResult{action: "continuar", newText: current}
}
switch cb {
case "chat_aprovar":
return dirResult{action: "sugestao_aplicada", newText: current}
case "chat_editar_mais":
// Restore original text if user wants to restart, or keep current?
// Keep current — user refines incrementally
bot.SendMessage("✏️ Ok, continue editando. Digite a próxima instrução:")
// Loop continues — next iteration reads another free-text message
case "chat_cancelar":
if current != original {
// Ask if they want to keep or discard changes
keepButtons := [][]telegram.InlineButton{
{{Text: "✅ Manter edições e voltar ao menu", CallbackData: "chat_cancelar_manter"}},
{{Text: "🗑️ Descartar edições e voltar ao menu", CallbackData: "chat_cancelar_descartar"}},
}
bot.SendMessageWithKeyboard("Você fez edições. O que prefere?", keepButtons)
cb2, err := bot.WaitForCallback([]string{"chat_cancelar_manter", "chat_cancelar_descartar"}, 24*time.Hour)
if err != nil || cb2 == "chat_cancelar_descartar" {
return dirResult{action: "continuar", newText: original}
}
return dirResult{action: "continuar", newText: current}
}
return dirResult{action: "continuar", newText: current}
}
}
}
// ─── Dry-run printer ──────────────────────────────────────────────────────────
func printDryRun(s *state.PostState, slug string, postText, originalText string,
inputImgs, outputImgs []string) {
sep := strings.Repeat("═", 60)
fmt.Println(sep)
fmt.Printf("ldpost-director | DRY-RUN | Post: %s\n", slug)
fmt.Println(sep)
fmt.Printf("Data: %s\n", time.Now().Format("02/01/2006"))
fmt.Printf("Formato: %s | Funil: %s\n", s.Formato, s.FunilTag)
fmt.Printf("Ciclos: %d\n", s.Aprovacoes.Texto.Ciclos)
fmt.Printf("Palavras post final: ~%d\n", wordCount(postText))
fmt.Println()
fmt.Println("── TEXTO ORIGINAL ──────────────────────────────────────")
fmt.Println(truncate(originalText, 800))
fmt.Println()
fmt.Println("── TEXTO FINAL ─────────────────────────────────────────")
fmt.Println(postText)
fmt.Println()
if len(inputImgs) > 0 {
fmt.Printf("── IMAGENS INPUT (%d) ──────────────────────────────────\n", len(inputImgs))
for _, p := range inputImgs {
fmt.Println(" ", p)
}
fmt.Println()
}
fmt.Printf("── IMAGENS OUTPUT (%d) ─────────────────────────────────\n", len(outputImgs))
for _, p := range outputImgs {
fmt.Println(" ", p)
}
fmt.Println(sep)
}
// ─── Main ─────────────────────────────────────────────────────────────────────
func main() {
log.SetFlags(log.Ltime)
var (
flagPost string
flagWorkspace string
flagDryRun bool
flagResume bool
flagSkipImages bool
)
root := &cobra.Command{
Use: "ldpost-director --post <slug>",
Short: "Aprovação final via Telegram antes de publicar",
RunE: func(cmd *cobra.Command, args []string) error {
if flagPost == "" {
return fmt.Errorf("--post é obrigatório")
}
cfg := config.Load()
if flagWorkspace != "" {
cfg.Workspace = flagWorkspace
}
// ── 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)
}
validStatuses := []string{state.StatusWaitingDirector}
if flagSkipImages {
validStatuses = append(validStatuses, state.StatusWaitingArt)
}
ok := false
for _, vs := range validStatuses {
if s.IsStatus(vs) {
ok = true
break
}
}
if !ok {
log.Printf("[ERROR] status atual: %q — esperado: %v", s.Status, validStatuses)
return fmt.Errorf("estado incorreto — rode o agente correto para o status atual")
}
// ── 2. Validar arquivos ────────────────────────────────────────
required := collectRequiredFiles(postPath)
if flagSkipImages {
// Only require text files
var textOnly []requiredFile
for _, f := range required {
if f.label != "output/slide1.png" && f.label != "output/slide2.png" {
textOnly = append(textOnly, f)
}
}
required = textOnly
}
if missing := validateFiles(required); len(missing) > 0 {
for _, m := range missing {
log.Printf("[ERROR] arquivo ausente: %s", m)
}
return fmt.Errorf("%d arquivo(s) ausente(s) — verifique os agentes anteriores", len(missing))
}
// ── 3. Ler conteúdos ──────────────────────────────────────────
finalPath := filepath.Join(workspace.WorkPath(postPath), "editor-final.md")
postData, err := os.ReadFile(finalPath)
if err != nil {
return fmt.Errorf("ler editor-final.md: %w", err)
}
postText := strings.TrimSpace(string(postData))
originalData, err := os.ReadFile(workspace.InputTextoPath(postPath))
if err != nil {
return fmt.Errorf("ler input/texto.md: %w", err)
}
originalText := strings.TrimSpace(string(originalData))
var outputImgs []string
if !flagSkipImages {
outputImgs = []string{
filepath.Join(workspace.OutputPath(postPath), "slide1.png"),
filepath.Join(workspace.OutputPath(postPath), "slide2.png"),
}
}
// Collect input images if they exist
var inputImgs []string
for _, name := range []string{"imagem1.png", "imagem1.jpg", "imagem2.png", "imagem2.jpg"} {
p := filepath.Join(workspace.InputPath(postPath), name)
if _, err := os.Stat(p); err == nil {
inputImgs = append(inputImgs, p)
break // only one needed per slide for display
}
}
// look for imagem2 separately
for _, name := range []string{"imagem2.png", "imagem2.jpg"} {
p := filepath.Join(workspace.InputPath(postPath), name)
if _, err := os.Stat(p); err == nil {
inputImgs = append(inputImgs, p)
break
}
}
log.Printf("[INFO] post=%s formato=%s palavras=%d", s.Slug, s.Formato, wordCount(postText))
// ── 4. Dry-run ────────────────────────────────────────────────
if flagDryRun {
printDryRun(s, flagPost, postText, originalText, inputImgs, outputImgs)
return nil
}
// ── 5. Telegram ───────────────────────────────────────────────
if cfg.TelegramBotToken == "" || cfg.TelegramChatID == "" {
log.Printf("[INFO] Telegram não configurado — aprovação automática local")
return finalizeAprovado(postPath, s, flagPost)
}
if !flagDryRun {
if err := cfg.Validate("groq"); err != nil {
return err
}
}
bot := telegram.NewBot(cfg.TelegramBotToken, cfg.TelegramChatID)
gc := groq.NewGroqClient(cfg.GroqAPIKey)
gem := gemini.New(cfg.GeminiAPIKey)
// Mark polling active
s.PollingActive = true
if err := state.SaveState(postPath, s); err != nil {
log.Printf("[WARN] state polling_active: %v", err)
}
for {
if !flagResume {
log.Printf("[INFO] enviando pacote ao Telegram...")
msgID, err := sendPackage(bot, s, flagPost, postText, originalText, inputImgs, outputImgs)
if err != nil {
log.Printf("[WARN] sendPackage: %v", err)
} else {
s.DirectorMessageID = msgID
state.SaveState(postPath, s)
}
}
flagResume = false // only skip send on first iteration if --resume
log.Printf("[INFO] aguardando decisão no Telegram (timeout 24h)...")
result := waitDecision(bot, gc, gem, postText)
switch result.action {
case "aprovar":
bot.SendMessage(fmt.Sprintf(
"✅ <b>Aprovado!</b>\n\nExecute: <code>ldpost-publisher --post %s</code>", flagPost))
s.PollingActive = false
return finalizeAprovado(postPath, s, flagPost)
case "reprovar":
bot.SendMessage(fmt.Sprintf(
"❌ Post reprovado e arquivado.\nOs arquivos de work/ e output/ foram mantidos para referência.\nPara recomeçar do zero: <code>ldpost-evaluator --force-slug %s</code>", flagPost))
s.Status = state.StatusRejected
s.PollingActive = false
s.SetEtapa("director", state.EtapaDone)
state.SaveState(postPath, s)
return fmt.Errorf("post reprovado pelo operador")
case "revisar_texto":
bot.SendMessage(fmt.Sprintf(
"🔄 Voltando para o editor.\nExecute: <code>ldpost-editor --post %s</code>", flagPost))
s.Status = state.StatusWaitingEditor
s.PollingActive = false
s.SetEtapa("director", state.EtapaPending)
s.SetEtapa("editor", state.EtapaWaiting)
state.SaveState(postPath, s)
fmt.Printf("De volta ao editor. Rode: ldpost-editor --post %s\n", flagPost)
return nil
case "revisar_arte":
bot.SendMessage(fmt.Sprintf(
"🎨 Voltando para o art.\nExecute: <code>ldpost-art --post %s</code>", flagPost))
s.Status = state.StatusWaitingArt
s.PollingActive = false
s.SetEtapa("director", state.EtapaPending)
s.SetEtapa("art", state.EtapaWaiting)
state.SaveState(postPath, s)
fmt.Printf("De volta ao art. Rode: ldpost-art --post %s\n", flagPost)
return nil
case "sugestao_aplicada", "continuar":
// Save updated text and loop back to re-send package
if result.newText == postText {
// No change (chat cancelled with no edits) — just re-send menu
continue
}
postText = result.newText
finalPath := filepath.Join(workspace.WorkPath(postPath), "editor-final.md")
if err := os.WriteFile(finalPath, []byte(postText), 0644); err != nil {
log.Printf("[WARN] salvar editor-final.md: %v", err)
}
if err := os.WriteFile(workspace.OutputPostPath(postPath), []byte(postText), 0644); err != nil {
log.Printf("[WARN] salvar output/post.md: %v", err)
}
_, lastN := workspace.LatestVersionFile(postPath, "editor")
vp := workspace.VersionedFile(postPath, "editor", lastN+1)
os.WriteFile(vp, []byte(postText), 0644)
log.Printf("[INFO] texto atualizado salvo em %s — reenviando pacote", filepath.Base(vp))
if result.action == "sugestao_aplicada" {
bot.SendMessage("✅ Sugestão aplicada. Reenviando pacote atualizado...")
} else {
bot.SendMessage("↩️ Voltando ao menu com texto editado...")
}
continue
}
}
},
}
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, "Exibe pacote no terminal sem enviar Telegram")
root.Flags().BoolVar(&flagResume, "resume", false, "Retoma director aguardando callback")
root.Flags().BoolVar(&flagSkipImages, "skip-images", false, "Pula validação e envio de imagens (teste de texto)")
if err := root.Execute(); err != nil {
os.Exit(1)
}
}
// ─── State helpers ────────────────────────────────────────────────────────────
func finalizeAprovado(postPath string, s *state.PostState, slug string) error {
s.Status = state.StatusWaitingPublisher
s.SetEtapa("director", state.EtapaDone)
s.SetEtapa("publisher", state.EtapaWaiting)
s.Aprovacoes.Final.Aprovado = true
s.Aprovacoes.Final.Timestamp = time.Now()
if err := state.SaveState(postPath, s); err != nil {
return fmt.Errorf("state: %w", err)
}
fmt.Printf("Aprovado. Rode: ldpost-publisher --post %s\n", slug)
return nil
}

16
editor/go.mod Normal file
View File

@ -0,0 +1,16 @@
module ldpost/editor
go 1.22
require (
github.com/spf13/cobra v1.8.0
ldpost/shared v0.0.0
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
)
replace ldpost/shared => ../shared

12
editor/go.sum Normal file
View File

@ -0,0 +1,12 @@
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

361
editor/main.go Normal file
View File

@ -0,0 +1,361 @@
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)
}
}

16
evaluator/go.mod Normal file
View File

@ -0,0 +1,16 @@
module ldpost/evaluator
go 1.22
require (
github.com/spf13/cobra v1.8.0
ldpost/shared v0.0.0
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
)
replace ldpost/shared => ../shared

12
evaluator/go.sum Normal file
View File

@ -0,0 +1,12 @@
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

894
evaluator/main.go Normal file
View File

@ -0,0 +1,894 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"ldpost/shared/config"
"ldpost/shared/formats"
"ldpost/shared/groq"
"ldpost/shared/history"
"ldpost/shared/state"
"ldpost/shared/telegram"
"ldpost/shared/workspace"
)
// ─── Domain types ─────────────────────────────────────────────────────────────
type Trend struct {
Tema string `json:"tema"`
Descricao string `json:"descricao"`
Mencoes int `json:"mencoes"`
Momentum string `json:"momentum"` // crescendo|estavel|caindo
RelevanciaProfile string `json:"relevancia_perfil"` // alta|media|baixa
CategoriaSugerida string `json:"categoria_sugerida"` // Codigo|Entregavel|Bastidor|Geral
}
type TrendResponse struct {
Trends []Trend `json:"trends"`
}
type SugestaoPost struct {
Slug string
Tema string
Categoria string
Formato string
Funil string
Resumo string
Imagem1 string
Imagem2 string
// Reciclagem
IsRecycled bool
BaseSlug string // slug original do seed
PreviousFormato string // formato da última publicação
DaysSincePublished int
}
// ─── Formato rotation ─────────────────────────────────────────────────────────
// formatoCycle defines the fixed rotation order for recycled posts.
var formatoCycle = []string{"como", "porque", "erro", "comparacao", "checklist", "bastidor"}
// nextFormato returns the next format in the rotation after current.
func nextFormato(current string) string {
for i, f := range formatoCycle {
if f == current {
return formatoCycle[(i+1)%len(formatoCycle)]
}
}
return formatoCycle[0]
}
// recycleSlug builds the new slug for a recycled post: baseSlug--newFormato.
// Strips any existing --format suffix before appending.
func recycleSlug(baseSlug, newFormato string) string {
base := baseSlug
if idx := strings.Index(baseSlug, "--"); idx >= 0 {
base = baseSlug[:idx]
}
return base + "--" + newFormato
}
type PendingCallback struct {
Trends []Trend `json:"trends"`
NextPost *SugestaoPost `json:"next_post"`
FormatoSorteado string `json:"formato_sorteado"`
FormatosBloqueados []string `json:"formatos_bloqueados"`
CreatedAt time.Time `json:"created_at"`
}
// ─── Trend fetching ───────────────────────────────────────────────────────────
type hnItem struct {
Title string `json:"title"`
Points int `json:"points"`
URL string `json:"url"`
CreatedAt string `json:"created_at"`
}
func fetchHN() ([]string, error) {
sevenDaysAgo := time.Now().AddDate(0, 0, -7).Unix()
url := fmt.Sprintf(
"https://hn.algolia.com/api/v1/search?query=AI+LLM+agent&tags=story&numericFilters=points>100,created_at_i>%d&hitsPerPage=10",
sevenDaysAgo,
)
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("HN fetch: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result struct {
Hits []hnItem `json:"hits"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("HN parse: %w", err)
}
var titles []string
for _, h := range result.Hits {
titles = append(titles, fmt.Sprintf("[HN %d pts] %s", h.Points, h.Title))
}
return titles, nil
}
func fetchReddit(subreddit string) ([]string, error) {
url := fmt.Sprintf("https://www.reddit.com/r/%s/top.json?t=week&limit=10", subreddit)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("User-Agent", "ldpost-evaluator/1.0")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("Reddit %s fetch: %w", subreddit, err)
}
defer resp.Body.Close()
if resp.StatusCode == 429 {
return nil, fmt.Errorf("rate_limited")
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("Reddit %s status %d", subreddit, resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
var result struct {
Data struct {
Children []struct {
Data struct {
Title string `json:"title"`
Score int `json:"score"`
CreatedUTC float64 `json:"created_utc"`
} `json:"data"`
} `json:"children"`
} `json:"data"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("Reddit %s parse: %w", subreddit, err)
}
var titles []string
for _, c := range result.Data.Children {
titles = append(titles, fmt.Sprintf("[Reddit r/%s %d pts] %s", subreddit, c.Data.Score, c.Data.Title))
}
return titles, nil
}
// ─── Consolidation via LLM ────────────────────────────────────────────────────
const systemPromptTrends = `Você é um analista de tendências de tecnologia especializado em IA, desenvolvimento de software e arquitetura de sistemas. Seu trabalho é identificar os 3 temas mais relevantes para um Tech Lead sênior brasileiro (22+ anos de experiência em C#/.NET, especialista em Clean Architecture, IA local, agentes, SAP BTP).
Retorne APENAS um JSON válido, sem markdown, sem explicação, no formato:
{
"trends": [
{
"tema": "string curta (máx 8 palavras)",
"descricao": "string de 1 frase explicando o trend",
"mencoes": número estimado de posts/threads sobre o tema,
"momentum": "crescendo|estavel|caindo",
"relevancia_perfil": "alta|media|baixa",
"categoria_sugerida": "Codigo|Entregavel|Bastidor|Geral"
}
]
}
Retorne exatamente 3 trends, priorizando os de relevancia_perfil=alta.`
func consolidateTrends(gc *groq.GroqClient, titles []string) ([]Trend, error) {
user := strings.Join(titles, "\n---\n")
var resp TrendResponse
msgs := groq.TextMessages(systemPromptTrends, user)
if err := gc.ChatJSON(groq.TextModel, msgs, 0.3, &resp); err != nil {
return nil, err
}
if len(resp.Trends) == 0 {
return nil, fmt.Errorf("LLM retornou trends vazio")
}
return resp.Trends, nil
}
// Fallback: se Groq falhar, criar trends simples a partir dos títulos brutos
func buildFallbackTrends(titles []string) []Trend {
var trends []Trend
for i, t := range titles {
if i >= 3 {
break
}
trends = append(trends, Trend{
Tema: truncate(t, 60),
Descricao: "Tema identificado nas fontes (sem consolidação LLM)",
Mencoes: 1,
Momentum: "estavel",
RelevanciaProfile: "media",
CategoriaSugerida: "Entregavel",
})
}
return trends
}
// ─── _sugestoes.md parser ─────────────────────────────────────────────────────
func parseSugestoes(path string) ([]SugestaoPost, error) {
f, err := os.Open(path)
if os.IsNotExist(err) {
return nil, fmt.Errorf("_sugestoes.md não encontrado em %s\n→ Crie o arquivo com o seed de 30 posts", path)
}
if err != nil {
return nil, err
}
defer f.Close()
var posts []SugestaoPost
var cur *SugestaoPost
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "## ") {
if cur != nil {
posts = append(posts, *cur)
}
rawSlug := strings.TrimSpace(strings.TrimPrefix(line, "## "))
cur = &SugestaoPost{Slug: workspace.SlugFromTitle(rawSlug)}
} else if cur != nil {
k, v := parseKV(line)
switch k {
case "Categoria":
cur.Categoria = v
case "Tema":
cur.Tema = v
case "Formato":
cur.Formato = v
case "Funil":
cur.Funil = v
case "Resumo":
cur.Resumo = v
case "Imagem 1", "Imagem1":
cur.Imagem1 = v
case "Imagem 2", "Imagem2":
cur.Imagem2 = v
}
}
}
if cur != nil {
posts = append(posts, *cur)
}
return posts, scanner.Err()
}
func parseKV(line string) (string, string) {
line = strings.TrimSpace(line)
line = strings.TrimPrefix(line, "**")
parts := strings.SplitN(line, ":**", 2)
if len(parts) != 2 {
parts = strings.SplitN(line, ": ", 2)
if len(parts) != 2 {
return "", ""
}
}
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
}
// findNextInQueue scans the seed queue and returns the next eligible post.
//
// Priority order for each slug:
// 1. Never created (no work/ folder) → return as new post.
// 2. In pipeline (exists, not published, not rejected) → skip (don't interrupt).
// 3. Published < 30 days ago → skip (too soon).
// 4. Published >= 30 days ago → return recycled with next format in rotation.
// 5. Rejected → skip entirely.
func findNextInQueue(ws string, posts []SugestaoPost) *SugestaoPost {
const recycleDays = 30
for i := range posts {
p := &posts[i]
if p.Slug == "" {
continue
}
variants := workspace.FindVariantsByBase(ws, p.Slug)
if len(variants) == 0 {
// Never created → new post
return p
}
// Find the most recently published variant
var latestFormato string
var latestPublishedAt time.Time
hasPublished := false
for _, vPath := range variants {
s, err := state.LoadState(vPath)
if err != nil {
continue
}
if s.IsStatus(state.StatusPublished) && s.PublishedAt != nil {
if !hasPublished || s.PublishedAt.After(latestPublishedAt) {
latestPublishedAt = *s.PublishedAt
latestFormato = s.Formato
hasPublished = true
}
}
}
if !hasPublished {
// Exists but not yet published → in pipeline or rejected → skip
continue
}
days := int(time.Since(latestPublishedAt).Hours() / 24)
if days < recycleDays {
// Published too recently
continue
}
// Ready for recycle: advance to next format
newFmt := nextFormato(latestFormato)
newSlug := recycleSlug(p.Slug, newFmt)
recycled := *p
recycled.Slug = newSlug
recycled.Formato = newFmt
recycled.IsRecycled = true
recycled.BaseSlug = p.Slug
recycled.PreviousFormato = latestFormato
recycled.DaysSincePublished = days
return &recycled
}
return nil
}
// ─── Pending callback persistence ────────────────────────────────────────────
func pendingPath(ws string) string {
return filepath.Join(ws, "_inbox", "_pending_callback.json")
}
func savePending(ws string, p *PendingCallback) error {
dir := filepath.Join(ws, "_inbox")
os.MkdirAll(dir, 0755)
data, _ := json.MarshalIndent(p, "", " ")
return os.WriteFile(pendingPath(ws), data, 0644)
}
func loadPending(ws string) (*PendingCallback, error) {
data, err := os.ReadFile(pendingPath(ws))
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
var p PendingCallback
if err := json.Unmarshal(data, &p); err != nil {
return nil, err
}
return &p, nil
}
func deletePending(ws string) {
os.Remove(pendingPath(ws))
}
// ─── Telegram messages ────────────────────────────────────────────────────────
func momentumEmoji(m string) string {
switch m {
case "crescendo":
return "📈"
case "estavel":
return "➡️"
case "caindo":
return "📉"
default:
return "📊"
}
}
func sendTrendMessages(bot *telegram.Bot, trends []Trend, next *SugestaoPost, sorteado string, bloqueados []string) error {
// Mensagem 1: Trends
var sb strings.Builder
sb.WriteString("🔍 <b>ldpost-evaluator</b>\n\n")
sb.WriteString("📊 <b>Trends desta semana:</b>\n\n")
labels := []string{"A", "B", "C"}
for i, t := range trends {
if i >= 3 {
break
}
fmt.Fprintf(&sb, "<b>%s)</b> %s\n", labels[i], t.Tema)
fmt.Fprintf(&sb, " <i>%s</i>\n", t.Descricao)
fmt.Fprintf(&sb, " %s ~%d menções | %s | relevância: %s\n\n",
momentumEmoji(t.Momentum), t.Mencoes, t.Momentum, t.RelevanciaProfile)
}
if _, err := bot.SendMessage(sb.String()); err != nil {
return fmt.Errorf("msg 1 trends: %w", err)
}
// Mensagem 2: Fila + formato
var sb2 strings.Builder
if next != nil {
if next.IsRecycled {
sb2.WriteString("♻️ <b>Reciclagem da sua fila:</b>\n")
fmt.Fprintf(&sb2, "D) <i>%s</i>\n", next.Tema)
fmt.Fprintf(&sb2, "Publicado há %d dias como <b>%s</b> → novo ângulo: <b>%s</b>\n",
next.DaysSincePublished,
formats.FormatLabel(next.PreviousFormato),
formats.FormatLabel(next.Formato))
fmt.Fprintf(&sb2, "<code>%s</code> → <code>%s</code>\n", next.BaseSlug, next.Slug)
} else {
sb2.WriteString("📝 <b>Próximo da sua fila:</b>\n")
fmt.Fprintf(&sb2, "D) <i>%s</i>\n", next.Tema)
if next.Formato != "" {
fmt.Fprintf(&sb2, "Formato: %s\n", formats.FormatLabel(next.Formato))
}
}
sb2.WriteString("\n")
} else {
sb2.WriteString("📝 <i>Fila vazia — nenhum post pendente em _sugestoes.md</i>\n\n")
}
fmt.Fprintf(&sb2, "🎲 <b>Sorteio sugere:</b> %s (%s)\n", sorteado, formats.FormatLabel(sorteado))
if len(bloqueados) > 0 {
fmt.Fprintf(&sb2, "<i>bloqueados: %s</i>\n", strings.Join(bloqueados, ", "))
}
if _, err := bot.SendMessage(sb2.String()); err != nil {
return fmt.Errorf("msg 2 fila: %w", err)
}
// Mensagem 3: Botões
buttons := [][]telegram.InlineButton{
{{Text: "A — " + truncate(trends[0].Tema, 30), CallbackData: "trend_a"}},
}
if len(trends) > 1 {
buttons = append(buttons, []telegram.InlineButton{
{Text: "B — " + truncate(trends[1].Tema, 30), CallbackData: "trend_b"},
})
}
if len(trends) > 2 {
buttons = append(buttons, []telegram.InlineButton{
{Text: "C — " + truncate(trends[2].Tema, 30), CallbackData: "trend_c"},
})
}
if next != nil {
if next.Formato != "" {
var labelD string
if next.IsRecycled {
labelD = "D — Reciclar (" + formats.FormatLabel(next.PreviousFormato) + " → " + formats.FormatLabel(next.Formato) + ")"
} else {
labelD = "D — Fila (formato: " + formats.FormatLabel(next.Formato) + ")"
}
buttons = append(buttons, []telegram.InlineButton{
{Text: labelD, CallbackData: "fila_original"},
})
}
buttons = append(buttons, []telegram.InlineButton{
{Text: "E — Fila (sorteado: " + formats.FormatLabel(sorteado) + ")", CallbackData: "fila_sorteado"},
})
}
if _, err := bot.SendMessageWithKeyboard("Qual seguimos?", buttons); err != nil {
return fmt.Errorf("msg 3 botões: %w", err)
}
return nil
}
// ─── Workspace creation ───────────────────────────────────────────────────────
func buildTextoMD(slug, categoria, formato, funil, trendRef, resumo, img1, img2 string) string {
var sb strings.Builder
sb.WriteString("---\n")
fmt.Fprintf(&sb, "slug: %s\n", slug)
fmt.Fprintf(&sb, "categoria: %s\n", categoria)
fmt.Fprintf(&sb, "formato: %s\n", formato)
if funil != "" {
fmt.Fprintf(&sb, "funil: %s\n", funil)
}
if trendRef != "" {
fmt.Fprintf(&sb, "trend_referencia: %s\n", trendRef)
}
sb.WriteString("---\n\n")
if resumo != "" {
sb.WriteString("## O que fiz\n")
sb.WriteString(resumo)
sb.WriteString("\n\n")
}
if img1 != "" {
sb.WriteString("## Imagem 1\n")
sb.WriteString(img1)
sb.WriteString("\n\n")
}
if img2 != "" {
sb.WriteString("## Imagem 2\n")
sb.WriteString(img2)
sb.WriteString("\n")
}
return sb.String()
}
func createPostWorkspace(cfg *config.Config, slug, categoria, formato, funil, trendRef, resumo, img1, img2 string) (string, error) {
postPath := workspace.PostPath(cfg.Workspace, categoria, slug)
if err := workspace.EnsureDirs(postPath); err != nil {
return "", fmt.Errorf("criar dirs: %w", err)
}
// Escrever input/texto.md
textoMD := buildTextoMD(slug, categoria, formato, funil, trendRef, resumo, img1, img2)
if err := os.WriteFile(workspace.InputTextoPath(postPath), []byte(textoMD), 0644); err != nil {
return "", fmt.Errorf("escrever texto.md: %w", err)
}
// Criar state.json
s := state.NewPostState(slug, categoria, formato, resumo, trendRef)
s.Status = state.StatusWaitingRedator
s.SetEtapa("evaluator", state.EtapaDone)
s.SetEtapa("redator", state.EtapaWaiting)
if trendRef != "" {
s.Aprovacoes.Tema.Aprovado = true
s.Aprovacoes.Tema.Timestamp = time.Now()
}
if err := state.SaveState(postPath, s); err != nil {
return "", fmt.Errorf("salvar state: %w", err)
}
return postPath, nil
}
// ─── Callback processing ──────────────────────────────────────────────────────
func processCallback(cfg *config.Config, cb string, pending *PendingCallback) (slug string, err error) {
var (
tema string
categoria string
formato string
funil string
trendRef string
resumo string
img1 string
img2 string
)
switch cb {
case "trend_a", "trend_b", "trend_c":
idx := map[string]int{"trend_a": 0, "trend_b": 1, "trend_c": 2}[cb]
if idx >= len(pending.Trends) {
return "", fmt.Errorf("trend index %d fora do range", idx)
}
t := pending.Trends[idx]
tema = t.Tema
categoria = t.CategoriaSugerida
if categoria == "" {
categoria = "Entregavel"
}
formato = pending.FormatoSorteado
trendRef = fmt.Sprintf("%s — %s", t.Tema, t.Descricao)
slug = workspace.SlugFromTitle(tema)
case "fila_original", "fila_sorteado":
if pending.NextPost == nil {
return "", fmt.Errorf("fila vazia — sem post para processar")
}
p := pending.NextPost
tema = p.Tema
categoria = p.Categoria
if categoria == "" {
categoria = "Geral"
}
funil = p.Funil
resumo = p.Resumo
img1 = p.Imagem1
img2 = p.Imagem2
slug = p.Slug
if cb == "fila_original" && p.Formato != "" {
formato = p.Formato
} else {
formato = pending.FormatoSorteado
}
default:
return "", fmt.Errorf("callback desconhecido: %q", cb)
}
if slug == "" {
slug = workspace.SlugFromTitle(tema)
}
postPath, err := createPostWorkspace(cfg, slug, categoria, formato, funil, trendRef, resumo, img1, img2)
if err != nil {
return "", err
}
log.Printf("[INFO] workspace criado: %s", postPath)
// Atualizar format-history
hist, _ := history.LoadHistory(cfg.Workspace)
if hist == nil {
hist = &history.FormatHistory{}
}
hist.AddEntry(slug, formato)
history.SaveHistory(cfg.Workspace, hist)
return slug, nil
}
// ─── Main ─────────────────────────────────────────────────────────────────────
func main() {
log.SetFlags(log.Ltime)
var (
flagDryRun bool
flagNoReddit bool
flagForceSlug string
flagWorkspace string
)
root := &cobra.Command{
Use: "ldpost-evaluator",
Short: "Busca trends, identifica fila, sorteia formato e envia para Telegram",
RunE: func(cmd *cobra.Command, args []string) error {
cfg := config.Load()
if flagWorkspace != "" {
cfg.Workspace = flagWorkspace
}
// ── Verificar se há callback pendente ─────────────────────────
if !flagDryRun {
if pending, err := loadPending(cfg.Workspace); err == nil && pending != nil {
age := time.Since(pending.CreatedAt)
log.Printf("[INFO] retomando callback pendente (criado há %s)", age.Round(time.Minute))
return resumeCallback(cfg, pending)
}
}
// ── 1. Buscar trends ──────────────────────────────────────────
var allTitles []string
log.Printf("[INFO] buscando trends no Hacker News...")
if !flagDryRun {
hnTitles, err := fetchHN()
if err != nil {
log.Printf("[WARN] HN: %v", err)
} else {
allTitles = append(allTitles, hnTitles...)
log.Printf("[INFO] HN: %d títulos", len(hnTitles))
}
}
if !flagNoReddit && !flagDryRun {
for _, sub := range []string{"LocalLLaMA", "MachineLearning"} {
log.Printf("[INFO] buscando Reddit r/%s...", sub)
rdTitles, err := fetchReddit(sub)
if err != nil {
if strings.Contains(err.Error(), "rate_limited") {
log.Printf("[WARN] Reddit rate limited, continuing without Reddit")
} else {
log.Printf("[WARN] Reddit r/%s: %v", sub, err)
}
} else {
allTitles = append(allTitles, rdTitles...)
log.Printf("[INFO] Reddit r/%s: %d títulos", sub, len(rdTitles))
}
}
}
if flagDryRun {
allTitles = []string{
"[HN 450 pts] LLM agents are replacing traditional automation",
"[HN 320 pts] Local AI with llama.cpp — production guide",
"[Reddit r/LocalLLaMA 890 pts] Running RAG locally without cloud",
"[Reddit r/MachineLearning 540 pts] New efficient fine-tuning approach",
"[HN 210 pts] Clean Architecture for AI systems",
}
}
// ── 2. Ler fila local ─────────────────────────────────────────
sugestoesPath := workspace.InboxSugestoes(cfg.Workspace)
allPosts, err := parseSugestoes(sugestoesPath)
if err != nil {
log.Printf("[WARN] %v", err)
} else {
log.Printf("[INFO] _sugestoes.md: %d posts", len(allPosts))
// Adicionar títulos da fila às fontes de trend
for _, p := range allPosts {
if p.Tema != "" {
allTitles = append(allTitles, "[Fila local] "+p.Tema)
}
}
}
// Identificar próximo da fila
var nextPost *SugestaoPost
if flagForceSlug != "" {
for i := range allPosts {
if allPosts[i].Slug == flagForceSlug {
nextPost = &allPosts[i]
break
}
}
if nextPost == nil {
return fmt.Errorf("--force-slug %q não encontrado em _sugestoes.md", flagForceSlug)
}
} else {
nextPost = findNextInQueue(cfg.Workspace, allPosts)
}
if nextPost != nil {
log.Printf("[INFO] próximo da fila: %s — %s", nextPost.Slug, nextPost.Tema)
} else {
log.Printf("[INFO] fila vazia")
}
// ── 3. Consolidar trends via LLM ──────────────────────────────
var trends []Trend
if !flagDryRun && cfg.GroqAPIKey != "" {
log.Printf("[INFO] consolidando %d títulos via Groq...", len(allTitles))
gc := groq.NewGroqClient(cfg.GroqAPIKey)
var groqErr error
for attempt := 1; attempt <= 3; attempt++ {
trends, groqErr = consolidateTrends(gc, allTitles)
if groqErr == nil {
break
}
log.Printf("[WARN] Groq retry %d/3: %v", attempt, groqErr)
time.Sleep(time.Duration(attempt) * 2 * time.Second)
}
if groqErr != nil {
log.Printf("[WARN] Groq falhou após 3 tentativas, usando títulos brutos")
trends = buildFallbackTrends(allTitles)
}
} else {
trends = buildFallbackTrends(allTitles)
}
// Garantir 3 trends (preencher com vazios se necessário)
for len(trends) < 3 {
trends = append(trends, Trend{
Tema: fmt.Sprintf("Trend %d (sem dados)", len(trends)+1),
Descricao: "Dados insuficientes",
Mencoes: 0,
Momentum: "estavel",
RelevanciaProfile: "baixa",
CategoriaSugerida: "Geral",
})
}
// ── 4. Sortear formato ────────────────────────────────────────
hist, _ := history.LoadHistory(cfg.Workspace)
if hist == nil {
hist = &history.FormatHistory{}
}
bloqueados := hist.LastN(3)
sorteado := formats.SortearFormato(bloqueados)
log.Printf("[INFO] formato sorteado: %s (bloqueados: %v)", sorteado, bloqueados)
// ── 5. Dry-run ────────────────────────────────────────────────
if flagDryRun {
fmt.Printf("=== DRY-RUN — ldpost-evaluator ===\n\n")
fmt.Printf("TRENDS:\n")
for i, t := range trends[:3] {
fmt.Printf(" %s) %s\n %s | %s | %s\n",
[]string{"A", "B", "C"}[i], t.Tema, t.Descricao, t.Momentum, t.RelevanciaProfile)
}
if nextPost != nil {
fmt.Printf("\nFILA PRÓXIMO: %s — %s (formato: %s)\n", nextPost.Slug, nextPost.Tema, nextPost.Formato)
} else {
fmt.Printf("\nFILA: vazia\n")
}
fmt.Printf("FORMATO SORTEADO: %s (bloqueados: %v)\n", sorteado, bloqueados)
return nil
}
// ── 6. Validar Telegram ───────────────────────────────────────
if err := cfg.Validate("telegram"); err != nil {
return fmt.Errorf("Telegram não configurado: %w\nUse --dry-run para testar sem Telegram", err)
}
bot := telegram.NewBot(cfg.TelegramBotToken, cfg.TelegramChatID)
// ── 7. Enviar mensagens Telegram ──────────────────────────────
log.Printf("[INFO] enviando mensagens Telegram...")
if err := sendTrendMessages(bot, trends, nextPost, sorteado, bloqueados); err != nil {
return fmt.Errorf("Telegram: %w", err)
}
// ── 8. Salvar pending callback ────────────────────────────────
pending := &PendingCallback{
Trends: trends,
NextPost: nextPost,
FormatoSorteado: sorteado,
FormatosBloqueados: bloqueados,
CreatedAt: time.Now(),
}
if err := savePending(cfg.Workspace, pending); err != nil {
log.Printf("[WARN] salvar pending: %v", err)
}
log.Printf("[INFO] aguardando callback no Telegram (sem timeout)...")
// ── 9. Aguardar callback ──────────────────────────────────────
return resumeCallback(cfg, pending)
},
}
root.Flags().BoolVar(&flagDryRun, "dry-run", false, "Executa sem chamar APIs nem escrever no disco")
root.Flags().BoolVar(&flagNoReddit, "no-reddit", false, "Pula Reddit (útil se rate limited)")
root.Flags().StringVar(&flagForceSlug, "force-slug", "", "Força slug específico da fila (pula seleção de tema)")
root.Flags().StringVar(&flagWorkspace, "workspace", "", "Override de LDPOST_WORKSPACE")
if err := root.Execute(); err != nil {
os.Exit(1)
}
}
// resumeCallback waits for one of the 5 valid callbacks then processes it.
func resumeCallback(cfg *config.Config, pending *PendingCallback) error {
bot := telegram.NewBot(cfg.TelegramBotToken, cfg.TelegramChatID)
valid := []string{"trend_a", "trend_b", "trend_c"}
if pending.NextPost != nil {
if pending.NextPost.Formato != "" {
valid = append(valid, "fila_original")
}
valid = append(valid, "fila_sorteado")
}
log.Printf("[INFO] esperando callbacks válidos: %v", valid)
cb, err := bot.WaitForCallback(valid, 0) // 0 = indefinido
if err != nil {
return fmt.Errorf("callback: %w", err)
}
log.Printf("[INFO] callback recebido: %s", cb)
slug, err := processCallback(cfg, cb, pending)
if err != nil {
bot.SendMessage(fmt.Sprintf("❌ Erro ao processar escolha: %s", err.Error()))
return err
}
// Confirmar no Telegram
var titulo string
if pending.NextPost != nil && (cb == "fila_original" || cb == "fila_sorteado") {
titulo = pending.NextPost.Tema
} else {
idx := map[string]int{"trend_a": 0, "trend_b": 1, "trend_c": 2}[cb]
if idx < len(pending.Trends) {
titulo = pending.Trends[idx].Tema
}
}
confirmMsg := fmt.Sprintf(
"✅ <b>Escolha registrada!</b>\n\nPost: <i>%s</i>\nSlug: <code>%s</code>\n\nPróximo passo:\n<code>ldpost-redator --post %s</code>",
titulo, slug, slug,
)
if _, err := bot.SendMessage(confirmMsg); err != nil {
log.Printf("[WARN] confirmação Telegram: %v", err)
}
// Limpar pending
deletePending(cfg.Workspace)
log.Printf("[INFO] done — post slug: %s", slug)
fmt.Println(slug)
return nil
}
// ─── Utilities ────────────────────────────────────────────────────────────────
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n-1] + "…"
}

11
go.work Normal file
View File

@ -0,0 +1,11 @@
go 1.22
use (
./shared
./evaluator
./redator
./editor
./art
./director
./publisher
)

15
go.work.sum Normal file
View File

@ -0,0 +1,15 @@
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

16
publisher/go.mod Normal file
View File

@ -0,0 +1,16 @@
module ldpost/publisher
go 1.22
require (
github.com/spf13/cobra v1.8.0
ldpost/shared v0.0.0
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
)
replace ldpost/shared => ../shared

12
publisher/go.sum Normal file
View File

@ -0,0 +1,12 @@
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

599
publisher/main.go Normal file
View File

@ -0,0 +1,599 @@
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/spf13/cobra"
"ldpost/shared/config"
"ldpost/shared/history"
"ldpost/shared/state"
"ldpost/shared/telegram"
"ldpost/shared/workspace"
)
const linkedInAPIBase = "https://api.linkedin.com/v2"
// ─── LinkedIn types ───────────────────────────────────────────────────────────
type ugcPost struct {
Author string `json:"author"`
LifecycleState string `json:"lifecycleState"`
SpecificContent specificContent `json:"specificContent"`
Visibility visibility `json:"visibility"`
}
type specificContent struct {
ShareContent shareContent `json:"com.linkedin.ugc.ShareContent"`
}
type shareContent struct {
ShareCommentary shareCommentary `json:"shareCommentary"`
ShareMediaCategory string `json:"shareMediaCategory"`
Media []ugcMedia `json:"media,omitempty"`
}
type shareCommentary struct {
Text string `json:"text"`
}
type ugcMedia struct {
Status string `json:"status"`
Description localText `json:"description"`
Media string `json:"media,omitempty"`
Title localText `json:"title"`
}
type localText struct {
Text string `json:"text"`
}
type visibility struct {
MemberNetworkVisibility string `json:"com.linkedin.ugc.MemberNetworkVisibility"`
}
// ─── LinkedIn helpers ─────────────────────────────────────────────────────────
// checkToken returns (personID, nil) on 200, ("", err) on failure or 401.
// Uses /v2/userinfo (OpenID Connect) — requires openid+profile scopes.
// The "sub" field contains the member ID used for urn:li:person:{id}.
func checkToken(token string) (string, error) {
req, _ := http.NewRequest("GET", linkedInAPIBase+"/userinfo", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("GET /userinfo: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == 401 {
return "", fmt.Errorf("token inválido ou expirado (401)")
}
body, _ := io.ReadAll(resp.Body)
var result struct {
Sub string `json:"sub"` // OpenID Connect member ID
}
if err := json.Unmarshal(body, &result); err != nil || result.Sub == "" {
return "", fmt.Errorf("LinkedIn /userinfo inválido: %s", string(body))
}
return result.Sub, nil
}
func uploadImage(token, personURN, imgPath, slideLabel string) (string, error) {
regReq := map[string]any{
"registerUploadRequest": map[string]any{
"recipes": []string{"urn:li:digitalmediaRecipe:feedshare-document"},
"owner": personURN,
"serviceRelationships": []map[string]string{
{"relationshipType": "OWNER", "identifier": "urn:li:userGeneratedContent"},
},
},
}
regData, _ := json.Marshal(regReq)
req, _ := http.NewRequest("POST", linkedInAPIBase+"/assets?action=registerUpload", bytes.NewReader(regData))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Restli-Protocol-Version", "2.0.0")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("registerUpload %s: %w", slideLabel, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var regResp struct {
Value struct {
UploadMechanism struct {
Req struct {
UploadURL string `json:"uploadUrl"`
Headers map[string]string `json:"headers"`
} `json:"com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest"`
} `json:"uploadMechanism"`
Asset string `json:"asset"`
} `json:"value"`
}
if err := json.Unmarshal(body, &regResp); err != nil {
return "", fmt.Errorf("parsear registerUpload %s: %w (body: %s)", slideLabel, err, string(body))
}
uploadURL := regResp.Value.UploadMechanism.Req.UploadURL
assetURN := regResp.Value.Asset
if uploadURL == "" {
return "", fmt.Errorf("uploadURL vazia para %s: %s", slideLabel, string(body))
}
imgData, err := os.ReadFile(imgPath)
if err != nil {
return "", fmt.Errorf("ler %s: %w", slideLabel, err)
}
putReq, _ := http.NewRequest("PUT", uploadURL, bytes.NewReader(imgData))
for k, v := range regResp.Value.UploadMechanism.Req.Headers {
putReq.Header.Set(k, v)
}
putReq.Header.Set("Content-Type", "application/octet-stream")
putResp, err := http.DefaultClient.Do(putReq)
if err != nil {
return "", fmt.Errorf("PUT %s: %w", slideLabel, err)
}
defer putResp.Body.Close()
if putResp.StatusCode >= 300 {
b, _ := io.ReadAll(putResp.Body)
return "", fmt.Errorf("upload %s status %d: %s", slideLabel, putResp.StatusCode, string(b))
}
log.Printf("[INFO] %s uploaded → %s", slideLabel, assetURN)
return assetURN, nil
}
func publishPost(token, personURN, text string, assetURNs []string) (string, error) {
var mediaList []ugcMedia
for i, urn := range assetURNs {
label := fmt.Sprintf("Slide %d", i+1)
mediaList = append(mediaList, ugcMedia{
Status: "READY",
Description: localText{label},
Media: urn,
Title: localText{label},
})
}
cat := "NONE"
if len(mediaList) > 0 {
cat = "IMAGE"
}
post := ugcPost{
Author: personURN,
LifecycleState: "PUBLISHED",
SpecificContent: specificContent{
ShareContent: shareContent{
ShareCommentary: shareCommentary{Text: text},
ShareMediaCategory: cat,
Media: mediaList,
},
},
Visibility: visibility{MemberNetworkVisibility: "PUBLIC"},
}
postData, _ := json.Marshal(post)
req, _ := http.NewRequest("POST", linkedInAPIBase+"/ugcPosts", bytes.NewReader(postData))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Restli-Protocol-Version", "2.0.0")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("POST /ugcPosts: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
return "", fmt.Errorf("ugcPosts status %d: %s", resp.StatusCode, string(body))
}
var result struct {
ID string `json:"id"`
}
if err := json.Unmarshal(body, &result); err != nil || result.ID == "" {
return "", fmt.Errorf("parsear ugcPosts: %w (body: %s)", err, string(body))
}
return result.ID, nil
}
// ─── Markdown cleanup ─────────────────────────────────────────────────────────
var headerRe = regexp.MustCompile(`(?m)^#{1,6}\s+`)
func cleanMarkdown(text string) string {
// Remove header markers
text = headerRe.ReplaceAllString(text, "")
// Remove bold/italic markers
text = strings.ReplaceAll(text, "**", "")
text = strings.ReplaceAll(text, "__", "")
// Remove YAML front-matter if present
if strings.HasPrefix(text, "---") {
if end := strings.Index(text[3:], "---"); end >= 0 {
text = text[end+6:]
}
}
return strings.TrimSpace(text)
}
// ─── OAuth2 flow ──────────────────────────────────────────────────────────────
func runOAuthFlow(cfg *config.Config) error {
if cfg.LinkedInClientID == "" || cfg.LinkedInClientSecret == "" {
return fmt.Errorf("LINKEDIN_CLIENT_ID e LINKEDIN_CLIENT_SECRET são obrigatórios para --auth")
}
redirectURI := "http://localhost:8080/callback"
authURL := fmt.Sprintf(
"https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=%s&redirect_uri=%s&scope=%s",
cfg.LinkedInClientID,
url.QueryEscape(redirectURI),
url.QueryEscape("w_member_social openid profile"),
)
fmt.Println("Token LinkedIn não encontrado. Configurando OAuth2.")
fmt.Println()
fmt.Println("1. Abra esta URL no navegador:")
fmt.Println(" ", authURL)
fmt.Println()
fmt.Println("2. Aguardando callback em", redirectURI, "...")
codeCh := make(chan string, 1)
errCh := make(chan error, 1)
srv := &http.Server{Addr: ":8080"}
http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
if code == "" {
errCh <- fmt.Errorf("código ausente no callback: %s", r.URL.RawQuery)
fmt.Fprintf(w, "Erro: código ausente.")
return
}
codeCh <- code
fmt.Fprintf(w, "Autorização recebida! Pode fechar esta janela.")
})
ln, err := net.Listen("tcp", ":8080")
if err != nil {
return fmt.Errorf("abrir porta 8080: %w", err)
}
go srv.Serve(ln)
var code string
select {
case code = <-codeCh:
case err = <-errCh:
srv.Shutdown(context.Background())
return err
case <-time.After(5 * time.Minute):
srv.Shutdown(context.Background())
return fmt.Errorf("timeout aguardando autorização (5 min)")
}
srv.Shutdown(context.Background())
// Exchange code for token
params := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {redirectURI},
"client_id": {cfg.LinkedInClientID},
"client_secret": {cfg.LinkedInClientSecret},
}
resp, err := http.PostForm("https://www.linkedin.com/oauth/v2/accessToken", params)
if err != nil {
return fmt.Errorf("trocar código por token: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var tokenResp struct {
AccessToken string `json:"access_token"`
}
if err := json.Unmarshal(body, &tokenResp); err != nil || tokenResp.AccessToken == "" {
return fmt.Errorf("parsear token response: %w (body: %s)", err, string(body))
}
// Append to .env
envEntry := fmt.Sprintf("\nLINKEDIN_ACCESS_TOKEN=%s\n", tokenResp.AccessToken)
f, err := os.OpenFile(".env", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
fmt.Printf("Token obtido! Adicione manualmente ao .env:\nLINKEDIN_ACCESS_TOKEN=%s\n", tokenResp.AccessToken)
return nil
}
f.WriteString(envEntry)
f.Close()
fmt.Println("✅ Token salvo em .env — rode ldpost-publisher novamente.")
return nil
}
// ─── Manual mode ─────────────────────────────────────────────────────────────
func runManualMode(postPath string, s *state.PostState, postText string, outputImgs []string, reason string) error {
sep := strings.Repeat("═", 51)
fmt.Println(sep)
fmt.Println("ldpost-publisher | MODO MANUAL")
fmt.Println(sep)
fmt.Println()
fmt.Printf("⚠️ %s\n Modo manual ativado — você vai postar manualmente.\n\n", reason)
fmt.Println("TEXTO DO POST (copie tudo abaixo da linha):")
fmt.Println(strings.Repeat("─", 51))
fmt.Println(postText)
fmt.Println(strings.Repeat("─", 51))
fmt.Println()
if len(outputImgs) > 0 {
fmt.Println("IMAGENS:")
for i, img := range outputImgs {
fmt.Printf(" Slide %d: %s\n", i+1, img)
}
fmt.Println()
}
fmt.Println("INSTRUÇÕES:")
fmt.Println(" 1. Abra o LinkedIn no navegador")
fmt.Println(" 2. Clique em \"Criar post\" → \"Adicionar fotos\"")
fmt.Println(" 3. Selecione slide1.png e slide2.png (nessa ordem)")
fmt.Println(" 4. Cole o texto acima na área de texto")
fmt.Println(" 5. Clique em \"Publicar\"")
fmt.Println()
fmt.Print("Após publicar, cole o link do post aqui para registrar:\nURL do post: ")
scanner := bufio.NewScanner(os.Stdin)
if !scanner.Scan() {
return fmt.Errorf("leitura cancelada")
}
postURL := strings.TrimSpace(scanner.Text())
if postURL == "" {
log.Printf("[WARN] URL não fornecida — state não atualizado")
return nil
}
// Extract post ID from URL
postID := extractPostID(postURL)
return finalizePublished(postPath, s, postID, postURL)
}
// extractPostID attempts to pull the URN from a LinkedIn post URL.
func extractPostID(rawURL string) string {
// e.g. https://www.linkedin.com/feed/update/urn:li:ugcPost:123/
parts := strings.Split(rawURL, "/")
for _, p := range parts {
if strings.HasPrefix(p, "urn:li:") {
return p
}
}
return rawURL // fallback: store the URL itself as ID
}
// ─── State finalization ───────────────────────────────────────────────────────
func finalizePublished(postPath string, s *state.PostState, postID, postURL string) error {
now := time.Now()
s.Status = state.StatusPublished
s.SetEtapa("publisher", state.EtapaDone)
s.LinkedInPostID = postID
s.PublishedAt = &now
if err := state.SaveState(postPath, s); err != nil {
return fmt.Errorf("state: %w", err)
}
fmt.Println()
fmt.Println("✅ POST PUBLICADO!")
fmt.Println()
fmt.Printf("LinkedIn: %s\n", postURL)
fmt.Printf("Slug: %s\n", s.Slug)
fmt.Printf("Publicado em: %s\n", now.Format("02/01/2006 15:04"))
fmt.Println()
fmt.Println("Parabéns. Agora atualize as métricas em 48h para o evaluator.")
return nil
}
// ─── Main ─────────────────────────────────────────────────────────────────────
func main() {
log.SetFlags(log.Ltime)
var (
flagPost string
flagWorkspace string
flagDryRun bool
flagManual bool
flagAuth bool
)
root := &cobra.Command{
Use: "ldpost-publisher --post <slug>",
Short: "Publica o post aprovado no LinkedIn",
RunE: func(cmd *cobra.Command, args []string) error {
cfg := config.Load()
if flagWorkspace != "" {
cfg.Workspace = flagWorkspace
}
// ── --auth: OAuth2 setup flow ─────────────────────────────────
if flagAuth {
return runOAuthFlow(cfg)
}
if flagPost == "" {
return fmt.Errorf("--post é obrigatório")
}
// ── 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.StatusWaitingPublisher) {
log.Printf("[ERROR] status atual: %q — esperado: %q", s.Status, state.StatusWaitingPublisher)
return fmt.Errorf("estado incorreto — rode ldpost-director --post %s primeiro", flagPost)
}
if !s.Aprovacoes.Final.Aprovado && !flagDryRun && !flagManual {
return fmt.Errorf("post sem aprovação final — rode ldpost-director --post %s", flagPost)
}
// ── 2. Ler conteúdo ───────────────────────────────────────────
postText := ""
if data, err := os.ReadFile(workspace.OutputPostPath(postPath)); err == nil {
postText = cleanMarkdown(string(data))
} else if data, err := os.ReadFile(filepath.Join(workspace.WorkPath(postPath), "editor-final.md")); err == nil {
postText = cleanMarkdown(string(data))
} else {
return fmt.Errorf("output/post.md e work/editor-final.md não encontrados")
}
slide1 := filepath.Join(workspace.OutputPath(postPath), "slide1.png")
slide2 := filepath.Join(workspace.OutputPath(postPath), "slide2.png")
outputImgs := []string{}
for _, p := range []string{slide1, slide2} {
if _, err := os.Stat(p); err == nil {
outputImgs = append(outputImgs, p)
}
}
log.Printf("[INFO] post=%s chars=%d imagens=%d", s.Slug, len(postText), len(outputImgs))
// ── 3. Dry-run ────────────────────────────────────────────────
if flagDryRun {
fmt.Println("⚠️ DRY-RUN — NENHUMA CHAMADA À API LINKEDIN SERÁ FEITA")
fmt.Println(strings.Repeat("═", 51))
fmt.Printf("Post: %s | Chars: %d | Imagens: %d\n\n", flagPost, len(postText), len(outputImgs))
fmt.Println("── TEXTO LIMPO ──────────────────────────────────────")
fmt.Println(postText)
fmt.Println(strings.Repeat("─", 51))
fmt.Printf("Aprovado: %v\n", s.Aprovacoes.Final.Aprovado)
return nil
}
// ── 4. Verificar token / decidir modo ─────────────────────────
var personID string
manualReason := ""
if flagManual {
manualReason = "Modo manual solicitado via --manual."
} else if cfg.LinkedInAccessToken == "" {
manualReason = "Token LinkedIn não configurado."
if cfg.LinkedInClientID != "" {
manualReason += " Configure com: ldpost-publisher --auth"
}
} else {
id, err := checkToken(cfg.LinkedInAccessToken)
if err != nil {
manualReason = fmt.Sprintf("Token LinkedIn inválido: %v", err)
} else {
personID = id
}
}
if manualReason != "" {
return runManualMode(postPath, s, postText, outputImgs, manualReason)
}
personURN := "urn:li:person:" + personID
log.Printf("[INFO] person URN: %s", personURN)
// ── 5. Confirmação antes de publicar ──────────────────────────
fmt.Printf("\n⚠ PUBLICAÇÃO IRREVERSÍVEL\n")
fmt.Printf("Post: %s | Formato: %s\n", s.Slug, s.Formato)
fmt.Printf("Confirmar publicação? (s/N): ")
scanner := bufio.NewScanner(os.Stdin)
if !scanner.Scan() || strings.ToLower(strings.TrimSpace(scanner.Text())) != "s" {
fmt.Println("Publicação cancelada.")
return nil
}
// ── 6. Upload imagens ─────────────────────────────────────────
var assetURNs []string
for i, imgPath := range outputImgs {
label := fmt.Sprintf("slide%d", i+1)
log.Printf("[INFO] upload %s...", label)
urn, err := uploadImage(cfg.LinkedInAccessToken, personURN, imgPath, label)
if err != nil {
log.Printf("[ERROR] upload %s: %v", label, err)
if len(assetURNs) > 0 {
log.Printf("[INFO] asset URNs já obtidos (para retry): %v", assetURNs)
}
return fmt.Errorf("falha no upload de %s: %w", label, err)
}
assetURNs = append(assetURNs, urn)
}
// ── 7. Publicar post ──────────────────────────────────────────
log.Printf("[INFO] publicando post no LinkedIn...")
postID, err := publishPost(cfg.LinkedInAccessToken, personURN, postText, assetURNs)
if err != nil {
log.Printf("[INFO] asset URNs para retry: %v", assetURNs)
return fmt.Errorf("publicar: %w", err)
}
postURL := fmt.Sprintf("https://www.linkedin.com/feed/update/%s/", postID)
log.Printf("[INFO] publicado: %s", postURL)
// ── 8. Finalizar state e histórico ────────────────────────────
if err := finalizePublished(postPath, s, postID, postURL); err != nil {
return err
}
hist, err := history.LoadHistory(cfg.Workspace)
if err != nil {
hist = &history.FormatHistory{}
}
hist.AddEntry(s.Slug, s.Formato)
if err := history.SaveHistory(cfg.Workspace, hist); err != nil {
log.Printf("[WARN] format-history: %v", err)
}
// Ensure output/post.md exists with clean text
if _, err := os.Stat(workspace.OutputPostPath(postPath)); os.IsNotExist(err) {
os.WriteFile(workspace.OutputPostPath(postPath), []byte(postText), 0644)
}
// ── 9. Notificação Telegram ───────────────────────────────────
if cfg.TelegramBotToken != "" && cfg.TelegramChatID != "" {
bot := telegram.NewBot(cfg.TelegramBotToken, cfg.TelegramChatID)
msg := fmt.Sprintf(
"🚀 <b>Post publicado!</b>\n\n<code>%s</code>\n🔗 %s\n📅 %s",
s.Slug, postURL, time.Now().Format("02/01/2006 15:04"),
)
if _, err := bot.SendMessage(msg); err != nil {
log.Printf("[WARN] Telegram: %v", err)
}
}
return nil
},
}
root.Flags().StringVar(&flagPost, "post", "", "Slug do post (obrigatório, exceto com --auth)")
root.Flags().StringVar(&flagWorkspace, "workspace", "", "Override de LDPOST_WORKSPACE")
root.Flags().BoolVar(&flagDryRun, "dry-run", false, "Prepara tudo sem chamar LinkedIn API")
root.Flags().BoolVar(&flagManual, "manual", false, "Força modo manual mesmo com token configurado")
root.Flags().BoolVar(&flagAuth, "auth", false, "Inicia fluxo OAuth2 para configurar token")
if err := root.Execute(); err != nil {
os.Exit(1)
}
}

16
redator/go.mod Normal file
View File

@ -0,0 +1,16 @@
module ldpost/redator
go 1.22
require (
github.com/spf13/cobra v1.8.0
ldpost/shared v0.0.0
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
)
replace ldpost/shared => ../shared

12
redator/go.sum Normal file
View File

@ -0,0 +1,12 @@
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

209
redator/main.go Normal file
View File

@ -0,0 +1,209 @@
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)
}
}

450
seed/_sugestoes.md Normal file
View File

@ -0,0 +1,450 @@
# Seed de Sugestões — ldpost-squad
# 30 posts baseados em experiências reais de Ricardo
# Formato: ## slug → campos **Chave:** Valor
# Copie para: C:\Textos-Linkedin\_inbox\_sugestoes.md
---
## templates-propostas-comerciais
**Categoria:** Entregavel
**Tema:** Como criei templates com indicadores substituíveis que mantêm a aparência das minhas propostas comerciais
**Formato:** como
**Funil:** meio
**Resumo:** Toda vez que eu precisava montar uma proposta comercial eu perdia tempo reformatando. Criei um sistema de templates Word/Google Docs com marcadores no estilo {{CLIENTE}}, {{VALOR}}, {{PRAZO}}, {{ESCOPO}} que são substituídos automaticamente. O resultado mantém sempre a mesma identidade visual profissional. Tenho prints mostrando a proposta antes (bagunçada, cada uma diferente) e depois (todas com a mesma cara). Economizo em média 45min por proposta. A substituição pode ser feita manualmente ou via script simples.
**Imagem 1:** Antes e depois: duas propostas comerciais lado a lado — esquerda com formatação inconsistente, direita com template padronizado e profissional, fundo branco com elementos em azul corporativo
**Imagem 2:** Template com marcadores coloridos em destaque {{CLIENTE}}, {{VALOR}}, {{PRAZO}} visíveis no documento, estilo minimalista, clean, profissional
---
## templates-docs-arquitetura
**Categoria:** Codigo
**Tema:** Template de documento de arquitetura com indicadores substituíveis — o que aprendi depois de 50+ ADRs
**Formato:** como
**Funil:** meio
**Resumo:** Depois de escrever mais de 50 Architecture Decision Records e documentos de design técnico, identifiquei os campos que são sempre os mesmos: contexto, decisão, alternativas consideradas, consequências. Criei um template Markdown com marcadores {{SISTEMA}}, {{DATA}}, {{DECISÃO}}, {{CONTEXTO}}, {{ALTERNATIVAS}} e {{TRADE_OFFS}}. O que mudou: novos devs conseguem escrever o primeiro ADR sem me perguntar como fazer. Tenho prints comparando um ADR escrito do zero vs um usando o template — qualidade e completude notavelmente diferentes.
**Imagem 1:** Documento de arquitetura técnica aberto no editor com seções bem delimitadas: Contexto, Decisão, Alternativas, Trade-offs — paleta azul/cinza tech, estilo clean
**Imagem 2:** Diagrama de fluxo: engenheiro recebe template → preenche campos → ADR pronto e revisável — ícones geométricos, sem rostos, estilo flat design
---
## templates-planilhas
**Categoria:** Entregavel
**Tema:** Templates de planilha com indicadores substituíveis: como padronizei relatórios que antes levavam 3h
**Formato:** como
**Funil:** meio
**Resumo:** Eu mantinha planilhas de acompanhamento de projetos, orçamentos e métricas de time. Cada uma era criada do zero e ficava diferente das outras. Criei templates com células nomeadas e fórmulas que referenciam essas células pelo nome (ex: =PROJETO_BUDGET * 1.1). Quando mudo o nome do projeto em uma célula, tudo atualiza. Tenho prints das planilhas antes e depois. O processo de criação de relatório mensal caiu de ~3h para ~40min. A chave foi usar Named Ranges em vez de referências diretas (A1, B2) — isso é o que permite a substituição sem quebrar fórmulas.
**Imagem 1:** Planilha de acompanhamento de projeto com células nomeadas destacadas em amarelo e fórmulas visíveis referenciando nomes, não coordenadas — estilo profissional, cores neutras
**Imagem 2:** Comparação lado a lado: planilha antiga com fórmulas hardcoded vs nova com Named Ranges — seta no centro indicando evolução, fundo branco
---
## criar-textos-voz-propria
**Categoria:** Bastidor
**Tema:** Como treinei a IA para escrever no meu tom — sem fine-tuning, só com prompts certos
**Formato:** como
**Funil:** topo
**Resumo:** Precisava que a IA gerasse textos que soassem como eu escrevo: direto, sem enrolação, técnico mas acessível, sem frases corporativas. Em vez de fine-tuning (caro e lento), usei uma abordagem de few-shot com exemplos meus reais no system prompt. O truque foi: 5 exemplos curtos de parágrafos que eu escrevi + lista explícita de frases que EU NUNCA USARIA (como "no cenário atual", "é importante destacar", "mergulho profundo"). Resultado: 80% dos rascunhos gerados precisam de menos de 3 ajustes antes de publicar.
**Imagem 1:** System prompt aberto em editor de código com seção "ESTILO DO AUTOR" contendo exemplos reais e lista de proibições — fundo escuro, código limpo
**Imagem 2:** Comparação: texto genérico da IA vs texto no estilo do autor — mesma informação, ton completamente diferente, dois blocos de texto lado a lado com contraste visual
---
## fluxo-trabalho-ia-conteudo
**Categoria:** Geral
**Tema:** O fluxo de trabalho que uso com IA para criar conteúdo técnico — explica, analisa, complementa, repete
**Formato:** checklist
**Funil:** topo
**Resumo:** Desenvolvi um fluxo iterativo para criar conteúdo com IA que evita o problema comum de "a IA inventou". O ciclo: (1) eu explico o que fiz com minhas palavras, (2) peço para a IA analisar e identificar gaps no meu raciocínio, (3) ela complementa com contexto técnico que eu não havia mencionado, (4) eu reviso e corrijo o que ela errou, (5) repito até estar satisfeito. A diferença crucial: eu nunca peço pra ela "criar" — peço pra ela "ampliar" o que eu já escrevi. Isso evita alucinações e mantém minha voz.
**Imagem 1:** Diagrama circular mostrando o ciclo: Escrever → Analisar → Complementar → Revisar → Publicar — cada etapa com ícone simples, setas conectando, paleta tech azul/branco
**Imagem 2:** Tela de chat com IA mostrando o prompt "Analise meu texto e indique o que está impreciso ou incompleto" seguido de resposta estruturada — estilo screenshot limpo, sem conteúdo sensível
---
## rag-conceito-quando-usar
**Categoria:** Codigo
**Tema:** RAG: o que é, quando usar e quando NÃO usar — sem hype
**Formato:** porque
**Funil:** topo
**Resumo:** RAG (Retrieval-Augmented Generation) virou buzzword mas poucos explicam quando ele realmente faz sentido. Usei RAG em 4 projetos diferentes: um funcionou muito bem (consulta de documentação interna), dois funcionaram razoavelmente (FAQ de produtos), um foi um fracasso total (código-fonte indexado sem chunking adequado). O padrão que aprendi: RAG funciona quando o usuário faz perguntas sobre documentos que EXISTEM e foram indexados. Não funciona como substituto de fine-tuning, não funciona com chunks grandes demais, não funciona quando a qualidade dos documentos originais é baixa. O erro mais comum: achar que indexar tudo resolve — na verdade piora.
**Imagem 1:** Diagrama de arquitetura RAG: Documento → Chunker → Embedder → Vector DB → Query → Retriever → LLM → Resposta — setas claras, ícones geométricos, fundo branco
**Imagem 2:** Tabela de decisão: "Use RAG quando..." vs "NÃO use RAG quando..." — duas colunas com checkmarks verdes e X vermelhos, design limpo
---
## rag-csharp-semantic-kernel
**Categoria:** Codigo
**Tema:** Construí RAG local em C# com Semantic Kernel — e a IA quase quebrou tudo usando APIs em PREVIEW
**Formato:** erro
**Funil:** fundo
**Resumo:** Pedi para a IA me ajudar a construir um RAG local com C# .NET 8 + Semantic Kernel 1.x. Ela gerou código que compilava perfeitamente. O problema: o Semantic Kernel tinha dezenas de recursos marcados como [Experimental] e [Preview] nas versões 1.x, e a IA usou todos eles sem avisar. Entre uma versão minor e outra, as assinaturas mudavam, interfaces sumiam, namespaces se reorganizavam. Tive que estudar o changelog manualmente, identificar o que era estável vs preview, e reescrever partes inteiras do código. O resultado final funcionou: pipeline RAG local sem custo, 380ms de latência, zero dependência de API externa. Mas o trabalho humano de navegar as versões instáveis não foi opcional — foi o que separou um projeto funcionando de um projeto que compilava mas quebrava em runtime.
**Imagem 1:** Comparação de duas versões de código C#: gerado pela IA (usando APIs [Experimental]) vs versão final corrigida (usando apenas APIs estáveis) — diff visual, vermelho/verde, fundo escuro
**Imagem 2:** Diagrama do pipeline RAG local que ficou no final: PDF → PdfPig → Chunker → nomic-embed-text (Ollama) → Qdrant → Query → Semantic Kernel (estável) → Llama 3.1 → Resposta
---
## fine-tuning-tom-modelo
**Categoria:** Codigo
**Tema:** Fine-tuning parece fácil até você descobrir que a parte difícil é 100% trabalho humano
**Formato:** porque
**Funil:** meio
**Resumo:** Queria um modelo que respondesse em português brasileiro, direto, sem floreios corporativos. A IA faz o fine-tuning em horas — esse não era o problema. O problema era o dataset: 400 pares de pergunta/resposta no meu estilo, que eu tive que escrever. Levou 3x mais tempo que o fine-tuning em si. Nenhuma IA pode fazer isso por mim — ela não sabe como eu escrevo, quais expressões eu uso, o que eu jamais diria. O que aprendi: fine-tuning de tom funciona bem com ~200-500 exemplos de qualidade. Abaixo de 100, o modelo "esquece" o estilo em prompts mais longos. Acima de 2000, começa catastrophic forgetting no conhecimento base. O trabalho técnico (treinamento, merge, quantização GGUF) levou 2h. O trabalho humano (escrever os exemplos) levou 3 dias. Quem acha que IA automatiza tudo ainda não fez fine-tuning sério.
**Imagem 1:** Gráfico de tempo: proporção do projeto — Dataset (humano, 72h) vs Fine-tuning (máquina, 2h) — barras contrastantes mostrando onde o tempo real foi
**Imagem 2:** Pipeline de fine-tuning: Dataset (JSONL) → Validação → Training (Unsloth) → Merge → Quantização (GGUF) → Teste — destacando em vermelho a etapa Dataset como gargalo humano
---
## fine-tuning-guardrail
**Categoria:** Codigo
**Tema:** Criei um guardrail de IA com fine-tuning — e o modelo só funciona porque eu classifiquei 800 exemplos à mão
**Formato:** porque
**Funil:** fundo
**Resumo:** Precisava filtrar respostas inaceitáveis do meu LLM no contexto específico do meu produto. Guardrails genéricos da OpenAI têm 87% de acurácia no meu domínio — bom, mas não suficiente. A solução: fine-tuning de um modelo pequeno (Phi-3 Mini) especificamente para o meu contexto. O que a IA fez: treinamento, validação, deploy — automático. O que eu fiz: classifiquei 800 exemplos reais (resposta + label: ok/bloqueado) em 2 tardes de trabalho manual. Sem esse trabalho humano de rotulagem, o modelo não existe. O resultado valeu: 94% de acurácia no meu domínio, 50ms de latência, custo zero por chamada. A lição: modelos de IA especializados são melhores que genéricos — mas alguém precisa ensinar o que é "certo" no seu contexto. Essa pessoa é você, não outra IA.
**Imagem 1:** Diagrama de arquitetura: Usuário → LLM Principal → Saída → Guardrail (Phi-3 Mini fine-tuned) → OK/Bloqueado → Resposta — fluxo com bifurcação, destaque no "fine-tuned por humano"
**Imagem 2:** Tabela comparativa: Guardrail próprio vs API externa OpenAI — linhas: custo, latência, acurácia no domínio, trabalho de setup — design minimalista
---
## mlnet-quando-usar
**Categoria:** Codigo
**Tema:** ML.NET em 2026: quando faz sentido e quando é overkill — minha experiência em 3 projetos
**Formato:** porque
**Funil:** meio
**Resumo:** Usei ML.NET em 3 projetos reais de .NET: (1) classificação de tickets de suporte por categoria — funcionou ótimo, modelo treinado em 2h, 89% de acurácia com 1500 exemplos; (2) previsão de churn de clientes — razoável, AUC 0.81; (3) geração de texto personalizado — fracasso total, ML.NET não é LLM, eu estava tentando usar ferramenta errada. O resumo: ML.NET faz sentido quando o problema é classificação, regressão ou forecasting, você tem dados tabulares ou texto simples, e não quer depender de APIs externas. Não faz sentido para geração de linguagem — aí é Ollama/Groq/OpenAI.
**Imagem 1:** Diagrama de decisão em árvore: "Qual ferramenta de ML para .NET?" com branches para ML.NET (tarefas clássicas) vs ONNX Runtime (modelos pré-treinados) vs API LLM (geração/raciocínio) — design clean
**Imagem 2:** Screenshot de código C# com ML.NET: pipeline de treino com IDataView, transformações e treinamento de classificação — fundo escuro, código limpo e legível
---
## squad-ia-postagens-linkedin
**Categoria:** Bastidor
**Tema:** Construindo uma squad de agentes de IA pra automatizar minhas postagens no LinkedIn — o que funciona
**Formato:** bastidor
**Funil:** topo
**Resumo:** Estou construindo um sistema de 6 agentes Go que automatizam o pipeline de criação de posts LinkedIn: evaluator (busca trends + sorteia formato), redator (gera rascunho), editor (formata para LinkedIn com loop interativo), art (gera imagens via Gemini), director (aprovação via Telegram) e publisher (posta via API). Cada agente é um CLI independente que se comunica via arquivos JSON no workspace. O estado atual: 6 agentes funcionando, testados individualmente. O maior desafio foi o loop de revisão do editor — como manter controle humano sem tornar o processo tedioso. Solução: terminal interativo com opções [A]provar/[R]eprovar/[A1-A4] ajustes específicos.
**Imagem 1:** Diagrama da arquitetura da squad: 6 boxes conectados por setas (evaluator → redator → editor → art → director → publisher) com ícones de arquivo JSON entre eles — fundo escuro tech, paleta azul/verde
**Imagem 2:** Terminal mostrando o loop interativo do editor com o post formatado, checklist automático e menu de opções — estilo screenshot real de CLI
---
## squad-ia-blog
**Categoria:** Entregavel
**Tema:** Blog parte humano, parte IA — como mantenho consistência sem perder minha voz
**Formato:** como
**Funil:** meio
**Resumo:** Para o blog, dividi o processo: eu escrevo o núcleo técnico (o que fiz, os números, os trade-offs — coisas que só eu sei), a IA expande com contexto e estrutura, eu reviso e corrijo o que ela inventou. A regra de ouro que adotei: qualquer afirmação técnica específica (versão de biblioteca, número, nome de API) precisa ter sido dita por mim antes de aparecer no texto final. A IA pode inferir analogias e contexto — não pode inventar fatos. O resultado prático: artigos técnicos de 1500-2000 palavras que antes me levavam 4-6h agora ficam prontos em 1.5-2h, e os leitores frequentemente me dizem "parece muito você".
**Imagem 1:** Fluxo de trabalho: Núcleo técnico (humano) → Expansão e estrutura (IA) → Revisão de fatos (humano) → Publicação — ícones de pessoa e robô alternando, design clean
**Imagem 2:** Diagrama de responsabilidades: o que o humano escreve (números, trade-offs, opiniões) vs o que a IA escreve (contexto, analogias, estrutura) — dois círculos com overlap no centro
---
## apps-ia-fluxo-prompts
**Categoria:** Codigo
**Tema:** Errei feio nas primeiras apps com IA — até entender que o humano precisa especificar antes de codar
**Formato:** erro
**Funil:** meio
**Resumo:** Nas primeiras apps com IA eu começava codando direto: pedia para a IA gerar o código, ela gerava, funcionava. Duas semanas depois precisava ajustar o comportamento — e descobria que o prompt estava hardcoded no meio de 3 arquivos diferentes, sem testes, sem histórico. A IA tinha feito exatamente o que eu pedi: código que funciona. Não fez o que eu precisava: código que eu consigo manter. O erro era meu: eu não havia especificado como queria que o sistema fosse construído, só o que ele devia fazer. O fluxo que adotei depois: (1) escrever o que o agente precisa FAZER em linguagem natural antes de abrir o editor, (2) criar e testar o prompt isoladamente antes de qualquer código, (3) definir casos de teste (entrada → saída esperada), (4) implementar com o prompt como configuração separada, não hardcoded. Parece burocrático. Salvou semanas de retrabalho.
**Imagem 1:** Diagrama do fluxo correto em 4 etapas: Especificação humana → Teste de prompt isolado → Implementação → Ajuste iterativo — destaque na etapa 1 como etapa que a IA não faz por você
**Imagem 2:** Comparação: código com prompt hardcoded (caos de manutenção) vs prompt em arquivo de configuração separado e versionado — diff visual, fundo escuro
---
## qdrant-vs-mongodb-rag
**Categoria:** Codigo
**Tema:** Migrei meu RAG de MongoDB Atlas Vector Search para Qdrant — o que mudou de verdade
**Formato:** comparacao
**Funil:** fundo
**Resumo:** Comecei com MongoDB Atlas Vector Search porque já tinha MongoDB no projeto e queria evitar mais um serviço. Funcionou, mas: latência P95 de 820ms nas buscas, configuração de índice vetorial confusa, sem suporte a filtros complexos sem degradação de performance. Migrei para Qdrant rodando via Docker. Resultado: latência P95 caiu para 180ms (4.5x mais rápido), payload filters funcionam sem degradar a busca vetorial, e a API é mais intuitiva para o caso de uso de RAG. O custo: mais um serviço para gerenciar. Para projetos onde MongoDB já existe e o volume é baixo, Atlas Vector Search serve. Para RAG como core feature, Qdrant é superior.
**Imagem 1:** Gráfico de barras comparando latência P95: MongoDB Atlas 820ms vs Qdrant 180ms — cores contrastantes azul/laranja, valores bem visíveis, fundo branco
**Imagem 2:** Tabela comparativa completa: Qdrant vs MongoDB Atlas — linhas: latência, filtros, facilidade de configuração, custo, casos ideais — design limpo, checkmarks e valores
---
## streaming-rag-csharp
**Categoria:** Codigo
**Tema:** Streaming de respostas RAG em C# com SSE — por que abandonei WebSocket e o que aprendi
**Formato:** erro
**Funil:** fundo
**Resumo:** Implementei streaming de respostas do RAG primeiro com WebSocket — parecia a escolha óbvia para comunicação bidirecional em tempo real. O problema: gerenciar conexões WebSocket é complexo (reconexão, heartbeat, estado), e para RAG você só precisa do fluxo de servidor para cliente. Resultado: 340 linhas de código de gerenciamento de conexão para algo que SSE resolve em 40 linhas. Migrei para Server-Sent Events com endpoint HTTP simples que retorna `text/event-stream`. O cliente consome com `EventSource`. Funcionou perfeitamente para o caso de uso — e o código ficou muito mais simples de manter.
**Imagem 1:** Comparação de código C#: endpoint WebSocket complexo (muitas linhas, gerenciamento de estado) vs endpoint SSE simples (poucas linhas, response.WriteAsync) — dois blocos lado a lado
**Imagem 2:** Diagrama de sequência: Cliente → GET /rag/stream → Server inicia streaming → chunks chegando em tempo real → conexão encerra naturalmente — estilo swimlane simplificado
---
## por-que-go-para-cli
**Categoria:** Codigo
**Tema:** Por que escolhi Go (e não Rust, Python ou Node) para construir meus agentes de IA em CLI
**Formato:** porque
**Funil:** topo
**Resumo:** Quando decidi construir a squad de agentes de IA como CLIs independentes, avaliei as opções: Python (óbvio para IA, mas startup lento, dependências pesadas), Node (rápido de escrever, mas carrego npm_modules pra todo lado), Rust (performance máxima, mas velocidade de desenvolvimento baixa para um projeto experimental), Go (binário único sem dependências, startup ~5ms, concorrência nativa com goroutines para chamar múltiplas APIs em paralelo, fácil de compilar para Windows/Linux). O argumento decisivo: Go produz executáveis que funcionam sem runtime instalado — posso distribuir como um .exe que qualquer pessoa roda. Para CLIs que precisam chamar APIs HTTP e processar JSON, Go é imbatível em simplicidade + performance.
**Imagem 1:** Tabela comparativa das 4 linguagens: Python vs Node vs Rust vs Go — critérios: tamanho do binário, startup time, distribuição, concorrência, curva de aprendizado — design limpo
**Imagem 2:** Terminal mostrando os 6 executáveis da squad (ldpost-*.exe) em ~8MB cada, comparado com uma aplicação Node equivalente com node_modules — tamanhos contrastantes, fundo escuro
---
## bot-whatsapp-go
**Categoria:** Codigo
**Tema:** Bot de WhatsApp em Go com whatsmeow — sem Selenium, sem Baileys, sem dor de cabeça
**Formato:** como
**Funil:** meio
**Resumo:** Precisava de um bot WhatsApp para automatizar notificações internas. As alternativas que descartei: Selenium (frágil, quebra com updates do WhatsApp Web), Baileys (Node, manutenção complicada, não é oficial), API oficial do WhatsApp Business (caro para volume baixo, processo de aprovação demorado). Escolhi whatsmeow — biblioteca Go que implementa o protocolo do WhatsApp diretamente. Setup: autenticação via QR code, sessão persistida em SQLite, handlers para mensagens recebidas. Em ~200 linhas de Go tinha um bot funcional enviando e recebendo mensagens. O ponto de atenção: violação de ToS potencial — uso apenas para casos internos, nunca spam.
**Imagem 1:** Diagrama de arquitetura do bot: whatsmeow → handlers Go → SQLite (sessão) → integração com sistema interno — conexão com ícone do WhatsApp (sem logo real), estilo clean
**Imagem 2:** Trecho de código Go com whatsmeow: registro do handler de mensagens e envio de resposta — código legível, ~20 linhas, fundo escuro
---
## qwen-gpu-intel-arc
**Categoria:** Codigo
**Tema:** Rodei Qwen2.5-Coder em GPU Intel Arc com SYCL — o que ninguém te conta antes de tentar
**Formato:** erro
**Funil:** fundo
**Resumo:** Queria usar a GPU Intel Arc A770 que tenho no home lab para inferência local ao invés de só usar CPU. A documentação do llama.cpp com suporte SYCL/Intel existe, mas é esparsa. O que aconteceu: 4h para configurar o ambiente SYCL (Intel oneAPI, drivers específicos, versão correta do llama.cpp), erros crípticos de compilação, e no final a inferência funcionou mas a 60% da velocidade da CPU para o modelo quantizado Q4. O VRAM da Arc A770 (16GB) deveria ser vantagem, mas o overhead de SYCL cancelou os ganhos para modelos pequenos. Conclusão: para modelos acima de 13B parâmetros a GPU começa a compensar. Abaixo disso, CPU com AVX512 ganha.
**Imagem 1:** Gráfico de tokens por segundo: CPU (AVX512) vs GPU Intel Arc SYCL — para modelos 7B, 13B, 34B — mostrando o ponto de crossover onde GPU passa a valer
**Imagem 2:** Terminal com output do processo de build do llama.cpp com suporte SYCL — mensagens de configuração, caminho de compilação, tempo total — estilo real de dev
---
## cli-maluco-bastidor
**Categoria:** Bastidor
**Tema:** O Ollama gera código C# perfeito — e coloca tudo no lugar errado. Por isso usei Roslyn para dar contexto a ele
**Formato:** bastidor
**Funil:** topo
**Resumo:** O CLI-Maluco é um gerador de código C# que usa Ollama (Qwen2.5-Coder) para gerar implementações seguindo os padrões do projeto. O problema que apareceu cedo: a IA gerava código correto mas sem saber onde ele se encaixava — criava Services sem implementar as interfaces certas, ignorava o padrão CQRS que o projeto usava, ou gerava na sequência errada (Controller antes de ter o Service). Solução: usar Roslyn (compilador C# em modo análise) para extrair o AST do projeto e alimentar a IA com contexto real — quais interfaces existem, quais estão sem implementação, quais dependências são necessárias. Agora a IA não "imagina" a arquitetura: ela lê o que existe. Desafio atual: a sequência de geração ainda é hardcoded, estou migrando para Memgraph (banco de grafo*) para deixar o grafo de dependências guiar a ordem. *Banco de grafo: armazena relações entre itens. Ex: [ServiceA] -[:DEPENDE_DE]-> [RepositoryB].
**Imagem 1:** Diagrama do CLI-Maluco: Codebase → Roslyn (lê AST real) → Context Extractor → Ollama/Qwen → Código Gerado → Review humano — destaque em "lê AST real" como diferencial
**Imagem 2:** Exemplo: interface C# sem implementação detectada pelo Roslyn → prompt gerado automaticamente → implementação correta gerada pelo Ollama — três blocos lado a lado
---
## knowledge-graph-step-provider
**Categoria:** Codigo
**Tema:** Por que substituí step provider hardcoded por knowledge graph no meu gerador de código
**Formato:** porque
**Funil:** fundo
**Resumo:** O CLI-Maluco tinha um "step provider" que definia a sequência de geração de artefatos: "primeiro a interface, depois o service, depois o controller". Hardcoded. Funcionava para projetos Clean Architecture padrão, mas quebrava quando a estrutura do projeto era diferente. A solução: usar Memgraph (grafo em memória compatível com Neo4j) para modelar as dependências reais do projeto como um grafo, e deixar o gerador navegar o grafo para descobrir a sequência correta. Se ServiceA depende de RepositoryB que depende de EntityC, o grafo já sabe a ordem. Não precisei mais hardcodar nada. Trade-off: mais complexo de configurar, mas muito mais flexível.
**Imagem 1:** Comparação visual: step provider hardcoded (lista sequencial fixa) vs knowledge graph (rede de nós conectados com dependências reais) — estilo diagrama tech, paleta azul/roxo
**Imagem 2:** Trecho de código C# ou Cypher query no Memgraph mostrando as relações entre artefatos de código — clean, legível, fundo escuro
---
## dataset-finetuning-gerado
**Categoria:** Codigo
**Tema:** Como gerei 1.000 exemplos de dataset para fine-tuning sem escrever 1.000 exemplos
**Formato:** como
**Funil:** meio
**Resumo:** Para fazer fine-tuning de tom do modelo, precisava de pares (pergunta / resposta no meu estilo). Escrever 1000 à mão levaria semanas. A abordagem que funcionou: (1) escrevi 50 exemplos reais de alta qualidade, (2) pedi para um LLM grande (GPT-4) gerar variações — para cada exemplo meu, ele gerava 10 variações da pergunta mantendo o estilo da minha resposta, (3) filtrei manualmente as variações que soavam artificiais (~30% descartado). Resultado: 350 exemplos próprios → 3.500 variações → depois de filtragem: 1.100 exemplos de qualidade aceitável. Tempo total: 2 tardes vs semanas escrevendo tudo. O filtro manual ainda é insubstituível — sem ele a qualidade cai muito.
**Imagem 1:** Diagrama do processo de amplificação de dataset: 50 exemplos reais → GPT-4 gera variações → filtro manual → 1100 exemplos finais — funil visual, com número em cada etapa
**Imagem 2:** Arquivo JSONL de fine-tuning aberto: estrutura com campos "prompt" e "completion" visíveis, formatação correta para API — código limpo, fundo escuro, sem conteúdo sensível
---
## parei-pagar-openai
**Categoria:** Geral
**Tema:** Gastei $80/mês com OpenAI por meses — até entender que eu estava pagando por capacidade que não precisava
**Formato:** porque
**Funil:** topo
**Resumo:** Comecei usando GPT-4 para tudo: geração de texto, código, análise, resumo. $80/mês. Parecia necessário — era o melhor modelo, meus projetos dependiam dele. O que aprendi depois de alguns meses: 90% do que eu usava o GPT-4 para fazer, modelos menores e gratuitos fazem igual. O problema não era o modelo — era eu não saber especificar o problema direito. GPT-4 "consertava" minhas especificações vagas com raciocínio sofisticado. Quando aprendi a especificar melhor, Llama 3.3 70B (Groq, gratuito) passou a resolver. Migrei: Groq para texto, Ollama local (Qwen2.5-Coder) para código, Qdrant local para vector search. O que ainda pago: Gemini API para imagens ($5/mês). Total: de $80 para $5. Qualidade nos meus casos de uso: 85-90% do GPT-4. O que perdi: modelos de reasoning complexo (o1) — mas isso representa menos de 5% do que eu realmente uso.
**Imagem 1:** Gráfico de barras: custo mensal antes ($80 OpenAI) vs depois (Groq $0 + Ollama $0 + Gemini $5) — com nota sobre o que mudou além do custo
**Imagem 2:** Diagrama do stack atual: texto → Groq/Llama, código → Ollama/Qwen, imagens → Gemini, vector → Qdrant — ícones genéricos, layout limpo
---
## orange-pi-cluster-homelab
**Categoria:** Bastidor
**Tema:** Cluster de mini-SaaS no Orange Pi 5: como meu home lab virou laboratório de produtos
**Formato:** bastidor
**Funil:** topo
**Resumo:** Tenho um Orange Pi 5 (8GB RAM, SSD NVMe 256GB) rodando 3 projetos reais: n8ngo (automação de fluxos), qrrapido (gerador de QR codes com analytics), bcards (cartões digitais). Todos containerizados, Traefik como reverse proxy, certificados Let's Encrypt automáticos. O custo de infra: ~R$30/mês de internet + energia elétrica. O que aprendi: Orange Pi com Ubuntu Server + Docker é surpreendentemente estável para workloads leves. O maior problema não foi técnico: foi criar o hábito de monitorar. Uso Uptime Kuma para alertas. Motivação para manter: serve como ambiente de teste real para tecnologias que depois aplico no trabalho.
**Imagem 1:** Diagrama da infraestrutura: Orange Pi 5 → Docker → containers (n8ngo, qrrapido, bcards, Traefik, Qdrant, Ollama) → internet via Traefik com SSL — estilo arquitetura de cloud mas em versão home lab
**Imagem 2:** Dashboard do Uptime Kuma mostrando os serviços e seus status (verde = online) — screenshot limpo, sem dados pessoais visíveis
---
## erro-chunking-qdrant
**Categoria:** Codigo
**Tema:** Indexei todo meu codebase no Qdrant e o RAG ficou inútil — o que aprendi sobre chunking
**Formato:** erro
**Funil:** meio
**Resumo:** Primeiro RAG que construí: peguei todos os arquivos .cs do projeto, dividi por arquivo (um arquivo = um chunk), vetorizei e indexei no Qdrant. Parecia razoável. Na prática: queries sobre "como implementar X" retornavam arquivos inteiros de 800 linhas como contexto, o LLM ficava confuso com muito contexto irrelevante, e a precisão foi horrível. O erro: chunk por arquivo é grande demais. Depois de pesquisar: chunks de 256-512 tokens com overlap de 50-100 tokens funcionam para documentação/texto. Para código: chunk por função/método é muito mais eficaz do que por arquivo ou por linhas fixas. Depois dessa mudança, a precisão do RAG melhorou de ~40% para ~78% nas minhas queries de teste.
**Imagem 1:** Comparação visual de estratégias de chunking: arquivo inteiro (vermelho, ineficiente) vs chunks de 512 tokens (amarelo, razoável) vs chunk por função (verde, melhor) — código representado como blocos coloridos
**Imagem 2:** Gráfico de barras mostrando precisão do RAG com diferentes estratégias de chunking: por arquivo, por linhas fixas, por tokens, por função — valores percentuais claros
---
## legado-aspnet-dotnet8
**Categoria:** Entregavel
**Tema:** De ASP.NET WebForms para .NET 8 sem reescrever tudo — o playbook que funciona
**Formato:** como
**Funil:** meio
**Resumo:** Herdei um sistema ASP.NET WebForms de 2008 em produção. Reescrever do zero: 2 anos de trabalho, risco enorme. A abordagem que adotei: migração incremental por módulos. (1) Identificar os módulos mais usados e menos acoplados, (2) criar um novo projeto .NET 8 rodando em paralelo, (3) usar proxy reverso para direcionar rotas específicas para o novo app, (4) migrar módulo por módulo. Em 14 meses, 70% das funcionalidades estavam no .NET 8. O segredo: nunca deixar o sistema "quebrado" — sempre manter o legado funcionando enquanto migra. O anti-padrão que vi em outros projetos: tentar migrar tudo de uma vez e travar o time por meses.
**Imagem 1:** Diagrama da estratégia de migração incremental: sistema legado + novo sistema rodando em paralelo + proxy reverso roteando por módulo — setas mostrando tráfego dividido, cores diferenciando legado/novo
**Imagem 2:** Timeline de 14 meses com % migrado por mês — gráfico de progresso mostrando avanço gradual sem grandes saltos ou regressões — design limpo
---
## entrevistas-tecnicas-framework
**Categoria:** Entregavel
**Tema:** Como conduzo entrevistas técnicas: o framework que me fez parar de perder horas com candidatos errados
**Formato:** como
**Funil:** topo
**Resumo:** Já conduzi mais de 80 entrevistas técnicas. Os erros que cometi por anos: perguntas de trivia (sabe decoreba de sintaxe?), live coding com problema de LeetCode difícil sob pressão (avalia ansiedade, não competência), entrevista sem estrutura (cada vez diferente). O framework que desenvolvi: 15 min de contexto do projeto real, 20 min de caso prático (resolva um problema parecido com o que você vai resolver no time), 15 min de discussão de decisões técnicas passadas ("me conte uma decisão técnica que tomou e que mudaria hoje"). Sinal verde: candidato explica trade-offs. Sinal vermelho: candidato apresenta solução como "a única certa".
**Imagem 1:** Template da entrevista em três fases: Contexto (15 min) → Caso Prático (20 min) → Decisões Passadas (15 min) — cada fase com bullets do que avaliar, design clean de documento
**Imagem 2:** Tabela de sinais: verde (menciona trade-offs, admite incerteza, faz perguntas) vs vermelho (decoreba, solução única, sem curiosidade) — duas colunas contrastantes
---
## prompts-testaveis-producao
**Categoria:** Codigo
**Tema:** Meu sistema de IA em produção parou de funcionar — e nenhum teste capturou porque eu não tratava prompts como código
**Formato:** erro
**Funil:** meio
**Resumo:** Tinha um sistema em produção usando LLM para classificar tickets de suporte. Funcionava. Um dia parou de funcionar direito — classificações erradas, comportamento inconsistente. O problema: o modelo foi atualizado pelo provedor, e o prompt que funcionava perfeitamente com a versão anterior passou a produzir resultados diferentes. Não havia nenhum teste. Não havia versionamento do prompt. Não havia histórico de quando e como o prompt tinha mudado. Descobri o problema 3 dias depois, por relato de usuário. A correção foi simples — ajustar o prompt. Mas o tempo para diagnóstico foi alto demais. O que mudei: prompts em arquivos .md versionados no Git, testes com fixtures de input/output esperado (igual a unit tests), script que roda todos os testes antes de merge. Implementado em Go. Não é 100% determinístico, mas captura regressões óbvias — e me avisa antes do usuário.
**Imagem 1:** Estrutura de diretórios: prompts/ com arquivos .md e .test.json correspondentes — estilo tree view, fundo escuro
**Imagem 2:** Pipeline de CI/CD com etapa "Prompt Tests" entre build e deploy — destaque na etapa que não existia antes do incidente
---
## por-que-ddd-ainda-vale
**Categoria:** Codigo
**Tema:** Por que ainda uso DDD em 2026 — mesmo quando os outros dizem que é overengineering
**Formato:** porque
**Funil:** topo
**Resumo:** DDD foi declarado "morto" ou "complexo demais" várias vezes. Minha posição: DDD é uma ferramenta de design, não uma arquitetura. O que sempre usei do DDD: Ubiquitous Language (falar a mesma língua que o negócio), Bounded Contexts (separar domínios com fronteiras explícitas), Value Objects (encapsular validações de domínio). O que raramente uso: Event Sourcing completo, CQRS em todo lugar, Aggregates complexos para domínios simples. O erro que vejo: times aplicam DDD completo em CRUDs simples e reclamam da complexidade. A pergunta certa não é "uso DDD ou não?" — é "qual parte do DDD resolve meu problema específico?".
**Imagem 1:** Diagrama de Bounded Contexts de um sistema real: dois ou três contextos separados com suas entidades internas e pontos de integração — cores diferentes por contexto, design limpo
**Imagem 2:** Tabela "Use isso do DDD vs Não precisa disso" — duas colunas com elementos do DDD categorizados por quando valem — checkmarks e X, design minimalista
---
## erro-overengineering-crud
**Categoria:** Codigo
**Tema:** Passei 3 semanas arquitetando um CRUD — e joguei tudo no lixo
**Formato:** erro
**Funil:** topo
**Resumo:** Recebi uma tarefa simples: criar um CRUD de cadastro de fornecedores. Minha resposta: Clean Architecture completo, CQRS com MediatR, Event Sourcing, repositórios genéricos, DTOs mapeados com AutoMapper, validações com FluentValidation. Três semanas depois: tinha uma arquitetura impressionante para gerenciar 50 linhas de dado que nunca mudariam a regra de negócio. O PM pediu uma mudança simples — levou 4 horas porque tinha 12 camadas para mexer. Joguei fora e refiz em 2 dias com Entity Framework direto, sem repositório, sem CQRS. Funciona igual, é mais simples de manter, e não impressiona ninguém — que é o objetivo.
**Imagem 1:** Diagrama satirizando a over-engenharia: CRUD simples no centro cercado por 8 camadas de abstração com setas para todos os lados — visual exagerado de propósito, estilo irônico mas clean
**Imagem 2:** Comparação de complexidade: versão over-engineered (muitos arquivos, muitas pastas) vs versão simples (poucos arquivos, estrutura flat) — file tree dos dois projetos lado a lado
---
## checklist-code-review
**Categoria:** Entregavel
**Tema:** Meu checklist de code review — o que eu olho nos primeiros 2 minutos de qualquer PR
**Formato:** checklist
**Funil:** meio
**Resumo:** Faço code review há 15 anos. Aprendi que os primeiros 2 minutos determinam 80% da qualidade da revisão — ou você foca no que importa ou perde tempo em detalhes cosméticos que o linter deveria pegar. Minha sequência: (1) O que esse PR faz? — se não dá pra responder em 1 frase pela descrição, devolvo antes de ler uma linha, (2) O tamanho está razoável? — PR acima de 400 linhas peço para dividir, (3) Tem teste? — não tem, devolvo, (4) A mudança faz o que a descrição diz? — leio o diff com a tarefa em mente, (5) Tem efeito colateral não óbvio? — lugares onde a mudança pode quebrar algo que não tem teste. Detalhes de estilo: deixo para o linter.
**Imagem 1:** Checklist visual em 5 etapas com ícones: Descrição clara → Tamanho adequado → Testes presentes → Faz o que diz → Efeitos colaterais — design de checklist profissional
**Imagem 2:** Gráfico de pizza ou barras: distribuição de onde o tempo de code review deve ir — lógica de negócio (40%), testes (30%), arquitetura (20%), estilo/formatação (10%) — cores distintas
---
## checklist-api-pre-lancamento
**Categoria:** Entregavel
**Tema:** 8 coisas que verifico antes de subir qualquer API em produção — aprendi na maioria das vezes da forma difícil
**Formato:** checklist
**Funil:** fundo
**Resumo:** Aprendi cada item dessa lista quebrando alguma coisa em produção. Os 8 itens que nunca pulo: (1) Rate limiting configurado — um bot indexou minha API de teste e gerou 50k chamadas em 10 min, (2) Autenticação testada com token expirado — JWT silenciosamente inválido ficou 6 meses em prod, (3) Payload máximo definido — recebi um JSON de 48MB de um cliente "testando", (4) Timeout de dependências externas — uma API terceira travou e segurou threads por 30s, (5) Logs estruturados funcionando — já debuguei produção sem log, é pesadelo, (6) Health check endpoint, (7) Variáveis de ambiente validadas no startup, (8) Documentação mínima de erros.
**Imagem 1:** Checklist visual com 8 itens, cada um com ícone representativo (cadeado, cronômetro, arquivo, etc.) — design profissional de documento técnico, paleta azul/branco
**Imagem 2:** Exemplo de health check endpoint em C# retornando status de dependências — código limpo, ~30 linhas, fundo escuro
---
## erro-jwt-silencioso
**Categoria:** Entregavel
**Tema:** O bug de JWT que ficou 6 meses em produção — e como nunca mais vou deixar isso acontecer
**Formato:** erro
**Funil:** meio
**Resumo:** Implementei autenticação JWT numa API. O bug: a validação do tempo de expiração estava com timezone errado — tokens "expirados" eram aceitos por mais 3 horas que o esperado. Ninguém reportou porque funcionava (usuários ficavam logados por mais tempo, o que eles consideravam "bom"). Descobri ao comparar o comportamento esperado com o real num code review de outro projeto. A causa: `DateTime.UtcNow` vs `DateTime.Now` — uma linha de diferença, consequência silenciosa. Solução implementada: testes de autenticação com tokens expirados como parte do suite de integração. Agora é impossível fazer deploy com essa categoria de bug.
**Imagem 1:** Diagrama de linha do tempo mostrando: token criado → expiração esperada → expiração real (3h depois) — diferença destacada em vermelho, linha horizontal clara
**Imagem 2:** Teste de integração C# que verifica comportamento com token expirado — código limpo com Assert.Returns401, ~15 linhas, fundo escuro
---
## checklist-onboarding-dev
**Categoria:** Entregavel
**Tema:** Meu checklist de onboarding para novos devs — o que mudou depois que parei de improvisar
**Formato:** checklist
**Funil:** topo
**Resumo:** Antes do checklist: cada onboarding era diferente, o dev passava os primeiros dias perdido procurando como configurar o ambiente, e eu perdia tempo respondendo as mesmas perguntas. Criei um documento de onboarding com: (1) Setup do ambiente (passo a passo testado, com print de como deve ficar), (2) Mapa do codebase — o que cada projeto faz e como se conecta, (3) Primeira tarefa definida antes do primeiro dia — sempre uma task pequena de impacto real (não "refatore esses testes"), (4) Buddy designado para primeiras 2 semanas, (5) Check-in na primeira semana: "o que não estava no documento mas você precisou descobrir?". Esse último é o que mantém o documento atualizado.
**Imagem 1:** Documento de onboarding estruturado com seções: Ambiente, Codebase, Primeira Tarefa, Buddy, Check-in — estilo de documento profissional, icons por seção
**Imagem 2:** Timeline da primeira semana do novo dev: Dia 1 (setup) → Dia 2 (mapa do codebase) → Dia 3 (primeira task) → Dia 5 (check-in) — design horizontal, cada dia com cor e atividade principal
---
## sk-versoes-preview-trap
**Categoria:** Codigo
**Tema:** Semantic Kernel tem ótimas ideias e APIs instáveis — aprendi isso da forma difícil
**Formato:** erro
**Funil:** meio
**Resumo:** Comecei a usar Semantic Kernel 1.x com entusiasmo: orquestração de agentes, plugins, memory, planner — tudo parecia resolvido. A IA que usava para me ajudar a codar gerava exemplos que funcionavam. Até que não funcionavam mais. O padrão que identifiquei: a IA treinada nos docs antigos do SK gerava código com interfaces que já tinham sido removidas ou renomeadas entre minor versions. IKernel virou Kernel, ISemanticTextMemory mudou de namespace, o Planner teve três redesigns em 6 meses. Cada update quebrava algo. A lição que aprendi: antes de usar qualquer feature do SK, abro o código-fonte no GitHub e verifico se tem [Experimental] ou [Preview] no atributo. Se tiver, ou espero estabilizar ou implemento manualmente. A IA não faz esse filtro — ela só sabe que o código compilava quando foi treinada.
**Imagem 1:** Screenshot do código-fonte do Semantic Kernel no GitHub mostrando atributos [Experimental] em cima de uma interface — real ou representativo, fundo branco estilo GitHub
**Imagem 2:** Timeline de breaking changes do Semantic Kernel 0.x → 1.0 → 1.x: principais renomeações e remoções de API — linha horizontal com marcos, vermelho nos pontos de quebra
---
## stripe-versoes-api-armadilha
**Categoria:** Codigo
**Tema:** A IA usou a versão errada da API do Stripe — e eu só descobri em produção
**Formato:** erro
**Funil:** fundo
**Resumo:** Estava integrando Stripe para cobranças recorrentes. Pedi ajuda à IA, ela gerou código correto, testei, funcionou em sandbox. Em produção, parte da integração de webhooks não processava eventos direito. O diagnóstico: a IA havia gerado código baseado em uma versão anterior da API do Stripe (que versiona por data: ex. 2023-10-16). Minha conta tinha uma versão diferente configurada no dashboard, e o shape dos eventos era diferente — campos renomeados, estrutura aninhada alterada. O Stripe não errou: ele documentou. Eu não li a documentação de migração entre versões. A IA também não leu — ela não sabe qual versão está na sua conta. Resolução: agora sempre fixo a versão da API explicitamente no código e leio o changelog antes de qualquer integração de pagamento.
**Imagem 1:** Código C# com a versão da API do Stripe fixada explicitamente no cliente (StripeClient + ApiVersion) — antes (sem versão) vs depois (com versão pinada) — diff verde/vermelho
**Imagem 2:** Diagrama mostrando o problema: IA treinada em docs de versão X → gera código → conta com versão Y → webhook event com shape diferente → bug silencioso em produção
---
## vector-db-problema-errado
**Categoria:** Codigo
**Tema:** Testei Chroma, Qdrant e Weaviate por semanas — até entender que o problema não era o banco
**Formato:** erro
**Funil:** meio
**Resumo:** Comecei a usar Qdrant para um projeto com IA. A IA me ajudou a modelar tudo: cadastros de usuário, histórico, preferências — tudo foi parar no Qdrant. As buscas ficaram lentas e inconsistentes. Troquei para Chroma. Mesmo problema. Testei Weaviate. Mesmo problema. Levei semanas até entender: o problema não era o banco vetorial — era eu usando banco vetorial para resolver problema de banco relacional. Para quem não conhece: banco de dados vetorial* não é um banco de dados no sentido tradicional — ele armazena representações matemáticas de texto (embeddings) e serve para uma coisa só: "ache os textos mais parecidos com este". Para filtrar por campo fixo (userId = X, status = ativo), SQL ou MongoDB são 100x mais rápidos e corretos. A arquitetura certa é duas camadas: banco tradicional para dados estruturados + vector DB só para a busca semântica. A IA nunca me avisou sobre isso — ela implementou o que eu pedi, não o que eu precisava. *Vector DB (banco vetorial): armazena embeddings — números que representam o "significado" de um texto. Permite buscar por similaridade: "mostre textos parecidos com este", não "mostre registros onde campo = valor".
**Imagem 1:** Diagrama de arquitetura correta: dados do usuário → PostgreSQL/MongoDB (campos fixos, filtros) + embeddings → Qdrant (busca semântica) — duas camadas separadas com setas mostrando quando usar cada uma
**Imagem 2:** Tabela comparativa: "Use vector DB quando..." vs "Use banco tradicional quando..." — exemplos concretos de queries em cada coluna, design limpo com checkmarks
---
## chatbot-grafo-intencoes
**Categoria:** Codigo
**Tema:** RAG não era suficiente para meu chatbot — construí um grafo de intenções e o comportamento mudou completamente
**Formato:** bastidor
**Funil:** topo
**Resumo:** Estava construindo um chatbot e RAG sozinho não resolvia: o bot dava respostas corretas isoladas mas não mantinha contexto de conversa nem navegava fluxos complexos. A solução que estou testando em POC: um banco de dados de grafo* (Memgraph) modelando intenções como nós e transições como arestas. Quando o usuário digita algo, o fluxo é: (1) identificar intenção via LLM, (2) buscar nó correspondente no grafo, (3) verificar se precisa de mais informação (arestas condicionais), (4) executar ação ou seguir para próximo nó. Se a intenção não está no grafo: verifica se deve retroceder para um ponto anterior da conversa → se não, busca no RAG → último recurso: "não entendi". A diferença em relação a RAG puro: o grafo dá estrutura às conversas, o RAG dá profundidade às respostas. Juntos, o bot sabe onde está na conversa e o que sabe sobre o assunto. *Banco de dados de grafo: armazena apenas relações entre itens, não registros. Ex: [Desconto] -[:REQUER]-> [Nome e CPF]. Não existe tabela — existem nós e conexões. Ideal para modelar fluxos, dependências e caminhos de navegação.
**Imagem 1:** Diagrama do grafo de intenções: nós (intenções como "consultar saldo", "cancelar pedido", "reclamação") conectados por arestas com condições — estilo grafo com nós circulares, setas direcionadas, paleta azul/roxo
**Imagem 2:** Fluxograma de decisão quando usuário digita algo: Identificar intenção → Nó no grafo? → Sim: seguir fluxo / Não: Retroceder? → RAG? → "Não entendi" — boxes com decisões em losango, design clean
---

90
shared/config/config.go Normal file
View File

@ -0,0 +1,90 @@
package config
import (
"fmt"
"os"
"strconv"
"strings"
"github.com/joho/godotenv"
)
type Config struct {
GroqAPIKey string
GeminiAPIKey string
TelegramBotToken string
TelegramChatID string
LinkedInClientID string
LinkedInClientSecret string
LinkedInAccessToken string
Workspace string
CropBottomPx int
CropRightPx int
}
// Load reads .env (if present) then maps env vars to Config.
// Never panics — callers check with Validate.
func Load() *Config {
_ = godotenv.Load() // silently ignore missing .env
ws := os.Getenv("LDPOST_WORKSPACE")
if ws == "" {
ws = `C:\Textos-Linkedin`
}
return &Config{
GroqAPIKey: os.Getenv("GROQ_API_KEY"),
GeminiAPIKey: os.Getenv("GEMINI_API_KEY"),
TelegramBotToken: os.Getenv("TELEGRAM_BOT_TOKEN"),
TelegramChatID: os.Getenv("TELEGRAM_CHAT_ID"),
LinkedInClientID: os.Getenv("LINKEDIN_CLIENT_ID"),
LinkedInClientSecret: os.Getenv("LINKEDIN_CLIENT_SECRET"),
LinkedInAccessToken: os.Getenv("LINKEDIN_ACCESS_TOKEN"),
Workspace: ws,
CropBottomPx: envInt("LDPOST_CROP_BOTTOM", 48),
CropRightPx: envInt("LDPOST_CROP_RIGHT", 48),
}
}
func envInt(key string, def int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
// Validate checks named field groups and returns a combined error (nil = OK).
// Accepts: "groq", "gemini", "telegram", "linkedin", "workspace".
// Returns error listing ALL missing vars — does not panic, safe for dry-run.
func (c *Config) Validate(fields ...string) error {
var missing []string
check := func(name, val string) {
if val == "" {
missing = append(missing, name)
}
}
for _, f := range fields {
switch f {
case "groq":
check("GROQ_API_KEY", c.GroqAPIKey)
case "gemini":
check("GEMINI_API_KEY", c.GeminiAPIKey)
case "telegram":
check("TELEGRAM_BOT_TOKEN", c.TelegramBotToken)
check("TELEGRAM_CHAT_ID", c.TelegramChatID)
case "linkedin":
check("LINKEDIN_ACCESS_TOKEN", c.LinkedInAccessToken)
case "workspace":
check("LDPOST_WORKSPACE", c.Workspace)
}
}
if len(missing) == 0 {
return nil
}
return fmt.Errorf("variáveis ausentes: %s", strings.Join(missing, ", "))
}

88
shared/formats/formats.go Normal file
View File

@ -0,0 +1,88 @@
package formats
import (
"fmt"
"math/rand"
"time"
)
// FormatPesos defines selection weights for each format.
var FormatPesos = map[string]int{
"como": 5,
"erro": 2,
"porque": 2,
"checklist": 1,
"comparacao": 1,
"bastidor": 1,
}
// SortearFormato picks a weighted random format, excluding bloqueados.
// Uses a fresh rand source seeded with time.Now().UnixNano() per call.
func SortearFormato(bloqueados []string) string {
r := rand.New(rand.NewSource(time.Now().UnixNano()))
blocked := make(map[string]bool, len(bloqueados))
for _, b := range bloqueados {
blocked[b] = true
}
type item struct {
formato string
peso int
}
var pool []item
total := 0
for formato, peso := range FormatPesos {
if !blocked[formato] {
pool = append(pool, item{formato, peso})
total += peso
}
}
// Fallback: unblock all when every format is blocked
if total == 0 {
for formato, peso := range FormatPesos {
pool = append(pool, item{formato, peso})
total += peso
}
}
pick := r.Intn(total)
acc := 0
for _, it := range pool {
acc += it.peso
if pick < acc {
return it.formato
}
}
return pool[0].formato
}
// ValidarFormato returns an error if f is not one of the 6 valid formats.
func ValidarFormato(f string) error {
if _, ok := FormatPesos[f]; ok {
return nil
}
return fmt.Errorf("formato %q inválido — válidos: como, erro, porque, checklist, comparacao, bastidor", f)
}
// FormatLabel returns the human-readable label for a format.
func FormatLabel(f string) string {
switch f {
case "como":
return "Como fazer"
case "erro":
return "Erro clássico"
case "porque":
return "Por que"
case "checklist":
return "Checklist"
case "comparacao":
return "Comparação"
case "bastidor":
return "Bastidor"
default:
return f
}
}

View File

@ -0,0 +1,84 @@
package formats_test
import (
"testing"
"ldpost/shared/formats"
)
func TestSortearFormato_NeverReturnsBlocked(t *testing.T) {
blocked := []string{"como", "erro", "porque"}
allowed := map[string]bool{"checklist": true, "comparacao": true, "bastidor": true}
for i := 0; i < 100; i++ {
got := formats.SortearFormato(blocked)
if !allowed[got] {
t.Errorf("iteração %d: retornou formato bloqueado %q", i, got)
}
}
}
func TestSortearFormato_AllBlocked_Fallback(t *testing.T) {
// All 6 blocked → should still return something valid
allFormatos := []string{"como", "erro", "porque", "checklist", "comparacao", "bastidor"}
got := formats.SortearFormato(allFormatos)
if err := formats.ValidarFormato(got); err != nil {
t.Errorf("fallback retornou formato inválido %q: %v", got, err)
}
}
func TestSortearFormato_WeightDistribution(t *testing.T) {
// Run 2000 samples with no blocked formats.
// "como" has weight 5 out of total 12 ≈ 41.7%
// Expected range after 2000 trials: ~35-50%
counts := make(map[string]int)
const n = 2000
for i := 0; i < n; i++ {
f := formats.SortearFormato(nil)
counts[f]++
}
comoRatio := float64(counts["como"]) / float64(n)
if comoRatio < 0.35 || comoRatio > 0.55 {
t.Errorf("\"como\" apareceu %.1f%% das vezes (esperado 35-55%%)", comoRatio*100)
}
// Verify all formats appear at least once
for _, f := range []string{"como", "erro", "porque", "checklist", "comparacao", "bastidor"} {
if counts[f] == 0 {
t.Errorf("formato %q nunca foi sorteado em %d iterações", f, n)
}
}
}
func TestValidarFormato(t *testing.T) {
valid := []string{"como", "erro", "porque", "checklist", "comparacao", "bastidor"}
for _, f := range valid {
if err := formats.ValidarFormato(f); err != nil {
t.Errorf("ValidarFormato(%q) retornou erro inesperado: %v", f, err)
}
}
invalid := []string{"", "tutorial", "video", "COMO", "Como"}
for _, f := range invalid {
if err := formats.ValidarFormato(f); err == nil {
t.Errorf("ValidarFormato(%q) deveria retornar erro", f)
}
}
}
func TestFormatLabel(t *testing.T) {
cases := map[string]string{
"como": "Como fazer",
"erro": "Erro clássico",
"porque": "Por que",
"checklist": "Checklist",
"comparacao": "Comparação",
"bastidor": "Bastidor",
}
for input, want := range cases {
if got := formats.FormatLabel(input); got != want {
t.Errorf("FormatLabel(%q) = %q, want %q", input, got, want)
}
}
}

255
shared/gemini/client.go Normal file
View File

@ -0,0 +1,255 @@
package gemini
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const (
BaseURL = "https://generativelanguage.googleapis.com/v1beta/models"
ImageModel = "gemini-2.0-flash-exp"
ImageModelV2 = "gemini-2.5-flash-preview-image-generation"
TextModel = "gemini-2.5-flash"
)
type Client struct {
APIKey string
http *http.Client
}
type part struct {
Text string `json:"text,omitempty"`
InlineData *inlineData `json:"inlineData,omitempty"`
}
type inlineData struct {
MIMEType string `json:"mimeType"`
Data string `json:"data"` // base64
}
type content struct {
Parts []part `json:"parts"`
}
type generateRequest struct {
Contents []content `json:"contents"`
GenerationConfig generationConfig `json:"generationConfig"`
}
type imagenConfig struct {
AspectRatio string `json:"aspectRatio,omitempty"`
}
type generationConfig struct {
ResponseModalities []string `json:"responseModalities"`
ImagenConfig *imagenConfig `json:"imagenConfig,omitempty"`
}
type generateResponse struct {
Candidates []struct {
Content struct {
Parts []struct {
Text string `json:"text,omitempty"`
InlineData *inlineData `json:"inlineData,omitempty"`
} `json:"parts"`
} `json:"content"`
} `json:"candidates"`
Error *struct {
Message string `json:"message"`
} `json:"error,omitempty"`
}
func New(apiKey string) *Client {
return &Client{
APIKey: apiKey,
http: &http.Client{Timeout: 180 * time.Second},
}
}
// Chat sends a system prompt + user message and returns the text response.
// model: e.g. gemini.TextModel ("gemini-2.5-flash")
func (c *Client) Chat(ctx context.Context, model, systemPrompt, userMsg string) (string, error) {
type chatContent struct {
Role string `json:"role"`
Parts []part `json:"parts"`
}
type chatRequest struct {
SystemInstruction *chatContent `json:"system_instruction,omitempty"`
Contents []chatContent `json:"contents"`
GenerationConfig generationConfig `json:"generationConfig"`
}
req := chatRequest{
SystemInstruction: &chatContent{
Parts: []part{{Text: systemPrompt}},
},
Contents: []chatContent{
{Role: "user", Parts: []part{{Text: userMsg}}},
},
GenerationConfig: generationConfig{
ResponseModalities: []string{"TEXT"},
},
}
data, err := json.Marshal(req)
if err != nil {
return "", fmt.Errorf("serializar request: %w", err)
}
delays := []time.Duration{2 * time.Second, 4 * time.Second, 8 * time.Second}
var lastErr error
for attempt := 0; attempt <= len(delays); attempt++ {
if attempt > 0 {
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(delays[attempt-1]):
}
}
url := fmt.Sprintf("%s/%s:generateContent?key=%s", BaseURL, model, c.APIKey)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
if err != nil {
return "", fmt.Errorf("criar request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(httpReq)
if err != nil {
lastErr = fmt.Errorf("HTTP: %w", err)
continue
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastErr = fmt.Errorf("ler resposta: %w", err)
continue
}
if resp.StatusCode == 429 || resp.StatusCode >= 500 {
lastErr = fmt.Errorf("status %d: %s", resp.StatusCode, string(body))
continue
}
var gr generateResponse
if err := json.Unmarshal(body, &gr); err != nil {
return "", fmt.Errorf("parsear resposta: %w (body: %s)", err, string(body))
}
if gr.Error != nil {
return "", fmt.Errorf("gemini: %s", gr.Error.Message)
}
if len(gr.Candidates) == 0 || len(gr.Candidates[0].Content.Parts) == 0 {
return "", fmt.Errorf("gemini: resposta vazia (body: %s)", string(body))
}
for _, p := range gr.Candidates[0].Content.Parts {
if p.Text != "" {
return p.Text, nil
}
}
return "", fmt.Errorf("gemini: sem texto na resposta")
}
return "", fmt.Errorf("gemini: falha após %d tentativas: %w", len(delays)+1, lastErr)
}
// GenerateImage sends a text prompt and returns raw PNG bytes.
func (c *Client) GenerateImage(ctx context.Context, model, prompt string) ([]byte, error) {
return c.generate(ctx, model, prompt, generationConfig{
ResponseModalities: []string{"IMAGE"},
})
}
// GenerateImageSquare generates a 1:1 aspect-ratio image using imagenConfig.
// Use with gemini-2.5-flash-preview-image-generation.
func (c *Client) GenerateImageSquare(ctx context.Context, model, prompt string) ([]byte, error) {
return c.generate(ctx, model, prompt, generationConfig{
ResponseModalities: []string{"IMAGE"},
ImagenConfig: &imagenConfig{AspectRatio: "1:1"},
})
}
func (c *Client) generate(ctx context.Context, model, prompt string, cfg generationConfig) ([]byte, error) {
reqBody := generateRequest{
Contents: []content{
{Parts: []part{{Text: prompt}}},
},
GenerationConfig: cfg,
}
data, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("serializar request: %w", err)
}
delays := []time.Duration{1 * time.Second, 2 * time.Second, 4 * time.Second}
var lastErr error
for attempt := 0; attempt <= len(delays); attempt++ {
if attempt > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delays[attempt-1]):
}
}
url := fmt.Sprintf("%s/%s:generateContent?key=%s", BaseURL, model, c.APIKey)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("criar request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
lastErr = fmt.Errorf("HTTP: %w", err)
continue
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastErr = fmt.Errorf("ler resposta: %w", err)
continue
}
if resp.StatusCode == 429 || resp.StatusCode >= 500 {
lastErr = fmt.Errorf("status %d: %s", resp.StatusCode, string(body))
continue
}
var gr generateResponse
if err := json.Unmarshal(body, &gr); err != nil {
return nil, fmt.Errorf("parsear resposta: %w (body: %s)", err, string(body))
}
if gr.Error != nil {
return nil, fmt.Errorf("gemini: %s", gr.Error.Message)
}
if len(gr.Candidates) == 0 {
return nil, fmt.Errorf("gemini: sem candidatos na resposta")
}
for _, p := range gr.Candidates[0].Content.Parts {
if p.InlineData != nil {
imgBytes, err := base64.StdEncoding.DecodeString(p.InlineData.Data)
if err != nil {
return nil, fmt.Errorf("decodificar imagem base64: %w", err)
}
return imgBytes, nil
}
}
return nil, fmt.Errorf("gemini: nenhuma imagem na resposta")
}
return nil, fmt.Errorf("gemini: falha após %d tentativas: %w", len(delays)+1, lastErr)
}

5
shared/go.mod Normal file
View File

@ -0,0 +1,5 @@
module ldpost/shared
go 1.22
require github.com/joho/godotenv v1.5.1

2
shared/go.sum Normal file
View File

@ -0,0 +1,2 @@
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=

207
shared/groq/client.go Normal file
View File

@ -0,0 +1,207 @@
package groq
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
)
const (
BaseURL = "https://api.groq.com/openai/v1/chat/completions"
TextModel = "llama-3.3-70b-versatile"
VisionModel = "meta-llama/llama-4-scout-17b-16e-instruct"
DefaultTimeout = 120 * time.Second
DefaultMaxTokens = 4096
)
type GroqClient struct {
APIKey string
HTTPClient *http.Client
}
type Message struct {
Role string `json:"role"`
Content any `json:"content"` // string or []ContentPart
}
type ContentPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *ImageURL `json:"image_url,omitempty"`
}
type ImageURL struct {
URL string `json:"url"`
}
// ─── Constructor ──────────────────────────────────────────────────────────────
func NewGroqClient(apiKey string) *GroqClient {
return &GroqClient{
APIKey: apiKey,
HTTPClient: &http.Client{Timeout: DefaultTimeout},
}
}
// ─── Public API ───────────────────────────────────────────────────────────────
// Chat sends a chat completion and returns the text response.
// temp=0 uses Groq default; maxTokens=0 uses DefaultMaxTokens.
func (c *GroqClient) Chat(model string, messages []Message, temp float64, maxTokens int) (string, error) {
if maxTokens == 0 {
maxTokens = DefaultMaxTokens
}
body := chatRequest{
Model: model,
Messages: messages,
Temperature: temp,
MaxTokens: maxTokens,
}
data, err := json.Marshal(body)
if err != nil {
return "", fmt.Errorf("serializar request: %w", err)
}
return c.doRequest(context.Background(), data)
}
// ChatJSON sends a chat completion with response_format=json_object and
// unmarshals the response text into dest.
func (c *GroqClient) ChatJSON(model string, messages []Message, temp float64, dest any) error {
if temp == 0 {
temp = 0.1 // low temp for structured JSON
}
body := chatRequest{
Model: model,
Messages: messages,
Temperature: temp,
MaxTokens: DefaultMaxTokens,
ResponseFormat: &respFormat{Type: "json_object"},
}
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("serializar request: %w", err)
}
text, err := c.doRequest(context.Background(), data)
if err != nil {
return err
}
if err := json.Unmarshal([]byte(text), dest); err != nil {
return fmt.Errorf("unmarshal JSON response: %w (text: %s)", err, text)
}
return nil
}
// ImageMessage builds a Message with text + base64-encoded image file.
// Useful for vision requests.
func ImageMessage(role, text, imagePath string) (Message, error) {
imgData, err := os.ReadFile(imagePath)
if err != nil {
return Message{}, fmt.Errorf("ler imagem %s: %w", imagePath, err)
}
b64 := base64.StdEncoding.EncodeToString(imgData)
dataURL := fmt.Sprintf("data:image/png;base64,%s", b64)
return Message{
Role: role,
Content: []ContentPart{
{Type: "text", Text: text},
{Type: "image_url", ImageURL: &ImageURL{URL: dataURL}},
},
}, nil
}
// TextMessages is a convenience helper that builds the typical system+user pair.
func TextMessages(system, user string) []Message {
return []Message{
{Role: "system", Content: system},
{Role: "user", Content: user},
}
}
// ─── Internal ─────────────────────────────────────────────────────────────────
type chatRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
ResponseFormat *respFormat `json:"response_format,omitempty"`
}
type respFormat struct {
Type string `json:"type"`
}
type chatResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error,omitempty"`
}
func (c *GroqClient) doRequest(ctx context.Context, body []byte) (string, error) {
delays := []time.Duration{1 * time.Second, 2 * time.Second, 4 * time.Second}
var lastErr error
for attempt := 0; attempt <= len(delays); attempt++ {
if attempt > 0 {
log.Printf("[WARN] Groq retry %d/%d: %v", attempt, len(delays), lastErr)
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(delays[attempt-1]):
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, BaseURL, bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("criar request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.APIKey)
resp, err := c.HTTPClient.Do(req)
if err != nil {
lastErr = fmt.Errorf("HTTP: %w", err)
continue
}
respBody, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastErr = fmt.Errorf("ler resposta: %w", err)
continue
}
// Retry on rate limit and server errors
if resp.StatusCode == 429 || resp.StatusCode == 500 || resp.StatusCode == 503 {
lastErr = fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody))
continue
}
var gr chatResponse
if err := json.Unmarshal(respBody, &gr); err != nil {
return "", fmt.Errorf("parsear resposta groq: %w (body: %s)", err, string(respBody))
}
if gr.Error != nil {
return "", fmt.Errorf("groq api: %s", gr.Error.Message)
}
if len(gr.Choices) == 0 {
return "", fmt.Errorf("groq: resposta sem choices")
}
return gr.Choices[0].Message.Content, nil
}
return "", fmt.Errorf("groq: falha após %d tentativas: %w", len(delays)+1, lastErr)
}

92
shared/history/history.go Normal file
View File

@ -0,0 +1,92 @@
package history
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
const (
histRelPath = "_history/format-history.json"
maxEntries = 20
)
type HistoryEntry struct {
Slug string `json:"slug"`
Formato string `json:"formato"`
Data string `json:"data"` // YYYY-MM-DD
}
type FormatHistory struct {
History []HistoryEntry `json:"history"`
}
// LoadHistory reads _history/format-history.json from workspacePath.
// Returns empty history if file doesn't exist.
func LoadHistory(workspacePath string) (*FormatHistory, error) {
path := filepath.Join(workspacePath, histRelPath)
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return &FormatHistory{}, nil
}
if err != nil {
return nil, fmt.Errorf("ler format-history: %w", err)
}
var h FormatHistory
if err := json.Unmarshal(data, &h); err != nil {
return nil, fmt.Errorf("parsear format-history: %w", err)
}
return &h, nil
}
// SaveHistory writes _history/format-history.json atomically.
func SaveHistory(workspacePath string, h *FormatHistory) error {
dir := filepath.Join(workspacePath, "_history")
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("criar _history/: %w", err)
}
data, err := json.MarshalIndent(h, "", " ")
if err != nil {
return fmt.Errorf("serializar history: %w", err)
}
path := filepath.Join(workspacePath, histRelPath)
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return fmt.Errorf("escrever tmp: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
return fmt.Errorf("rename tmp: %w", err)
}
return nil
}
// LastN returns the last n formats used (most recent first).
func (h *FormatHistory) LastN(n int) []string {
var out []string
for i, e := range h.History {
if i >= n {
break
}
out = append(out, e.Formato)
}
return out
}
// AddEntry prepends a new entry and trims to maxEntries.
func (h *FormatHistory) AddEntry(slug, formato string) {
entry := HistoryEntry{
Slug: slug,
Formato: formato,
Data: time.Now().Format("2006-01-02"),
}
h.History = append([]HistoryEntry{entry}, h.History...)
if len(h.History) > maxEntries {
h.History = h.History[:maxEntries]
}
}

169
shared/state/state.go Normal file
View File

@ -0,0 +1,169 @@
package state
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
// ─── Status constants ─────────────────────────────────────────────────────────
const (
StatusWaitingEvaluator = "waiting_evaluator"
StatusWaitingRedator = "waiting_redator"
StatusWaitingEditor = "waiting_editor"
StatusWaitingArt = "waiting_art"
StatusWaitingDirector = "waiting_director"
StatusWaitingPublisher = "waiting_publisher"
StatusPublished = "published"
StatusRejected = "rejected"
EtapaDone = "done"
EtapaPending = "pending"
EtapaWaiting = "waiting"
)
// ─── Schema ───────────────────────────────────────────────────────────────────
type Etapas struct {
Evaluator string `json:"evaluator"`
Redator string `json:"redator"`
Editor string `json:"editor"`
Art string `json:"art"`
Director string `json:"director"`
Publisher string `json:"publisher"`
}
type Aprovacao struct {
Aprovado bool `json:"aprovado"`
Timestamp time.Time `json:"timestamp,omitempty"`
Ciclos int `json:"ciclos,omitempty"`
}
type Aprovacoes struct {
Tema Aprovacao `json:"tema"`
Texto Aprovacao `json:"texto"`
Imagens Aprovacao `json:"imagens"`
Final Aprovacao `json:"final,omitempty"`
}
type PostState struct {
Slug string `json:"slug"`
Categoria string `json:"categoria"`
Status string `json:"status"`
Formato string `json:"formato"`
TemaEscolhido string `json:"tema_escolhido"`
TrendReferencia string `json:"trend_referencia"`
FunilTag string `json:"funil_tag,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Etapas Etapas `json:"etapas"`
Aprovacoes Aprovacoes `json:"aprovacoes"`
PollingActive bool `json:"polling_active,omitempty"`
DirectorMessageID int `json:"director_message_id,omitempty"`
LinkedInPostID string `json:"linkedin_post_id,omitempty"`
PublishedAt *time.Time `json:"published_at,omitempty"`
}
// ─── I/O ──────────────────────────────────────────────────────────────────────
// LoadState reads work/state.json relative to postPath.
// postPath is the post root: <workspace>/<categoria>/<slug>
func LoadState(postPath string) (*PostState, error) {
path := stateFilePath(postPath)
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("ler state.json (%s): %w", path, err)
}
var s PostState
if err := json.Unmarshal(data, &s); err != nil {
return nil, fmt.Errorf("parsear state.json: %w", err)
}
return &s, nil
}
// SaveState writes work/state.json atomically (tmp + rename).
// Updates UpdatedAt automatically.
func SaveState(postPath string, s *PostState) error {
s.UpdatedAt = time.Now()
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return fmt.Errorf("serializar state: %w", err)
}
workDir := filepath.Join(postPath, "work")
if err := os.MkdirAll(workDir, 0755); err != nil {
return fmt.Errorf("criar work/: %w", err)
}
path := stateFilePath(postPath)
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return fmt.Errorf("escrever tmp: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
return fmt.Errorf("rename tmp→state.json: %w", err)
}
return nil
}
// ─── Methods ──────────────────────────────────────────────────────────────────
// SetEtapa updates a specific agent's step status and bumps UpdatedAt.
func (s *PostState) SetEtapa(agente, status string) {
s.UpdatedAt = time.Now()
switch agente {
case "evaluator":
s.Etapas.Evaluator = status
case "redator":
s.Etapas.Redator = status
case "editor":
s.Etapas.Editor = status
case "art":
s.Etapas.Art = status
case "director":
s.Etapas.Director = status
case "publisher":
s.Etapas.Publisher = status
}
}
// IsStatus reports whether the current Status matches expected.
func (s *PostState) IsStatus(expected string) bool {
return s.Status == expected
}
// ─── Constructor ──────────────────────────────────────────────────────────────
func NewPostState(slug, categoria, formato, tema, trend string) *PostState {
now := time.Now()
return &PostState{
Slug: slug,
Categoria: categoria,
Status: StatusWaitingEvaluator,
Formato: formato,
TemaEscolhido: tema,
TrendReferencia: trend,
CreatedAt: now,
UpdatedAt: now,
Etapas: Etapas{
Evaluator: EtapaPending,
Redator: EtapaPending,
Editor: EtapaPending,
Art: EtapaPending,
Director: EtapaPending,
Publisher: EtapaPending,
},
}
}
// ─── Internal ─────────────────────────────────────────────────────────────────
func stateFilePath(postPath string) string {
return filepath.Join(postPath, "work", "state.json")
}

111
shared/state/state_test.go Normal file
View File

@ -0,0 +1,111 @@
package state_test
import (
"os"
"path/filepath"
"testing"
"time"
"ldpost/shared/state"
)
func TestSaveAndLoadState(t *testing.T) {
dir := t.TempDir()
postPath := filepath.Join(dir, "Codigo", "test-slug")
s := state.NewPostState("test-slug", "Codigo", "como", "Test tema", "Test trend")
s.Status = state.StatusWaitingRedator
s.SetEtapa("evaluator", state.EtapaDone)
if err := state.SaveState(postPath, s); err != nil {
t.Fatalf("SaveState: %v", err)
}
loaded, err := state.LoadState(postPath)
if err != nil {
t.Fatalf("LoadState: %v", err)
}
if loaded.Slug != "test-slug" {
t.Errorf("slug: got %q, want %q", loaded.Slug, "test-slug")
}
if loaded.Status != state.StatusWaitingRedator {
t.Errorf("status: got %q, want %q", loaded.Status, state.StatusWaitingRedator)
}
if loaded.Etapas.Evaluator != state.EtapaDone {
t.Errorf("evaluator etapa: got %q, want %q", loaded.Etapas.Evaluator, state.EtapaDone)
}
}
func TestAtomicWrite(t *testing.T) {
dir := t.TempDir()
postPath := filepath.Join(dir, "Cat", "slug-atomic")
// Write initial state
s1 := state.NewPostState("slug-atomic", "Cat", "como", "Tema 1", "Trend 1")
if err := state.SaveState(postPath, s1); err != nil {
t.Fatalf("first SaveState: %v", err)
}
// Verify original file exists
stateFile := filepath.Join(postPath, "work", "state.json")
before, err := os.ReadFile(stateFile)
if err != nil {
t.Fatalf("read state file: %v", err)
}
// Write second state
s2 := state.NewPostState("slug-atomic", "Cat", "erro", "Tema 2", "Trend 2")
if err := state.SaveState(postPath, s2); err != nil {
t.Fatalf("second SaveState: %v", err)
}
after, err := os.ReadFile(stateFile)
if err != nil {
t.Fatalf("read state file after: %v", err)
}
// Files should differ (second write changed the content)
if string(before) == string(after) {
t.Error("estado não mudou após segundo SaveState")
}
// Verify no .tmp files left behind
tmpFile := stateFile + ".tmp"
if _, err := os.Stat(tmpFile); !os.IsNotExist(err) {
t.Error("arquivo .tmp deixado para trás após SaveState")
}
}
func TestSetEtapa(t *testing.T) {
s := state.NewPostState("slug", "Cat", "como", "tema", "trend")
agentes := []string{"evaluator", "redator", "editor", "art", "director", "publisher"}
for _, ag := range agentes {
before := s.UpdatedAt
time.Sleep(time.Millisecond) // ensure time advances
s.SetEtapa(ag, state.EtapaDone)
if s.UpdatedAt.Equal(before) || s.UpdatedAt.Before(before) {
t.Errorf("SetEtapa(%s): UpdatedAt não avançou", ag)
}
}
if s.Etapas.Evaluator != state.EtapaDone {
t.Errorf("evaluator etapa: got %q", s.Etapas.Evaluator)
}
if s.Etapas.Publisher != state.EtapaDone {
t.Errorf("publisher etapa: got %q", s.Etapas.Publisher)
}
}
func TestIsStatus(t *testing.T) {
s := state.NewPostState("slug", "Cat", "como", "tema", "trend")
s.Status = state.StatusWaitingEditor
if !s.IsStatus(state.StatusWaitingEditor) {
t.Error("IsStatus deveria retornar true")
}
if s.IsStatus(state.StatusPublished) {
t.Error("IsStatus deveria retornar false")
}
}

399
shared/telegram/bot.go Normal file
View File

@ -0,0 +1,399 @@
package telegram
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
const (
apiBase = "https://api.telegram.org/bot"
msgLimit = 4000
pollTimeout = 30 // seconds for long polling
)
// ─── Types ────────────────────────────────────────────────────────────────────
type Bot struct {
Token string
ChatID string
Client *http.Client
offset int // persistent across WaitFor* calls — avoids re-delivering consumed updates
}
type InlineButton struct {
Text string
CallbackData string
}
type MediaFile struct {
Path string
Caption string
}
// ─── Constructor ──────────────────────────────────────────────────────────────
func NewBot(token, chatID string) *Bot {
return &Bot{
Token: token,
ChatID: chatID,
Client: &http.Client{Timeout: 35 * time.Second},
}
}
// ─── Sending ──────────────────────────────────────────────────────────────────
// SendMessage sends text with HTML parse mode.
// Long messages are split at newlines.
func (b *Bot) SendMessage(text string) (int, error) {
chunks := splitText(text, msgLimit)
var lastID int
for _, chunk := range chunks {
id, err := b.sendText(chunk, nil)
if err != nil {
return 0, err
}
lastID = id
}
return lastID, nil
}
// SendMessageWithKeyboard sends HTML text with an inline keyboard.
func (b *Bot) SendMessageWithKeyboard(text string, buttons [][]InlineButton) (int, error) {
var rows [][]tgButton
for _, row := range buttons {
var tgRow []tgButton
for _, btn := range row {
tgRow = append(tgRow, tgButton{Text: btn.Text, CallbackData: btn.CallbackData})
}
rows = append(rows, tgRow)
}
return b.sendText(text, &inlineKeyboard{InlineKeyboard: rows})
}
// SendPhoto sends a single image file with optional caption.
func (b *Bot) SendPhoto(filePath, caption string) error {
f, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("abrir imagem: %w", err)
}
defer f.Close()
var body bytes.Buffer
w := multipart.NewWriter(&body)
_ = w.WriteField("chat_id", b.ChatID)
if caption != "" {
_ = w.WriteField("caption", caption)
}
fw, err := w.CreateFormFile("photo", filepath.Base(filePath))
if err != nil {
return err
}
if _, err := io.Copy(fw, f); err != nil {
return err
}
w.Close()
return b.postMultipart("sendPhoto", w.FormDataContentType(), &body)
}
// SendMediaGroup sends up to 10 images as an album.
func (b *Bot) SendMediaGroup(files []MediaFile) error {
switch len(files) {
case 0:
return nil
case 1:
return b.SendPhoto(files[0].Path, files[0].Caption)
}
var body bytes.Buffer
w := multipart.NewWriter(&body)
_ = w.WriteField("chat_id", b.ChatID)
type inputMedia struct {
Type string `json:"type"`
Media string `json:"media"`
Caption string `json:"caption,omitempty"`
}
var mediaJSON []inputMedia
for i, mf := range files {
fieldName := fmt.Sprintf("file%d", i)
f, err := os.Open(mf.Path)
if err != nil {
return fmt.Errorf("abrir %s: %w", mf.Path, err)
}
fw, err := w.CreateFormFile(fieldName, filepath.Base(mf.Path))
if err != nil {
f.Close()
return err
}
_, err = io.Copy(fw, f)
f.Close()
if err != nil {
return err
}
mediaJSON = append(mediaJSON, inputMedia{
Type: "photo",
Media: "attach://" + fieldName,
Caption: mf.Caption,
})
}
mediaBytes, _ := json.Marshal(mediaJSON)
_ = w.WriteField("media", string(mediaBytes))
w.Close()
return b.postMultipart("sendMediaGroup", w.FormDataContentType(), &body)
}
// ─── Polling ──────────────────────────────────────────────────────────────────
// WaitForCallback polls getUpdates until one of validCallbacks arrives.
// timeout=0 means wait indefinitely.
// Uses b.offset so consumed updates are not re-delivered to subsequent WaitFor* calls.
func (b *Bot) WaitForCallback(validCallbacks []string, timeout time.Duration) (string, error) {
valid := make(map[string]bool, len(validCallbacks))
for _, c := range validCallbacks {
valid[c] = true
}
var ctx context.Context
var cancel context.CancelFunc
if timeout > 0 {
ctx, cancel = context.WithTimeout(context.Background(), timeout)
} else {
ctx, cancel = context.WithCancel(context.Background())
}
defer cancel()
for {
updates, err := b.getUpdates(ctx, b.offset)
if err != nil {
if ctx.Err() != nil {
return "", fmt.Errorf("timeout aguardando callback: %w", ctx.Err())
}
time.Sleep(2 * time.Second)
continue
}
for _, u := range updates {
b.offset = u.UpdateID + 1
if u.CallbackQuery == nil {
continue
}
b.answerCallback(u.CallbackQuery.ID)
if valid[u.CallbackQuery.Data] {
return u.CallbackQuery.Data, nil
}
}
}
}
// ─── Extended polling ─────────────────────────────────────────────────────────
// Event represents either a Telegram callback or a free-text message.
type Event struct {
IsCallback bool
Text string // CallbackData or message text
}
// WaitForAny polls until either a CallbackQuery or a text Message arrives.
// Uses b.offset so consumed updates are not re-delivered to subsequent WaitFor* calls.
func (b *Bot) WaitForAny(timeout time.Duration) (*Event, error) {
var ctx context.Context
var cancel context.CancelFunc
if timeout > 0 {
ctx, cancel = context.WithTimeout(context.Background(), timeout)
} else {
ctx, cancel = context.WithCancel(context.Background())
}
defer cancel()
for {
updates, err := b.getUpdates(ctx, b.offset)
if err != nil {
if ctx.Err() != nil {
return nil, fmt.Errorf("timeout: %w", ctx.Err())
}
time.Sleep(2 * time.Second)
continue
}
for _, u := range updates {
b.offset = u.UpdateID + 1
if u.CallbackQuery != nil {
b.answerCallback(u.CallbackQuery.ID)
return &Event{IsCallback: true, Text: u.CallbackQuery.Data}, nil
}
if u.Message != nil && u.Message.Text != "" {
return &Event{IsCallback: false, Text: u.Message.Text}, nil
}
}
}
}
// ─── EscapeMarkdown ───────────────────────────────────────────────────────────
// EscapeMarkdown escapes all MarkdownV2 special characters:
// _ * [ ] ( ) ~ ` > # + - = | { } . !
func EscapeMarkdown(s string) string {
special := `\_*[]()~` + "`" + `>#+-=|{}.!`
var b strings.Builder
b.Grow(len(s) + 16)
for _, r := range s {
if strings.ContainsRune(special, r) {
b.WriteRune('\\')
}
b.WriteRune(r)
}
return b.String()
}
// ─── Internal types ───────────────────────────────────────────────────────────
type tgButton struct {
Text string `json:"text"`
CallbackData string `json:"callback_data"`
}
type inlineKeyboard struct {
InlineKeyboard [][]tgButton `json:"inline_keyboard"`
}
type tgUpdate struct {
UpdateID int `json:"update_id"`
Message *tgMessage `json:"message,omitempty"`
CallbackQuery *tgCallbackQuery `json:"callback_query,omitempty"`
}
type tgMessage struct {
MessageID int `json:"message_id"`
Text string `json:"text"`
}
type tgCallbackQuery struct {
ID string `json:"id"`
Data string `json:"data"`
Message *tgMessage `json:"message,omitempty"`
}
// ─── Internal helpers ─────────────────────────────────────────────────────────
func (b *Bot) sendText(text string, markup *inlineKeyboard) (int, error) {
chatIDInt, err := strconv.ParseInt(b.ChatID, 10, 64)
if err != nil {
return 0, fmt.Errorf("chat_id inválido %q: %w", b.ChatID, err)
}
reqBody := map[string]any{
"chat_id": chatIDInt,
"text": text,
"parse_mode": "HTML",
}
if markup != nil {
reqBody["reply_markup"] = markup
}
data, _ := json.Marshal(reqBody)
resp, err := b.call("sendMessage", data)
if err != nil {
return 0, err
}
var result struct {
OK bool `json:"ok"`
Description string `json:"description,omitempty"`
Result struct {
MessageID int `json:"message_id"`
} `json:"result"`
}
if err := json.Unmarshal(resp, &result); err != nil {
return 0, fmt.Errorf("parsear sendMessage: %w", err)
}
if !result.OK {
return 0, fmt.Errorf("telegram sendMessage: %s", result.Description)
}
return result.Result.MessageID, nil
}
func (b *Bot) postMultipart(method, contentType string, body *bytes.Buffer) error {
url := apiBase + b.Token + "/" + method
resp, err := http.Post(url, contentType, body)
if err != nil {
return fmt.Errorf("%s: %w", method, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
rb, _ := io.ReadAll(resp.Body)
return fmt.Errorf("%s status %d: %s", method, resp.StatusCode, string(rb))
}
return nil
}
func (b *Bot) getUpdates(ctx context.Context, offset int) ([]tgUpdate, error) {
reqBody := map[string]any{"offset": offset, "timeout": pollTimeout}
data, _ := json.Marshal(reqBody)
url := apiBase + b.Token + "/getUpdates"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
longClient := &http.Client{Timeout: time.Duration(pollTimeout+10) * time.Second}
resp, err := longClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result struct {
OK bool `json:"ok"`
Result []tgUpdate `json:"result"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parsear getUpdates: %w", err)
}
return result.Result, nil
}
func (b *Bot) answerCallback(callbackID string) {
data, _ := json.Marshal(map[string]string{"callback_query_id": callbackID})
_, _ = b.call("answerCallbackQuery", data)
}
func (b *Bot) call(method string, body []byte) ([]byte, error) {
url := apiBase + b.Token + "/" + method
resp, err := b.Client.Post(url, "application/json", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("telegram %s: %w", method, err)
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
func splitText(text string, limit int) []string {
if len(text) <= limit {
return []string{text}
}
var chunks []string
for len(text) > limit {
cut := limit
if idx := strings.LastIndex(text[:limit], "\n"); idx > 0 {
cut = idx
}
chunks = append(chunks, text[:cut])
text = text[cut:]
}
return append(chunks, text)
}

View File

@ -0,0 +1,76 @@
package telegram_test
import (
"testing"
"ldpost/shared/telegram"
)
func TestEscapeMarkdown(t *testing.T) {
// All MarkdownV2 special chars must be escaped with backslash
special := []rune{'_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'}
for _, ch := range special {
input := string(ch)
got := telegram.EscapeMarkdown(input)
want := "\\" + input
if got != want {
t.Errorf("EscapeMarkdown(%q) = %q, want %q", input, got, want)
}
}
}
func TestEscapeMarkdown_PlainText(t *testing.T) {
cases := []struct {
input string
want string
}{
{"hello world", "hello world"},
{"", ""},
{"abc123", "abc123"},
}
for _, c := range cases {
got := telegram.EscapeMarkdown(c.input)
if got != c.want {
t.Errorf("EscapeMarkdown(%q) = %q, want %q", c.input, got, c.want)
}
}
}
func TestEscapeMarkdown_MixedContent(t *testing.T) {
input := "Hello, world! How are you?"
got := telegram.EscapeMarkdown(input)
// '!' and '.' and '?' (not in special) — only '!' should be escaped
// ',' not special, '!' is special, '.' is special, '?' not special
// expected: "Hello, world\! How are you?"
want := `Hello, world\! How are you?`
if got != want {
t.Errorf("EscapeMarkdown(%q)\n got: %q\n want: %q", input, got, want)
}
}
func TestEscapeMarkdown_CodeBlock(t *testing.T) {
// Typical tech content
input := "Use fmt.Println(\"hello\") in Go"
got := telegram.EscapeMarkdown(input)
// '.' is special → escaped
// '(' and ')' are special → escaped
// '"' is not special
want := `Use fmt\.Println\("hello"\) in Go`
if got != want {
t.Errorf("EscapeMarkdown(%q)\n got: %q\n want: %q", input, got, want)
}
}
func TestNewBot(t *testing.T) {
bot := telegram.NewBot("test-token", "123456")
if bot == nil {
t.Fatal("NewBot retornou nil")
}
if bot.Token != "test-token" {
t.Errorf("Token: got %q, want %q", bot.Token, "test-token")
}
if bot.ChatID != "123456" {
t.Errorf("ChatID: got %q, want %q", bot.ChatID, "123456")
}
}

32
test-cleanup.sh Normal file
View File

@ -0,0 +1,32 @@
#!/usr/bin/env bash
# test-cleanup.sh — remove todo o workspace de teste
# Executa depois de test-pipeline.sh para desfazer completamente.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
TEST_WS="$SCRIPT_DIR/workspace-test"
if [[ ! -d "$TEST_WS" ]]; then
echo "Nada a limpar — $TEST_WS não existe."
exit 0
fi
echo ""
echo "Removendo workspace de teste: $TEST_WS"
echo "Conteúdo atual:"
find "$TEST_WS" -mindepth 2 -maxdepth 3 -name "state.json" | while read -r f; do
slug=$(basename "$(dirname "$(dirname "$f")")")
status=$(grep '"status"' "$f" | head -1 | sed 's/.*: "\(.*\)".*/\1/')
echo "$slug$status"
done
echo ""
read -rp "Confirmar remoção? [s/N]: " confirm
if [[ "${confirm,,}" != "s" ]]; then
echo "Cancelado."
exit 0
fi
rm -rf "$TEST_WS"
echo "✅ Workspace de teste removido."

89
test-pipeline.sh Normal file
View File

@ -0,0 +1,89 @@
#!/usr/bin/env bash
# test-pipeline.sh — roda o pipeline completo sem postar no LinkedIn
# Usa workspace de teste separado para não contaminar o real.
#
# Uso: bash test-pipeline.sh [slug]
# slug: força um slug específico do _sugestoes.md (opcional)
#
# Para desfazer tudo depois: bash test-cleanup.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
TEST_WS="$SCRIPT_DIR/workspace-test"
BINS="$SCRIPT_DIR/bin"
# Copiar _sugestoes.md para o workspace de teste
mkdir -p "$TEST_WS/_inbox"
cp "$SCRIPT_DIR/workspace/_inbox/_sugestoes.md" "$TEST_WS/_inbox/_sugestoes.md"
echo ""
echo "══════════════════════════════════════════════"
echo " ldpost — PIPELINE DE TESTE"
echo " Workspace: $TEST_WS"
echo "══════════════════════════════════════════════"
echo ""
# ── 1. Evaluator ──────────────────────────────────────────────────────────────
echo "▶ [1/5] Evaluator — sorteio + aprovação no Telegram..."
if [[ -n "${1:-}" ]]; then
EVALUATOR_FLAGS="--force-slug $1"
else
EVALUATOR_FLAGS=""
fi
SLUG=$("$BINS/ldpost-evaluator.exe" \
--workspace "$TEST_WS" \
$EVALUATOR_FLAGS \
2>&1 | tee /dev/stderr | tail -1)
if [[ -z "$SLUG" ]]; then
echo "❌ Evaluator não retornou slug. Abortando."
exit 1
fi
echo ""
echo "✅ Slug criado: $SLUG"
echo ""
# ── 2. Redator ────────────────────────────────────────────────────────────────
echo "▶ [2/5] Redator — gerando rascunho via Groq..."
"$BINS/ldpost-redator.exe" \
--workspace "$TEST_WS" \
--post "$SLUG"
echo ""
# ── 3. Editor ─────────────────────────────────────────────────────────────────
echo "▶ [3/5] Editor — formatando para LinkedIn..."
echo " (modo não-interativo — use --no-interactive para pular loop)"
"$BINS/ldpost-editor.exe" \
--workspace "$TEST_WS" \
--post "$SLUG" \
--no-interactive
echo ""
# ── 4. Director ───────────────────────────────────────────────────────────────
echo "▶ [4/5] Director — revisão final no Telegram (sem imagens)..."
"$BINS/ldpost-director.exe" \
--workspace "$TEST_WS" \
--post "$SLUG" \
--skip-images
echo ""
# ── 5. Publisher (manual — não posta) ─────────────────────────────────────────
echo "▶ [5/5] Publisher — modo manual (NÃO posta no LinkedIn)"
echo " Quando pedir URL do post, pressione Enter para pular."
echo ""
"$BINS/ldpost-publisher.exe" \
--workspace "$TEST_WS" \
--post "$SLUG" \
--manual
echo ""
echo "══════════════════════════════════════════════"
echo " Pipeline de teste concluído!"
echo " Para limpar: bash test-cleanup.sh"
echo "══════════════════════════════════════════════"