From ea532659b05d856b98589cd1fd74fbf544b17826 Mon Sep 17 00:00:00 2001 From: Ricardo Carneiro Date: Sun, 3 May 2026 18:55:39 -0300 Subject: [PATCH] feat: pipeline inicial ldpost-squad (6 agentes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 21 + .gitignore | 20 + INSTRUCOES.md | 179 +++++++ Makefile | 60 +++ STATUS.md | 154 ++++++ art/go.mod | 18 + art/go.sum | 17 + art/main.go | 504 +++++++++++++++++++ director/go.mod | 16 + director/go.sum | 12 + director/main.go | 666 ++++++++++++++++++++++++ editor/go.mod | 16 + editor/go.sum | 12 + editor/main.go | 361 +++++++++++++ evaluator/go.mod | 16 + evaluator/go.sum | 12 + evaluator/main.go | 894 +++++++++++++++++++++++++++++++++ go.work | 11 + go.work.sum | 15 + publisher/go.mod | 16 + publisher/go.sum | 12 + publisher/main.go | 599 ++++++++++++++++++++++ redator/go.mod | 16 + redator/go.sum | 12 + redator/main.go | 209 ++++++++ seed/_sugestoes.md | 450 +++++++++++++++++ shared/config/config.go | 90 ++++ shared/formats/formats.go | 88 ++++ shared/formats/formats_test.go | 84 ++++ shared/gemini/client.go | 255 ++++++++++ shared/go.mod | 5 + shared/go.sum | 2 + shared/groq/client.go | 207 ++++++++ shared/history/history.go | 92 ++++ shared/state/state.go | 169 +++++++ shared/state/state_test.go | 111 ++++ shared/telegram/bot.go | 399 +++++++++++++++ shared/telegram/bot_test.go | 76 +++ test-cleanup.sh | 32 ++ test-pipeline.sh | 89 ++++ 40 files changed, 6017 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 INSTRUCOES.md create mode 100644 Makefile create mode 100644 STATUS.md create mode 100644 art/go.mod create mode 100644 art/go.sum create mode 100644 art/main.go create mode 100644 director/go.mod create mode 100644 director/go.sum create mode 100644 director/main.go create mode 100644 editor/go.mod create mode 100644 editor/go.sum create mode 100644 editor/main.go create mode 100644 evaluator/go.mod create mode 100644 evaluator/go.sum create mode 100644 evaluator/main.go create mode 100644 go.work create mode 100644 go.work.sum create mode 100644 publisher/go.mod create mode 100644 publisher/go.sum create mode 100644 publisher/main.go create mode 100644 redator/go.mod create mode 100644 redator/go.sum create mode 100644 redator/main.go create mode 100644 seed/_sugestoes.md create mode 100644 shared/config/config.go create mode 100644 shared/formats/formats.go create mode 100644 shared/formats/formats_test.go create mode 100644 shared/gemini/client.go create mode 100644 shared/go.mod create mode 100644 shared/go.sum create mode 100644 shared/groq/client.go create mode 100644 shared/history/history.go create mode 100644 shared/state/state.go create mode 100644 shared/state/state_test.go create mode 100644 shared/telegram/bot.go create mode 100644 shared/telegram/bot_test.go create mode 100644 test-cleanup.sh create mode 100644 test-pipeline.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..05e4eeb --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..edd2358 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/INSTRUCOES.md b/INSTRUCOES.md new file mode 100644 index 0000000..36d410d --- /dev/null +++ b/INSTRUCOES.md @@ -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 ` 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/ + / + 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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9fb537b --- /dev/null +++ b/Makefile @@ -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-* diff --git a/STATUS.md b/STATUS.md new file mode 100644 index 0000000..011137e --- /dev/null +++ b/STATUS.md @@ -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//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//post.json` | [ ] | +| Gera rascunho via Groq/Gemini | [ ] | +| Salva `$LDPOST_WORKSPACE//draft.md` | [ ] | +| Flag `--post ` (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//final.md` | [ ] | +| Flag `--post ` (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//img/*.png` | [ ] | +| Flag `--post ` (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//status.json` | [ ] | +| Flag `--post ` (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 ` (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 diff --git a/art/go.mod b/art/go.mod new file mode 100644 index 0000000..e35644b --- /dev/null +++ b/art/go.mod @@ -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 diff --git a/art/go.sum b/art/go.sum new file mode 100644 index 0000000..3f11ea5 --- /dev/null +++ b/art/go.sum @@ -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= diff --git a/art/main.go b/art/main.go new file mode 100644 index 0000000..2f2a0dc --- /dev/null +++ b/art/main.go @@ -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( + "🎨 ldpost-art | %s\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 ", + 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: ldpost-director --post %s", + 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 +} diff --git a/director/go.mod b/director/go.mod new file mode 100644 index 0000000..e68de8e --- /dev/null +++ b/director/go.mod @@ -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 diff --git a/director/go.sum b/director/go.sum new file mode 100644 index 0000000..c7571c5 --- /dev/null +++ b/director/go.sum @@ -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= diff --git a/director/main.go b/director/main.go new file mode 100644 index 0000000..46f1a89 --- /dev/null +++ b/director/main.go @@ -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( + "📋 ldpost-director | Aprovação Final\n📅 %s\n📝 Post: %s\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 := "📄 TEXTO ORIGINAL (seu resumo)\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 := "✍️ TEXTO FINAL (post LinkedIn)\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("⬇️ Decisão final:", buttons) + return +} + +func sendSugestoes(bot *telegram.Bot, sug [3]string) error { + text := fmt.Sprintf("💡 Sugestões do squad:\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("✏️ Modo edição via chat\n\nDigite o que quer mudar no texto.\nExemplos: \"gancho mais direto\", \"remove hashtags de vendas\", \"encurta o terceiro parágrafo\"") + + 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 := "✍️ Texto atualizado:\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 ", + 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( + "✅ Aprovado!\n\nExecute: ldpost-publisher --post %s", 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: ldpost-evaluator --force-slug %s", 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: ldpost-editor --post %s", 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: ldpost-art --post %s", 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 +} diff --git a/editor/go.mod b/editor/go.mod new file mode 100644 index 0000000..cb295f0 --- /dev/null +++ b/editor/go.mod @@ -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 diff --git a/editor/go.sum b/editor/go.sum new file mode 100644 index 0000000..c7571c5 --- /dev/null +++ b/editor/go.sum @@ -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= diff --git a/editor/main.go b/editor/main.go new file mode 100644 index 0000000..43b5163 --- /dev/null +++ b/editor/main.go @@ -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 ", + 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) + } +} diff --git a/evaluator/go.mod b/evaluator/go.mod new file mode 100644 index 0000000..48d0d87 --- /dev/null +++ b/evaluator/go.mod @@ -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 diff --git a/evaluator/go.sum b/evaluator/go.sum new file mode 100644 index 0000000..c7571c5 --- /dev/null +++ b/evaluator/go.sum @@ -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= diff --git a/evaluator/main.go b/evaluator/main.go new file mode 100644 index 0000000..6cb991a --- /dev/null +++ b/evaluator/main.go @@ -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("🔍 ldpost-evaluator\n\n") + sb.WriteString("📊 Trends desta semana:\n\n") + + labels := []string{"A", "B", "C"} + for i, t := range trends { + if i >= 3 { + break + } + fmt.Fprintf(&sb, "%s) %s\n", labels[i], t.Tema) + fmt.Fprintf(&sb, " %s\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("♻️ Reciclagem da sua fila:\n") + fmt.Fprintf(&sb2, "D) %s\n", next.Tema) + fmt.Fprintf(&sb2, "Publicado há %d dias como %s → novo ângulo: %s\n", + next.DaysSincePublished, + formats.FormatLabel(next.PreviousFormato), + formats.FormatLabel(next.Formato)) + fmt.Fprintf(&sb2, "%s%s\n", next.BaseSlug, next.Slug) + } else { + sb2.WriteString("📝 Próximo da sua fila:\n") + fmt.Fprintf(&sb2, "D) %s\n", next.Tema) + if next.Formato != "" { + fmt.Fprintf(&sb2, "Formato: %s\n", formats.FormatLabel(next.Formato)) + } + } + sb2.WriteString("\n") + } else { + sb2.WriteString("📝 Fila vazia — nenhum post pendente em _sugestoes.md\n\n") + } + + fmt.Fprintf(&sb2, "🎲 Sorteio sugere: %s (%s)\n", sorteado, formats.FormatLabel(sorteado)) + if len(bloqueados) > 0 { + fmt.Fprintf(&sb2, "bloqueados: %s\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( + "✅ Escolha registrada!\n\nPost: %s\nSlug: %s\n\nPróximo passo:\nldpost-redator --post %s", + 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] + "…" +} diff --git a/go.work b/go.work new file mode 100644 index 0000000..fff2e94 --- /dev/null +++ b/go.work @@ -0,0 +1,11 @@ +go 1.22 + +use ( + ./shared + ./evaluator + ./redator + ./editor + ./art + ./director + ./publisher +) diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000..f994866 --- /dev/null +++ b/go.work.sum @@ -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= diff --git a/publisher/go.mod b/publisher/go.mod new file mode 100644 index 0000000..a4b2a20 --- /dev/null +++ b/publisher/go.mod @@ -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 diff --git a/publisher/go.sum b/publisher/go.sum new file mode 100644 index 0000000..c7571c5 --- /dev/null +++ b/publisher/go.sum @@ -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= diff --git a/publisher/main.go b/publisher/main.go new file mode 100644 index 0000000..6a958ea --- /dev/null +++ b/publisher/main.go @@ -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, ®Resp); 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 ", + 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( + "🚀 Post publicado!\n\n%s\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) + } +} diff --git a/redator/go.mod b/redator/go.mod new file mode 100644 index 0000000..8d814ed --- /dev/null +++ b/redator/go.mod @@ -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 diff --git a/redator/go.sum b/redator/go.sum new file mode 100644 index 0000000..c7571c5 --- /dev/null +++ b/redator/go.sum @@ -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= diff --git a/redator/main.go b/redator/main.go new file mode 100644 index 0000000..f606c64 --- /dev/null +++ b/redator/main.go @@ -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 ", + 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) + } +} diff --git a/seed/_sugestoes.md b/seed/_sugestoes.md new file mode 100644 index 0000000..108d263 --- /dev/null +++ b/seed/_sugestoes.md @@ -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 + +--- diff --git a/shared/config/config.go b/shared/config/config.go new file mode 100644 index 0000000..31545c5 --- /dev/null +++ b/shared/config/config.go @@ -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, ", ")) +} diff --git a/shared/formats/formats.go b/shared/formats/formats.go new file mode 100644 index 0000000..c34cc90 --- /dev/null +++ b/shared/formats/formats.go @@ -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 + } +} diff --git a/shared/formats/formats_test.go b/shared/formats/formats_test.go new file mode 100644 index 0000000..2ae0fda --- /dev/null +++ b/shared/formats/formats_test.go @@ -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) + } + } +} diff --git a/shared/gemini/client.go b/shared/gemini/client.go new file mode 100644 index 0000000..3622dc3 --- /dev/null +++ b/shared/gemini/client.go @@ -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) +} diff --git a/shared/go.mod b/shared/go.mod new file mode 100644 index 0000000..5c2a0d7 --- /dev/null +++ b/shared/go.mod @@ -0,0 +1,5 @@ +module ldpost/shared + +go 1.22 + +require github.com/joho/godotenv v1.5.1 diff --git a/shared/go.sum b/shared/go.sum new file mode 100644 index 0000000..d61b19e --- /dev/null +++ b/shared/go.sum @@ -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= diff --git a/shared/groq/client.go b/shared/groq/client.go new file mode 100644 index 0000000..86d93ba --- /dev/null +++ b/shared/groq/client.go @@ -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) +} diff --git a/shared/history/history.go b/shared/history/history.go new file mode 100644 index 0000000..baaa225 --- /dev/null +++ b/shared/history/history.go @@ -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] + } +} diff --git a/shared/state/state.go b/shared/state/state.go new file mode 100644 index 0000000..d2d8d08 --- /dev/null +++ b/shared/state/state.go @@ -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: // +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") +} diff --git a/shared/state/state_test.go b/shared/state/state_test.go new file mode 100644 index 0000000..206015a --- /dev/null +++ b/shared/state/state_test.go @@ -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") + } +} diff --git a/shared/telegram/bot.go b/shared/telegram/bot.go new file mode 100644 index 0000000..68b1f42 --- /dev/null +++ b/shared/telegram/bot.go @@ -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) +} diff --git a/shared/telegram/bot_test.go b/shared/telegram/bot_test.go new file mode 100644 index 0000000..62c0264 --- /dev/null +++ b/shared/telegram/bot_test.go @@ -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") + } +} diff --git a/test-cleanup.sh b/test-cleanup.sh new file mode 100644 index 0000000..c8a900f --- /dev/null +++ b/test-cleanup.sh @@ -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." diff --git a/test-pipeline.sh b/test-pipeline.sh new file mode 100644 index 0000000..0667e39 --- /dev/null +++ b/test-pipeline.sh @@ -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 "══════════════════════════════════════════════"