feat: pipeline inicial ldpost-squad (6 agentes)
Pipeline completo de publicação no LinkedIn: evaluator → redator → editor → art → director → publisher - Seed com 37 posts em _sugestoes.md - Sorteio de formato com N=3 bloqueados (format-history) - Reciclagem mensal de posts com rotação de formato - Revisão via Telegram com chat livre (Gemini 2.5 Flash) - Publicação via LinkedIn API (OAuth2) - Makefile com targets para Windows/Linux/ARM64 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
ea532659b0
21
.env.example
Normal file
21
.env.example
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# Groq — https://console.groq.com
|
||||||
|
GROQ_API_KEY=
|
||||||
|
|
||||||
|
# Gemini — https://aistudio.google.com/app/apikey
|
||||||
|
GEMINI_API_KEY=
|
||||||
|
|
||||||
|
# Telegram — @BotFather → /newbot
|
||||||
|
TELEGRAM_BOT_TOKEN=
|
||||||
|
TELEGRAM_CHAT_ID=
|
||||||
|
|
||||||
|
# LinkedIn — gerado via: ldpost-publisher --auth
|
||||||
|
LINKEDIN_ACCESS_TOKEN=
|
||||||
|
|
||||||
|
# Workspace — pasta raiz onde os posts ficam
|
||||||
|
# Windows: C:\Textos-Linkedin
|
||||||
|
# Linux/Termux: /data/data/com.termux/files/home/workspace
|
||||||
|
LDPOST_WORKSPACE=
|
||||||
|
|
||||||
|
# Crop do watermark Gemini (padrão 48px — ajuste se necessário)
|
||||||
|
# LDPOST_CROP_BOTTOM=48
|
||||||
|
# LDPOST_CROP_RIGHT=48
|
||||||
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# Secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Compiled binaries
|
||||||
|
bin/
|
||||||
|
*.exe
|
||||||
|
|
||||||
|
# Workspace — conteúdo pessoal dos posts
|
||||||
|
workspace/
|
||||||
|
workspace-test/
|
||||||
|
|
||||||
|
# Go build cache
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
179
INSTRUCOES.md
Normal file
179
INSTRUCOES.md
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
# ldpost-squad — Instruções Gerais
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Princípio Central: Testabilidade Independente
|
||||||
|
|
||||||
|
**Cada CLI deve rodar de forma isolada, sem depender que outro CLI tenha rodado antes.**
|
||||||
|
|
||||||
|
Regras:
|
||||||
|
- Toda CLI aceita `--post <slug>` como entrada principal
|
||||||
|
- Toda CLI aceita `--dry-run` para operar sem side effects (sem salvar arquivo, sem chamar API externa)
|
||||||
|
- Toda CLI pode ser testada com um fixture manual (arquivo criado à mão no workspace)
|
||||||
|
- Nenhuma CLI assume estado global — lê e escreve apenas no workspace do post
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workspace Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
$LDPOST_WORKSPACE/
|
||||||
|
<slug>/
|
||||||
|
post.json ← metadados: tema, formato, imagens desc, status
|
||||||
|
draft.md ← rascunho cru (gerado pelo redator)
|
||||||
|
final.md ← versão formatada LinkedIn (gerado pelo editor)
|
||||||
|
img/
|
||||||
|
cover.png ← imagem principal
|
||||||
|
slide-*.png ← slides do carrossel (se aplicável)
|
||||||
|
status.json ← pipeline state: pending|approved|published + URL
|
||||||
|
```
|
||||||
|
|
||||||
|
**Como testar qualquer CLI isoladamente:**
|
||||||
|
```bash
|
||||||
|
# Crie o fixture mínimo manualmente
|
||||||
|
mkdir -p "$LDPOST_WORKSPACE/meu-slug"
|
||||||
|
echo '{"slug":"meu-slug","topic":"IA no RH","format":"lista"}' \
|
||||||
|
> "$LDPOST_WORKSPACE/meu-slug/post.json"
|
||||||
|
|
||||||
|
# Agora rode qualquer CLI contra esse slug
|
||||||
|
ldpost-redator --post meu-slug --dry-run
|
||||||
|
ldpost-editor --post meu-slug --no-interactive
|
||||||
|
ldpost-art --post meu-slug --dry-run
|
||||||
|
ldpost-director --post meu-slug --skip-telegram
|
||||||
|
ldpost-publisher --post meu-slug --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Variáveis de Ambiente
|
||||||
|
|
||||||
|
Arquivo `.env` na raiz do workspace **ou** exportadas no shell.
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Obrigatórias
|
||||||
|
GROQ_API_KEY= # modelo rápido (redator, evaluator)
|
||||||
|
GEMINI_API_KEY= # imagens e análise (art, editor)
|
||||||
|
LDPOST_WORKSPACE=C:\Textos-LinkedIn
|
||||||
|
|
||||||
|
# Telegram (director)
|
||||||
|
TELEGRAM_BOT_TOKEN=
|
||||||
|
TELEGRAM_CHAT_ID=
|
||||||
|
|
||||||
|
# LinkedIn (publisher) — opcional, fallback manual
|
||||||
|
LINKEDIN_ACCESS_TOKEN=
|
||||||
|
LINKEDIN_CLIENT_ID=
|
||||||
|
LINKEDIN_CLIENT_SECRET=
|
||||||
|
```
|
||||||
|
|
||||||
|
Cada CLI deve chamar `godotenv.Load()` no init para carregar `.env` automaticamente se presente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estrutura de Módulo Go
|
||||||
|
|
||||||
|
Cada CLI é um módulo Go **independente** dentro de `C:\gocode\jobmaker-ldpost\`:
|
||||||
|
|
||||||
|
```
|
||||||
|
jobmaker-ldpost/
|
||||||
|
shared/ ← módulo compartilhado (go.mod: ldpost/shared)
|
||||||
|
evaluator/ ← go.mod: ldpost/evaluator
|
||||||
|
redator/ ← go.mod: ldpost/redator
|
||||||
|
editor/ ← go.mod: ldpost/editor
|
||||||
|
art/ ← go.mod: ldpost/art
|
||||||
|
director/ ← go.mod: ldpost/director
|
||||||
|
publisher/ ← go.mod: ldpost/publisher
|
||||||
|
```
|
||||||
|
|
||||||
|
CLIs dependem de `ldpost/shared` via `replace` no `go.mod`:
|
||||||
|
```
|
||||||
|
require ldpost/shared v0.0.0
|
||||||
|
replace ldpost/shared => ../shared
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Convenções de Código
|
||||||
|
|
||||||
|
### Flags padrão (toda CLI implementa)
|
||||||
|
| Flag | Tipo | Descrição |
|
||||||
|
|------|------|-----------|
|
||||||
|
| `--post` | string | Slug do post (obrigatória na maioria) |
|
||||||
|
| `--dry-run` | bool | Opera sem salvar/publicar |
|
||||||
|
| `--model` | string | Override do modelo LLM |
|
||||||
|
| `--workspace` | string | Override de `LDPOST_WORKSPACE` |
|
||||||
|
| `--verbose` | bool | Log detalhado |
|
||||||
|
|
||||||
|
### Saída padrão
|
||||||
|
- Sucesso: imprime caminho do arquivo gerado ou URL publicada
|
||||||
|
- Erro: `fmt.Fprintf(os.Stderr, "erro: %v\n", err)` + `os.Exit(1)`
|
||||||
|
- Dry-run: imprime conteúdo que seria gerado/enviado
|
||||||
|
|
||||||
|
### Arquivo `post.json` (schema mínimo)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"slug": "ia-no-rh-2026",
|
||||||
|
"topic": "IA no recrutamento em 2026",
|
||||||
|
"format": "lista",
|
||||||
|
"images": [
|
||||||
|
{"index": 1, "prompt": "robô entrevistando humano, estilo flat design"},
|
||||||
|
{"index": 2, "prompt": "dashboard de triagem de currículos com IA"}
|
||||||
|
],
|
||||||
|
"created_at": "2026-05-02T11:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Arquivo `status.json`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"slug": "ia-no-rh-2026",
|
||||||
|
"pipeline_status": "pending",
|
||||||
|
"steps_completed": ["evaluated", "drafted", "edited"],
|
||||||
|
"approved_at": null,
|
||||||
|
"published_url": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Padrões do jobmaker-squad (reutilizar)
|
||||||
|
|
||||||
|
Referência: `C:\gocode\jobmaker-squad\`
|
||||||
|
|
||||||
|
| Padrão | Onde está | O que reusar |
|
||||||
|
|--------|-----------|-------------|
|
||||||
|
| Gemini API calls | `jobmaker-redator/main.go` | Stream + retry pattern |
|
||||||
|
| Imagen (geração de imagem) | `jobmaker-art/main.go` | Gemini Imagen 3.0 call |
|
||||||
|
| Telegram bot | `jobmaker-editor/internal/telegram/` | Keyboard inline, await response |
|
||||||
|
| Config loader | `jobmaker-editor/internal/config/` | `.env` + flags merge |
|
||||||
|
| Vector store local | `jobmaker-editor/internal/vectorstore/` | chromem-go pattern |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Como Rodar Testes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Cada CLI
|
||||||
|
cd evaluator && go test ./... -v
|
||||||
|
|
||||||
|
# Shared
|
||||||
|
cd shared && go test ./... -v
|
||||||
|
|
||||||
|
# Smoke test completo (requer .env configurado)
|
||||||
|
./scripts/smoke-test.sh meu-slug
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ordem de Implementação
|
||||||
|
|
||||||
|
Seguir essa ordem — cada passo desbloqueia o próximo:
|
||||||
|
|
||||||
|
1. `shared/` — structs + workspace utils
|
||||||
|
2. `evaluator/` — sem este, nenhum slug existe
|
||||||
|
3. `redator/` — depende de `post.json` do evaluator
|
||||||
|
4. `editor/` — depende de `draft.md` do redator
|
||||||
|
5. `art/` — depende de `post.json` (descriptions de imagem)
|
||||||
|
6. `director/` — depende de `final.md` + imagens
|
||||||
|
7. `publisher/` — depende de `status.json` aprovado
|
||||||
|
|
||||||
|
Mas cada um pode ser desenvolvido/testado **isoladamente** com fixtures manuais.
|
||||||
60
Makefile
Normal file
60
Makefile
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
AGENTS := evaluator redator editor art director publisher
|
||||||
|
BIN := ./bin
|
||||||
|
|
||||||
|
# ── Detecta OS para extensão dos binários ─────────────────────────────────────
|
||||||
|
ifeq ($(OS),Windows_NT)
|
||||||
|
EXT := .exe
|
||||||
|
else
|
||||||
|
EXT :=
|
||||||
|
endif
|
||||||
|
|
||||||
|
.PHONY: all build build-windows build-linux build-arm64 clean seed-inbox
|
||||||
|
|
||||||
|
# Build para o OS atual
|
||||||
|
all: build
|
||||||
|
|
||||||
|
build:
|
||||||
|
@mkdir -p $(BIN)
|
||||||
|
@$(foreach agent,$(AGENTS), \
|
||||||
|
echo " build $(agent)..." && \
|
||||||
|
go build -o $(BIN)/ldpost-$(agent)$(EXT) ./$(agent)/ ; \
|
||||||
|
)
|
||||||
|
@echo "Done → $(BIN)/"
|
||||||
|
|
||||||
|
# Cross-compile explícito
|
||||||
|
build-windows:
|
||||||
|
@mkdir -p $(BIN)
|
||||||
|
@$(foreach agent,$(AGENTS), \
|
||||||
|
echo " [windows/amd64] $(agent)" && \
|
||||||
|
GOOS=windows GOARCH=amd64 go build -o $(BIN)/ldpost-$(agent).exe ./$(agent)/ ; \
|
||||||
|
)
|
||||||
|
|
||||||
|
build-linux:
|
||||||
|
@mkdir -p $(BIN)
|
||||||
|
@$(foreach agent,$(AGENTS), \
|
||||||
|
echo " [linux/amd64] $(agent)" && \
|
||||||
|
GOOS=linux GOARCH=amd64 go build -o $(BIN)/ldpost-$(agent) ./$(agent)/ ; \
|
||||||
|
)
|
||||||
|
|
||||||
|
build-arm64:
|
||||||
|
@mkdir -p $(BIN)
|
||||||
|
@$(foreach agent,$(AGENTS), \
|
||||||
|
echo " [linux/arm64] $(agent)" && \
|
||||||
|
GOOS=linux GOARCH=arm64 go build -o $(BIN)/ldpost-$(agent) ./$(agent)/ ; \
|
||||||
|
)
|
||||||
|
|
||||||
|
# Testes
|
||||||
|
test:
|
||||||
|
go test ./shared/...
|
||||||
|
|
||||||
|
# Copia _sugestoes.md do seed para o workspace
|
||||||
|
seed-inbox:
|
||||||
|
@if [ -z "$(LDPOST_WORKSPACE)" ]; then \
|
||||||
|
echo "LDPOST_WORKSPACE não definido — exporte ou copie .env.example para .env"; exit 1; \
|
||||||
|
fi
|
||||||
|
@mkdir -p "$(LDPOST_WORKSPACE)/_inbox"
|
||||||
|
@cp seed/_sugestoes.md "$(LDPOST_WORKSPACE)/_inbox/_sugestoes.md"
|
||||||
|
@echo "Seed copiado para $(LDPOST_WORKSPACE)/_inbox/_sugestoes.md"
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f $(BIN)/ldpost-*
|
||||||
154
STATUS.md
Normal file
154
STATUS.md
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
# ldpost-squad — Status do Projeto
|
||||||
|
|
||||||
|
> Atualizado: 2026-05-02
|
||||||
|
> Todos os itens devem ser testáveis de forma independente via CLI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fase 0 — Spec & Arquitetura
|
||||||
|
|
||||||
|
| Item | Status |
|
||||||
|
|------|--------|
|
||||||
|
| `00-arquitetura-mestre.md` — contratos, convenções, estrutura | [ ] |
|
||||||
|
| `00b-shared-module.md` — módulo compartilhado | [ ] |
|
||||||
|
| `.env.example` com todas as variáveis necessárias | [ ] |
|
||||||
|
| `seed/_sugestoes.md` — 30 posts com resumo + 2 imagens cada | [ ] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Módulo Compartilhado (`shared/`)
|
||||||
|
|
||||||
|
| Item | Status |
|
||||||
|
|------|--------|
|
||||||
|
| Diretório + `go.mod` | [ ] |
|
||||||
|
| Estruturas comuns (`Post`, `Slug`, `WorkspaceLayout`) | [ ] |
|
||||||
|
| Utilitários de workspace (resolver paths, criar dirs) | [ ] |
|
||||||
|
| Helper `.env` / config loader | [ ] |
|
||||||
|
| Testável: `go test ./shared/...` | [ ] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI 1 — `ldpost-evaluator`
|
||||||
|
|
||||||
|
Busca trends, sorteia formato, cria post stub no workspace.
|
||||||
|
|
||||||
|
| Item | Status |
|
||||||
|
|------|--------|
|
||||||
|
| Diretório + `go.mod` | [ ] |
|
||||||
|
| Integração Groq/Gemini para buscar trends | [ ] |
|
||||||
|
| Sorteio de formato (carrossel / artigo / lista / storytelling) | [ ] |
|
||||||
|
| Cria `$LDPOST_WORKSPACE/<slug>/post.json` com metadados | [ ] |
|
||||||
|
| Flag `--topic` para forçar tema manual | [ ] |
|
||||||
|
| Flag `--format` para forçar formato | [ ] |
|
||||||
|
| Flag `--dry-run` imprime sem salvar | [ ] |
|
||||||
|
| Testável: `ldpost-evaluator --topic "IA no RH" --dry-run` | [ ] |
|
||||||
|
| Testes escritos | [ ] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI 2 — `ldpost-redator`
|
||||||
|
|
||||||
|
Gera rascunho do post a partir do stub criado pelo evaluator.
|
||||||
|
|
||||||
|
| Item | Status |
|
||||||
|
|------|--------|
|
||||||
|
| Diretório + `go.mod` | [ ] |
|
||||||
|
| Lê `$LDPOST_WORKSPACE/<slug>/post.json` | [ ] |
|
||||||
|
| Gera rascunho via Groq/Gemini | [ ] |
|
||||||
|
| Salva `$LDPOST_WORKSPACE/<slug>/draft.md` | [ ] |
|
||||||
|
| Flag `--post <slug>` (obrigatória) | [ ] |
|
||||||
|
| Flag `--model` para escolher modelo LLM | [ ] |
|
||||||
|
| Flag `--dry-run` imprime sem salvar | [ ] |
|
||||||
|
| Testável: `ldpost-redator --post meu-slug --dry-run` | [ ] |
|
||||||
|
| Testes escritos | [ ] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI 3 — `ldpost-editor`
|
||||||
|
|
||||||
|
Formata para LinkedIn + loop de revisão interativo.
|
||||||
|
|
||||||
|
| Item | Status |
|
||||||
|
|------|--------|
|
||||||
|
| Diretório + `go.mod` | [ ] |
|
||||||
|
| Lê `draft.md` do workspace do post | [ ] |
|
||||||
|
| Aplica formatação LinkedIn (emojis, quebras, hashtags) | [ ] |
|
||||||
|
| Loop de revisão: mostra diff, aceita/rejeita/refina | [ ] |
|
||||||
|
| Salva `$LDPOST_WORKSPACE/<slug>/final.md` | [ ] |
|
||||||
|
| Flag `--post <slug>` (obrigatória) | [ ] |
|
||||||
|
| Flag `--no-interactive` para modo batch | [ ] |
|
||||||
|
| Testável: `ldpost-editor --post meu-slug --no-interactive` | [ ] |
|
||||||
|
| Testes escritos | [ ] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI 4 — `ldpost-art`
|
||||||
|
|
||||||
|
Gera e cropa imagens para o post.
|
||||||
|
|
||||||
|
| Item | Status |
|
||||||
|
|------|--------|
|
||||||
|
| Diretório + `go.mod` | [ ] |
|
||||||
|
| Lê descrições de imagem do `post.json` | [ ] |
|
||||||
|
| Gera imagens via Gemini Imagen (ou Groq) | [ ] |
|
||||||
|
| Cropa para formato LinkedIn (1200x627 ou 1080x1080) | [ ] |
|
||||||
|
| Salva `$LDPOST_WORKSPACE/<slug>/img/*.png` | [ ] |
|
||||||
|
| Flag `--post <slug>` (obrigatória) | [ ] |
|
||||||
|
| Flag `--format square|landscape` (default: landscape) | [ ] |
|
||||||
|
| Flag `--dry-run` gera prompt sem chamar API | [ ] |
|
||||||
|
| Testável: `ldpost-art --post meu-slug --dry-run` | [ ] |
|
||||||
|
| Testes escritos | [ ] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI 5 — `ldpost-director`
|
||||||
|
|
||||||
|
Aprovação final via Telegram.
|
||||||
|
|
||||||
|
| Item | Status |
|
||||||
|
|------|--------|
|
||||||
|
| Diretório + `go.mod` | [ ] |
|
||||||
|
| Lê `final.md` + imagens do workspace | [ ] |
|
||||||
|
| Envia preview completo via Telegram | [ ] |
|
||||||
|
| Aguarda resposta: aprovar / rejeitar / editar | [ ] |
|
||||||
|
| Salva status em `$LDPOST_WORKSPACE/<slug>/status.json` | [ ] |
|
||||||
|
| Flag `--post <slug>` (obrigatória) | [ ] |
|
||||||
|
| Flag `--skip-telegram` aprova localmente sem bot | [ ] |
|
||||||
|
| Testável: `ldpost-director --post meu-slug --skip-telegram` | [ ] |
|
||||||
|
| Testes escritos | [ ] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI 6 — `ldpost-publisher`
|
||||||
|
|
||||||
|
Publica no LinkedIn.
|
||||||
|
|
||||||
|
| Item | Status |
|
||||||
|
|------|--------|
|
||||||
|
| Diretório + `go.mod` | [ ] |
|
||||||
|
| Lê `final.md` + imagens + `status.json` (deve estar approved) | [ ] |
|
||||||
|
| Publica via LinkedIn API (ou imprime instruções manuais) | [ ] |
|
||||||
|
| Registra URL do post publicado em `status.json` | [ ] |
|
||||||
|
| Flag `--post <slug>` (obrigatória) | [ ] |
|
||||||
|
| Flag `--manual` imprime conteúdo formatado sem publicar | [ ] |
|
||||||
|
| Flag `--dry-run` valida sem publicar | [ ] |
|
||||||
|
| Testável: `ldpost-publisher --post meu-slug --dry-run` | [ ] |
|
||||||
|
| Testes escritos | [ ] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integração End-to-End
|
||||||
|
|
||||||
|
| Item | Status |
|
||||||
|
|------|--------|
|
||||||
|
| Script de pipeline completo (`run-all.sh` / `.ps1`) | [ ] |
|
||||||
|
| Testado com post real do zero ao publicado | [ ] |
|
||||||
|
| Documentação de troubleshooting | [ ] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Legenda
|
||||||
|
|
||||||
|
- `[ ]` Não iniciado
|
||||||
|
- `[~]` Em progresso
|
||||||
|
- `[x]` Concluído
|
||||||
18
art/go.mod
Normal file
18
art/go.mod
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
module ldpost/art
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/disintegration/imaging v1.6.2
|
||||||
|
github.com/spf13/cobra v1.8.0
|
||||||
|
ldpost/shared v0.0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/joho/godotenv v1.5.1 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
replace ldpost/shared => ../shared
|
||||||
17
art/go.sum
Normal file
17
art/go.sum
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
|
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||||
|
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||||
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
|
||||||
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
504
art/main.go
Normal file
504
art/main.go
Normal file
@ -0,0 +1,504 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/disintegration/imaging"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"ldpost/shared/config"
|
||||||
|
"ldpost/shared/gemini"
|
||||||
|
"ldpost/shared/groq"
|
||||||
|
"ldpost/shared/state"
|
||||||
|
"ldpost/shared/telegram"
|
||||||
|
"ldpost/shared/workspace"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type slidePrompts struct {
|
||||||
|
DescricaoOriginal string `json:"descricao_original"`
|
||||||
|
PromptGemini string `json:"prompt_gemini"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type artPromptsFile struct {
|
||||||
|
Slide1 slidePrompts `json:"slide1"`
|
||||||
|
Slide2 slidePrompts `json:"slide2"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type artChoices struct {
|
||||||
|
Slide1 string // "bottom" or "right"
|
||||||
|
Slide2 string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Text parsing ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// parseImageSection extracts content under "## Imagem N" heading.
|
||||||
|
func parseImageSection(text, heading string) string {
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
inSection := false
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.HasPrefix(line, "## ") {
|
||||||
|
title := strings.TrimSpace(strings.TrimPrefix(line, "## "))
|
||||||
|
if strings.EqualFold(title, heading) {
|
||||||
|
inSection = true
|
||||||
|
continue
|
||||||
|
} else if inSection {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inSection {
|
||||||
|
sb.WriteString(line + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstNWords returns first n words from a string.
|
||||||
|
func firstNWords(s string, n int) string {
|
||||||
|
words := strings.Fields(s)
|
||||||
|
if len(words) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return strings.Join(words[:n], " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Groq prompt builder ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const systemPromptImgTranslate = `Você converte descrições de imagens em prompts otimizados para geração com Gemini Image Generation.
|
||||||
|
|
||||||
|
Regras:
|
||||||
|
- Prompt em inglês
|
||||||
|
- Estilo: clean, professional, tech-focused, suitable for LinkedIn carousel
|
||||||
|
- Incluir: composição, estilo visual, paleta de cores sugerida (azul/branco/cinza para tech)
|
||||||
|
- Evitar: rostos humanos, texto em destaque (o Gemini erra texto), logos de marcas reais
|
||||||
|
- Máximo 100 palavras
|
||||||
|
- Retornar APENAS o prompt, sem explicação`
|
||||||
|
|
||||||
|
func buildGeminiPrompt(gc *groq.GroqClient, postContext, desc string) (string, error) {
|
||||||
|
system := systemPromptImgTranslate + "\n\nContexto do post: " + firstNWords(postContext, 50)
|
||||||
|
user := "Descrição original: " + desc
|
||||||
|
prompt, err := gc.Chat(groq.TextModel, groq.TextMessages(system, user), 0.4, 150)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("traduzir prompt: %w", err)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(prompt), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Image helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// findInputImage looks for input/imagemN in common formats.
|
||||||
|
func findInputImage(inputPath, base string) string {
|
||||||
|
for _, ext := range []string{".png", ".jpg", ".jpeg", ".webp"} {
|
||||||
|
p := filepath.Join(inputPath, base+ext)
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func cropBottomVariant(img image.Image, px int) image.Image {
|
||||||
|
b := img.Bounds()
|
||||||
|
newH := b.Max.Y - b.Min.Y - px
|
||||||
|
if newH <= 0 {
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
return imaging.Crop(img, image.Rect(b.Min.X, b.Min.Y, b.Max.X, b.Min.Y+newH))
|
||||||
|
}
|
||||||
|
|
||||||
|
func cropRightVariant(img image.Image, px int) image.Image {
|
||||||
|
b := img.Bounds()
|
||||||
|
newW := b.Max.X - b.Min.X - px
|
||||||
|
if newW <= 0 {
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
return imaging.Crop(img, image.Rect(b.Min.X, b.Min.Y, b.Min.X+newW, b.Max.Y))
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveVariants crops bottom and right variants, returns (bottomPath, rightPath).
|
||||||
|
func saveVariants(rawPath, artRawDir, name string, cropBottom, cropRight int) (string, string, error) {
|
||||||
|
img, err := imaging.Open(rawPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("abrir %s: %w", rawPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bottomPath := filepath.Join(artRawDir, name+"-crop-bottom.png")
|
||||||
|
rightPath := filepath.Join(artRawDir, name+"-crop-right.png")
|
||||||
|
|
||||||
|
if err := imaging.Save(cropBottomVariant(img, cropBottom), bottomPath); err != nil {
|
||||||
|
return "", "", fmt.Errorf("salvar crop-bottom: %w", err)
|
||||||
|
}
|
||||||
|
if err := imaging.Save(cropRightVariant(img, cropRight), rightPath); err != nil {
|
||||||
|
return "", "", fmt.Errorf("salvar crop-right: %w", err)
|
||||||
|
}
|
||||||
|
return bottomPath, rightPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Telegram flow ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func sendArtPreview(bot *telegram.Bot, slug string,
|
||||||
|
s1bottom, s1right, s2bottom, s2right string) error {
|
||||||
|
|
||||||
|
if _, err := bot.SendMessage(fmt.Sprintf(
|
||||||
|
"🎨 <b>ldpost-art</b> | <code>%s</code>\n\nGerei 2 imagens para o carrossel.\nEscolha a variante de cada uma:",
|
||||||
|
slug)); err != nil {
|
||||||
|
return fmt.Errorf("msg intro: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bot.SendMediaGroup([]telegram.MediaFile{
|
||||||
|
{Path: s1bottom, Caption: "Slide 1 — A) Corte de baixo"},
|
||||||
|
{Path: s1right, Caption: "Slide 1 — B) Corte da direita"},
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("[WARN] media group slide1: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bot.SendMediaGroup([]telegram.MediaFile{
|
||||||
|
{Path: s2bottom, Caption: "Slide 2 — A) Corte de baixo"},
|
||||||
|
{Path: s2right, Caption: "Slide 2 — B) Corte da direita"},
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("[WARN] media group slide2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buttons := [][]telegram.InlineButton{
|
||||||
|
{
|
||||||
|
{Text: "Slide 1: Corte de baixo", CallbackData: "s1_bottom"},
|
||||||
|
{Text: "Slide 1: Corte da direita", CallbackData: "s1_right"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
{Text: "Slide 2: Corte de baixo", CallbackData: "s2_bottom"},
|
||||||
|
{Text: "Slide 2: Corte da direita", CallbackData: "s2_right"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
{Text: "🔄 Regenerar tudo", CallbackData: "regenerar"},
|
||||||
|
{Text: "✅ Confirmar escolhas", CallbackData: "confirmar"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if _, err := bot.SendMessageWithKeyboard("Selecione as variantes preferidas:", buttons); err != nil {
|
||||||
|
return fmt.Errorf("botões: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitChoices polls Telegram until both slides are chosen and confirmed.
|
||||||
|
// Returns ("regenerar", {}) if user requests full regeneration.
|
||||||
|
func waitChoices(bot *telegram.Bot) (action string, choices artChoices) {
|
||||||
|
all := []string{"s1_bottom", "s1_right", "s2_bottom", "s2_right", "regenerar", "confirmar"}
|
||||||
|
for {
|
||||||
|
cb, err := bot.WaitForCallback(all, 0)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[WARN] WaitForCallback: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch cb {
|
||||||
|
case "s1_bottom":
|
||||||
|
choices.Slide1 = "bottom"
|
||||||
|
bot.SendMessage("✓ Slide 1: corte de baixo selecionado")
|
||||||
|
case "s1_right":
|
||||||
|
choices.Slide1 = "right"
|
||||||
|
bot.SendMessage("✓ Slide 1: corte da direita selecionado")
|
||||||
|
case "s2_bottom":
|
||||||
|
choices.Slide2 = "bottom"
|
||||||
|
bot.SendMessage("✓ Slide 2: corte de baixo selecionado")
|
||||||
|
case "s2_right":
|
||||||
|
choices.Slide2 = "right"
|
||||||
|
bot.SendMessage("✓ Slide 2: corte da direita selecionado")
|
||||||
|
case "regenerar":
|
||||||
|
return "regenerar", artChoices{}
|
||||||
|
case "confirmar":
|
||||||
|
if choices.Slide1 == "" || choices.Slide2 == "" {
|
||||||
|
bot.SendMessage("⚠️ Selecione variante para ambos os slides antes de confirmar.")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return "confirmar", choices
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.SetFlags(log.Ltime)
|
||||||
|
|
||||||
|
var (
|
||||||
|
flagPost string
|
||||||
|
flagWorkspace string
|
||||||
|
flagDryRun bool
|
||||||
|
flagSkipGeneration bool
|
||||||
|
flagCropBottom int
|
||||||
|
flagCropRight int
|
||||||
|
)
|
||||||
|
|
||||||
|
root := &cobra.Command{
|
||||||
|
Use: "ldpost-art --post <slug>",
|
||||||
|
Short: "Gera imagens do carrossel via Gemini e envia para aprovação no Telegram",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if flagPost == "" {
|
||||||
|
return fmt.Errorf("--post é obrigatório")
|
||||||
|
}
|
||||||
|
cfg := config.Load()
|
||||||
|
if flagWorkspace != "" {
|
||||||
|
cfg.Workspace = flagWorkspace
|
||||||
|
}
|
||||||
|
// CLI flags override .env
|
||||||
|
if !cmd.Flags().Changed("crop-bottom") {
|
||||||
|
flagCropBottom = cfg.CropBottomPx
|
||||||
|
}
|
||||||
|
if !cmd.Flags().Changed("crop-right") {
|
||||||
|
flagCropRight = cfg.CropRightPx
|
||||||
|
}
|
||||||
|
|
||||||
|
if !flagDryRun && !flagSkipGeneration {
|
||||||
|
if err := cfg.Validate("groq", "gemini"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if !flagDryRun {
|
||||||
|
if err := cfg.Validate("groq"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. Encontrar post e validar state ─────────────────────────
|
||||||
|
postPath, err := workspace.FindPostBySlug(cfg.Workspace, flagPost)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("post %q: %w", flagPost, err)
|
||||||
|
}
|
||||||
|
s, err := state.LoadState(postPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ler state: %w", err)
|
||||||
|
}
|
||||||
|
if !s.IsStatus(state.StatusWaitingArt) {
|
||||||
|
log.Printf("[ERROR] status atual: %q — esperado: %q", s.Status, state.StatusWaitingArt)
|
||||||
|
return fmt.Errorf("estado incorreto — rode o agente correto para o status atual")
|
||||||
|
}
|
||||||
|
|
||||||
|
inputPath := workspace.InputPath(postPath)
|
||||||
|
artRawPath := workspace.ArtRawPath(postPath)
|
||||||
|
outputPath := workspace.OutputPath(postPath)
|
||||||
|
|
||||||
|
// ── 2. Ler input/texto.md ─────────────────────────────────────
|
||||||
|
textoRaw, err := os.ReadFile(workspace.InputTextoPath(postPath))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("input/texto.md: %w", err)
|
||||||
|
}
|
||||||
|
textoStr := string(textoRaw)
|
||||||
|
|
||||||
|
desc1 := parseImageSection(textoStr, "Imagem 1")
|
||||||
|
desc2 := parseImageSection(textoStr, "Imagem 2")
|
||||||
|
if desc1 == "" {
|
||||||
|
desc1 = "Abstract tech illustration for LinkedIn post slide 1"
|
||||||
|
log.Printf("[WARN] seção 'Imagem 1' não encontrada em texto.md — usando fallback")
|
||||||
|
}
|
||||||
|
if desc2 == "" {
|
||||||
|
desc2 = "Abstract tech illustration for LinkedIn post slide 2"
|
||||||
|
log.Printf("[WARN] seção 'Imagem 2' não encontrada em texto.md — usando fallback")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Ler post final para contexto ──────────────────────────
|
||||||
|
postContext := ""
|
||||||
|
if data, err := os.ReadFile(workspace.OutputPostPath(postPath)); err == nil {
|
||||||
|
postContext = strings.TrimSpace(string(data))
|
||||||
|
} else if finalPath := workspace.WorkPath(postPath) + "/editor-final.md"; func() bool {
|
||||||
|
_, e := os.Stat(finalPath)
|
||||||
|
return e == nil
|
||||||
|
}() {
|
||||||
|
if data, err := os.ReadFile(workspace.WorkPath(postPath) + "/editor-final.md"); err == nil {
|
||||||
|
postContext = strings.TrimSpace(string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gc := groq.NewGroqClient(cfg.GroqAPIKey)
|
||||||
|
|
||||||
|
// ── 4. Loop principal (suporta regeneração) ───────────────────
|
||||||
|
for {
|
||||||
|
// ── 4a. Checar imagens de input direto ────────────────────
|
||||||
|
var raw1, raw2 string
|
||||||
|
if flagSkipGeneration {
|
||||||
|
raw1 = findInputImage(inputPath, "imagem1")
|
||||||
|
raw2 = findInputImage(inputPath, "imagem2")
|
||||||
|
if raw1 == "" || raw2 == "" {
|
||||||
|
return fmt.Errorf("--skip-generation requer input/imagem1.* e input/imagem2.*")
|
||||||
|
}
|
||||||
|
log.Printf("[INFO] usando imagens de input: %s, %s", filepath.Base(raw1), filepath.Base(raw2))
|
||||||
|
} else {
|
||||||
|
// ── 4b. Construir prompts via Groq ────────────────────
|
||||||
|
log.Printf("[INFO] gerando prompts Gemini via Groq...")
|
||||||
|
prompt1, err := buildGeminiPrompt(gc, postContext, desc1)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[WARN] prompt slide1: %v — usando descrição original", err)
|
||||||
|
prompt1 = desc1
|
||||||
|
}
|
||||||
|
prompt2, err := buildGeminiPrompt(gc, postContext, desc2)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[WARN] prompt slide2: %v — usando descrição original", err)
|
||||||
|
prompt2 = desc2
|
||||||
|
}
|
||||||
|
|
||||||
|
apf := artPromptsFile{
|
||||||
|
Slide1: slidePrompts{DescricaoOriginal: desc1, PromptGemini: prompt1},
|
||||||
|
Slide2: slidePrompts{DescricaoOriginal: desc2, PromptGemini: prompt2},
|
||||||
|
}
|
||||||
|
apfData, _ := json.MarshalIndent(apf, "", " ")
|
||||||
|
if err := os.WriteFile(workspace.ArtPromptsPath(postPath), apfData, 0644); err != nil {
|
||||||
|
log.Printf("[WARN] salvar art-prompts.json: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("[INFO] art-prompts.json salvo")
|
||||||
|
|
||||||
|
if flagDryRun {
|
||||||
|
fmt.Printf("=== DRY-RUN — ldpost-art ===\n")
|
||||||
|
fmt.Printf("post: %s\n", flagPost)
|
||||||
|
fmt.Printf("modelo: %s\n", gemini.ImageModelV2)
|
||||||
|
fmt.Printf("crop: bottom=%dpx right=%dpx\n", flagCropBottom, flagCropRight)
|
||||||
|
fmt.Printf("\nSlide 1 — descrição: %s\n", desc1)
|
||||||
|
fmt.Printf("Slide 1 — prompt: %s\n\n", prompt1)
|
||||||
|
fmt.Printf("Slide 2 — descrição: %s\n", desc2)
|
||||||
|
fmt.Printf("Slide 2 — prompt: %s\n", prompt2)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4c. Gerar imagens via Gemini ──────────────────────
|
||||||
|
gm := gemini.New(cfg.GeminiAPIKey)
|
||||||
|
imageModel := gemini.ImageModelV2
|
||||||
|
|
||||||
|
log.Printf("[INFO] gerando slide1 via Gemini...")
|
||||||
|
img1Bytes, err := gm.GenerateImageSquare(context.Background(), imageModel, prompt1)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[WARN] gemini slide1: %v", err)
|
||||||
|
// fallback para input/imagem1.*
|
||||||
|
raw1 = findInputImage(inputPath, "imagem1")
|
||||||
|
if raw1 == "" {
|
||||||
|
return fmt.Errorf("falha ao gerar slide1 e sem fallback em input/imagem1.*: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("[INFO] usando fallback: %s", filepath.Base(raw1))
|
||||||
|
} else {
|
||||||
|
raw1 = filepath.Join(artRawPath, "slide1-raw.png")
|
||||||
|
if err := os.WriteFile(raw1, img1Bytes, 0644); err != nil {
|
||||||
|
return fmt.Errorf("salvar slide1-raw.png: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[INFO] gerando slide2 via Gemini...")
|
||||||
|
img2Bytes, err := gm.GenerateImageSquare(context.Background(), imageModel, prompt2)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[WARN] gemini slide2: %v", err)
|
||||||
|
raw2 = findInputImage(inputPath, "imagem2")
|
||||||
|
if raw2 == "" {
|
||||||
|
return fmt.Errorf("falha ao gerar slide2 e sem fallback em input/imagem2.*: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("[INFO] usando fallback: %s", filepath.Base(raw2))
|
||||||
|
} else {
|
||||||
|
raw2 = filepath.Join(artRawPath, "slide2-raw.png")
|
||||||
|
if err := os.WriteFile(raw2, img2Bytes, 0644); err != nil {
|
||||||
|
return fmt.Errorf("salvar slide2-raw.png: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4d. Crop 2 variantes por imagem ──────────────────────
|
||||||
|
log.Printf("[INFO] gerando variantes de crop (bottom=%dpx, right=%dpx)...", flagCropBottom, flagCropRight)
|
||||||
|
s1bottom, s1right, err := saveVariants(raw1, artRawPath, "slide1", flagCropBottom, flagCropRight)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("crop slide1: %w", err)
|
||||||
|
}
|
||||||
|
s2bottom, s2right, err := saveVariants(raw2, artRawPath, "slide2", flagCropBottom, flagCropRight)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("crop slide2: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("[INFO] variantes salvas em %s", artRawPath)
|
||||||
|
|
||||||
|
// ── 4e. Telegram: enviar preview e aguardar escolha ───────
|
||||||
|
if cfg.TelegramBotToken == "" || cfg.TelegramChatID == "" {
|
||||||
|
// Auto-approve first variant
|
||||||
|
log.Printf("[INFO] Telegram não configurado — aprovando variante 'bottom' automaticamente")
|
||||||
|
if err := copyFile(s1bottom, filepath.Join(outputPath, "slide1.png")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := copyFile(s2bottom, filepath.Join(outputPath, "slide2.png")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
bot := telegram.NewBot(cfg.TelegramBotToken, cfg.TelegramChatID)
|
||||||
|
if err := sendArtPreview(bot, flagPost, s1bottom, s1right, s2bottom, s2right); err != nil {
|
||||||
|
log.Printf("[WARN] enviar preview: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
action, choices := waitChoices(bot)
|
||||||
|
if action == "regenerar" {
|
||||||
|
if flagSkipGeneration {
|
||||||
|
return fmt.Errorf("--skip-generation ativo: não é possível regenerar")
|
||||||
|
}
|
||||||
|
log.Printf("[INFO] regenerando imagens...")
|
||||||
|
bot.SendMessage("🔄 Regenerando imagens...")
|
||||||
|
continue // restart loop
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copiar variantes escolhidas para output/
|
||||||
|
slide1src := s1bottom
|
||||||
|
if choices.Slide1 == "right" {
|
||||||
|
slide1src = s1right
|
||||||
|
}
|
||||||
|
slide2src := s2bottom
|
||||||
|
if choices.Slide2 == "right" {
|
||||||
|
slide2src = s2right
|
||||||
|
}
|
||||||
|
if err := copyFile(slide1src, filepath.Join(outputPath, "slide1.png")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := copyFile(slide2src, filepath.Join(outputPath, "slide2.png")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.SendMessage(fmt.Sprintf(
|
||||||
|
"✅ Imagens salvas em output/\n\nPróximo: <code>ldpost-director --post %s</code>",
|
||||||
|
flagPost))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. Atualizar state ────────────────────────────────────────
|
||||||
|
s.Status = state.StatusWaitingDirector
|
||||||
|
s.SetEtapa("art", state.EtapaDone)
|
||||||
|
s.SetEtapa("director", state.EtapaWaiting)
|
||||||
|
if err := state.SaveState(postPath, s); err != nil {
|
||||||
|
return fmt.Errorf("state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("✅ Arte concluída.\n")
|
||||||
|
fmt.Printf(" output/slide1.png e output/slide2.png\n")
|
||||||
|
fmt.Printf(" Próximo: ldpost-director --post %s\n", flagPost)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
root.Flags().StringVar(&flagPost, "post", "", "Slug do post (obrigatório)")
|
||||||
|
root.Flags().StringVar(&flagWorkspace, "workspace", "", "Override de LDPOST_WORKSPACE")
|
||||||
|
root.Flags().BoolVar(&flagDryRun, "dry-run", false, "Mostra prompts sem chamar Gemini")
|
||||||
|
root.Flags().BoolVar(&flagSkipGeneration, "skip-generation", false, "Usa input/imagem1.* diretamente")
|
||||||
|
root.Flags().IntVar(&flagCropBottom, "crop-bottom", 48, "Pixels a cortar de baixo (override LDPOST_CROP_BOTTOM)")
|
||||||
|
root.Flags().IntVar(&flagCropRight, "crop-right", 48, "Pixels a cortar da direita (override LDPOST_CROP_RIGHT)")
|
||||||
|
|
||||||
|
if err := root.Execute(); err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func copyFile(src, dst string) error {
|
||||||
|
data, err := os.ReadFile(src)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ler %s: %w", src, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(dst, data, 0644); err != nil {
|
||||||
|
return fmt.Errorf("escrever %s: %w", dst, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
16
director/go.mod
Normal file
16
director/go.mod
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
module ldpost/director
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/spf13/cobra v1.8.0
|
||||||
|
ldpost/shared v0.0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/joho/godotenv v1.5.1 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
replace ldpost/shared => ../shared
|
||||||
12
director/go.sum
Normal file
12
director/go.sum
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||||
|
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||||
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
666
director/main.go
Normal file
666
director/main.go
Normal file
@ -0,0 +1,666 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"ldpost/shared/config"
|
||||||
|
"ldpost/shared/gemini"
|
||||||
|
"ldpost/shared/groq"
|
||||||
|
"ldpost/shared/state"
|
||||||
|
"ldpost/shared/telegram"
|
||||||
|
"ldpost/shared/workspace"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Validation ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type requiredFile struct {
|
||||||
|
label string
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectRequiredFiles(postPath string) []requiredFile {
|
||||||
|
return []requiredFile{
|
||||||
|
{"input/texto.md", workspace.InputTextoPath(postPath)},
|
||||||
|
{"work/editor-final.md", filepath.Join(workspace.WorkPath(postPath), "editor-final.md")},
|
||||||
|
{"output/slide1.png", filepath.Join(workspace.OutputPath(postPath), "slide1.png")},
|
||||||
|
{"output/slide2.png", filepath.Join(workspace.OutputPath(postPath), "slide2.png")},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateFiles(files []requiredFile) []string {
|
||||||
|
var missing []string
|
||||||
|
for _, f := range files {
|
||||||
|
if _, err := os.Stat(f.path); err != nil {
|
||||||
|
missing = append(missing, f.label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return missing
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Text helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func wordCount(s string) int { return len(strings.Fields(s)) }
|
||||||
|
|
||||||
|
func truncate(s string, maxChars int) string {
|
||||||
|
if len(s) <= maxChars {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:maxChars] + "\n... (truncado — ver arquivo completo)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Groq helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const systemSugestoes = `Você é um consultor de conteúdo LinkedIn especializado em tech. Analise o post abaixo e sugira 3 melhorias específicas e acionáveis. Cada sugestão deve:
|
||||||
|
- Ser específica (não "melhore o gancho" — diga exatamente o que mudar e para o quê)
|
||||||
|
- Ter no máximo 2 linhas
|
||||||
|
- Focar em impacto no engagement (compartilhamento, comentário, salvamento)
|
||||||
|
|
||||||
|
Retorne APENAS as 3 sugestões numeradas. Sem introdução.`
|
||||||
|
|
||||||
|
func getSugestoes(gc *groq.GroqClient, postText string) (string, error) {
|
||||||
|
return gc.Chat(groq.TextModel, groq.TextMessages(systemSugestoes, postText), 0.7, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applySugestao(gc *gemini.Client, postText, sugestao string) (string, error) {
|
||||||
|
system := `Você é editor especializado em conteúdo LinkedIn para Ricardo, Tech Lead brasileiro direto e técnico.
|
||||||
|
|
||||||
|
O input do usuário pode ser de dois tipos — identifique e aja conforme:
|
||||||
|
|
||||||
|
TIPO A — Instrução de edição direta:
|
||||||
|
Exemplos: "mude o gancho", "remova hashtags", "encurta o terceiro parágrafo", "torna mais direto"
|
||||||
|
Ação: aplique cirurgicamente, mantendo TUDO o mais intacto possível.
|
||||||
|
|
||||||
|
TIPO B — Informação/contexto adicional do autor:
|
||||||
|
Exemplos: parágrafos descritivos, números reais, detalhes da experiência, correções de fato
|
||||||
|
Ação: reescreva as partes relevantes do post integrando essas informações de forma fluida.
|
||||||
|
CRÍTICO para Tipo B:
|
||||||
|
- NÃO cole o texto bruto do usuário — integre com a voz do Ricardo
|
||||||
|
- Preserve TODAS as distinções que o usuário fez (ex: "ainda faço X, eliminei Y")
|
||||||
|
- Preserve TODOS os números exatos (percentuais, dias, minutos)
|
||||||
|
- Preserve TODOS os tipos/termos específicos mencionados (ex: "tarefas, risco, protótipo")
|
||||||
|
- Se o usuário corrigiu um detalhe, use a versão correta — não resuma
|
||||||
|
|
||||||
|
REGRAS SEMPRE ATIVAS:
|
||||||
|
- Parágrafos máximo 3 linhas, linha em branco entre blocos
|
||||||
|
- Mantenha as hashtags do post original a menos que o usuário peça para mudar
|
||||||
|
- Mantenha a estrutura geral do formato (gancho → problema → solução → resultado → CTA)
|
||||||
|
- Nunca invente fatos além do que foi fornecido
|
||||||
|
- Nunca use: "mergulho profundo", "no cenário atual", "é importante destacar", "robusto", "abrangente"
|
||||||
|
- Retorne APENAS o post modificado. Sem explicação, sem cabeçalho, sem "aqui está".`
|
||||||
|
|
||||||
|
user := fmt.Sprintf("Input do usuário:\n%s\n\nPost atual:\n%s", sugestao, postText)
|
||||||
|
return gc.Chat(context.Background(), gemini.TextModel, system, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSugestoes extracts numbered lines from LLM suggestion output.
|
||||||
|
func parseSugestoes(raw string) [3]string {
|
||||||
|
var result [3]string
|
||||||
|
lines := strings.Split(strings.TrimSpace(raw), "\n")
|
||||||
|
idx := 0
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Strip leading "1. " "2. " etc.
|
||||||
|
for _, prefix := range []string{"1. ", "2. ", "3. ", "1) ", "2) ", "3) "} {
|
||||||
|
if strings.HasPrefix(line, prefix) {
|
||||||
|
line = strings.TrimPrefix(line, prefix)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx < 3 {
|
||||||
|
result[idx] = line
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Telegram senders ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func sendPackage(bot *telegram.Bot, s *state.PostState, slug, postText, originalText string,
|
||||||
|
inputImgs, outputImgs []string) (msgID int, err error) {
|
||||||
|
|
||||||
|
// Msg 1 — header
|
||||||
|
header := fmt.Sprintf(
|
||||||
|
"📋 <b>ldpost-director</b> | Aprovação Final\n📅 %s\n📝 Post: <code>%s</code>\n🎯 Formato: %s | Funil: %s\n🔄 Ciclos de revisão: %d",
|
||||||
|
time.Now().Format("02/01/2006"),
|
||||||
|
slug, s.Formato, s.FunilTag,
|
||||||
|
s.Aprovacoes.Texto.Ciclos,
|
||||||
|
)
|
||||||
|
if _, err := bot.SendMessage(header); err != nil {
|
||||||
|
log.Printf("[WARN] header: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Msg 2 — original text
|
||||||
|
orig := "📄 <b>TEXTO ORIGINAL (seu resumo)</b>\n─────────────────────────────\n" +
|
||||||
|
truncate(originalText, 800)
|
||||||
|
if _, err := bot.SendMessage(orig); err != nil {
|
||||||
|
log.Printf("[WARN] texto original: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Msg 3 — final post (split if needed)
|
||||||
|
finalHeader := "✍️ <b>TEXTO FINAL (post LinkedIn)</b>\n─────────────────────────────\n"
|
||||||
|
if len(finalHeader)+len(postText) > 3000 {
|
||||||
|
mid := len(postText) / 2
|
||||||
|
// Split at nearest newline
|
||||||
|
if idx := strings.LastIndex(postText[:mid], "\n"); idx > 0 {
|
||||||
|
mid = idx
|
||||||
|
}
|
||||||
|
if _, err := bot.SendMessage(finalHeader + postText[:mid]); err != nil {
|
||||||
|
log.Printf("[WARN] texto final 1: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := bot.SendMessage(postText[mid:]); err != nil {
|
||||||
|
log.Printf("[WARN] texto final 2: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if _, err := bot.SendMessage(finalHeader + postText); err != nil {
|
||||||
|
log.Printf("[WARN] texto final: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Msg 4 — input images (optional)
|
||||||
|
if len(inputImgs) > 0 {
|
||||||
|
var mf []telegram.MediaFile
|
||||||
|
for i, p := range inputImgs {
|
||||||
|
cap := ""
|
||||||
|
if i == 0 {
|
||||||
|
cap = "🖼️ Imagens originais (seu input)"
|
||||||
|
}
|
||||||
|
mf = append(mf, telegram.MediaFile{Path: p, Caption: cap})
|
||||||
|
}
|
||||||
|
if err := bot.SendMediaGroup(mf); err != nil {
|
||||||
|
log.Printf("[WARN] imagens input: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Msg 5 — output images
|
||||||
|
if len(outputImgs) > 0 {
|
||||||
|
var mf []telegram.MediaFile
|
||||||
|
for i, p := range outputImgs {
|
||||||
|
cap := ""
|
||||||
|
if i == 0 {
|
||||||
|
cap = "🎨 Imagens finais (geradas pelo squad)"
|
||||||
|
}
|
||||||
|
mf = append(mf, telegram.MediaFile{Path: p, Caption: cap})
|
||||||
|
}
|
||||||
|
if err := bot.SendMediaGroup(mf); err != nil {
|
||||||
|
log.Printf("[WARN] imagens output: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Msg 6 — decision buttons
|
||||||
|
buttons := [][]telegram.InlineButton{
|
||||||
|
{{Text: "✅ Aprovar e publicar", CallbackData: "aprovar"}},
|
||||||
|
{{Text: "❌ Reprovar tudo", CallbackData: "reprovar"}},
|
||||||
|
{{Text: "🔄 Revisar texto", CallbackData: "revisar_texto"},
|
||||||
|
{Text: "🎨 Revisar imagens", CallbackData: "revisar_arte"}},
|
||||||
|
{{Text: "✏️ Editar via chat", CallbackData: "editar_chat"},
|
||||||
|
{Text: "💡 Sugerir alternativas", CallbackData: "sugestoes"}},
|
||||||
|
}
|
||||||
|
msgID, err = bot.SendMessageWithKeyboard("⬇️ <b>Decisão final:</b>", buttons)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendSugestoes(bot *telegram.Bot, sug [3]string) error {
|
||||||
|
text := fmt.Sprintf("💡 <b>Sugestões do squad:</b>\n\n1. %s\n\n2. %s\n\n3. %s",
|
||||||
|
sug[0], sug[1], sug[2])
|
||||||
|
buttons := [][]telegram.InlineButton{
|
||||||
|
{{Text: "Aplicar sugestão 1", CallbackData: "aplicar_1"}},
|
||||||
|
{{Text: "Aplicar sugestão 2", CallbackData: "aplicar_2"}},
|
||||||
|
{{Text: "Aplicar sugestão 3", CallbackData: "aplicar_3"}},
|
||||||
|
{{Text: "Ignorar e aprovar mesmo assim", CallbackData: "aprovar"},
|
||||||
|
{Text: "Ignorar e reprovar", CallbackData: "reprovar"}},
|
||||||
|
}
|
||||||
|
_, err := bot.SendMessageWithKeyboard(text, buttons)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Decision loop ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type dirResult struct {
|
||||||
|
action string // "aprovar", "reprovar", "revisar_texto", "revisar_arte", "sugestao_aplicada"
|
||||||
|
newText string // filled when sugestão applied
|
||||||
|
}
|
||||||
|
|
||||||
|
var mainCallbacks = []string{"aprovar", "reprovar", "revisar_texto", "revisar_arte", "sugestoes", "editar_chat"}
|
||||||
|
var sugCallbacks = []string{"aplicar_1", "aplicar_2", "aplicar_3", "aprovar", "reprovar"}
|
||||||
|
var chatCallbacks = []string{"chat_aprovar", "chat_editar_mais", "chat_cancelar"}
|
||||||
|
|
||||||
|
func waitDecision(bot *telegram.Bot, gc *groq.GroqClient, gem *gemini.Client, postText string) dirResult {
|
||||||
|
for {
|
||||||
|
cb, err := bot.WaitForCallback(mainCallbacks, 24*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[WARN] timeout aguardando decisão — retomando polling")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch cb {
|
||||||
|
case "aprovar", "reprovar", "revisar_texto", "revisar_arte":
|
||||||
|
return dirResult{action: cb}
|
||||||
|
|
||||||
|
case "editar_chat":
|
||||||
|
result := runChatEdit(bot, gem, postText)
|
||||||
|
if result.action == "continuar" {
|
||||||
|
// User cancelled chat — stay in main loop with (possibly) updated text
|
||||||
|
postText = result.newText
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
case "sugestoes":
|
||||||
|
log.Printf("[INFO] gerando sugestões via Groq...")
|
||||||
|
raw, err := getSugestoes(gc, postText)
|
||||||
|
if err != nil {
|
||||||
|
bot.SendMessage(fmt.Sprintf("⚠️ Erro ao gerar sugestões: %v", err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sug := parseSugestoes(raw)
|
||||||
|
if err := sendSugestoes(bot, sug); err != nil {
|
||||||
|
log.Printf("[WARN] enviar sugestões: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cb2, err := bot.WaitForCallback(sugCallbacks, 24*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[WARN] timeout aguardando sugestão — retomando")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch cb2 {
|
||||||
|
case "aprovar", "reprovar":
|
||||||
|
return dirResult{action: cb2}
|
||||||
|
case "aplicar_1", "aplicar_2", "aplicar_3":
|
||||||
|
idx := int(cb2[len(cb2)-1] - '1')
|
||||||
|
chosen := sug[idx]
|
||||||
|
log.Printf("[INFO] aplicando sugestão %d: %s", idx+1, chosen)
|
||||||
|
updated, err := applySugestao(gem, postText, chosen)
|
||||||
|
if err != nil {
|
||||||
|
bot.SendMessage(fmt.Sprintf("⚠️ Erro ao aplicar sugestão: %v", err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return dirResult{action: "sugestao_aplicada", newText: strings.TrimSpace(updated)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runChatEdit runs a free-text editing loop with the user via Telegram.
|
||||||
|
// The user types instructions in plain text; the LLM applies them to the post.
|
||||||
|
// Returns:
|
||||||
|
// - action="sugestao_aplicada" + newText → user approved a version
|
||||||
|
// - action="reprovar" → user rejected in chat
|
||||||
|
// - action="continuar" + newText → user cancelled chat (back to main menu)
|
||||||
|
func runChatEdit(bot *telegram.Bot, gem *gemini.Client, postText string) dirResult {
|
||||||
|
original := postText
|
||||||
|
current := postText
|
||||||
|
|
||||||
|
bot.SendMessage("✏️ <b>Modo edição via chat</b>\n\nDigite o que quer mudar no texto.\n<i>Exemplos: \"gancho mais direto\", \"remove hashtags de vendas\", \"encurta o terceiro parágrafo\"</i>")
|
||||||
|
|
||||||
|
for {
|
||||||
|
// Wait for free text from user
|
||||||
|
event, err := bot.WaitForAny(24 * time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[WARN] chat edit timeout: %v", err)
|
||||||
|
return dirResult{action: "continuar", newText: current}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user sent a callback (tapped an old button), ignore
|
||||||
|
if event.IsCallback {
|
||||||
|
bot.SendMessage("💬 Estamos no modo chat. Digite sua instrução de edição como texto, ou use os botões abaixo quando estiver pronto.")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
instrucao := strings.TrimSpace(event.Text)
|
||||||
|
if instrucao == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[INFO] chat edit: %q", instrucao)
|
||||||
|
bot.SendMessage("⏳ Aplicando sua instrução...")
|
||||||
|
|
||||||
|
updated, err := applySugestao(gem, current, instrucao)
|
||||||
|
if err != nil {
|
||||||
|
bot.SendMessage(fmt.Sprintf("⚠️ Erro ao aplicar edição: %v\n\nTente novamente.", err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current = strings.TrimSpace(updated)
|
||||||
|
|
||||||
|
// Show updated post
|
||||||
|
preview := "✍️ <b>Texto atualizado:</b>\n─────────────────────────────\n" + truncate(current, 2500)
|
||||||
|
bot.SendMessage(preview)
|
||||||
|
|
||||||
|
// Show chat action buttons
|
||||||
|
buttons := [][]telegram.InlineButton{
|
||||||
|
{{Text: "✅ Aprovar esta versão", CallbackData: "chat_aprovar"}},
|
||||||
|
{{Text: "✏️ Editar mais", CallbackData: "chat_editar_mais"}},
|
||||||
|
{{Text: "↩️ Cancelar e voltar ao menu", CallbackData: "chat_cancelar"}},
|
||||||
|
}
|
||||||
|
if _, err := bot.SendMessageWithKeyboard("O que fazemos?", buttons); err != nil {
|
||||||
|
log.Printf("[WARN] botões chat: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for button response
|
||||||
|
cb, err := bot.WaitForCallback(chatCallbacks, 24*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[WARN] chat button timeout: %v", err)
|
||||||
|
return dirResult{action: "continuar", newText: current}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch cb {
|
||||||
|
case "chat_aprovar":
|
||||||
|
return dirResult{action: "sugestao_aplicada", newText: current}
|
||||||
|
|
||||||
|
case "chat_editar_mais":
|
||||||
|
// Restore original text if user wants to restart, or keep current?
|
||||||
|
// Keep current — user refines incrementally
|
||||||
|
bot.SendMessage("✏️ Ok, continue editando. Digite a próxima instrução:")
|
||||||
|
// Loop continues — next iteration reads another free-text message
|
||||||
|
|
||||||
|
case "chat_cancelar":
|
||||||
|
if current != original {
|
||||||
|
// Ask if they want to keep or discard changes
|
||||||
|
keepButtons := [][]telegram.InlineButton{
|
||||||
|
{{Text: "✅ Manter edições e voltar ao menu", CallbackData: "chat_cancelar_manter"}},
|
||||||
|
{{Text: "🗑️ Descartar edições e voltar ao menu", CallbackData: "chat_cancelar_descartar"}},
|
||||||
|
}
|
||||||
|
bot.SendMessageWithKeyboard("Você fez edições. O que prefere?", keepButtons)
|
||||||
|
|
||||||
|
cb2, err := bot.WaitForCallback([]string{"chat_cancelar_manter", "chat_cancelar_descartar"}, 24*time.Hour)
|
||||||
|
if err != nil || cb2 == "chat_cancelar_descartar" {
|
||||||
|
return dirResult{action: "continuar", newText: original}
|
||||||
|
}
|
||||||
|
return dirResult{action: "continuar", newText: current}
|
||||||
|
}
|
||||||
|
return dirResult{action: "continuar", newText: current}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dry-run printer ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func printDryRun(s *state.PostState, slug string, postText, originalText string,
|
||||||
|
inputImgs, outputImgs []string) {
|
||||||
|
sep := strings.Repeat("═", 60)
|
||||||
|
fmt.Println(sep)
|
||||||
|
fmt.Printf("ldpost-director | DRY-RUN | Post: %s\n", slug)
|
||||||
|
fmt.Println(sep)
|
||||||
|
fmt.Printf("Data: %s\n", time.Now().Format("02/01/2006"))
|
||||||
|
fmt.Printf("Formato: %s | Funil: %s\n", s.Formato, s.FunilTag)
|
||||||
|
fmt.Printf("Ciclos: %d\n", s.Aprovacoes.Texto.Ciclos)
|
||||||
|
fmt.Printf("Palavras post final: ~%d\n", wordCount(postText))
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("── TEXTO ORIGINAL ──────────────────────────────────────")
|
||||||
|
fmt.Println(truncate(originalText, 800))
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("── TEXTO FINAL ─────────────────────────────────────────")
|
||||||
|
fmt.Println(postText)
|
||||||
|
fmt.Println()
|
||||||
|
if len(inputImgs) > 0 {
|
||||||
|
fmt.Printf("── IMAGENS INPUT (%d) ──────────────────────────────────\n", len(inputImgs))
|
||||||
|
for _, p := range inputImgs {
|
||||||
|
fmt.Println(" ", p)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
fmt.Printf("── IMAGENS OUTPUT (%d) ─────────────────────────────────\n", len(outputImgs))
|
||||||
|
for _, p := range outputImgs {
|
||||||
|
fmt.Println(" ", p)
|
||||||
|
}
|
||||||
|
fmt.Println(sep)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.SetFlags(log.Ltime)
|
||||||
|
|
||||||
|
var (
|
||||||
|
flagPost string
|
||||||
|
flagWorkspace string
|
||||||
|
flagDryRun bool
|
||||||
|
flagResume bool
|
||||||
|
flagSkipImages bool
|
||||||
|
)
|
||||||
|
|
||||||
|
root := &cobra.Command{
|
||||||
|
Use: "ldpost-director --post <slug>",
|
||||||
|
Short: "Aprovação final via Telegram antes de publicar",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if flagPost == "" {
|
||||||
|
return fmt.Errorf("--post é obrigatório")
|
||||||
|
}
|
||||||
|
cfg := config.Load()
|
||||||
|
if flagWorkspace != "" {
|
||||||
|
cfg.Workspace = flagWorkspace
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. Encontrar post e validar state ─────────────────────────
|
||||||
|
postPath, err := workspace.FindPostBySlug(cfg.Workspace, flagPost)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("post %q: %w", flagPost, err)
|
||||||
|
}
|
||||||
|
s, err := state.LoadState(postPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ler state: %w", err)
|
||||||
|
}
|
||||||
|
validStatuses := []string{state.StatusWaitingDirector}
|
||||||
|
if flagSkipImages {
|
||||||
|
validStatuses = append(validStatuses, state.StatusWaitingArt)
|
||||||
|
}
|
||||||
|
ok := false
|
||||||
|
for _, vs := range validStatuses {
|
||||||
|
if s.IsStatus(vs) {
|
||||||
|
ok = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[ERROR] status atual: %q — esperado: %v", s.Status, validStatuses)
|
||||||
|
return fmt.Errorf("estado incorreto — rode o agente correto para o status atual")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Validar arquivos ────────────────────────────────────────
|
||||||
|
required := collectRequiredFiles(postPath)
|
||||||
|
if flagSkipImages {
|
||||||
|
// Only require text files
|
||||||
|
var textOnly []requiredFile
|
||||||
|
for _, f := range required {
|
||||||
|
if f.label != "output/slide1.png" && f.label != "output/slide2.png" {
|
||||||
|
textOnly = append(textOnly, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
required = textOnly
|
||||||
|
}
|
||||||
|
if missing := validateFiles(required); len(missing) > 0 {
|
||||||
|
for _, m := range missing {
|
||||||
|
log.Printf("[ERROR] arquivo ausente: %s", m)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%d arquivo(s) ausente(s) — verifique os agentes anteriores", len(missing))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Ler conteúdos ──────────────────────────────────────────
|
||||||
|
finalPath := filepath.Join(workspace.WorkPath(postPath), "editor-final.md")
|
||||||
|
postData, err := os.ReadFile(finalPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ler editor-final.md: %w", err)
|
||||||
|
}
|
||||||
|
postText := strings.TrimSpace(string(postData))
|
||||||
|
|
||||||
|
originalData, err := os.ReadFile(workspace.InputTextoPath(postPath))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ler input/texto.md: %w", err)
|
||||||
|
}
|
||||||
|
originalText := strings.TrimSpace(string(originalData))
|
||||||
|
|
||||||
|
var outputImgs []string
|
||||||
|
if !flagSkipImages {
|
||||||
|
outputImgs = []string{
|
||||||
|
filepath.Join(workspace.OutputPath(postPath), "slide1.png"),
|
||||||
|
filepath.Join(workspace.OutputPath(postPath), "slide2.png"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect input images if they exist
|
||||||
|
var inputImgs []string
|
||||||
|
for _, name := range []string{"imagem1.png", "imagem1.jpg", "imagem2.png", "imagem2.jpg"} {
|
||||||
|
p := filepath.Join(workspace.InputPath(postPath), name)
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
inputImgs = append(inputImgs, p)
|
||||||
|
break // only one needed per slide for display
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// look for imagem2 separately
|
||||||
|
for _, name := range []string{"imagem2.png", "imagem2.jpg"} {
|
||||||
|
p := filepath.Join(workspace.InputPath(postPath), name)
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
inputImgs = append(inputImgs, p)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[INFO] post=%s formato=%s palavras=%d", s.Slug, s.Formato, wordCount(postText))
|
||||||
|
|
||||||
|
// ── 4. Dry-run ────────────────────────────────────────────────
|
||||||
|
if flagDryRun {
|
||||||
|
printDryRun(s, flagPost, postText, originalText, inputImgs, outputImgs)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. Telegram ───────────────────────────────────────────────
|
||||||
|
if cfg.TelegramBotToken == "" || cfg.TelegramChatID == "" {
|
||||||
|
log.Printf("[INFO] Telegram não configurado — aprovação automática local")
|
||||||
|
return finalizeAprovado(postPath, s, flagPost)
|
||||||
|
}
|
||||||
|
if !flagDryRun {
|
||||||
|
if err := cfg.Validate("groq"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bot := telegram.NewBot(cfg.TelegramBotToken, cfg.TelegramChatID)
|
||||||
|
gc := groq.NewGroqClient(cfg.GroqAPIKey)
|
||||||
|
gem := gemini.New(cfg.GeminiAPIKey)
|
||||||
|
|
||||||
|
// Mark polling active
|
||||||
|
s.PollingActive = true
|
||||||
|
if err := state.SaveState(postPath, s); err != nil {
|
||||||
|
log.Printf("[WARN] state polling_active: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
if !flagResume {
|
||||||
|
log.Printf("[INFO] enviando pacote ao Telegram...")
|
||||||
|
msgID, err := sendPackage(bot, s, flagPost, postText, originalText, inputImgs, outputImgs)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[WARN] sendPackage: %v", err)
|
||||||
|
} else {
|
||||||
|
s.DirectorMessageID = msgID
|
||||||
|
state.SaveState(postPath, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flagResume = false // only skip send on first iteration if --resume
|
||||||
|
|
||||||
|
log.Printf("[INFO] aguardando decisão no Telegram (timeout 24h)...")
|
||||||
|
result := waitDecision(bot, gc, gem, postText)
|
||||||
|
|
||||||
|
switch result.action {
|
||||||
|
case "aprovar":
|
||||||
|
bot.SendMessage(fmt.Sprintf(
|
||||||
|
"✅ <b>Aprovado!</b>\n\nExecute: <code>ldpost-publisher --post %s</code>", flagPost))
|
||||||
|
s.PollingActive = false
|
||||||
|
return finalizeAprovado(postPath, s, flagPost)
|
||||||
|
|
||||||
|
case "reprovar":
|
||||||
|
bot.SendMessage(fmt.Sprintf(
|
||||||
|
"❌ Post reprovado e arquivado.\nOs arquivos de work/ e output/ foram mantidos para referência.\nPara recomeçar do zero: <code>ldpost-evaluator --force-slug %s</code>", flagPost))
|
||||||
|
s.Status = state.StatusRejected
|
||||||
|
s.PollingActive = false
|
||||||
|
s.SetEtapa("director", state.EtapaDone)
|
||||||
|
state.SaveState(postPath, s)
|
||||||
|
return fmt.Errorf("post reprovado pelo operador")
|
||||||
|
|
||||||
|
case "revisar_texto":
|
||||||
|
bot.SendMessage(fmt.Sprintf(
|
||||||
|
"🔄 Voltando para o editor.\nExecute: <code>ldpost-editor --post %s</code>", flagPost))
|
||||||
|
s.Status = state.StatusWaitingEditor
|
||||||
|
s.PollingActive = false
|
||||||
|
s.SetEtapa("director", state.EtapaPending)
|
||||||
|
s.SetEtapa("editor", state.EtapaWaiting)
|
||||||
|
state.SaveState(postPath, s)
|
||||||
|
fmt.Printf("De volta ao editor. Rode: ldpost-editor --post %s\n", flagPost)
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "revisar_arte":
|
||||||
|
bot.SendMessage(fmt.Sprintf(
|
||||||
|
"🎨 Voltando para o art.\nExecute: <code>ldpost-art --post %s</code>", flagPost))
|
||||||
|
s.Status = state.StatusWaitingArt
|
||||||
|
s.PollingActive = false
|
||||||
|
s.SetEtapa("director", state.EtapaPending)
|
||||||
|
s.SetEtapa("art", state.EtapaWaiting)
|
||||||
|
state.SaveState(postPath, s)
|
||||||
|
fmt.Printf("De volta ao art. Rode: ldpost-art --post %s\n", flagPost)
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "sugestao_aplicada", "continuar":
|
||||||
|
// Save updated text and loop back to re-send package
|
||||||
|
if result.newText == postText {
|
||||||
|
// No change (chat cancelled with no edits) — just re-send menu
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
postText = result.newText
|
||||||
|
finalPath := filepath.Join(workspace.WorkPath(postPath), "editor-final.md")
|
||||||
|
if err := os.WriteFile(finalPath, []byte(postText), 0644); err != nil {
|
||||||
|
log.Printf("[WARN] salvar editor-final.md: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(workspace.OutputPostPath(postPath), []byte(postText), 0644); err != nil {
|
||||||
|
log.Printf("[WARN] salvar output/post.md: %v", err)
|
||||||
|
}
|
||||||
|
_, lastN := workspace.LatestVersionFile(postPath, "editor")
|
||||||
|
vp := workspace.VersionedFile(postPath, "editor", lastN+1)
|
||||||
|
os.WriteFile(vp, []byte(postText), 0644)
|
||||||
|
log.Printf("[INFO] texto atualizado salvo em %s — reenviando pacote", filepath.Base(vp))
|
||||||
|
if result.action == "sugestao_aplicada" {
|
||||||
|
bot.SendMessage("✅ Sugestão aplicada. Reenviando pacote atualizado...")
|
||||||
|
} else {
|
||||||
|
bot.SendMessage("↩️ Voltando ao menu com texto editado...")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
root.Flags().StringVar(&flagPost, "post", "", "Slug do post (obrigatório)")
|
||||||
|
root.Flags().StringVar(&flagWorkspace, "workspace", "", "Override de LDPOST_WORKSPACE")
|
||||||
|
root.Flags().BoolVar(&flagDryRun, "dry-run", false, "Exibe pacote no terminal sem enviar Telegram")
|
||||||
|
root.Flags().BoolVar(&flagResume, "resume", false, "Retoma director aguardando callback")
|
||||||
|
root.Flags().BoolVar(&flagSkipImages, "skip-images", false, "Pula validação e envio de imagens (teste de texto)")
|
||||||
|
|
||||||
|
if err := root.Execute(); err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── State helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func finalizeAprovado(postPath string, s *state.PostState, slug string) error {
|
||||||
|
s.Status = state.StatusWaitingPublisher
|
||||||
|
s.SetEtapa("director", state.EtapaDone)
|
||||||
|
s.SetEtapa("publisher", state.EtapaWaiting)
|
||||||
|
s.Aprovacoes.Final.Aprovado = true
|
||||||
|
s.Aprovacoes.Final.Timestamp = time.Now()
|
||||||
|
if err := state.SaveState(postPath, s); err != nil {
|
||||||
|
return fmt.Errorf("state: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Aprovado. Rode: ldpost-publisher --post %s\n", slug)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
16
editor/go.mod
Normal file
16
editor/go.mod
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
module ldpost/editor
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/spf13/cobra v1.8.0
|
||||||
|
ldpost/shared v0.0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/joho/godotenv v1.5.1 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
replace ldpost/shared => ../shared
|
||||||
12
editor/go.sum
Normal file
12
editor/go.sum
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||||
|
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||||
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
361
editor/main.go
Normal file
361
editor/main.go
Normal file
@ -0,0 +1,361 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"ldpost/shared/config"
|
||||||
|
"ldpost/shared/groq"
|
||||||
|
"ldpost/shared/state"
|
||||||
|
"ldpost/shared/workspace"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── System prompt ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const systemPromptEditor = `Você é um editor especializado em conteúdo técnico para LinkedIn. Seu trabalho é transformar um rascunho técnico cru em um post LinkedIn que seja compartilhável, legível e que preserve a voz do autor (Ricardo, Tech Lead brasileiro direto e técnico).
|
||||||
|
|
||||||
|
VOZ DO AUTOR:
|
||||||
|
Ricardo está construindo um curso sobre uso de IA como ferramenta produtiva para profissionais de tecnologia. Ele acredita e pratica que a IA potencializa humanos, não os substitui. Quando o post tocar em produtividade, automação, ferramentas ou processos, deixe essa perspectiva aparecer de forma natural — pelo exemplo, pelo resultado concreto, pela experiência vivida. Nunca use a frase literal. Nunca force em posts onde o tema não permite.
|
||||||
|
|
||||||
|
REGRAS ABSOLUTAS:
|
||||||
|
- Nunca usar: "mergulho profundo", "no cenário atual", "é importante destacar", "como profissional", "apaixonado por", "robusto", "abrangente"
|
||||||
|
- Parágrafos máximo de 3 linhas
|
||||||
|
- Linha em branco entre cada parágrafo/bloco
|
||||||
|
- Primeira linha deve parar o scroll (número, contradição, afirmação forte ou pergunta provocativa)
|
||||||
|
- Última linha: CTA de baixa fricção (pergunta, ou "salva pra depois se você trabalha com X")
|
||||||
|
- 3-5 hashtags no final, específicas do conteúdo
|
||||||
|
- Preservar os fatos e números do rascunho — não inventar nada
|
||||||
|
|
||||||
|
FORMATO DE RETORNO:
|
||||||
|
Retorne APENAS o post formatado. Sem explicação, sem "aqui está o post", sem markdown de cabeçalho.
|
||||||
|
Depois do post, em uma linha separada com "---", adicione:
|
||||||
|
CHECKLIST:
|
||||||
|
✅ ou ❌ Gancho para scroll
|
||||||
|
✅ ou ❌ Parágrafos curtos
|
||||||
|
✅ ou ❌ Frase salvável
|
||||||
|
✅ ou ❌ CTA de baixa fricção
|
||||||
|
✅ ou ❌ 3-5 hashtags específicas
|
||||||
|
✅ ou ❌ Sem frases robóticas`
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func isTerminal() bool {
|
||||||
|
fi, err := os.Stdin.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (fi.Mode() & os.ModeCharDevice) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func wordCount(s string) int {
|
||||||
|
return len(strings.Fields(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseOutput splits LLM response into post body and checklist section.
|
||||||
|
func parseOutput(raw string) (post, checklist string) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
idx := strings.Index(raw, "\n---")
|
||||||
|
if idx < 0 {
|
||||||
|
return raw, ""
|
||||||
|
}
|
||||||
|
post = strings.TrimSpace(raw[:idx])
|
||||||
|
rest := strings.TrimSpace(raw[idx+4:])
|
||||||
|
rest = strings.TrimPrefix(rest, "---")
|
||||||
|
checklist = strings.TrimSpace(rest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func printSeparatorDouble() {
|
||||||
|
fmt.Println(strings.Repeat("═", 51))
|
||||||
|
}
|
||||||
|
|
||||||
|
func printSeparatorSingle() {
|
||||||
|
fmt.Println(strings.Repeat("─", 51))
|
||||||
|
}
|
||||||
|
|
||||||
|
func printPost(post, checklist, slug string, version, ciclos int) {
|
||||||
|
wc := wordCount(post)
|
||||||
|
printSeparatorDouble()
|
||||||
|
fmt.Printf("ldpost-editor | Post: %s | Versão: %d | ~%d palavras\n", slug, version, wc)
|
||||||
|
printSeparatorDouble()
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(post)
|
||||||
|
fmt.Println()
|
||||||
|
if checklist != "" {
|
||||||
|
printSeparatorSingle()
|
||||||
|
fmt.Println("Checklist automático:")
|
||||||
|
for _, line := range strings.Split(checklist, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line != "" && line != "CHECKLIST:" {
|
||||||
|
fmt.Println(" ", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
printSeparatorSingle()
|
||||||
|
if ciclos > 0 {
|
||||||
|
fmt.Printf("Ciclos de revisão: %d\n", ciclos)
|
||||||
|
printSeparatorSingle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printMenu() {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("O que fazemos?")
|
||||||
|
fmt.Println(" [A] Aprovar — salvar e avançar para arte")
|
||||||
|
fmt.Println(" [R] Reprovar — nova versão (abordagem diferente)")
|
||||||
|
fmt.Println(" [A1] Ajustar gancho")
|
||||||
|
fmt.Println(" [A2] Ajustar CTA")
|
||||||
|
fmt.Println(" [A3] Ajustar hashtags")
|
||||||
|
fmt.Println(" [A4] Digitar instrução livre")
|
||||||
|
fmt.Println(" [V] Ver rascunho original do redator")
|
||||||
|
fmt.Print("\nEscolha: ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRefinementPrompt builds the user message for a refinement call.
|
||||||
|
func buildRefinementPrompt(instruction, previousPost string) string {
|
||||||
|
return fmt.Sprintf("%s\n\nPost atual para referência:\n%s", instruction, previousPost)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.SetFlags(log.Ltime)
|
||||||
|
|
||||||
|
var (
|
||||||
|
flagPost string
|
||||||
|
flagWorkspace string
|
||||||
|
flagDryRun bool
|
||||||
|
flagMaxCiclos int
|
||||||
|
flagNoInteractive bool
|
||||||
|
)
|
||||||
|
|
||||||
|
root := &cobra.Command{
|
||||||
|
Use: "ldpost-editor --post <slug>",
|
||||||
|
Short: "Formata rascunho para LinkedIn com loop interativo no terminal",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if flagPost == "" {
|
||||||
|
return fmt.Errorf("--post é obrigatório")
|
||||||
|
}
|
||||||
|
cfg := config.Load()
|
||||||
|
if flagWorkspace != "" {
|
||||||
|
cfg.Workspace = flagWorkspace
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-TTY → dry-run automatically (unless --no-interactive bypasses this)
|
||||||
|
if !isTerminal() && !flagDryRun && !flagNoInteractive {
|
||||||
|
log.Printf("[INFO] não-TTY detectado — modo dry-run automático")
|
||||||
|
flagDryRun = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !flagDryRun {
|
||||||
|
if err := cfg.Validate("groq"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. Encontrar post e validar state ─────────────────────────
|
||||||
|
postPath, err := workspace.FindPostBySlug(cfg.Workspace, flagPost)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("post %q: %w", flagPost, err)
|
||||||
|
}
|
||||||
|
s, err := state.LoadState(postPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ler state: %w", err)
|
||||||
|
}
|
||||||
|
if !s.IsStatus(state.StatusWaitingEditor) {
|
||||||
|
log.Printf("[ERROR] status atual: %q — esperado: %q", s.Status, state.StatusWaitingEditor)
|
||||||
|
return fmt.Errorf("estado incorreto — rode o agente correto para o status atual")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Identificar versão mais recente para usar como base ────
|
||||||
|
basePath, _ := workspace.LatestVersionFile(postPath, "editor")
|
||||||
|
if basePath == "" {
|
||||||
|
basePath, _ = workspace.LatestVersionFile(postPath, "redator")
|
||||||
|
}
|
||||||
|
if basePath == "" {
|
||||||
|
return fmt.Errorf("nenhum rascunho em %s — rode ldpost-redator primeiro", workspace.WorkPath(postPath))
|
||||||
|
}
|
||||||
|
baseData, err := os.ReadFile(basePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ler base: %w", err)
|
||||||
|
}
|
||||||
|
baseDraft := string(baseData)
|
||||||
|
// Strip YAML front-matter if present
|
||||||
|
if strings.HasPrefix(baseDraft, "---") {
|
||||||
|
if end := strings.Index(baseDraft[3:], "---"); end >= 0 {
|
||||||
|
baseDraft = strings.TrimSpace(baseDraft[end+6:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("[INFO] base: %s (%d chars)", basePath, utf8.RuneCountInString(baseDraft))
|
||||||
|
|
||||||
|
// ── 3. Dry-run ────────────────────────────────────────────────
|
||||||
|
if flagDryRun {
|
||||||
|
fmt.Printf("=== DRY-RUN — ldpost-editor ===\n")
|
||||||
|
fmt.Printf("post: %s\n", flagPost)
|
||||||
|
fmt.Printf("base: %s\n", basePath)
|
||||||
|
fmt.Printf("formato: %s\n", s.Formato)
|
||||||
|
fmt.Printf("\n--- SYSTEM ---\n%s\n", systemPromptEditor)
|
||||||
|
fmt.Printf("\n--- USER ---\nFormate este rascunho para LinkedIn:\n\n%s\n", baseDraft)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
gc := groq.NewGroqClient(cfg.GroqAPIKey)
|
||||||
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
|
|
||||||
|
_, lastEditorN := workspace.LatestVersionFile(postPath, "editor")
|
||||||
|
editorN := lastEditorN + 1
|
||||||
|
ciclos := 0
|
||||||
|
currentPost := ""
|
||||||
|
currentChecklist := ""
|
||||||
|
|
||||||
|
// redatorPath for [V] command
|
||||||
|
redatorPath, _ := workspace.LatestVersionFile(postPath, "redator")
|
||||||
|
|
||||||
|
// ── 4. First generation ───────────────────────────────────────
|
||||||
|
userMsg := "Formate este rascunho para LinkedIn:\n\n" + baseDraft
|
||||||
|
log.Printf("[INFO] gerando versão %d (temp=0.7)...", editorN)
|
||||||
|
raw, err := gc.Chat(groq.TextModel, groq.TextMessages(systemPromptEditor, userMsg), 0.7, 0)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("groq: %w", err)
|
||||||
|
}
|
||||||
|
currentPost, currentChecklist = parseOutput(raw)
|
||||||
|
|
||||||
|
outPath := workspace.VersionedFile(postPath, "editor", editorN)
|
||||||
|
if err := os.WriteFile(outPath, []byte(raw), 0644); err != nil {
|
||||||
|
return fmt.Errorf("salvar editor-v%d: %w", editorN, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. Loop interativo (skip if --no-interactive) ─────────────
|
||||||
|
if flagNoInteractive {
|
||||||
|
printPost(currentPost, currentChecklist, flagPost, editorN, 0)
|
||||||
|
log.Printf("[INFO] --no-interactive: aprovando automaticamente versão %d", editorN)
|
||||||
|
} else {
|
||||||
|
loop:
|
||||||
|
for {
|
||||||
|
printPost(currentPost, currentChecklist, flagPost, editorN, ciclos)
|
||||||
|
|
||||||
|
if ciclos >= flagMaxCiclos {
|
||||||
|
fmt.Printf("\n⚠️ %d ciclos de revisão — considere aprovar ou rejeitar.\n", ciclos)
|
||||||
|
}
|
||||||
|
|
||||||
|
printMenu()
|
||||||
|
|
||||||
|
if !scanner.Scan() {
|
||||||
|
break loop
|
||||||
|
}
|
||||||
|
choice := strings.ToUpper(strings.TrimSpace(scanner.Text()))
|
||||||
|
|
||||||
|
var instruction string
|
||||||
|
temp := 0.7
|
||||||
|
|
||||||
|
switch choice {
|
||||||
|
case "A":
|
||||||
|
break loop
|
||||||
|
|
||||||
|
case "V":
|
||||||
|
if redatorPath != "" {
|
||||||
|
data, err := os.ReadFile(redatorPath)
|
||||||
|
if err == nil {
|
||||||
|
printSeparatorDouble()
|
||||||
|
fmt.Printf("RASCUNHO ORIGINAL — %s\n", redatorPath)
|
||||||
|
printSeparatorDouble()
|
||||||
|
fmt.Println(string(data))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Println("(rascunho do redator não encontrado)")
|
||||||
|
}
|
||||||
|
continue loop
|
||||||
|
|
||||||
|
case "R":
|
||||||
|
instruction = fmt.Sprintf(
|
||||||
|
"Gere uma versão completamente diferente do post anterior. Mude o gancho, a estrutura e o tom, mas preserve os fatos. Versão anterior para referência:\n%s",
|
||||||
|
currentPost)
|
||||||
|
temp = 0.9
|
||||||
|
|
||||||
|
case "A1":
|
||||||
|
instruction = "Reescreva apenas o gancho (primeiras 1-2 linhas) tornando-o mais impactante — use número, contradição ou afirmação forte. Mantenha o resto do post exatamente igual."
|
||||||
|
|
||||||
|
case "A2":
|
||||||
|
instruction = "Reescreva apenas o CTA final (última linha antes das hashtags) com pergunta aberta ou convite a salvar. Mantenha o resto do post exatamente igual."
|
||||||
|
|
||||||
|
case "A3":
|
||||||
|
instruction = "Substitua as hashtags por 3-5 opções mais específicas e relevantes ao conteúdo técnico. Mantenha o resto do post exatamente igual."
|
||||||
|
|
||||||
|
case "A4":
|
||||||
|
fmt.Print("Instrução: ")
|
||||||
|
if !scanner.Scan() {
|
||||||
|
continue loop
|
||||||
|
}
|
||||||
|
instruction = strings.TrimSpace(scanner.Text())
|
||||||
|
if instruction == "" {
|
||||||
|
fmt.Println("(instrução vazia — ignorada)")
|
||||||
|
continue loop
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
fmt.Printf("Opção inválida: %q\n", choice)
|
||||||
|
continue loop
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new version
|
||||||
|
ciclos++
|
||||||
|
editorN++
|
||||||
|
refinedMsg := buildRefinementPrompt(instruction, currentPost)
|
||||||
|
log.Printf("[INFO] ciclo %d — gerando versão %d (temp=%.1f)...", ciclos, editorN, temp)
|
||||||
|
raw, err := gc.Chat(groq.TextModel, groq.TextMessages(systemPromptEditor, refinedMsg), temp, 0)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[ERROR] groq ciclo %d: %v", ciclos, err)
|
||||||
|
fmt.Printf("Erro ao chamar Groq: %v\n", err)
|
||||||
|
continue loop
|
||||||
|
}
|
||||||
|
currentPost, currentChecklist = parseOutput(raw)
|
||||||
|
vPath := workspace.VersionedFile(postPath, "editor", editorN)
|
||||||
|
if err := os.WriteFile(vPath, []byte(raw), 0644); err != nil {
|
||||||
|
log.Printf("[WARN] salvar editor-v%d: %v", editorN, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. Salvar aprovação ───────────────────────────────────────
|
||||||
|
finalPath := workspace.WorkPath(postPath) + "/editor-final.md"
|
||||||
|
if err := os.WriteFile(finalPath, []byte(currentPost), 0644); err != nil {
|
||||||
|
return fmt.Errorf("salvar editor-final.md: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(workspace.OutputPostPath(postPath), []byte(currentPost), 0644); err != nil {
|
||||||
|
return fmt.Errorf("salvar output/post.md: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
s.Status = state.StatusWaitingArt
|
||||||
|
s.SetEtapa("editor", state.EtapaDone)
|
||||||
|
s.SetEtapa("art", state.EtapaWaiting)
|
||||||
|
s.Aprovacoes.Texto.Aprovado = true
|
||||||
|
s.Aprovacoes.Texto.Ciclos = ciclos
|
||||||
|
s.Aprovacoes.Texto.Timestamp = now
|
||||||
|
if err := state.SaveState(postPath, s); err != nil {
|
||||||
|
return fmt.Errorf("state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n✅ Post aprovado após %d ciclo(s).\n", ciclos)
|
||||||
|
fmt.Printf(" Salvo: %s\n", finalPath)
|
||||||
|
fmt.Printf(" Próximo: ldpost-art --post %s\n", flagPost)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
root.Flags().StringVar(&flagPost, "post", "", "Slug do post (obrigatório)")
|
||||||
|
root.Flags().StringVar(&flagWorkspace, "workspace", "", "Override de LDPOST_WORKSPACE")
|
||||||
|
root.Flags().BoolVar(&flagDryRun, "dry-run", false, "Mostra prompt sem chamar Groq")
|
||||||
|
root.Flags().IntVar(&flagMaxCiclos, "max-ciclos", 5, "Máximo de ciclos antes de alertar")
|
||||||
|
root.Flags().BoolVar(&flagNoInteractive, "no-interactive", false, "Gera e aprova automaticamente sem loop interativo")
|
||||||
|
|
||||||
|
if err := root.Execute(); err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
16
evaluator/go.mod
Normal file
16
evaluator/go.mod
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
module ldpost/evaluator
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/spf13/cobra v1.8.0
|
||||||
|
ldpost/shared v0.0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/joho/godotenv v1.5.1 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
replace ldpost/shared => ../shared
|
||||||
12
evaluator/go.sum
Normal file
12
evaluator/go.sum
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||||
|
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||||
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
894
evaluator/main.go
Normal file
894
evaluator/main.go
Normal file
@ -0,0 +1,894 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"ldpost/shared/config"
|
||||||
|
"ldpost/shared/formats"
|
||||||
|
"ldpost/shared/groq"
|
||||||
|
"ldpost/shared/history"
|
||||||
|
"ldpost/shared/state"
|
||||||
|
"ldpost/shared/telegram"
|
||||||
|
"ldpost/shared/workspace"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Domain types ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Trend struct {
|
||||||
|
Tema string `json:"tema"`
|
||||||
|
Descricao string `json:"descricao"`
|
||||||
|
Mencoes int `json:"mencoes"`
|
||||||
|
Momentum string `json:"momentum"` // crescendo|estavel|caindo
|
||||||
|
RelevanciaProfile string `json:"relevancia_perfil"` // alta|media|baixa
|
||||||
|
CategoriaSugerida string `json:"categoria_sugerida"` // Codigo|Entregavel|Bastidor|Geral
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrendResponse struct {
|
||||||
|
Trends []Trend `json:"trends"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SugestaoPost struct {
|
||||||
|
Slug string
|
||||||
|
Tema string
|
||||||
|
Categoria string
|
||||||
|
Formato string
|
||||||
|
Funil string
|
||||||
|
Resumo string
|
||||||
|
Imagem1 string
|
||||||
|
Imagem2 string
|
||||||
|
// Reciclagem
|
||||||
|
IsRecycled bool
|
||||||
|
BaseSlug string // slug original do seed
|
||||||
|
PreviousFormato string // formato da última publicação
|
||||||
|
DaysSincePublished int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Formato rotation ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// formatoCycle defines the fixed rotation order for recycled posts.
|
||||||
|
var formatoCycle = []string{"como", "porque", "erro", "comparacao", "checklist", "bastidor"}
|
||||||
|
|
||||||
|
// nextFormato returns the next format in the rotation after current.
|
||||||
|
func nextFormato(current string) string {
|
||||||
|
for i, f := range formatoCycle {
|
||||||
|
if f == current {
|
||||||
|
return formatoCycle[(i+1)%len(formatoCycle)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return formatoCycle[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// recycleSlug builds the new slug for a recycled post: baseSlug--newFormato.
|
||||||
|
// Strips any existing --format suffix before appending.
|
||||||
|
func recycleSlug(baseSlug, newFormato string) string {
|
||||||
|
base := baseSlug
|
||||||
|
if idx := strings.Index(baseSlug, "--"); idx >= 0 {
|
||||||
|
base = baseSlug[:idx]
|
||||||
|
}
|
||||||
|
return base + "--" + newFormato
|
||||||
|
}
|
||||||
|
|
||||||
|
type PendingCallback struct {
|
||||||
|
Trends []Trend `json:"trends"`
|
||||||
|
NextPost *SugestaoPost `json:"next_post"`
|
||||||
|
FormatoSorteado string `json:"formato_sorteado"`
|
||||||
|
FormatosBloqueados []string `json:"formatos_bloqueados"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Trend fetching ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type hnItem struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Points int `json:"points"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchHN() ([]string, error) {
|
||||||
|
sevenDaysAgo := time.Now().AddDate(0, 0, -7).Unix()
|
||||||
|
url := fmt.Sprintf(
|
||||||
|
"https://hn.algolia.com/api/v1/search?query=AI+LLM+agent&tags=story&numericFilters=points>100,created_at_i>%d&hitsPerPage=10",
|
||||||
|
sevenDaysAgo,
|
||||||
|
)
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("HN fetch: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Hits []hnItem `json:"hits"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("HN parse: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var titles []string
|
||||||
|
for _, h := range result.Hits {
|
||||||
|
titles = append(titles, fmt.Sprintf("[HN %d pts] %s", h.Points, h.Title))
|
||||||
|
}
|
||||||
|
return titles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchReddit(subreddit string) ([]string, error) {
|
||||||
|
url := fmt.Sprintf("https://www.reddit.com/r/%s/top.json?t=week&limit=10", subreddit)
|
||||||
|
req, _ := http.NewRequest("GET", url, nil)
|
||||||
|
req.Header.Set("User-Agent", "ldpost-evaluator/1.0")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Reddit %s fetch: %w", subreddit, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == 429 {
|
||||||
|
return nil, fmt.Errorf("rate_limited")
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("Reddit %s status %d", subreddit, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
var result struct {
|
||||||
|
Data struct {
|
||||||
|
Children []struct {
|
||||||
|
Data struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Score int `json:"score"`
|
||||||
|
CreatedUTC float64 `json:"created_utc"`
|
||||||
|
} `json:"data"`
|
||||||
|
} `json:"children"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("Reddit %s parse: %w", subreddit, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var titles []string
|
||||||
|
for _, c := range result.Data.Children {
|
||||||
|
titles = append(titles, fmt.Sprintf("[Reddit r/%s %d pts] %s", subreddit, c.Data.Score, c.Data.Title))
|
||||||
|
}
|
||||||
|
return titles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Consolidation via LLM ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const systemPromptTrends = `Você é um analista de tendências de tecnologia especializado em IA, desenvolvimento de software e arquitetura de sistemas. Seu trabalho é identificar os 3 temas mais relevantes para um Tech Lead sênior brasileiro (22+ anos de experiência em C#/.NET, especialista em Clean Architecture, IA local, agentes, SAP BTP).
|
||||||
|
|
||||||
|
Retorne APENAS um JSON válido, sem markdown, sem explicação, no formato:
|
||||||
|
{
|
||||||
|
"trends": [
|
||||||
|
{
|
||||||
|
"tema": "string curta (máx 8 palavras)",
|
||||||
|
"descricao": "string de 1 frase explicando o trend",
|
||||||
|
"mencoes": número estimado de posts/threads sobre o tema,
|
||||||
|
"momentum": "crescendo|estavel|caindo",
|
||||||
|
"relevancia_perfil": "alta|media|baixa",
|
||||||
|
"categoria_sugerida": "Codigo|Entregavel|Bastidor|Geral"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Retorne exatamente 3 trends, priorizando os de relevancia_perfil=alta.`
|
||||||
|
|
||||||
|
func consolidateTrends(gc *groq.GroqClient, titles []string) ([]Trend, error) {
|
||||||
|
user := strings.Join(titles, "\n---\n")
|
||||||
|
var resp TrendResponse
|
||||||
|
msgs := groq.TextMessages(systemPromptTrends, user)
|
||||||
|
if err := gc.ChatJSON(groq.TextModel, msgs, 0.3, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(resp.Trends) == 0 {
|
||||||
|
return nil, fmt.Errorf("LLM retornou trends vazio")
|
||||||
|
}
|
||||||
|
return resp.Trends, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: se Groq falhar, criar trends simples a partir dos títulos brutos
|
||||||
|
func buildFallbackTrends(titles []string) []Trend {
|
||||||
|
var trends []Trend
|
||||||
|
for i, t := range titles {
|
||||||
|
if i >= 3 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
trends = append(trends, Trend{
|
||||||
|
Tema: truncate(t, 60),
|
||||||
|
Descricao: "Tema identificado nas fontes (sem consolidação LLM)",
|
||||||
|
Mencoes: 1,
|
||||||
|
Momentum: "estavel",
|
||||||
|
RelevanciaProfile: "media",
|
||||||
|
CategoriaSugerida: "Entregavel",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return trends
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── _sugestoes.md parser ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func parseSugestoes(path string) ([]SugestaoPost, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, fmt.Errorf("_sugestoes.md não encontrado em %s\n→ Crie o arquivo com o seed de 30 posts", path)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var posts []SugestaoPost
|
||||||
|
var cur *SugestaoPost
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.HasPrefix(line, "## ") {
|
||||||
|
if cur != nil {
|
||||||
|
posts = append(posts, *cur)
|
||||||
|
}
|
||||||
|
rawSlug := strings.TrimSpace(strings.TrimPrefix(line, "## "))
|
||||||
|
cur = &SugestaoPost{Slug: workspace.SlugFromTitle(rawSlug)}
|
||||||
|
} else if cur != nil {
|
||||||
|
k, v := parseKV(line)
|
||||||
|
switch k {
|
||||||
|
case "Categoria":
|
||||||
|
cur.Categoria = v
|
||||||
|
case "Tema":
|
||||||
|
cur.Tema = v
|
||||||
|
case "Formato":
|
||||||
|
cur.Formato = v
|
||||||
|
case "Funil":
|
||||||
|
cur.Funil = v
|
||||||
|
case "Resumo":
|
||||||
|
cur.Resumo = v
|
||||||
|
case "Imagem 1", "Imagem1":
|
||||||
|
cur.Imagem1 = v
|
||||||
|
case "Imagem 2", "Imagem2":
|
||||||
|
cur.Imagem2 = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cur != nil {
|
||||||
|
posts = append(posts, *cur)
|
||||||
|
}
|
||||||
|
return posts, scanner.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseKV(line string) (string, string) {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
line = strings.TrimPrefix(line, "**")
|
||||||
|
parts := strings.SplitN(line, ":**", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
parts = strings.SplitN(line, ": ", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// findNextInQueue scans the seed queue and returns the next eligible post.
|
||||||
|
//
|
||||||
|
// Priority order for each slug:
|
||||||
|
// 1. Never created (no work/ folder) → return as new post.
|
||||||
|
// 2. In pipeline (exists, not published, not rejected) → skip (don't interrupt).
|
||||||
|
// 3. Published < 30 days ago → skip (too soon).
|
||||||
|
// 4. Published >= 30 days ago → return recycled with next format in rotation.
|
||||||
|
// 5. Rejected → skip entirely.
|
||||||
|
func findNextInQueue(ws string, posts []SugestaoPost) *SugestaoPost {
|
||||||
|
const recycleDays = 30
|
||||||
|
|
||||||
|
for i := range posts {
|
||||||
|
p := &posts[i]
|
||||||
|
if p.Slug == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
variants := workspace.FindVariantsByBase(ws, p.Slug)
|
||||||
|
if len(variants) == 0 {
|
||||||
|
// Never created → new post
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the most recently published variant
|
||||||
|
var latestFormato string
|
||||||
|
var latestPublishedAt time.Time
|
||||||
|
hasPublished := false
|
||||||
|
|
||||||
|
for _, vPath := range variants {
|
||||||
|
s, err := state.LoadState(vPath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s.IsStatus(state.StatusPublished) && s.PublishedAt != nil {
|
||||||
|
if !hasPublished || s.PublishedAt.After(latestPublishedAt) {
|
||||||
|
latestPublishedAt = *s.PublishedAt
|
||||||
|
latestFormato = s.Formato
|
||||||
|
hasPublished = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasPublished {
|
||||||
|
// Exists but not yet published → in pipeline or rejected → skip
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
days := int(time.Since(latestPublishedAt).Hours() / 24)
|
||||||
|
if days < recycleDays {
|
||||||
|
// Published too recently
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ready for recycle: advance to next format
|
||||||
|
newFmt := nextFormato(latestFormato)
|
||||||
|
newSlug := recycleSlug(p.Slug, newFmt)
|
||||||
|
|
||||||
|
recycled := *p
|
||||||
|
recycled.Slug = newSlug
|
||||||
|
recycled.Formato = newFmt
|
||||||
|
recycled.IsRecycled = true
|
||||||
|
recycled.BaseSlug = p.Slug
|
||||||
|
recycled.PreviousFormato = latestFormato
|
||||||
|
recycled.DaysSincePublished = days
|
||||||
|
return &recycled
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Pending callback persistence ────────────────────────────────────────────
|
||||||
|
|
||||||
|
func pendingPath(ws string) string {
|
||||||
|
return filepath.Join(ws, "_inbox", "_pending_callback.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func savePending(ws string, p *PendingCallback) error {
|
||||||
|
dir := filepath.Join(ws, "_inbox")
|
||||||
|
os.MkdirAll(dir, 0755)
|
||||||
|
data, _ := json.MarshalIndent(p, "", " ")
|
||||||
|
return os.WriteFile(pendingPath(ws), data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadPending(ws string) (*PendingCallback, error) {
|
||||||
|
data, err := os.ReadFile(pendingPath(ws))
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var p PendingCallback
|
||||||
|
if err := json.Unmarshal(data, &p); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deletePending(ws string) {
|
||||||
|
os.Remove(pendingPath(ws))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Telegram messages ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func momentumEmoji(m string) string {
|
||||||
|
switch m {
|
||||||
|
case "crescendo":
|
||||||
|
return "📈"
|
||||||
|
case "estavel":
|
||||||
|
return "➡️"
|
||||||
|
case "caindo":
|
||||||
|
return "📉"
|
||||||
|
default:
|
||||||
|
return "📊"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendTrendMessages(bot *telegram.Bot, trends []Trend, next *SugestaoPost, sorteado string, bloqueados []string) error {
|
||||||
|
// Mensagem 1: Trends
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("🔍 <b>ldpost-evaluator</b>\n\n")
|
||||||
|
sb.WriteString("📊 <b>Trends desta semana:</b>\n\n")
|
||||||
|
|
||||||
|
labels := []string{"A", "B", "C"}
|
||||||
|
for i, t := range trends {
|
||||||
|
if i >= 3 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&sb, "<b>%s)</b> %s\n", labels[i], t.Tema)
|
||||||
|
fmt.Fprintf(&sb, " <i>%s</i>\n", t.Descricao)
|
||||||
|
fmt.Fprintf(&sb, " %s ~%d menções | %s | relevância: %s\n\n",
|
||||||
|
momentumEmoji(t.Momentum), t.Mencoes, t.Momentum, t.RelevanciaProfile)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := bot.SendMessage(sb.String()); err != nil {
|
||||||
|
return fmt.Errorf("msg 1 trends: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mensagem 2: Fila + formato
|
||||||
|
var sb2 strings.Builder
|
||||||
|
if next != nil {
|
||||||
|
if next.IsRecycled {
|
||||||
|
sb2.WriteString("♻️ <b>Reciclagem da sua fila:</b>\n")
|
||||||
|
fmt.Fprintf(&sb2, "D) <i>%s</i>\n", next.Tema)
|
||||||
|
fmt.Fprintf(&sb2, "Publicado há %d dias como <b>%s</b> → novo ângulo: <b>%s</b>\n",
|
||||||
|
next.DaysSincePublished,
|
||||||
|
formats.FormatLabel(next.PreviousFormato),
|
||||||
|
formats.FormatLabel(next.Formato))
|
||||||
|
fmt.Fprintf(&sb2, "<code>%s</code> → <code>%s</code>\n", next.BaseSlug, next.Slug)
|
||||||
|
} else {
|
||||||
|
sb2.WriteString("📝 <b>Próximo da sua fila:</b>\n")
|
||||||
|
fmt.Fprintf(&sb2, "D) <i>%s</i>\n", next.Tema)
|
||||||
|
if next.Formato != "" {
|
||||||
|
fmt.Fprintf(&sb2, "Formato: %s\n", formats.FormatLabel(next.Formato))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb2.WriteString("\n")
|
||||||
|
} else {
|
||||||
|
sb2.WriteString("📝 <i>Fila vazia — nenhum post pendente em _sugestoes.md</i>\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(&sb2, "🎲 <b>Sorteio sugere:</b> %s (%s)\n", sorteado, formats.FormatLabel(sorteado))
|
||||||
|
if len(bloqueados) > 0 {
|
||||||
|
fmt.Fprintf(&sb2, "<i>bloqueados: %s</i>\n", strings.Join(bloqueados, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := bot.SendMessage(sb2.String()); err != nil {
|
||||||
|
return fmt.Errorf("msg 2 fila: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mensagem 3: Botões
|
||||||
|
buttons := [][]telegram.InlineButton{
|
||||||
|
{{Text: "A — " + truncate(trends[0].Tema, 30), CallbackData: "trend_a"}},
|
||||||
|
}
|
||||||
|
if len(trends) > 1 {
|
||||||
|
buttons = append(buttons, []telegram.InlineButton{
|
||||||
|
{Text: "B — " + truncate(trends[1].Tema, 30), CallbackData: "trend_b"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(trends) > 2 {
|
||||||
|
buttons = append(buttons, []telegram.InlineButton{
|
||||||
|
{Text: "C — " + truncate(trends[2].Tema, 30), CallbackData: "trend_c"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if next != nil {
|
||||||
|
if next.Formato != "" {
|
||||||
|
var labelD string
|
||||||
|
if next.IsRecycled {
|
||||||
|
labelD = "D — Reciclar (" + formats.FormatLabel(next.PreviousFormato) + " → " + formats.FormatLabel(next.Formato) + ")"
|
||||||
|
} else {
|
||||||
|
labelD = "D — Fila (formato: " + formats.FormatLabel(next.Formato) + ")"
|
||||||
|
}
|
||||||
|
buttons = append(buttons, []telegram.InlineButton{
|
||||||
|
{Text: labelD, CallbackData: "fila_original"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
buttons = append(buttons, []telegram.InlineButton{
|
||||||
|
{Text: "E — Fila (sorteado: " + formats.FormatLabel(sorteado) + ")", CallbackData: "fila_sorteado"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := bot.SendMessageWithKeyboard("Qual seguimos?", buttons); err != nil {
|
||||||
|
return fmt.Errorf("msg 3 botões: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Workspace creation ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func buildTextoMD(slug, categoria, formato, funil, trendRef, resumo, img1, img2 string) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("---\n")
|
||||||
|
fmt.Fprintf(&sb, "slug: %s\n", slug)
|
||||||
|
fmt.Fprintf(&sb, "categoria: %s\n", categoria)
|
||||||
|
fmt.Fprintf(&sb, "formato: %s\n", formato)
|
||||||
|
if funil != "" {
|
||||||
|
fmt.Fprintf(&sb, "funil: %s\n", funil)
|
||||||
|
}
|
||||||
|
if trendRef != "" {
|
||||||
|
fmt.Fprintf(&sb, "trend_referencia: %s\n", trendRef)
|
||||||
|
}
|
||||||
|
sb.WriteString("---\n\n")
|
||||||
|
|
||||||
|
if resumo != "" {
|
||||||
|
sb.WriteString("## O que fiz\n")
|
||||||
|
sb.WriteString(resumo)
|
||||||
|
sb.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
if img1 != "" {
|
||||||
|
sb.WriteString("## Imagem 1\n")
|
||||||
|
sb.WriteString(img1)
|
||||||
|
sb.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
if img2 != "" {
|
||||||
|
sb.WriteString("## Imagem 2\n")
|
||||||
|
sb.WriteString(img2)
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func createPostWorkspace(cfg *config.Config, slug, categoria, formato, funil, trendRef, resumo, img1, img2 string) (string, error) {
|
||||||
|
postPath := workspace.PostPath(cfg.Workspace, categoria, slug)
|
||||||
|
|
||||||
|
if err := workspace.EnsureDirs(postPath); err != nil {
|
||||||
|
return "", fmt.Errorf("criar dirs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escrever input/texto.md
|
||||||
|
textoMD := buildTextoMD(slug, categoria, formato, funil, trendRef, resumo, img1, img2)
|
||||||
|
if err := os.WriteFile(workspace.InputTextoPath(postPath), []byte(textoMD), 0644); err != nil {
|
||||||
|
return "", fmt.Errorf("escrever texto.md: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Criar state.json
|
||||||
|
s := state.NewPostState(slug, categoria, formato, resumo, trendRef)
|
||||||
|
s.Status = state.StatusWaitingRedator
|
||||||
|
s.SetEtapa("evaluator", state.EtapaDone)
|
||||||
|
s.SetEtapa("redator", state.EtapaWaiting)
|
||||||
|
if trendRef != "" {
|
||||||
|
s.Aprovacoes.Tema.Aprovado = true
|
||||||
|
s.Aprovacoes.Tema.Timestamp = time.Now()
|
||||||
|
}
|
||||||
|
if err := state.SaveState(postPath, s); err != nil {
|
||||||
|
return "", fmt.Errorf("salvar state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return postPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Callback processing ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func processCallback(cfg *config.Config, cb string, pending *PendingCallback) (slug string, err error) {
|
||||||
|
var (
|
||||||
|
tema string
|
||||||
|
categoria string
|
||||||
|
formato string
|
||||||
|
funil string
|
||||||
|
trendRef string
|
||||||
|
resumo string
|
||||||
|
img1 string
|
||||||
|
img2 string
|
||||||
|
)
|
||||||
|
|
||||||
|
switch cb {
|
||||||
|
case "trend_a", "trend_b", "trend_c":
|
||||||
|
idx := map[string]int{"trend_a": 0, "trend_b": 1, "trend_c": 2}[cb]
|
||||||
|
if idx >= len(pending.Trends) {
|
||||||
|
return "", fmt.Errorf("trend index %d fora do range", idx)
|
||||||
|
}
|
||||||
|
t := pending.Trends[idx]
|
||||||
|
tema = t.Tema
|
||||||
|
categoria = t.CategoriaSugerida
|
||||||
|
if categoria == "" {
|
||||||
|
categoria = "Entregavel"
|
||||||
|
}
|
||||||
|
formato = pending.FormatoSorteado
|
||||||
|
trendRef = fmt.Sprintf("%s — %s", t.Tema, t.Descricao)
|
||||||
|
slug = workspace.SlugFromTitle(tema)
|
||||||
|
|
||||||
|
case "fila_original", "fila_sorteado":
|
||||||
|
if pending.NextPost == nil {
|
||||||
|
return "", fmt.Errorf("fila vazia — sem post para processar")
|
||||||
|
}
|
||||||
|
p := pending.NextPost
|
||||||
|
tema = p.Tema
|
||||||
|
categoria = p.Categoria
|
||||||
|
if categoria == "" {
|
||||||
|
categoria = "Geral"
|
||||||
|
}
|
||||||
|
funil = p.Funil
|
||||||
|
resumo = p.Resumo
|
||||||
|
img1 = p.Imagem1
|
||||||
|
img2 = p.Imagem2
|
||||||
|
slug = p.Slug
|
||||||
|
|
||||||
|
if cb == "fila_original" && p.Formato != "" {
|
||||||
|
formato = p.Formato
|
||||||
|
} else {
|
||||||
|
formato = pending.FormatoSorteado
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("callback desconhecido: %q", cb)
|
||||||
|
}
|
||||||
|
|
||||||
|
if slug == "" {
|
||||||
|
slug = workspace.SlugFromTitle(tema)
|
||||||
|
}
|
||||||
|
|
||||||
|
postPath, err := createPostWorkspace(cfg, slug, categoria, formato, funil, trendRef, resumo, img1, img2)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
log.Printf("[INFO] workspace criado: %s", postPath)
|
||||||
|
|
||||||
|
// Atualizar format-history
|
||||||
|
hist, _ := history.LoadHistory(cfg.Workspace)
|
||||||
|
if hist == nil {
|
||||||
|
hist = &history.FormatHistory{}
|
||||||
|
}
|
||||||
|
hist.AddEntry(slug, formato)
|
||||||
|
history.SaveHistory(cfg.Workspace, hist)
|
||||||
|
|
||||||
|
return slug, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.SetFlags(log.Ltime)
|
||||||
|
|
||||||
|
var (
|
||||||
|
flagDryRun bool
|
||||||
|
flagNoReddit bool
|
||||||
|
flagForceSlug string
|
||||||
|
flagWorkspace string
|
||||||
|
)
|
||||||
|
|
||||||
|
root := &cobra.Command{
|
||||||
|
Use: "ldpost-evaluator",
|
||||||
|
Short: "Busca trends, identifica fila, sorteia formato e envia para Telegram",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cfg := config.Load()
|
||||||
|
if flagWorkspace != "" {
|
||||||
|
cfg.Workspace = flagWorkspace
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Verificar se há callback pendente ─────────────────────────
|
||||||
|
if !flagDryRun {
|
||||||
|
if pending, err := loadPending(cfg.Workspace); err == nil && pending != nil {
|
||||||
|
age := time.Since(pending.CreatedAt)
|
||||||
|
log.Printf("[INFO] retomando callback pendente (criado há %s)", age.Round(time.Minute))
|
||||||
|
return resumeCallback(cfg, pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. Buscar trends ──────────────────────────────────────────
|
||||||
|
var allTitles []string
|
||||||
|
|
||||||
|
log.Printf("[INFO] buscando trends no Hacker News...")
|
||||||
|
if !flagDryRun {
|
||||||
|
hnTitles, err := fetchHN()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[WARN] HN: %v", err)
|
||||||
|
} else {
|
||||||
|
allTitles = append(allTitles, hnTitles...)
|
||||||
|
log.Printf("[INFO] HN: %d títulos", len(hnTitles))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !flagNoReddit && !flagDryRun {
|
||||||
|
for _, sub := range []string{"LocalLLaMA", "MachineLearning"} {
|
||||||
|
log.Printf("[INFO] buscando Reddit r/%s...", sub)
|
||||||
|
rdTitles, err := fetchReddit(sub)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "rate_limited") {
|
||||||
|
log.Printf("[WARN] Reddit rate limited, continuing without Reddit")
|
||||||
|
} else {
|
||||||
|
log.Printf("[WARN] Reddit r/%s: %v", sub, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
allTitles = append(allTitles, rdTitles...)
|
||||||
|
log.Printf("[INFO] Reddit r/%s: %d títulos", sub, len(rdTitles))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if flagDryRun {
|
||||||
|
allTitles = []string{
|
||||||
|
"[HN 450 pts] LLM agents are replacing traditional automation",
|
||||||
|
"[HN 320 pts] Local AI with llama.cpp — production guide",
|
||||||
|
"[Reddit r/LocalLLaMA 890 pts] Running RAG locally without cloud",
|
||||||
|
"[Reddit r/MachineLearning 540 pts] New efficient fine-tuning approach",
|
||||||
|
"[HN 210 pts] Clean Architecture for AI systems",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Ler fila local ─────────────────────────────────────────
|
||||||
|
sugestoesPath := workspace.InboxSugestoes(cfg.Workspace)
|
||||||
|
allPosts, err := parseSugestoes(sugestoesPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[WARN] %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("[INFO] _sugestoes.md: %d posts", len(allPosts))
|
||||||
|
// Adicionar títulos da fila às fontes de trend
|
||||||
|
for _, p := range allPosts {
|
||||||
|
if p.Tema != "" {
|
||||||
|
allTitles = append(allTitles, "[Fila local] "+p.Tema)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identificar próximo da fila
|
||||||
|
var nextPost *SugestaoPost
|
||||||
|
if flagForceSlug != "" {
|
||||||
|
for i := range allPosts {
|
||||||
|
if allPosts[i].Slug == flagForceSlug {
|
||||||
|
nextPost = &allPosts[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if nextPost == nil {
|
||||||
|
return fmt.Errorf("--force-slug %q não encontrado em _sugestoes.md", flagForceSlug)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nextPost = findNextInQueue(cfg.Workspace, allPosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
if nextPost != nil {
|
||||||
|
log.Printf("[INFO] próximo da fila: %s — %s", nextPost.Slug, nextPost.Tema)
|
||||||
|
} else {
|
||||||
|
log.Printf("[INFO] fila vazia")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Consolidar trends via LLM ──────────────────────────────
|
||||||
|
var trends []Trend
|
||||||
|
if !flagDryRun && cfg.GroqAPIKey != "" {
|
||||||
|
log.Printf("[INFO] consolidando %d títulos via Groq...", len(allTitles))
|
||||||
|
gc := groq.NewGroqClient(cfg.GroqAPIKey)
|
||||||
|
|
||||||
|
var groqErr error
|
||||||
|
for attempt := 1; attempt <= 3; attempt++ {
|
||||||
|
trends, groqErr = consolidateTrends(gc, allTitles)
|
||||||
|
if groqErr == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
log.Printf("[WARN] Groq retry %d/3: %v", attempt, groqErr)
|
||||||
|
time.Sleep(time.Duration(attempt) * 2 * time.Second)
|
||||||
|
}
|
||||||
|
if groqErr != nil {
|
||||||
|
log.Printf("[WARN] Groq falhou após 3 tentativas, usando títulos brutos")
|
||||||
|
trends = buildFallbackTrends(allTitles)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
trends = buildFallbackTrends(allTitles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garantir 3 trends (preencher com vazios se necessário)
|
||||||
|
for len(trends) < 3 {
|
||||||
|
trends = append(trends, Trend{
|
||||||
|
Tema: fmt.Sprintf("Trend %d (sem dados)", len(trends)+1),
|
||||||
|
Descricao: "Dados insuficientes",
|
||||||
|
Mencoes: 0,
|
||||||
|
Momentum: "estavel",
|
||||||
|
RelevanciaProfile: "baixa",
|
||||||
|
CategoriaSugerida: "Geral",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Sortear formato ────────────────────────────────────────
|
||||||
|
hist, _ := history.LoadHistory(cfg.Workspace)
|
||||||
|
if hist == nil {
|
||||||
|
hist = &history.FormatHistory{}
|
||||||
|
}
|
||||||
|
bloqueados := hist.LastN(3)
|
||||||
|
sorteado := formats.SortearFormato(bloqueados)
|
||||||
|
log.Printf("[INFO] formato sorteado: %s (bloqueados: %v)", sorteado, bloqueados)
|
||||||
|
|
||||||
|
// ── 5. Dry-run ────────────────────────────────────────────────
|
||||||
|
if flagDryRun {
|
||||||
|
fmt.Printf("=== DRY-RUN — ldpost-evaluator ===\n\n")
|
||||||
|
fmt.Printf("TRENDS:\n")
|
||||||
|
for i, t := range trends[:3] {
|
||||||
|
fmt.Printf(" %s) %s\n %s | %s | %s\n",
|
||||||
|
[]string{"A", "B", "C"}[i], t.Tema, t.Descricao, t.Momentum, t.RelevanciaProfile)
|
||||||
|
}
|
||||||
|
if nextPost != nil {
|
||||||
|
fmt.Printf("\nFILA PRÓXIMO: %s — %s (formato: %s)\n", nextPost.Slug, nextPost.Tema, nextPost.Formato)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("\nFILA: vazia\n")
|
||||||
|
}
|
||||||
|
fmt.Printf("FORMATO SORTEADO: %s (bloqueados: %v)\n", sorteado, bloqueados)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. Validar Telegram ───────────────────────────────────────
|
||||||
|
if err := cfg.Validate("telegram"); err != nil {
|
||||||
|
return fmt.Errorf("Telegram não configurado: %w\nUse --dry-run para testar sem Telegram", err)
|
||||||
|
}
|
||||||
|
bot := telegram.NewBot(cfg.TelegramBotToken, cfg.TelegramChatID)
|
||||||
|
|
||||||
|
// ── 7. Enviar mensagens Telegram ──────────────────────────────
|
||||||
|
log.Printf("[INFO] enviando mensagens Telegram...")
|
||||||
|
if err := sendTrendMessages(bot, trends, nextPost, sorteado, bloqueados); err != nil {
|
||||||
|
return fmt.Errorf("Telegram: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 8. Salvar pending callback ────────────────────────────────
|
||||||
|
pending := &PendingCallback{
|
||||||
|
Trends: trends,
|
||||||
|
NextPost: nextPost,
|
||||||
|
FormatoSorteado: sorteado,
|
||||||
|
FormatosBloqueados: bloqueados,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := savePending(cfg.Workspace, pending); err != nil {
|
||||||
|
log.Printf("[WARN] salvar pending: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("[INFO] aguardando callback no Telegram (sem timeout)...")
|
||||||
|
|
||||||
|
// ── 9. Aguardar callback ──────────────────────────────────────
|
||||||
|
return resumeCallback(cfg, pending)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
root.Flags().BoolVar(&flagDryRun, "dry-run", false, "Executa sem chamar APIs nem escrever no disco")
|
||||||
|
root.Flags().BoolVar(&flagNoReddit, "no-reddit", false, "Pula Reddit (útil se rate limited)")
|
||||||
|
root.Flags().StringVar(&flagForceSlug, "force-slug", "", "Força slug específico da fila (pula seleção de tema)")
|
||||||
|
root.Flags().StringVar(&flagWorkspace, "workspace", "", "Override de LDPOST_WORKSPACE")
|
||||||
|
|
||||||
|
if err := root.Execute(); err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resumeCallback waits for one of the 5 valid callbacks then processes it.
|
||||||
|
func resumeCallback(cfg *config.Config, pending *PendingCallback) error {
|
||||||
|
bot := telegram.NewBot(cfg.TelegramBotToken, cfg.TelegramChatID)
|
||||||
|
|
||||||
|
valid := []string{"trend_a", "trend_b", "trend_c"}
|
||||||
|
if pending.NextPost != nil {
|
||||||
|
if pending.NextPost.Formato != "" {
|
||||||
|
valid = append(valid, "fila_original")
|
||||||
|
}
|
||||||
|
valid = append(valid, "fila_sorteado")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[INFO] esperando callbacks válidos: %v", valid)
|
||||||
|
cb, err := bot.WaitForCallback(valid, 0) // 0 = indefinido
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("callback: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("[INFO] callback recebido: %s", cb)
|
||||||
|
|
||||||
|
slug, err := processCallback(cfg, cb, pending)
|
||||||
|
if err != nil {
|
||||||
|
bot.SendMessage(fmt.Sprintf("❌ Erro ao processar escolha: %s", err.Error()))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirmar no Telegram
|
||||||
|
var titulo string
|
||||||
|
if pending.NextPost != nil && (cb == "fila_original" || cb == "fila_sorteado") {
|
||||||
|
titulo = pending.NextPost.Tema
|
||||||
|
} else {
|
||||||
|
idx := map[string]int{"trend_a": 0, "trend_b": 1, "trend_c": 2}[cb]
|
||||||
|
if idx < len(pending.Trends) {
|
||||||
|
titulo = pending.Trends[idx].Tema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmMsg := fmt.Sprintf(
|
||||||
|
"✅ <b>Escolha registrada!</b>\n\nPost: <i>%s</i>\nSlug: <code>%s</code>\n\nPróximo passo:\n<code>ldpost-redator --post %s</code>",
|
||||||
|
titulo, slug, slug,
|
||||||
|
)
|
||||||
|
if _, err := bot.SendMessage(confirmMsg); err != nil {
|
||||||
|
log.Printf("[WARN] confirmação Telegram: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limpar pending
|
||||||
|
deletePending(cfg.Workspace)
|
||||||
|
log.Printf("[INFO] done — post slug: %s", slug)
|
||||||
|
fmt.Println(slug)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Utilities ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n-1] + "…"
|
||||||
|
}
|
||||||
11
go.work
Normal file
11
go.work
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
go 1.22
|
||||||
|
|
||||||
|
use (
|
||||||
|
./shared
|
||||||
|
./evaluator
|
||||||
|
./redator
|
||||||
|
./editor
|
||||||
|
./art
|
||||||
|
./director
|
||||||
|
./publisher
|
||||||
|
)
|
||||||
15
go.work.sum
Normal file
15
go.work.sum
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||||
|
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||||
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
16
publisher/go.mod
Normal file
16
publisher/go.mod
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
module ldpost/publisher
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/spf13/cobra v1.8.0
|
||||||
|
ldpost/shared v0.0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/joho/godotenv v1.5.1 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
replace ldpost/shared => ../shared
|
||||||
12
publisher/go.sum
Normal file
12
publisher/go.sum
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||||
|
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||||
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
599
publisher/main.go
Normal file
599
publisher/main.go
Normal file
@ -0,0 +1,599 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"ldpost/shared/config"
|
||||||
|
"ldpost/shared/history"
|
||||||
|
"ldpost/shared/state"
|
||||||
|
"ldpost/shared/telegram"
|
||||||
|
"ldpost/shared/workspace"
|
||||||
|
)
|
||||||
|
|
||||||
|
const linkedInAPIBase = "https://api.linkedin.com/v2"
|
||||||
|
|
||||||
|
// ─── LinkedIn types ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ugcPost struct {
|
||||||
|
Author string `json:"author"`
|
||||||
|
LifecycleState string `json:"lifecycleState"`
|
||||||
|
SpecificContent specificContent `json:"specificContent"`
|
||||||
|
Visibility visibility `json:"visibility"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type specificContent struct {
|
||||||
|
ShareContent shareContent `json:"com.linkedin.ugc.ShareContent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type shareContent struct {
|
||||||
|
ShareCommentary shareCommentary `json:"shareCommentary"`
|
||||||
|
ShareMediaCategory string `json:"shareMediaCategory"`
|
||||||
|
Media []ugcMedia `json:"media,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type shareCommentary struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ugcMedia struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Description localText `json:"description"`
|
||||||
|
Media string `json:"media,omitempty"`
|
||||||
|
Title localText `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type localText struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type visibility struct {
|
||||||
|
MemberNetworkVisibility string `json:"com.linkedin.ugc.MemberNetworkVisibility"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── LinkedIn helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// checkToken returns (personID, nil) on 200, ("", err) on failure or 401.
|
||||||
|
// Uses /v2/userinfo (OpenID Connect) — requires openid+profile scopes.
|
||||||
|
// The "sub" field contains the member ID used for urn:li:person:{id}.
|
||||||
|
func checkToken(token string) (string, error) {
|
||||||
|
req, _ := http.NewRequest("GET", linkedInAPIBase+"/userinfo", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("GET /userinfo: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == 401 {
|
||||||
|
return "", fmt.Errorf("token inválido ou expirado (401)")
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
var result struct {
|
||||||
|
Sub string `json:"sub"` // OpenID Connect member ID
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil || result.Sub == "" {
|
||||||
|
return "", fmt.Errorf("LinkedIn /userinfo inválido: %s", string(body))
|
||||||
|
}
|
||||||
|
return result.Sub, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadImage(token, personURN, imgPath, slideLabel string) (string, error) {
|
||||||
|
regReq := map[string]any{
|
||||||
|
"registerUploadRequest": map[string]any{
|
||||||
|
"recipes": []string{"urn:li:digitalmediaRecipe:feedshare-document"},
|
||||||
|
"owner": personURN,
|
||||||
|
"serviceRelationships": []map[string]string{
|
||||||
|
{"relationshipType": "OWNER", "identifier": "urn:li:userGeneratedContent"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
regData, _ := json.Marshal(regReq)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("POST", linkedInAPIBase+"/assets?action=registerUpload", bytes.NewReader(regData))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Restli-Protocol-Version", "2.0.0")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("registerUpload %s: %w", slideLabel, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
var regResp struct {
|
||||||
|
Value struct {
|
||||||
|
UploadMechanism struct {
|
||||||
|
Req struct {
|
||||||
|
UploadURL string `json:"uploadUrl"`
|
||||||
|
Headers map[string]string `json:"headers"`
|
||||||
|
} `json:"com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest"`
|
||||||
|
} `json:"uploadMechanism"`
|
||||||
|
Asset string `json:"asset"`
|
||||||
|
} `json:"value"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, ®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 <slug>",
|
||||||
|
Short: "Publica o post aprovado no LinkedIn",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cfg := config.Load()
|
||||||
|
if flagWorkspace != "" {
|
||||||
|
cfg.Workspace = flagWorkspace
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── --auth: OAuth2 setup flow ─────────────────────────────────
|
||||||
|
if flagAuth {
|
||||||
|
return runOAuthFlow(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if flagPost == "" {
|
||||||
|
return fmt.Errorf("--post é obrigatório")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. Encontrar post e validar state ─────────────────────────
|
||||||
|
postPath, err := workspace.FindPostBySlug(cfg.Workspace, flagPost)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("post %q: %w", flagPost, err)
|
||||||
|
}
|
||||||
|
s, err := state.LoadState(postPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ler state: %w", err)
|
||||||
|
}
|
||||||
|
if !s.IsStatus(state.StatusWaitingPublisher) {
|
||||||
|
log.Printf("[ERROR] status atual: %q — esperado: %q", s.Status, state.StatusWaitingPublisher)
|
||||||
|
return fmt.Errorf("estado incorreto — rode ldpost-director --post %s primeiro", flagPost)
|
||||||
|
}
|
||||||
|
if !s.Aprovacoes.Final.Aprovado && !flagDryRun && !flagManual {
|
||||||
|
return fmt.Errorf("post sem aprovação final — rode ldpost-director --post %s", flagPost)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Ler conteúdo ───────────────────────────────────────────
|
||||||
|
postText := ""
|
||||||
|
if data, err := os.ReadFile(workspace.OutputPostPath(postPath)); err == nil {
|
||||||
|
postText = cleanMarkdown(string(data))
|
||||||
|
} else if data, err := os.ReadFile(filepath.Join(workspace.WorkPath(postPath), "editor-final.md")); err == nil {
|
||||||
|
postText = cleanMarkdown(string(data))
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("output/post.md e work/editor-final.md não encontrados")
|
||||||
|
}
|
||||||
|
|
||||||
|
slide1 := filepath.Join(workspace.OutputPath(postPath), "slide1.png")
|
||||||
|
slide2 := filepath.Join(workspace.OutputPath(postPath), "slide2.png")
|
||||||
|
outputImgs := []string{}
|
||||||
|
for _, p := range []string{slide1, slide2} {
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
outputImgs = append(outputImgs, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[INFO] post=%s chars=%d imagens=%d", s.Slug, len(postText), len(outputImgs))
|
||||||
|
|
||||||
|
// ── 3. Dry-run ────────────────────────────────────────────────
|
||||||
|
if flagDryRun {
|
||||||
|
fmt.Println("⚠️ DRY-RUN — NENHUMA CHAMADA À API LINKEDIN SERÁ FEITA")
|
||||||
|
fmt.Println(strings.Repeat("═", 51))
|
||||||
|
fmt.Printf("Post: %s | Chars: %d | Imagens: %d\n\n", flagPost, len(postText), len(outputImgs))
|
||||||
|
fmt.Println("── TEXTO LIMPO ──────────────────────────────────────")
|
||||||
|
fmt.Println(postText)
|
||||||
|
fmt.Println(strings.Repeat("─", 51))
|
||||||
|
fmt.Printf("Aprovado: %v\n", s.Aprovacoes.Final.Aprovado)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Verificar token / decidir modo ─────────────────────────
|
||||||
|
var personID string
|
||||||
|
manualReason := ""
|
||||||
|
|
||||||
|
if flagManual {
|
||||||
|
manualReason = "Modo manual solicitado via --manual."
|
||||||
|
} else if cfg.LinkedInAccessToken == "" {
|
||||||
|
manualReason = "Token LinkedIn não configurado."
|
||||||
|
if cfg.LinkedInClientID != "" {
|
||||||
|
manualReason += " Configure com: ldpost-publisher --auth"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
id, err := checkToken(cfg.LinkedInAccessToken)
|
||||||
|
if err != nil {
|
||||||
|
manualReason = fmt.Sprintf("Token LinkedIn inválido: %v", err)
|
||||||
|
} else {
|
||||||
|
personID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if manualReason != "" {
|
||||||
|
return runManualMode(postPath, s, postText, outputImgs, manualReason)
|
||||||
|
}
|
||||||
|
|
||||||
|
personURN := "urn:li:person:" + personID
|
||||||
|
log.Printf("[INFO] person URN: %s", personURN)
|
||||||
|
|
||||||
|
// ── 5. Confirmação antes de publicar ──────────────────────────
|
||||||
|
fmt.Printf("\n⚠️ PUBLICAÇÃO IRREVERSÍVEL\n")
|
||||||
|
fmt.Printf("Post: %s | Formato: %s\n", s.Slug, s.Formato)
|
||||||
|
fmt.Printf("Confirmar publicação? (s/N): ")
|
||||||
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
|
if !scanner.Scan() || strings.ToLower(strings.TrimSpace(scanner.Text())) != "s" {
|
||||||
|
fmt.Println("Publicação cancelada.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. Upload imagens ─────────────────────────────────────────
|
||||||
|
var assetURNs []string
|
||||||
|
for i, imgPath := range outputImgs {
|
||||||
|
label := fmt.Sprintf("slide%d", i+1)
|
||||||
|
log.Printf("[INFO] upload %s...", label)
|
||||||
|
urn, err := uploadImage(cfg.LinkedInAccessToken, personURN, imgPath, label)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[ERROR] upload %s: %v", label, err)
|
||||||
|
if len(assetURNs) > 0 {
|
||||||
|
log.Printf("[INFO] asset URNs já obtidos (para retry): %v", assetURNs)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("falha no upload de %s: %w", label, err)
|
||||||
|
}
|
||||||
|
assetURNs = append(assetURNs, urn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 7. Publicar post ──────────────────────────────────────────
|
||||||
|
log.Printf("[INFO] publicando post no LinkedIn...")
|
||||||
|
postID, err := publishPost(cfg.LinkedInAccessToken, personURN, postText, assetURNs)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[INFO] asset URNs para retry: %v", assetURNs)
|
||||||
|
return fmt.Errorf("publicar: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
postURL := fmt.Sprintf("https://www.linkedin.com/feed/update/%s/", postID)
|
||||||
|
log.Printf("[INFO] publicado: %s", postURL)
|
||||||
|
|
||||||
|
// ── 8. Finalizar state e histórico ────────────────────────────
|
||||||
|
if err := finalizePublished(postPath, s, postID, postURL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
hist, err := history.LoadHistory(cfg.Workspace)
|
||||||
|
if err != nil {
|
||||||
|
hist = &history.FormatHistory{}
|
||||||
|
}
|
||||||
|
hist.AddEntry(s.Slug, s.Formato)
|
||||||
|
if err := history.SaveHistory(cfg.Workspace, hist); err != nil {
|
||||||
|
log.Printf("[WARN] format-history: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure output/post.md exists with clean text
|
||||||
|
if _, err := os.Stat(workspace.OutputPostPath(postPath)); os.IsNotExist(err) {
|
||||||
|
os.WriteFile(workspace.OutputPostPath(postPath), []byte(postText), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 9. Notificação Telegram ───────────────────────────────────
|
||||||
|
if cfg.TelegramBotToken != "" && cfg.TelegramChatID != "" {
|
||||||
|
bot := telegram.NewBot(cfg.TelegramBotToken, cfg.TelegramChatID)
|
||||||
|
msg := fmt.Sprintf(
|
||||||
|
"🚀 <b>Post publicado!</b>\n\n<code>%s</code>\n🔗 %s\n📅 %s",
|
||||||
|
s.Slug, postURL, time.Now().Format("02/01/2006 15:04"),
|
||||||
|
)
|
||||||
|
if _, err := bot.SendMessage(msg); err != nil {
|
||||||
|
log.Printf("[WARN] Telegram: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
root.Flags().StringVar(&flagPost, "post", "", "Slug do post (obrigatório, exceto com --auth)")
|
||||||
|
root.Flags().StringVar(&flagWorkspace, "workspace", "", "Override de LDPOST_WORKSPACE")
|
||||||
|
root.Flags().BoolVar(&flagDryRun, "dry-run", false, "Prepara tudo sem chamar LinkedIn API")
|
||||||
|
root.Flags().BoolVar(&flagManual, "manual", false, "Força modo manual mesmo com token configurado")
|
||||||
|
root.Flags().BoolVar(&flagAuth, "auth", false, "Inicia fluxo OAuth2 para configurar token")
|
||||||
|
|
||||||
|
if err := root.Execute(); err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
16
redator/go.mod
Normal file
16
redator/go.mod
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
module ldpost/redator
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/spf13/cobra v1.8.0
|
||||||
|
ldpost/shared v0.0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/joho/godotenv v1.5.1 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
replace ldpost/shared => ../shared
|
||||||
12
redator/go.sum
Normal file
12
redator/go.sum
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||||
|
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||||
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
209
redator/main.go
Normal file
209
redator/main.go
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"ldpost/shared/config"
|
||||||
|
"ldpost/shared/groq"
|
||||||
|
"ldpost/shared/state"
|
||||||
|
"ldpost/shared/workspace"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── System prompts ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const systemBase = `Você é o ghostwriter técnico de Ricardo, um Tech Lead brasileiro com 22+ anos de experiência em C#/.NET, especialista em Clean Architecture, IA local, agentes de IA e SAP BTP. Ricardo tem um livro publicado sobre C# e 6 anos de experiência ensinando.
|
||||||
|
|
||||||
|
PERFIL E VOZ DO AUTOR:
|
||||||
|
Ricardo escreve de forma direta, sem rodeios, com linguagem técnica mas acessível. Ele usa exemplos concretos, cita números reais quando tem, e não tem medo de opiniões fortes. Ele não escreve "como profissional apaixonado" nem usa frases corporativas vazias.
|
||||||
|
|
||||||
|
Ricardo está construindo um curso sobre uso de IA como ferramenta produtiva para profissionais de tecnologia. Ele acredita — e pratica — que a IA potencializa humanos, não os substitui. Quando o post tocar em produtividade, automação, templates, ferramentas ou processos de trabalho, reflita essa perspectiva de forma natural e indireta. Não force em todo post. Nunca use a frase literal "a IA não substitui os humanos" — mostre isso pelo exemplo, pelo tom, pela experiência concreta que o Ricardo viveu.
|
||||||
|
|
||||||
|
Seu trabalho é escrever um RASCUNHO CRU do post — sem preocupação com hashtags, formatação LinkedIn ou CTA ainda. Foque em:
|
||||||
|
1. Extrair o máximo de valor técnico do resumo fornecido
|
||||||
|
2. Expandir com contexto que um leitor dev precisaria para entender
|
||||||
|
3. Ser específico: nomes de tecnologias, versões, números, trade-offs reais
|
||||||
|
4. NÃO inventar fatos — se o resumo não menciona, não coloque
|
||||||
|
5. Escrever em português brasileiro, tom informal-técnico
|
||||||
|
6. Tamanho: 300-500 palavras (o editor vai ajustar depois)
|
||||||
|
|
||||||
|
Retorne APENAS o rascunho em markdown simples. Sem cabeçalho, sem rodapé, sem explicação.`
|
||||||
|
|
||||||
|
var formatBlocks = map[string]string{
|
||||||
|
"como": `Estruture como: problema/contexto (1 parágrafo) → solução passo a passo (3-5 passos numerados, cada um com detalhe técnico) → resultado/benefício (1 parágrafo). Seja didático mas não condescendente.`,
|
||||||
|
|
||||||
|
"porque": `Estruture como: afirmação contraintuitiva forte (1 frase impactante) → o que a maioria pensa (1 parágrafo) → por que está errado/incompleto (2-3 argumentos com evidência do resumo) → sua perspectiva (1 parágrafo conclusivo). Seja direto e não se desculpe pela opinião.`,
|
||||||
|
|
||||||
|
"erro": `Estruture como: o erro específico que cometeu (concreto, não genérico) → por que aconteceu (contexto/pressão) → o impacto real (quanto tempo/dinheiro/frustração custou) → a solução que funcionou (específica, testada) → o que aprendeu. Use primeira pessoa, seja vulnerável mas técnico.`,
|
||||||
|
|
||||||
|
"comparacao": `Estruture como: o que estava comparando e por quê (contexto) → critérios de comparação (3-5 critérios objetivos) → resultado de cada critério (específico, com números se tiver) → conclusão com recomendação clara para cada cenário. Evite "depende" como resposta final — dê uma recomendação.`,
|
||||||
|
|
||||||
|
"checklist": `Estruture como: por que essa checklist existe (o problema que ela resolve) → os itens (5-7 itens, cada um com 1-2 frases de contexto explicando por que importa) → como usar na prática. Cada item deve ser acionável, não abstrato.`,
|
||||||
|
|
||||||
|
"bastidor": `Estruture como: o que está construindo e por que agora (contexto) → o estado atual (o que funciona, o que não funciona, o que está decidindo) → a próxima decisão ou desafio aberto → o que vem por aí. Seja honesto sobre incertezas — build in public funciona pela autenticidade.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSystemPrompt(formato string) string {
|
||||||
|
block, ok := formatBlocks[formato]
|
||||||
|
if !ok {
|
||||||
|
block = formatBlocks["como"] // fallback
|
||||||
|
}
|
||||||
|
return systemBase + "\n\nESTRUTURA DO FORMATO '" + strings.ToUpper(formato) + "':\n" + block
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildUserMessage(s *state.PostState, textoBody string) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
fmt.Fprintf(&sb, "FORMATO: %s\n", s.Formato)
|
||||||
|
fmt.Fprintf(&sb, "TEMA: %s\n", s.TemaEscolhido)
|
||||||
|
if s.TrendReferencia != "" {
|
||||||
|
fmt.Fprintf(&sb, "TREND: %s\n", s.TrendReferencia)
|
||||||
|
}
|
||||||
|
if s.Categoria != "" {
|
||||||
|
fmt.Fprintf(&sb, "CATEGORIA: %s\n", s.Categoria)
|
||||||
|
}
|
||||||
|
sb.WriteString("\nRESUMO TÉCNICO DO AUTOR:\n")
|
||||||
|
sb.WriteString(textoBody)
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func wordCount(s string) int {
|
||||||
|
return len(strings.Fields(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.SetFlags(log.Ltime)
|
||||||
|
|
||||||
|
var (
|
||||||
|
flagPost string
|
||||||
|
flagWorkspace string
|
||||||
|
flagDryRun bool
|
||||||
|
flagTemp float64
|
||||||
|
)
|
||||||
|
|
||||||
|
root := &cobra.Command{
|
||||||
|
Use: "ldpost-redator --post <slug>",
|
||||||
|
Short: "Gera rascunho cru do post via Groq",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if flagPost == "" {
|
||||||
|
return fmt.Errorf("--post é obrigatório")
|
||||||
|
}
|
||||||
|
cfg := config.Load()
|
||||||
|
if flagWorkspace != "" {
|
||||||
|
cfg.Workspace = flagWorkspace
|
||||||
|
}
|
||||||
|
if !flagDryRun {
|
||||||
|
if err := cfg.Validate("groq"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. Encontrar post e validar state ─────────────────────────
|
||||||
|
postPath, err := workspace.FindPostBySlug(cfg.Workspace, flagPost)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("post %q: %w — rode ldpost-evaluator primeiro", flagPost, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := state.LoadState(postPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ler state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.IsStatus(state.StatusWaitingRedator) {
|
||||||
|
log.Printf("[ERROR] status atual: %q — esperado: %q", s.Status, state.StatusWaitingRedator)
|
||||||
|
return fmt.Errorf("estado incorreto — rode o agente correto para o status atual")
|
||||||
|
}
|
||||||
|
log.Printf("[INFO] post=%s formato=%s", s.Slug, s.Formato)
|
||||||
|
|
||||||
|
// ── 2. Ler input/texto.md ─────────────────────────────────────
|
||||||
|
textoRaw, err := os.ReadFile(workspace.InputTextoPath(postPath))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("input/texto.md não encontrado: %w — rode ldpost-evaluator primeiro", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip YAML front-matter
|
||||||
|
body := string(textoRaw)
|
||||||
|
if strings.HasPrefix(body, "---") {
|
||||||
|
if end := strings.Index(body[3:], "---"); end >= 0 {
|
||||||
|
body = body[end+6:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
textoBody := strings.TrimSpace(body)
|
||||||
|
|
||||||
|
wc := wordCount(textoBody)
|
||||||
|
log.Printf("[INFO] texto.md: %d chars, ~%d palavras", len(textoBody), wc)
|
||||||
|
if wc < 100 {
|
||||||
|
log.Printf("[WARN] resumo curto (%d palavras) — LLM fará o que pode", wc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Build prompts ──────────────────────────────────────────
|
||||||
|
systemPrompt := buildSystemPrompt(s.Formato)
|
||||||
|
userMsg := buildUserMessage(s, textoBody)
|
||||||
|
|
||||||
|
// ── 4. Dry-run ────────────────────────────────────────────────
|
||||||
|
if flagDryRun {
|
||||||
|
fmt.Printf("=== DRY-RUN — ldpost-redator ===\n")
|
||||||
|
fmt.Printf("post: %s\n", flagPost)
|
||||||
|
fmt.Printf("formato: %s\n", s.Formato)
|
||||||
|
fmt.Printf("temp: %.1f\n", flagTemp)
|
||||||
|
fmt.Printf("\n--- SYSTEM ---\n%s\n", systemPrompt)
|
||||||
|
fmt.Printf("\n--- USER ---\n%s\n", userMsg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. Chamar Groq ────────────────────────────────────────────
|
||||||
|
log.Printf("[INFO] chamando Groq (temp=%.1f)...", flagTemp)
|
||||||
|
gc := groq.NewGroqClient(cfg.GroqAPIKey)
|
||||||
|
draft, err := gc.Chat(groq.TextModel, groq.TextMessages(systemPrompt, userMsg), flagTemp, 1000)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("groq: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
draftWC := wordCount(draft)
|
||||||
|
if draftWC < 100 {
|
||||||
|
log.Printf("[ERROR] output do LLM muito curto (%d palavras) — provável falha de API", draftWC)
|
||||||
|
log.Printf("[ERROR] use --dry-run para inspecionar o prompt")
|
||||||
|
return fmt.Errorf("rascunho inválido: %d palavras (mínimo 100)", draftWC)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. Salvar work/redator-v1.md ─────────────────────────────
|
||||||
|
_, lastN := workspace.LatestVersionFile(postPath, "redator")
|
||||||
|
outPath := workspace.VersionedFile(postPath, "redator", lastN+1)
|
||||||
|
|
||||||
|
header := fmt.Sprintf("---\nversao: %d\nformato: %s\ngerado_em: %s\n---\n\n",
|
||||||
|
lastN+1, s.Formato, time.Now().Format(time.RFC3339))
|
||||||
|
content := header + draft
|
||||||
|
|
||||||
|
if err := os.WriteFile(outPath, []byte(content), 0644); err != nil {
|
||||||
|
return fmt.Errorf("salvar rascunho: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 7. Atualizar state ────────────────────────────────────────
|
||||||
|
s.Status = state.StatusWaitingEditor
|
||||||
|
s.SetEtapa("redator", state.EtapaDone)
|
||||||
|
s.SetEtapa("editor", state.EtapaWaiting)
|
||||||
|
if err := state.SaveState(postPath, s); err != nil {
|
||||||
|
return fmt.Errorf("atualizar state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("✅ Rascunho gerado: %s\n", outPath)
|
||||||
|
fmt.Printf(" Formato: %s | Palavras: ~%d\n", s.Formato, draftWC)
|
||||||
|
fmt.Printf(" Próximo: ldpost-editor --post %s\n", flagPost)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
root.Flags().StringVar(&flagPost, "post", "", "Slug do post (obrigatório)")
|
||||||
|
root.Flags().StringVar(&flagWorkspace, "workspace", "", "Override de LDPOST_WORKSPACE")
|
||||||
|
root.Flags().BoolVar(&flagDryRun, "dry-run", false, "Mostra prompt sem chamar Groq")
|
||||||
|
root.Flags().Float64Var(&flagTemp, "temp", 0.7, "Temperatura do LLM (0.0-1.0)")
|
||||||
|
|
||||||
|
if err := root.Execute(); err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
450
seed/_sugestoes.md
Normal file
450
seed/_sugestoes.md
Normal file
@ -0,0 +1,450 @@
|
|||||||
|
# Seed de Sugestões — ldpost-squad
|
||||||
|
# 30 posts baseados em experiências reais de Ricardo
|
||||||
|
# Formato: ## slug → campos **Chave:** Valor
|
||||||
|
# Copie para: C:\Textos-Linkedin\_inbox\_sugestoes.md
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## templates-propostas-comerciais
|
||||||
|
|
||||||
|
**Categoria:** Entregavel
|
||||||
|
**Tema:** Como criei templates com indicadores substituíveis que mantêm a aparência das minhas propostas comerciais
|
||||||
|
**Formato:** como
|
||||||
|
**Funil:** meio
|
||||||
|
**Resumo:** Toda vez que eu precisava montar uma proposta comercial eu perdia tempo reformatando. Criei um sistema de templates Word/Google Docs com marcadores no estilo {{CLIENTE}}, {{VALOR}}, {{PRAZO}}, {{ESCOPO}} que são substituídos automaticamente. O resultado mantém sempre a mesma identidade visual profissional. Tenho prints mostrando a proposta antes (bagunçada, cada uma diferente) e depois (todas com a mesma cara). Economizo em média 45min por proposta. A substituição pode ser feita manualmente ou via script simples.
|
||||||
|
**Imagem 1:** Antes e depois: duas propostas comerciais lado a lado — esquerda com formatação inconsistente, direita com template padronizado e profissional, fundo branco com elementos em azul corporativo
|
||||||
|
**Imagem 2:** Template com marcadores coloridos em destaque {{CLIENTE}}, {{VALOR}}, {{PRAZO}} visíveis no documento, estilo minimalista, clean, profissional
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## templates-docs-arquitetura
|
||||||
|
|
||||||
|
**Categoria:** Codigo
|
||||||
|
**Tema:** Template de documento de arquitetura com indicadores substituíveis — o que aprendi depois de 50+ ADRs
|
||||||
|
**Formato:** como
|
||||||
|
**Funil:** meio
|
||||||
|
**Resumo:** Depois de escrever mais de 50 Architecture Decision Records e documentos de design técnico, identifiquei os campos que são sempre os mesmos: contexto, decisão, alternativas consideradas, consequências. Criei um template Markdown com marcadores {{SISTEMA}}, {{DATA}}, {{DECISÃO}}, {{CONTEXTO}}, {{ALTERNATIVAS}} e {{TRADE_OFFS}}. O que mudou: novos devs conseguem escrever o primeiro ADR sem me perguntar como fazer. Tenho prints comparando um ADR escrito do zero vs um usando o template — qualidade e completude notavelmente diferentes.
|
||||||
|
**Imagem 1:** Documento de arquitetura técnica aberto no editor com seções bem delimitadas: Contexto, Decisão, Alternativas, Trade-offs — paleta azul/cinza tech, estilo clean
|
||||||
|
**Imagem 2:** Diagrama de fluxo: engenheiro recebe template → preenche campos → ADR pronto e revisável — ícones geométricos, sem rostos, estilo flat design
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## templates-planilhas
|
||||||
|
|
||||||
|
**Categoria:** Entregavel
|
||||||
|
**Tema:** Templates de planilha com indicadores substituíveis: como padronizei relatórios que antes levavam 3h
|
||||||
|
**Formato:** como
|
||||||
|
**Funil:** meio
|
||||||
|
**Resumo:** Eu mantinha planilhas de acompanhamento de projetos, orçamentos e métricas de time. Cada uma era criada do zero e ficava diferente das outras. Criei templates com células nomeadas e fórmulas que referenciam essas células pelo nome (ex: =PROJETO_BUDGET * 1.1). Quando mudo o nome do projeto em uma célula, tudo atualiza. Tenho prints das planilhas antes e depois. O processo de criação de relatório mensal caiu de ~3h para ~40min. A chave foi usar Named Ranges em vez de referências diretas (A1, B2) — isso é o que permite a substituição sem quebrar fórmulas.
|
||||||
|
**Imagem 1:** Planilha de acompanhamento de projeto com células nomeadas destacadas em amarelo e fórmulas visíveis referenciando nomes, não coordenadas — estilo profissional, cores neutras
|
||||||
|
**Imagem 2:** Comparação lado a lado: planilha antiga com fórmulas hardcoded vs nova com Named Ranges — seta no centro indicando evolução, fundo branco
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## criar-textos-voz-propria
|
||||||
|
|
||||||
|
**Categoria:** Bastidor
|
||||||
|
**Tema:** Como treinei a IA para escrever no meu tom — sem fine-tuning, só com prompts certos
|
||||||
|
**Formato:** como
|
||||||
|
**Funil:** topo
|
||||||
|
**Resumo:** Precisava que a IA gerasse textos que soassem como eu escrevo: direto, sem enrolação, técnico mas acessível, sem frases corporativas. Em vez de fine-tuning (caro e lento), usei uma abordagem de few-shot com exemplos meus reais no system prompt. O truque foi: 5 exemplos curtos de parágrafos que eu escrevi + lista explícita de frases que EU NUNCA USARIA (como "no cenário atual", "é importante destacar", "mergulho profundo"). Resultado: 80% dos rascunhos gerados precisam de menos de 3 ajustes antes de publicar.
|
||||||
|
**Imagem 1:** System prompt aberto em editor de código com seção "ESTILO DO AUTOR" contendo exemplos reais e lista de proibições — fundo escuro, código limpo
|
||||||
|
**Imagem 2:** Comparação: texto genérico da IA vs texto no estilo do autor — mesma informação, ton completamente diferente, dois blocos de texto lado a lado com contraste visual
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## fluxo-trabalho-ia-conteudo
|
||||||
|
|
||||||
|
**Categoria:** Geral
|
||||||
|
**Tema:** O fluxo de trabalho que uso com IA para criar conteúdo técnico — explica, analisa, complementa, repete
|
||||||
|
**Formato:** checklist
|
||||||
|
**Funil:** topo
|
||||||
|
**Resumo:** Desenvolvi um fluxo iterativo para criar conteúdo com IA que evita o problema comum de "a IA inventou". O ciclo: (1) eu explico o que fiz com minhas palavras, (2) peço para a IA analisar e identificar gaps no meu raciocínio, (3) ela complementa com contexto técnico que eu não havia mencionado, (4) eu reviso e corrijo o que ela errou, (5) repito até estar satisfeito. A diferença crucial: eu nunca peço pra ela "criar" — peço pra ela "ampliar" o que eu já escrevi. Isso evita alucinações e mantém minha voz.
|
||||||
|
**Imagem 1:** Diagrama circular mostrando o ciclo: Escrever → Analisar → Complementar → Revisar → Publicar — cada etapa com ícone simples, setas conectando, paleta tech azul/branco
|
||||||
|
**Imagem 2:** Tela de chat com IA mostrando o prompt "Analise meu texto e indique o que está impreciso ou incompleto" seguido de resposta estruturada — estilo screenshot limpo, sem conteúdo sensível
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## rag-conceito-quando-usar
|
||||||
|
|
||||||
|
**Categoria:** Codigo
|
||||||
|
**Tema:** RAG: o que é, quando usar e quando NÃO usar — sem hype
|
||||||
|
**Formato:** porque
|
||||||
|
**Funil:** topo
|
||||||
|
**Resumo:** RAG (Retrieval-Augmented Generation) virou buzzword mas poucos explicam quando ele realmente faz sentido. Usei RAG em 4 projetos diferentes: um funcionou muito bem (consulta de documentação interna), dois funcionaram razoavelmente (FAQ de produtos), um foi um fracasso total (código-fonte indexado sem chunking adequado). O padrão que aprendi: RAG funciona quando o usuário faz perguntas sobre documentos que EXISTEM e foram indexados. Não funciona como substituto de fine-tuning, não funciona com chunks grandes demais, não funciona quando a qualidade dos documentos originais é baixa. O erro mais comum: achar que indexar tudo resolve — na verdade piora.
|
||||||
|
**Imagem 1:** Diagrama de arquitetura RAG: Documento → Chunker → Embedder → Vector DB → Query → Retriever → LLM → Resposta — setas claras, ícones geométricos, fundo branco
|
||||||
|
**Imagem 2:** Tabela de decisão: "Use RAG quando..." vs "NÃO use RAG quando..." — duas colunas com checkmarks verdes e X vermelhos, design limpo
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## rag-csharp-semantic-kernel
|
||||||
|
|
||||||
|
**Categoria:** Codigo
|
||||||
|
**Tema:** Construí RAG local em C# com Semantic Kernel — e a IA quase quebrou tudo usando APIs em PREVIEW
|
||||||
|
**Formato:** erro
|
||||||
|
**Funil:** fundo
|
||||||
|
**Resumo:** Pedi para a IA me ajudar a construir um RAG local com C# .NET 8 + Semantic Kernel 1.x. Ela gerou código que compilava perfeitamente. O problema: o Semantic Kernel tinha dezenas de recursos marcados como [Experimental] e [Preview] nas versões 1.x, e a IA usou todos eles sem avisar. Entre uma versão minor e outra, as assinaturas mudavam, interfaces sumiam, namespaces se reorganizavam. Tive que estudar o changelog manualmente, identificar o que era estável vs preview, e reescrever partes inteiras do código. O resultado final funcionou: pipeline RAG local sem custo, 380ms de latência, zero dependência de API externa. Mas o trabalho humano de navegar as versões instáveis não foi opcional — foi o que separou um projeto funcionando de um projeto que compilava mas quebrava em runtime.
|
||||||
|
**Imagem 1:** Comparação de duas versões de código C#: gerado pela IA (usando APIs [Experimental]) vs versão final corrigida (usando apenas APIs estáveis) — diff visual, vermelho/verde, fundo escuro
|
||||||
|
**Imagem 2:** Diagrama do pipeline RAG local que ficou no final: PDF → PdfPig → Chunker → nomic-embed-text (Ollama) → Qdrant → Query → Semantic Kernel (estável) → Llama 3.1 → Resposta
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## fine-tuning-tom-modelo
|
||||||
|
|
||||||
|
**Categoria:** Codigo
|
||||||
|
**Tema:** Fine-tuning parece fácil até você descobrir que a parte difícil é 100% trabalho humano
|
||||||
|
**Formato:** porque
|
||||||
|
**Funil:** meio
|
||||||
|
**Resumo:** Queria um modelo que respondesse em português brasileiro, direto, sem floreios corporativos. A IA faz o fine-tuning em horas — esse não era o problema. O problema era o dataset: 400 pares de pergunta/resposta no meu estilo, que eu tive que escrever. Levou 3x mais tempo que o fine-tuning em si. Nenhuma IA pode fazer isso por mim — ela não sabe como eu escrevo, quais expressões eu uso, o que eu jamais diria. O que aprendi: fine-tuning de tom funciona bem com ~200-500 exemplos de qualidade. Abaixo de 100, o modelo "esquece" o estilo em prompts mais longos. Acima de 2000, começa catastrophic forgetting no conhecimento base. O trabalho técnico (treinamento, merge, quantização GGUF) levou 2h. O trabalho humano (escrever os exemplos) levou 3 dias. Quem acha que IA automatiza tudo ainda não fez fine-tuning sério.
|
||||||
|
**Imagem 1:** Gráfico de tempo: proporção do projeto — Dataset (humano, 72h) vs Fine-tuning (máquina, 2h) — barras contrastantes mostrando onde o tempo real foi
|
||||||
|
**Imagem 2:** Pipeline de fine-tuning: Dataset (JSONL) → Validação → Training (Unsloth) → Merge → Quantização (GGUF) → Teste — destacando em vermelho a etapa Dataset como gargalo humano
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## fine-tuning-guardrail
|
||||||
|
|
||||||
|
**Categoria:** Codigo
|
||||||
|
**Tema:** Criei um guardrail de IA com fine-tuning — e o modelo só funciona porque eu classifiquei 800 exemplos à mão
|
||||||
|
**Formato:** porque
|
||||||
|
**Funil:** fundo
|
||||||
|
**Resumo:** Precisava filtrar respostas inaceitáveis do meu LLM no contexto específico do meu produto. Guardrails genéricos da OpenAI têm 87% de acurácia no meu domínio — bom, mas não suficiente. A solução: fine-tuning de um modelo pequeno (Phi-3 Mini) especificamente para o meu contexto. O que a IA fez: treinamento, validação, deploy — automático. O que eu fiz: classifiquei 800 exemplos reais (resposta + label: ok/bloqueado) em 2 tardes de trabalho manual. Sem esse trabalho humano de rotulagem, o modelo não existe. O resultado valeu: 94% de acurácia no meu domínio, 50ms de latência, custo zero por chamada. A lição: modelos de IA especializados são melhores que genéricos — mas alguém precisa ensinar o que é "certo" no seu contexto. Essa pessoa é você, não outra IA.
|
||||||
|
**Imagem 1:** Diagrama de arquitetura: Usuário → LLM Principal → Saída → Guardrail (Phi-3 Mini fine-tuned) → OK/Bloqueado → Resposta — fluxo com bifurcação, destaque no "fine-tuned por humano"
|
||||||
|
**Imagem 2:** Tabela comparativa: Guardrail próprio vs API externa OpenAI — linhas: custo, latência, acurácia no domínio, trabalho de setup — design minimalista
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## mlnet-quando-usar
|
||||||
|
|
||||||
|
**Categoria:** Codigo
|
||||||
|
**Tema:** ML.NET em 2026: quando faz sentido e quando é overkill — minha experiência em 3 projetos
|
||||||
|
**Formato:** porque
|
||||||
|
**Funil:** meio
|
||||||
|
**Resumo:** Usei ML.NET em 3 projetos reais de .NET: (1) classificação de tickets de suporte por categoria — funcionou ótimo, modelo treinado em 2h, 89% de acurácia com 1500 exemplos; (2) previsão de churn de clientes — razoável, AUC 0.81; (3) geração de texto personalizado — fracasso total, ML.NET não é LLM, eu estava tentando usar ferramenta errada. O resumo: ML.NET faz sentido quando o problema é classificação, regressão ou forecasting, você tem dados tabulares ou texto simples, e não quer depender de APIs externas. Não faz sentido para geração de linguagem — aí é Ollama/Groq/OpenAI.
|
||||||
|
**Imagem 1:** Diagrama de decisão em árvore: "Qual ferramenta de ML para .NET?" com branches para ML.NET (tarefas clássicas) vs ONNX Runtime (modelos pré-treinados) vs API LLM (geração/raciocínio) — design clean
|
||||||
|
**Imagem 2:** Screenshot de código C# com ML.NET: pipeline de treino com IDataView, transformações e treinamento de classificação — fundo escuro, código limpo e legível
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## squad-ia-postagens-linkedin
|
||||||
|
|
||||||
|
**Categoria:** Bastidor
|
||||||
|
**Tema:** Construindo uma squad de agentes de IA pra automatizar minhas postagens no LinkedIn — o que funciona
|
||||||
|
**Formato:** bastidor
|
||||||
|
**Funil:** topo
|
||||||
|
**Resumo:** Estou construindo um sistema de 6 agentes Go que automatizam o pipeline de criação de posts LinkedIn: evaluator (busca trends + sorteia formato), redator (gera rascunho), editor (formata para LinkedIn com loop interativo), art (gera imagens via Gemini), director (aprovação via Telegram) e publisher (posta via API). Cada agente é um CLI independente que se comunica via arquivos JSON no workspace. O estado atual: 6 agentes funcionando, testados individualmente. O maior desafio foi o loop de revisão do editor — como manter controle humano sem tornar o processo tedioso. Solução: terminal interativo com opções [A]provar/[R]eprovar/[A1-A4] ajustes específicos.
|
||||||
|
**Imagem 1:** Diagrama da arquitetura da squad: 6 boxes conectados por setas (evaluator → redator → editor → art → director → publisher) com ícones de arquivo JSON entre eles — fundo escuro tech, paleta azul/verde
|
||||||
|
**Imagem 2:** Terminal mostrando o loop interativo do editor com o post formatado, checklist automático e menu de opções — estilo screenshot real de CLI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## squad-ia-blog
|
||||||
|
|
||||||
|
**Categoria:** Entregavel
|
||||||
|
**Tema:** Blog parte humano, parte IA — como mantenho consistência sem perder minha voz
|
||||||
|
**Formato:** como
|
||||||
|
**Funil:** meio
|
||||||
|
**Resumo:** Para o blog, dividi o processo: eu escrevo o núcleo técnico (o que fiz, os números, os trade-offs — coisas que só eu sei), a IA expande com contexto e estrutura, eu reviso e corrijo o que ela inventou. A regra de ouro que adotei: qualquer afirmação técnica específica (versão de biblioteca, número, nome de API) precisa ter sido dita por mim antes de aparecer no texto final. A IA pode inferir analogias e contexto — não pode inventar fatos. O resultado prático: artigos técnicos de 1500-2000 palavras que antes me levavam 4-6h agora ficam prontos em 1.5-2h, e os leitores frequentemente me dizem "parece muito você".
|
||||||
|
**Imagem 1:** Fluxo de trabalho: Núcleo técnico (humano) → Expansão e estrutura (IA) → Revisão de fatos (humano) → Publicação — ícones de pessoa e robô alternando, design clean
|
||||||
|
**Imagem 2:** Diagrama de responsabilidades: o que o humano escreve (números, trade-offs, opiniões) vs o que a IA escreve (contexto, analogias, estrutura) — dois círculos com overlap no centro
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## apps-ia-fluxo-prompts
|
||||||
|
|
||||||
|
**Categoria:** Codigo
|
||||||
|
**Tema:** Errei feio nas primeiras apps com IA — até entender que o humano precisa especificar antes de codar
|
||||||
|
**Formato:** erro
|
||||||
|
**Funil:** meio
|
||||||
|
**Resumo:** Nas primeiras apps com IA eu começava codando direto: pedia para a IA gerar o código, ela gerava, funcionava. Duas semanas depois precisava ajustar o comportamento — e descobria que o prompt estava hardcoded no meio de 3 arquivos diferentes, sem testes, sem histórico. A IA tinha feito exatamente o que eu pedi: código que funciona. Não fez o que eu precisava: código que eu consigo manter. O erro era meu: eu não havia especificado como queria que o sistema fosse construído, só o que ele devia fazer. O fluxo que adotei depois: (1) escrever o que o agente precisa FAZER em linguagem natural antes de abrir o editor, (2) criar e testar o prompt isoladamente antes de qualquer código, (3) definir casos de teste (entrada → saída esperada), (4) implementar com o prompt como configuração separada, não hardcoded. Parece burocrático. Salvou semanas de retrabalho.
|
||||||
|
**Imagem 1:** Diagrama do fluxo correto em 4 etapas: Especificação humana → Teste de prompt isolado → Implementação → Ajuste iterativo — destaque na etapa 1 como etapa que a IA não faz por você
|
||||||
|
**Imagem 2:** Comparação: código com prompt hardcoded (caos de manutenção) vs prompt em arquivo de configuração separado e versionado — diff visual, fundo escuro
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## qdrant-vs-mongodb-rag
|
||||||
|
|
||||||
|
**Categoria:** Codigo
|
||||||
|
**Tema:** Migrei meu RAG de MongoDB Atlas Vector Search para Qdrant — o que mudou de verdade
|
||||||
|
**Formato:** comparacao
|
||||||
|
**Funil:** fundo
|
||||||
|
**Resumo:** Comecei com MongoDB Atlas Vector Search porque já tinha MongoDB no projeto e queria evitar mais um serviço. Funcionou, mas: latência P95 de 820ms nas buscas, configuração de índice vetorial confusa, sem suporte a filtros complexos sem degradação de performance. Migrei para Qdrant rodando via Docker. Resultado: latência P95 caiu para 180ms (4.5x mais rápido), payload filters funcionam sem degradar a busca vetorial, e a API é mais intuitiva para o caso de uso de RAG. O custo: mais um serviço para gerenciar. Para projetos onde MongoDB já existe e o volume é baixo, Atlas Vector Search serve. Para RAG como core feature, Qdrant é superior.
|
||||||
|
**Imagem 1:** Gráfico de barras comparando latência P95: MongoDB Atlas 820ms vs Qdrant 180ms — cores contrastantes azul/laranja, valores bem visíveis, fundo branco
|
||||||
|
**Imagem 2:** Tabela comparativa completa: Qdrant vs MongoDB Atlas — linhas: latência, filtros, facilidade de configuração, custo, casos ideais — design limpo, checkmarks e valores
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## streaming-rag-csharp
|
||||||
|
|
||||||
|
**Categoria:** Codigo
|
||||||
|
**Tema:** Streaming de respostas RAG em C# com SSE — por que abandonei WebSocket e o que aprendi
|
||||||
|
**Formato:** erro
|
||||||
|
**Funil:** fundo
|
||||||
|
**Resumo:** Implementei streaming de respostas do RAG primeiro com WebSocket — parecia a escolha óbvia para comunicação bidirecional em tempo real. O problema: gerenciar conexões WebSocket é complexo (reconexão, heartbeat, estado), e para RAG você só precisa do fluxo de servidor para cliente. Resultado: 340 linhas de código de gerenciamento de conexão para algo que SSE resolve em 40 linhas. Migrei para Server-Sent Events com endpoint HTTP simples que retorna `text/event-stream`. O cliente consome com `EventSource`. Funcionou perfeitamente para o caso de uso — e o código ficou muito mais simples de manter.
|
||||||
|
**Imagem 1:** Comparação de código C#: endpoint WebSocket complexo (muitas linhas, gerenciamento de estado) vs endpoint SSE simples (poucas linhas, response.WriteAsync) — dois blocos lado a lado
|
||||||
|
**Imagem 2:** Diagrama de sequência: Cliente → GET /rag/stream → Server inicia streaming → chunks chegando em tempo real → conexão encerra naturalmente — estilo swimlane simplificado
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## por-que-go-para-cli
|
||||||
|
|
||||||
|
**Categoria:** Codigo
|
||||||
|
**Tema:** Por que escolhi Go (e não Rust, Python ou Node) para construir meus agentes de IA em CLI
|
||||||
|
**Formato:** porque
|
||||||
|
**Funil:** topo
|
||||||
|
**Resumo:** Quando decidi construir a squad de agentes de IA como CLIs independentes, avaliei as opções: Python (óbvio para IA, mas startup lento, dependências pesadas), Node (rápido de escrever, mas carrego npm_modules pra todo lado), Rust (performance máxima, mas velocidade de desenvolvimento baixa para um projeto experimental), Go (binário único sem dependências, startup ~5ms, concorrência nativa com goroutines para chamar múltiplas APIs em paralelo, fácil de compilar para Windows/Linux). O argumento decisivo: Go produz executáveis que funcionam sem runtime instalado — posso distribuir como um .exe que qualquer pessoa roda. Para CLIs que precisam chamar APIs HTTP e processar JSON, Go é imbatível em simplicidade + performance.
|
||||||
|
**Imagem 1:** Tabela comparativa das 4 linguagens: Python vs Node vs Rust vs Go — critérios: tamanho do binário, startup time, distribuição, concorrência, curva de aprendizado — design limpo
|
||||||
|
**Imagem 2:** Terminal mostrando os 6 executáveis da squad (ldpost-*.exe) em ~8MB cada, comparado com uma aplicação Node equivalente com node_modules — tamanhos contrastantes, fundo escuro
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bot-whatsapp-go
|
||||||
|
|
||||||
|
**Categoria:** Codigo
|
||||||
|
**Tema:** Bot de WhatsApp em Go com whatsmeow — sem Selenium, sem Baileys, sem dor de cabeça
|
||||||
|
**Formato:** como
|
||||||
|
**Funil:** meio
|
||||||
|
**Resumo:** Precisava de um bot WhatsApp para automatizar notificações internas. As alternativas que descartei: Selenium (frágil, quebra com updates do WhatsApp Web), Baileys (Node, manutenção complicada, não é oficial), API oficial do WhatsApp Business (caro para volume baixo, processo de aprovação demorado). Escolhi whatsmeow — biblioteca Go que implementa o protocolo do WhatsApp diretamente. Setup: autenticação via QR code, sessão persistida em SQLite, handlers para mensagens recebidas. Em ~200 linhas de Go tinha um bot funcional enviando e recebendo mensagens. O ponto de atenção: violação de ToS potencial — uso apenas para casos internos, nunca spam.
|
||||||
|
**Imagem 1:** Diagrama de arquitetura do bot: whatsmeow → handlers Go → SQLite (sessão) → integração com sistema interno — conexão com ícone do WhatsApp (sem logo real), estilo clean
|
||||||
|
**Imagem 2:** Trecho de código Go com whatsmeow: registro do handler de mensagens e envio de resposta — código legível, ~20 linhas, fundo escuro
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## qwen-gpu-intel-arc
|
||||||
|
|
||||||
|
**Categoria:** Codigo
|
||||||
|
**Tema:** Rodei Qwen2.5-Coder em GPU Intel Arc com SYCL — o que ninguém te conta antes de tentar
|
||||||
|
**Formato:** erro
|
||||||
|
**Funil:** fundo
|
||||||
|
**Resumo:** Queria usar a GPU Intel Arc A770 que tenho no home lab para inferência local ao invés de só usar CPU. A documentação do llama.cpp com suporte SYCL/Intel existe, mas é esparsa. O que aconteceu: 4h para configurar o ambiente SYCL (Intel oneAPI, drivers específicos, versão correta do llama.cpp), erros crípticos de compilação, e no final a inferência funcionou mas a 60% da velocidade da CPU para o modelo quantizado Q4. O VRAM da Arc A770 (16GB) deveria ser vantagem, mas o overhead de SYCL cancelou os ganhos para modelos pequenos. Conclusão: para modelos acima de 13B parâmetros a GPU começa a compensar. Abaixo disso, CPU com AVX512 ganha.
|
||||||
|
**Imagem 1:** Gráfico de tokens por segundo: CPU (AVX512) vs GPU Intel Arc SYCL — para modelos 7B, 13B, 34B — mostrando o ponto de crossover onde GPU passa a valer
|
||||||
|
**Imagem 2:** Terminal com output do processo de build do llama.cpp com suporte SYCL — mensagens de configuração, caminho de compilação, tempo total — estilo real de dev
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## cli-maluco-bastidor
|
||||||
|
|
||||||
|
**Categoria:** Bastidor
|
||||||
|
**Tema:** O Ollama gera código C# perfeito — e coloca tudo no lugar errado. Por isso usei Roslyn para dar contexto a ele
|
||||||
|
**Formato:** bastidor
|
||||||
|
**Funil:** topo
|
||||||
|
**Resumo:** O CLI-Maluco é um gerador de código C# que usa Ollama (Qwen2.5-Coder) para gerar implementações seguindo os padrões do projeto. O problema que apareceu cedo: a IA gerava código correto mas sem saber onde ele se encaixava — criava Services sem implementar as interfaces certas, ignorava o padrão CQRS que o projeto usava, ou gerava na sequência errada (Controller antes de ter o Service). Solução: usar Roslyn (compilador C# em modo análise) para extrair o AST do projeto e alimentar a IA com contexto real — quais interfaces existem, quais estão sem implementação, quais dependências são necessárias. Agora a IA não "imagina" a arquitetura: ela lê o que existe. Desafio atual: a sequência de geração ainda é hardcoded, estou migrando para Memgraph (banco de grafo*) para deixar o grafo de dependências guiar a ordem. *Banco de grafo: armazena relações entre itens. Ex: [ServiceA] -[:DEPENDE_DE]-> [RepositoryB].
|
||||||
|
**Imagem 1:** Diagrama do CLI-Maluco: Codebase → Roslyn (lê AST real) → Context Extractor → Ollama/Qwen → Código Gerado → Review humano — destaque em "lê AST real" como diferencial
|
||||||
|
**Imagem 2:** Exemplo: interface C# sem implementação detectada pelo Roslyn → prompt gerado automaticamente → implementação correta gerada pelo Ollama — três blocos lado a lado
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## knowledge-graph-step-provider
|
||||||
|
|
||||||
|
**Categoria:** Codigo
|
||||||
|
**Tema:** Por que substituí step provider hardcoded por knowledge graph no meu gerador de código
|
||||||
|
**Formato:** porque
|
||||||
|
**Funil:** fundo
|
||||||
|
**Resumo:** O CLI-Maluco tinha um "step provider" que definia a sequência de geração de artefatos: "primeiro a interface, depois o service, depois o controller". Hardcoded. Funcionava para projetos Clean Architecture padrão, mas quebrava quando a estrutura do projeto era diferente. A solução: usar Memgraph (grafo em memória compatível com Neo4j) para modelar as dependências reais do projeto como um grafo, e deixar o gerador navegar o grafo para descobrir a sequência correta. Se ServiceA depende de RepositoryB que depende de EntityC, o grafo já sabe a ordem. Não precisei mais hardcodar nada. Trade-off: mais complexo de configurar, mas muito mais flexível.
|
||||||
|
**Imagem 1:** Comparação visual: step provider hardcoded (lista sequencial fixa) vs knowledge graph (rede de nós conectados com dependências reais) — estilo diagrama tech, paleta azul/roxo
|
||||||
|
**Imagem 2:** Trecho de código C# ou Cypher query no Memgraph mostrando as relações entre artefatos de código — clean, legível, fundo escuro
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## dataset-finetuning-gerado
|
||||||
|
|
||||||
|
**Categoria:** Codigo
|
||||||
|
**Tema:** Como gerei 1.000 exemplos de dataset para fine-tuning sem escrever 1.000 exemplos
|
||||||
|
**Formato:** como
|
||||||
|
**Funil:** meio
|
||||||
|
**Resumo:** Para fazer fine-tuning de tom do modelo, precisava de pares (pergunta / resposta no meu estilo). Escrever 1000 à mão levaria semanas. A abordagem que funcionou: (1) escrevi 50 exemplos reais de alta qualidade, (2) pedi para um LLM grande (GPT-4) gerar variações — para cada exemplo meu, ele gerava 10 variações da pergunta mantendo o estilo da minha resposta, (3) filtrei manualmente as variações que soavam artificiais (~30% descartado). Resultado: 350 exemplos próprios → 3.500 variações → depois de filtragem: 1.100 exemplos de qualidade aceitável. Tempo total: 2 tardes vs semanas escrevendo tudo. O filtro manual ainda é insubstituível — sem ele a qualidade cai muito.
|
||||||
|
**Imagem 1:** Diagrama do processo de amplificação de dataset: 50 exemplos reais → GPT-4 gera variações → filtro manual → 1100 exemplos finais — funil visual, com número em cada etapa
|
||||||
|
**Imagem 2:** Arquivo JSONL de fine-tuning aberto: estrutura com campos "prompt" e "completion" visíveis, formatação correta para API — código limpo, fundo escuro, sem conteúdo sensível
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## parei-pagar-openai
|
||||||
|
|
||||||
|
**Categoria:** Geral
|
||||||
|
**Tema:** Gastei $80/mês com OpenAI por meses — até entender que eu estava pagando por capacidade que não precisava
|
||||||
|
**Formato:** porque
|
||||||
|
**Funil:** topo
|
||||||
|
**Resumo:** Comecei usando GPT-4 para tudo: geração de texto, código, análise, resumo. $80/mês. Parecia necessário — era o melhor modelo, meus projetos dependiam dele. O que aprendi depois de alguns meses: 90% do que eu usava o GPT-4 para fazer, modelos menores e gratuitos fazem igual. O problema não era o modelo — era eu não saber especificar o problema direito. GPT-4 "consertava" minhas especificações vagas com raciocínio sofisticado. Quando aprendi a especificar melhor, Llama 3.3 70B (Groq, gratuito) passou a resolver. Migrei: Groq para texto, Ollama local (Qwen2.5-Coder) para código, Qdrant local para vector search. O que ainda pago: Gemini API para imagens ($5/mês). Total: de $80 para $5. Qualidade nos meus casos de uso: 85-90% do GPT-4. O que perdi: modelos de reasoning complexo (o1) — mas isso representa menos de 5% do que eu realmente uso.
|
||||||
|
**Imagem 1:** Gráfico de barras: custo mensal antes ($80 OpenAI) vs depois (Groq $0 + Ollama $0 + Gemini $5) — com nota sobre o que mudou além do custo
|
||||||
|
**Imagem 2:** Diagrama do stack atual: texto → Groq/Llama, código → Ollama/Qwen, imagens → Gemini, vector → Qdrant — ícones genéricos, layout limpo
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## orange-pi-cluster-homelab
|
||||||
|
|
||||||
|
**Categoria:** Bastidor
|
||||||
|
**Tema:** Cluster de mini-SaaS no Orange Pi 5: como meu home lab virou laboratório de produtos
|
||||||
|
**Formato:** bastidor
|
||||||
|
**Funil:** topo
|
||||||
|
**Resumo:** Tenho um Orange Pi 5 (8GB RAM, SSD NVMe 256GB) rodando 3 projetos reais: n8ngo (automação de fluxos), qrrapido (gerador de QR codes com analytics), bcards (cartões digitais). Todos containerizados, Traefik como reverse proxy, certificados Let's Encrypt automáticos. O custo de infra: ~R$30/mês de internet + energia elétrica. O que aprendi: Orange Pi com Ubuntu Server + Docker é surpreendentemente estável para workloads leves. O maior problema não foi técnico: foi criar o hábito de monitorar. Uso Uptime Kuma para alertas. Motivação para manter: serve como ambiente de teste real para tecnologias que depois aplico no trabalho.
|
||||||
|
**Imagem 1:** Diagrama da infraestrutura: Orange Pi 5 → Docker → containers (n8ngo, qrrapido, bcards, Traefik, Qdrant, Ollama) → internet via Traefik com SSL — estilo arquitetura de cloud mas em versão home lab
|
||||||
|
**Imagem 2:** Dashboard do Uptime Kuma mostrando os serviços e seus status (verde = online) — screenshot limpo, sem dados pessoais visíveis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## erro-chunking-qdrant
|
||||||
|
|
||||||
|
**Categoria:** Codigo
|
||||||
|
**Tema:** Indexei todo meu codebase no Qdrant e o RAG ficou inútil — o que aprendi sobre chunking
|
||||||
|
**Formato:** erro
|
||||||
|
**Funil:** meio
|
||||||
|
**Resumo:** Primeiro RAG que construí: peguei todos os arquivos .cs do projeto, dividi por arquivo (um arquivo = um chunk), vetorizei e indexei no Qdrant. Parecia razoável. Na prática: queries sobre "como implementar X" retornavam arquivos inteiros de 800 linhas como contexto, o LLM ficava confuso com muito contexto irrelevante, e a precisão foi horrível. O erro: chunk por arquivo é grande demais. Depois de pesquisar: chunks de 256-512 tokens com overlap de 50-100 tokens funcionam para documentação/texto. Para código: chunk por função/método é muito mais eficaz do que por arquivo ou por linhas fixas. Depois dessa mudança, a precisão do RAG melhorou de ~40% para ~78% nas minhas queries de teste.
|
||||||
|
**Imagem 1:** Comparação visual de estratégias de chunking: arquivo inteiro (vermelho, ineficiente) vs chunks de 512 tokens (amarelo, razoável) vs chunk por função (verde, melhor) — código representado como blocos coloridos
|
||||||
|
**Imagem 2:** Gráfico de barras mostrando precisão do RAG com diferentes estratégias de chunking: por arquivo, por linhas fixas, por tokens, por função — valores percentuais claros
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## legado-aspnet-dotnet8
|
||||||
|
|
||||||
|
**Categoria:** Entregavel
|
||||||
|
**Tema:** De ASP.NET WebForms para .NET 8 sem reescrever tudo — o playbook que funciona
|
||||||
|
**Formato:** como
|
||||||
|
**Funil:** meio
|
||||||
|
**Resumo:** Herdei um sistema ASP.NET WebForms de 2008 em produção. Reescrever do zero: 2 anos de trabalho, risco enorme. A abordagem que adotei: migração incremental por módulos. (1) Identificar os módulos mais usados e menos acoplados, (2) criar um novo projeto .NET 8 rodando em paralelo, (3) usar proxy reverso para direcionar rotas específicas para o novo app, (4) migrar módulo por módulo. Em 14 meses, 70% das funcionalidades estavam no .NET 8. O segredo: nunca deixar o sistema "quebrado" — sempre manter o legado funcionando enquanto migra. O anti-padrão que vi em outros projetos: tentar migrar tudo de uma vez e travar o time por meses.
|
||||||
|
**Imagem 1:** Diagrama da estratégia de migração incremental: sistema legado + novo sistema rodando em paralelo + proxy reverso roteando por módulo — setas mostrando tráfego dividido, cores diferenciando legado/novo
|
||||||
|
**Imagem 2:** Timeline de 14 meses com % migrado por mês — gráfico de progresso mostrando avanço gradual sem grandes saltos ou regressões — design limpo
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## entrevistas-tecnicas-framework
|
||||||
|
|
||||||
|
**Categoria:** Entregavel
|
||||||
|
**Tema:** Como conduzo entrevistas técnicas: o framework que me fez parar de perder horas com candidatos errados
|
||||||
|
**Formato:** como
|
||||||
|
**Funil:** topo
|
||||||
|
**Resumo:** Já conduzi mais de 80 entrevistas técnicas. Os erros que cometi por anos: perguntas de trivia (sabe decoreba de sintaxe?), live coding com problema de LeetCode difícil sob pressão (avalia ansiedade, não competência), entrevista sem estrutura (cada vez diferente). O framework que desenvolvi: 15 min de contexto do projeto real, 20 min de caso prático (resolva um problema parecido com o que você vai resolver no time), 15 min de discussão de decisões técnicas passadas ("me conte uma decisão técnica que tomou e que mudaria hoje"). Sinal verde: candidato explica trade-offs. Sinal vermelho: candidato apresenta solução como "a única certa".
|
||||||
|
**Imagem 1:** Template da entrevista em três fases: Contexto (15 min) → Caso Prático (20 min) → Decisões Passadas (15 min) — cada fase com bullets do que avaliar, design clean de documento
|
||||||
|
**Imagem 2:** Tabela de sinais: verde (menciona trade-offs, admite incerteza, faz perguntas) vs vermelho (decoreba, solução única, sem curiosidade) — duas colunas contrastantes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## prompts-testaveis-producao
|
||||||
|
|
||||||
|
**Categoria:** Codigo
|
||||||
|
**Tema:** Meu sistema de IA em produção parou de funcionar — e nenhum teste capturou porque eu não tratava prompts como código
|
||||||
|
**Formato:** erro
|
||||||
|
**Funil:** meio
|
||||||
|
**Resumo:** Tinha um sistema em produção usando LLM para classificar tickets de suporte. Funcionava. Um dia parou de funcionar direito — classificações erradas, comportamento inconsistente. O problema: o modelo foi atualizado pelo provedor, e o prompt que funcionava perfeitamente com a versão anterior passou a produzir resultados diferentes. Não havia nenhum teste. Não havia versionamento do prompt. Não havia histórico de quando e como o prompt tinha mudado. Descobri o problema 3 dias depois, por relato de usuário. A correção foi simples — ajustar o prompt. Mas o tempo para diagnóstico foi alto demais. O que mudei: prompts em arquivos .md versionados no Git, testes com fixtures de input/output esperado (igual a unit tests), script que roda todos os testes antes de merge. Implementado em Go. Não é 100% determinístico, mas captura regressões óbvias — e me avisa antes do usuário.
|
||||||
|
**Imagem 1:** Estrutura de diretórios: prompts/ com arquivos .md e .test.json correspondentes — estilo tree view, fundo escuro
|
||||||
|
**Imagem 2:** Pipeline de CI/CD com etapa "Prompt Tests" entre build e deploy — destaque na etapa que não existia antes do incidente
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## por-que-ddd-ainda-vale
|
||||||
|
|
||||||
|
**Categoria:** Codigo
|
||||||
|
**Tema:** Por que ainda uso DDD em 2026 — mesmo quando os outros dizem que é overengineering
|
||||||
|
**Formato:** porque
|
||||||
|
**Funil:** topo
|
||||||
|
**Resumo:** DDD foi declarado "morto" ou "complexo demais" várias vezes. Minha posição: DDD é uma ferramenta de design, não uma arquitetura. O que sempre usei do DDD: Ubiquitous Language (falar a mesma língua que o negócio), Bounded Contexts (separar domínios com fronteiras explícitas), Value Objects (encapsular validações de domínio). O que raramente uso: Event Sourcing completo, CQRS em todo lugar, Aggregates complexos para domínios simples. O erro que vejo: times aplicam DDD completo em CRUDs simples e reclamam da complexidade. A pergunta certa não é "uso DDD ou não?" — é "qual parte do DDD resolve meu problema específico?".
|
||||||
|
**Imagem 1:** Diagrama de Bounded Contexts de um sistema real: dois ou três contextos separados com suas entidades internas e pontos de integração — cores diferentes por contexto, design limpo
|
||||||
|
**Imagem 2:** Tabela "Use isso do DDD vs Não precisa disso" — duas colunas com elementos do DDD categorizados por quando valem — checkmarks e X, design minimalista
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## erro-overengineering-crud
|
||||||
|
|
||||||
|
**Categoria:** Codigo
|
||||||
|
**Tema:** Passei 3 semanas arquitetando um CRUD — e joguei tudo no lixo
|
||||||
|
**Formato:** erro
|
||||||
|
**Funil:** topo
|
||||||
|
**Resumo:** Recebi uma tarefa simples: criar um CRUD de cadastro de fornecedores. Minha resposta: Clean Architecture completo, CQRS com MediatR, Event Sourcing, repositórios genéricos, DTOs mapeados com AutoMapper, validações com FluentValidation. Três semanas depois: tinha uma arquitetura impressionante para gerenciar 50 linhas de dado que nunca mudariam a regra de negócio. O PM pediu uma mudança simples — levou 4 horas porque tinha 12 camadas para mexer. Joguei fora e refiz em 2 dias com Entity Framework direto, sem repositório, sem CQRS. Funciona igual, é mais simples de manter, e não impressiona ninguém — que é o objetivo.
|
||||||
|
**Imagem 1:** Diagrama satirizando a over-engenharia: CRUD simples no centro cercado por 8 camadas de abstração com setas para todos os lados — visual exagerado de propósito, estilo irônico mas clean
|
||||||
|
**Imagem 2:** Comparação de complexidade: versão over-engineered (muitos arquivos, muitas pastas) vs versão simples (poucos arquivos, estrutura flat) — file tree dos dois projetos lado a lado
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## checklist-code-review
|
||||||
|
|
||||||
|
**Categoria:** Entregavel
|
||||||
|
**Tema:** Meu checklist de code review — o que eu olho nos primeiros 2 minutos de qualquer PR
|
||||||
|
**Formato:** checklist
|
||||||
|
**Funil:** meio
|
||||||
|
**Resumo:** Faço code review há 15 anos. Aprendi que os primeiros 2 minutos determinam 80% da qualidade da revisão — ou você foca no que importa ou perde tempo em detalhes cosméticos que o linter deveria pegar. Minha sequência: (1) O que esse PR faz? — se não dá pra responder em 1 frase pela descrição, devolvo antes de ler uma linha, (2) O tamanho está razoável? — PR acima de 400 linhas peço para dividir, (3) Tem teste? — não tem, devolvo, (4) A mudança faz o que a descrição diz? — leio o diff com a tarefa em mente, (5) Tem efeito colateral não óbvio? — lugares onde a mudança pode quebrar algo que não tem teste. Detalhes de estilo: deixo para o linter.
|
||||||
|
**Imagem 1:** Checklist visual em 5 etapas com ícones: Descrição clara → Tamanho adequado → Testes presentes → Faz o que diz → Efeitos colaterais — design de checklist profissional
|
||||||
|
**Imagem 2:** Gráfico de pizza ou barras: distribuição de onde o tempo de code review deve ir — lógica de negócio (40%), testes (30%), arquitetura (20%), estilo/formatação (10%) — cores distintas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## checklist-api-pre-lancamento
|
||||||
|
|
||||||
|
**Categoria:** Entregavel
|
||||||
|
**Tema:** 8 coisas que verifico antes de subir qualquer API em produção — aprendi na maioria das vezes da forma difícil
|
||||||
|
**Formato:** checklist
|
||||||
|
**Funil:** fundo
|
||||||
|
**Resumo:** Aprendi cada item dessa lista quebrando alguma coisa em produção. Os 8 itens que nunca pulo: (1) Rate limiting configurado — um bot indexou minha API de teste e gerou 50k chamadas em 10 min, (2) Autenticação testada com token expirado — JWT silenciosamente inválido ficou 6 meses em prod, (3) Payload máximo definido — recebi um JSON de 48MB de um cliente "testando", (4) Timeout de dependências externas — uma API terceira travou e segurou threads por 30s, (5) Logs estruturados funcionando — já debuguei produção sem log, é pesadelo, (6) Health check endpoint, (7) Variáveis de ambiente validadas no startup, (8) Documentação mínima de erros.
|
||||||
|
**Imagem 1:** Checklist visual com 8 itens, cada um com ícone representativo (cadeado, cronômetro, arquivo, etc.) — design profissional de documento técnico, paleta azul/branco
|
||||||
|
**Imagem 2:** Exemplo de health check endpoint em C# retornando status de dependências — código limpo, ~30 linhas, fundo escuro
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## erro-jwt-silencioso
|
||||||
|
|
||||||
|
**Categoria:** Entregavel
|
||||||
|
**Tema:** O bug de JWT que ficou 6 meses em produção — e como nunca mais vou deixar isso acontecer
|
||||||
|
**Formato:** erro
|
||||||
|
**Funil:** meio
|
||||||
|
**Resumo:** Implementei autenticação JWT numa API. O bug: a validação do tempo de expiração estava com timezone errado — tokens "expirados" eram aceitos por mais 3 horas que o esperado. Ninguém reportou porque funcionava (usuários ficavam logados por mais tempo, o que eles consideravam "bom"). Descobri ao comparar o comportamento esperado com o real num code review de outro projeto. A causa: `DateTime.UtcNow` vs `DateTime.Now` — uma linha de diferença, consequência silenciosa. Solução implementada: testes de autenticação com tokens expirados como parte do suite de integração. Agora é impossível fazer deploy com essa categoria de bug.
|
||||||
|
**Imagem 1:** Diagrama de linha do tempo mostrando: token criado → expiração esperada → expiração real (3h depois) — diferença destacada em vermelho, linha horizontal clara
|
||||||
|
**Imagem 2:** Teste de integração C# que verifica comportamento com token expirado — código limpo com Assert.Returns401, ~15 linhas, fundo escuro
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## checklist-onboarding-dev
|
||||||
|
|
||||||
|
**Categoria:** Entregavel
|
||||||
|
**Tema:** Meu checklist de onboarding para novos devs — o que mudou depois que parei de improvisar
|
||||||
|
**Formato:** checklist
|
||||||
|
**Funil:** topo
|
||||||
|
**Resumo:** Antes do checklist: cada onboarding era diferente, o dev passava os primeiros dias perdido procurando como configurar o ambiente, e eu perdia tempo respondendo as mesmas perguntas. Criei um documento de onboarding com: (1) Setup do ambiente (passo a passo testado, com print de como deve ficar), (2) Mapa do codebase — o que cada projeto faz e como se conecta, (3) Primeira tarefa definida antes do primeiro dia — sempre uma task pequena de impacto real (não "refatore esses testes"), (4) Buddy designado para primeiras 2 semanas, (5) Check-in na primeira semana: "o que não estava no documento mas você precisou descobrir?". Esse último é o que mantém o documento atualizado.
|
||||||
|
**Imagem 1:** Documento de onboarding estruturado com seções: Ambiente, Codebase, Primeira Tarefa, Buddy, Check-in — estilo de documento profissional, icons por seção
|
||||||
|
**Imagem 2:** Timeline da primeira semana do novo dev: Dia 1 (setup) → Dia 2 (mapa do codebase) → Dia 3 (primeira task) → Dia 5 (check-in) — design horizontal, cada dia com cor e atividade principal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## sk-versoes-preview-trap
|
||||||
|
|
||||||
|
**Categoria:** Codigo
|
||||||
|
**Tema:** Semantic Kernel tem ótimas ideias e APIs instáveis — aprendi isso da forma difícil
|
||||||
|
**Formato:** erro
|
||||||
|
**Funil:** meio
|
||||||
|
**Resumo:** Comecei a usar Semantic Kernel 1.x com entusiasmo: orquestração de agentes, plugins, memory, planner — tudo parecia resolvido. A IA que usava para me ajudar a codar gerava exemplos que funcionavam. Até que não funcionavam mais. O padrão que identifiquei: a IA treinada nos docs antigos do SK gerava código com interfaces que já tinham sido removidas ou renomeadas entre minor versions. IKernel virou Kernel, ISemanticTextMemory mudou de namespace, o Planner teve três redesigns em 6 meses. Cada update quebrava algo. A lição que aprendi: antes de usar qualquer feature do SK, abro o código-fonte no GitHub e verifico se tem [Experimental] ou [Preview] no atributo. Se tiver, ou espero estabilizar ou implemento manualmente. A IA não faz esse filtro — ela só sabe que o código compilava quando foi treinada.
|
||||||
|
**Imagem 1:** Screenshot do código-fonte do Semantic Kernel no GitHub mostrando atributos [Experimental] em cima de uma interface — real ou representativo, fundo branco estilo GitHub
|
||||||
|
**Imagem 2:** Timeline de breaking changes do Semantic Kernel 0.x → 1.0 → 1.x: principais renomeações e remoções de API — linha horizontal com marcos, vermelho nos pontos de quebra
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## stripe-versoes-api-armadilha
|
||||||
|
|
||||||
|
**Categoria:** Codigo
|
||||||
|
**Tema:** A IA usou a versão errada da API do Stripe — e eu só descobri em produção
|
||||||
|
**Formato:** erro
|
||||||
|
**Funil:** fundo
|
||||||
|
**Resumo:** Estava integrando Stripe para cobranças recorrentes. Pedi ajuda à IA, ela gerou código correto, testei, funcionou em sandbox. Em produção, parte da integração de webhooks não processava eventos direito. O diagnóstico: a IA havia gerado código baseado em uma versão anterior da API do Stripe (que versiona por data: ex. 2023-10-16). Minha conta tinha uma versão diferente configurada no dashboard, e o shape dos eventos era diferente — campos renomeados, estrutura aninhada alterada. O Stripe não errou: ele documentou. Eu não li a documentação de migração entre versões. A IA também não leu — ela não sabe qual versão está na sua conta. Resolução: agora sempre fixo a versão da API explicitamente no código e leio o changelog antes de qualquer integração de pagamento.
|
||||||
|
**Imagem 1:** Código C# com a versão da API do Stripe fixada explicitamente no cliente (StripeClient + ApiVersion) — antes (sem versão) vs depois (com versão pinada) — diff verde/vermelho
|
||||||
|
**Imagem 2:** Diagrama mostrando o problema: IA treinada em docs de versão X → gera código → conta com versão Y → webhook event com shape diferente → bug silencioso em produção
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## vector-db-problema-errado
|
||||||
|
|
||||||
|
**Categoria:** Codigo
|
||||||
|
**Tema:** Testei Chroma, Qdrant e Weaviate por semanas — até entender que o problema não era o banco
|
||||||
|
**Formato:** erro
|
||||||
|
**Funil:** meio
|
||||||
|
**Resumo:** Comecei a usar Qdrant para um projeto com IA. A IA me ajudou a modelar tudo: cadastros de usuário, histórico, preferências — tudo foi parar no Qdrant. As buscas ficaram lentas e inconsistentes. Troquei para Chroma. Mesmo problema. Testei Weaviate. Mesmo problema. Levei semanas até entender: o problema não era o banco vetorial — era eu usando banco vetorial para resolver problema de banco relacional. Para quem não conhece: banco de dados vetorial* não é um banco de dados no sentido tradicional — ele armazena representações matemáticas de texto (embeddings) e serve para uma coisa só: "ache os textos mais parecidos com este". Para filtrar por campo fixo (userId = X, status = ativo), SQL ou MongoDB são 100x mais rápidos e corretos. A arquitetura certa é duas camadas: banco tradicional para dados estruturados + vector DB só para a busca semântica. A IA nunca me avisou sobre isso — ela implementou o que eu pedi, não o que eu precisava. *Vector DB (banco vetorial): armazena embeddings — números que representam o "significado" de um texto. Permite buscar por similaridade: "mostre textos parecidos com este", não "mostre registros onde campo = valor".
|
||||||
|
**Imagem 1:** Diagrama de arquitetura correta: dados do usuário → PostgreSQL/MongoDB (campos fixos, filtros) + embeddings → Qdrant (busca semântica) — duas camadas separadas com setas mostrando quando usar cada uma
|
||||||
|
**Imagem 2:** Tabela comparativa: "Use vector DB quando..." vs "Use banco tradicional quando..." — exemplos concretos de queries em cada coluna, design limpo com checkmarks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## chatbot-grafo-intencoes
|
||||||
|
|
||||||
|
**Categoria:** Codigo
|
||||||
|
**Tema:** RAG não era suficiente para meu chatbot — construí um grafo de intenções e o comportamento mudou completamente
|
||||||
|
**Formato:** bastidor
|
||||||
|
**Funil:** topo
|
||||||
|
**Resumo:** Estava construindo um chatbot e RAG sozinho não resolvia: o bot dava respostas corretas isoladas mas não mantinha contexto de conversa nem navegava fluxos complexos. A solução que estou testando em POC: um banco de dados de grafo* (Memgraph) modelando intenções como nós e transições como arestas. Quando o usuário digita algo, o fluxo é: (1) identificar intenção via LLM, (2) buscar nó correspondente no grafo, (3) verificar se precisa de mais informação (arestas condicionais), (4) executar ação ou seguir para próximo nó. Se a intenção não está no grafo: verifica se deve retroceder para um ponto anterior da conversa → se não, busca no RAG → último recurso: "não entendi". A diferença em relação a RAG puro: o grafo dá estrutura às conversas, o RAG dá profundidade às respostas. Juntos, o bot sabe onde está na conversa e o que sabe sobre o assunto. *Banco de dados de grafo: armazena apenas relações entre itens, não registros. Ex: [Desconto] -[:REQUER]-> [Nome e CPF]. Não existe tabela — existem nós e conexões. Ideal para modelar fluxos, dependências e caminhos de navegação.
|
||||||
|
**Imagem 1:** Diagrama do grafo de intenções: nós (intenções como "consultar saldo", "cancelar pedido", "reclamação") conectados por arestas com condições — estilo grafo com nós circulares, setas direcionadas, paleta azul/roxo
|
||||||
|
**Imagem 2:** Fluxograma de decisão quando usuário digita algo: Identificar intenção → Nó no grafo? → Sim: seguir fluxo / Não: Retroceder? → RAG? → "Não entendi" — boxes com decisões em losango, design clean
|
||||||
|
|
||||||
|
---
|
||||||
90
shared/config/config.go
Normal file
90
shared/config/config.go
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
GroqAPIKey string
|
||||||
|
GeminiAPIKey string
|
||||||
|
TelegramBotToken string
|
||||||
|
TelegramChatID string
|
||||||
|
LinkedInClientID string
|
||||||
|
LinkedInClientSecret string
|
||||||
|
LinkedInAccessToken string
|
||||||
|
Workspace string
|
||||||
|
CropBottomPx int
|
||||||
|
CropRightPx int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads .env (if present) then maps env vars to Config.
|
||||||
|
// Never panics — callers check with Validate.
|
||||||
|
func Load() *Config {
|
||||||
|
_ = godotenv.Load() // silently ignore missing .env
|
||||||
|
|
||||||
|
ws := os.Getenv("LDPOST_WORKSPACE")
|
||||||
|
if ws == "" {
|
||||||
|
ws = `C:\Textos-Linkedin`
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Config{
|
||||||
|
GroqAPIKey: os.Getenv("GROQ_API_KEY"),
|
||||||
|
GeminiAPIKey: os.Getenv("GEMINI_API_KEY"),
|
||||||
|
TelegramBotToken: os.Getenv("TELEGRAM_BOT_TOKEN"),
|
||||||
|
TelegramChatID: os.Getenv("TELEGRAM_CHAT_ID"),
|
||||||
|
LinkedInClientID: os.Getenv("LINKEDIN_CLIENT_ID"),
|
||||||
|
LinkedInClientSecret: os.Getenv("LINKEDIN_CLIENT_SECRET"),
|
||||||
|
LinkedInAccessToken: os.Getenv("LINKEDIN_ACCESS_TOKEN"),
|
||||||
|
Workspace: ws,
|
||||||
|
CropBottomPx: envInt("LDPOST_CROP_BOTTOM", 48),
|
||||||
|
CropRightPx: envInt("LDPOST_CROP_RIGHT", 48),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func envInt(key string, def int) int {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks named field groups and returns a combined error (nil = OK).
|
||||||
|
// Accepts: "groq", "gemini", "telegram", "linkedin", "workspace".
|
||||||
|
// Returns error listing ALL missing vars — does not panic, safe for dry-run.
|
||||||
|
func (c *Config) Validate(fields ...string) error {
|
||||||
|
var missing []string
|
||||||
|
|
||||||
|
check := func(name, val string) {
|
||||||
|
if val == "" {
|
||||||
|
missing = append(missing, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range fields {
|
||||||
|
switch f {
|
||||||
|
case "groq":
|
||||||
|
check("GROQ_API_KEY", c.GroqAPIKey)
|
||||||
|
case "gemini":
|
||||||
|
check("GEMINI_API_KEY", c.GeminiAPIKey)
|
||||||
|
case "telegram":
|
||||||
|
check("TELEGRAM_BOT_TOKEN", c.TelegramBotToken)
|
||||||
|
check("TELEGRAM_CHAT_ID", c.TelegramChatID)
|
||||||
|
case "linkedin":
|
||||||
|
check("LINKEDIN_ACCESS_TOKEN", c.LinkedInAccessToken)
|
||||||
|
case "workspace":
|
||||||
|
check("LDPOST_WORKSPACE", c.Workspace)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(missing) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("variáveis ausentes: %s", strings.Join(missing, ", "))
|
||||||
|
}
|
||||||
88
shared/formats/formats.go
Normal file
88
shared/formats/formats.go
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
package formats
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FormatPesos defines selection weights for each format.
|
||||||
|
var FormatPesos = map[string]int{
|
||||||
|
"como": 5,
|
||||||
|
"erro": 2,
|
||||||
|
"porque": 2,
|
||||||
|
"checklist": 1,
|
||||||
|
"comparacao": 1,
|
||||||
|
"bastidor": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
// SortearFormato picks a weighted random format, excluding bloqueados.
|
||||||
|
// Uses a fresh rand source seeded with time.Now().UnixNano() per call.
|
||||||
|
func SortearFormato(bloqueados []string) string {
|
||||||
|
r := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||||
|
|
||||||
|
blocked := make(map[string]bool, len(bloqueados))
|
||||||
|
for _, b := range bloqueados {
|
||||||
|
blocked[b] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
type item struct {
|
||||||
|
formato string
|
||||||
|
peso int
|
||||||
|
}
|
||||||
|
|
||||||
|
var pool []item
|
||||||
|
total := 0
|
||||||
|
for formato, peso := range FormatPesos {
|
||||||
|
if !blocked[formato] {
|
||||||
|
pool = append(pool, item{formato, peso})
|
||||||
|
total += peso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: unblock all when every format is blocked
|
||||||
|
if total == 0 {
|
||||||
|
for formato, peso := range FormatPesos {
|
||||||
|
pool = append(pool, item{formato, peso})
|
||||||
|
total += peso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pick := r.Intn(total)
|
||||||
|
acc := 0
|
||||||
|
for _, it := range pool {
|
||||||
|
acc += it.peso
|
||||||
|
if pick < acc {
|
||||||
|
return it.formato
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pool[0].formato
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidarFormato returns an error if f is not one of the 6 valid formats.
|
||||||
|
func ValidarFormato(f string) error {
|
||||||
|
if _, ok := FormatPesos[f]; ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("formato %q inválido — válidos: como, erro, porque, checklist, comparacao, bastidor", f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatLabel returns the human-readable label for a format.
|
||||||
|
func FormatLabel(f string) string {
|
||||||
|
switch f {
|
||||||
|
case "como":
|
||||||
|
return "Como fazer"
|
||||||
|
case "erro":
|
||||||
|
return "Erro clássico"
|
||||||
|
case "porque":
|
||||||
|
return "Por que"
|
||||||
|
case "checklist":
|
||||||
|
return "Checklist"
|
||||||
|
case "comparacao":
|
||||||
|
return "Comparação"
|
||||||
|
case "bastidor":
|
||||||
|
return "Bastidor"
|
||||||
|
default:
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
}
|
||||||
84
shared/formats/formats_test.go
Normal file
84
shared/formats/formats_test.go
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
package formats_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"ldpost/shared/formats"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSortearFormato_NeverReturnsBlocked(t *testing.T) {
|
||||||
|
blocked := []string{"como", "erro", "porque"}
|
||||||
|
allowed := map[string]bool{"checklist": true, "comparacao": true, "bastidor": true}
|
||||||
|
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
got := formats.SortearFormato(blocked)
|
||||||
|
if !allowed[got] {
|
||||||
|
t.Errorf("iteração %d: retornou formato bloqueado %q", i, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSortearFormato_AllBlocked_Fallback(t *testing.T) {
|
||||||
|
// All 6 blocked → should still return something valid
|
||||||
|
allFormatos := []string{"como", "erro", "porque", "checklist", "comparacao", "bastidor"}
|
||||||
|
got := formats.SortearFormato(allFormatos)
|
||||||
|
if err := formats.ValidarFormato(got); err != nil {
|
||||||
|
t.Errorf("fallback retornou formato inválido %q: %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSortearFormato_WeightDistribution(t *testing.T) {
|
||||||
|
// Run 2000 samples with no blocked formats.
|
||||||
|
// "como" has weight 5 out of total 12 ≈ 41.7%
|
||||||
|
// Expected range after 2000 trials: ~35-50%
|
||||||
|
counts := make(map[string]int)
|
||||||
|
const n = 2000
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
f := formats.SortearFormato(nil)
|
||||||
|
counts[f]++
|
||||||
|
}
|
||||||
|
|
||||||
|
comoRatio := float64(counts["como"]) / float64(n)
|
||||||
|
if comoRatio < 0.35 || comoRatio > 0.55 {
|
||||||
|
t.Errorf("\"como\" apareceu %.1f%% das vezes (esperado 35-55%%)", comoRatio*100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all formats appear at least once
|
||||||
|
for _, f := range []string{"como", "erro", "porque", "checklist", "comparacao", "bastidor"} {
|
||||||
|
if counts[f] == 0 {
|
||||||
|
t.Errorf("formato %q nunca foi sorteado em %d iterações", f, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidarFormato(t *testing.T) {
|
||||||
|
valid := []string{"como", "erro", "porque", "checklist", "comparacao", "bastidor"}
|
||||||
|
for _, f := range valid {
|
||||||
|
if err := formats.ValidarFormato(f); err != nil {
|
||||||
|
t.Errorf("ValidarFormato(%q) retornou erro inesperado: %v", f, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
invalid := []string{"", "tutorial", "video", "COMO", "Como"}
|
||||||
|
for _, f := range invalid {
|
||||||
|
if err := formats.ValidarFormato(f); err == nil {
|
||||||
|
t.Errorf("ValidarFormato(%q) deveria retornar erro", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatLabel(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"como": "Como fazer",
|
||||||
|
"erro": "Erro clássico",
|
||||||
|
"porque": "Por que",
|
||||||
|
"checklist": "Checklist",
|
||||||
|
"comparacao": "Comparação",
|
||||||
|
"bastidor": "Bastidor",
|
||||||
|
}
|
||||||
|
for input, want := range cases {
|
||||||
|
if got := formats.FormatLabel(input); got != want {
|
||||||
|
t.Errorf("FormatLabel(%q) = %q, want %q", input, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
255
shared/gemini/client.go
Normal file
255
shared/gemini/client.go
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
package gemini
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
BaseURL = "https://generativelanguage.googleapis.com/v1beta/models"
|
||||||
|
ImageModel = "gemini-2.0-flash-exp"
|
||||||
|
ImageModelV2 = "gemini-2.5-flash-preview-image-generation"
|
||||||
|
TextModel = "gemini-2.5-flash"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
APIKey string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type part struct {
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
InlineData *inlineData `json:"inlineData,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type inlineData struct {
|
||||||
|
MIMEType string `json:"mimeType"`
|
||||||
|
Data string `json:"data"` // base64
|
||||||
|
}
|
||||||
|
|
||||||
|
type content struct {
|
||||||
|
Parts []part `json:"parts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type generateRequest struct {
|
||||||
|
Contents []content `json:"contents"`
|
||||||
|
GenerationConfig generationConfig `json:"generationConfig"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type imagenConfig struct {
|
||||||
|
AspectRatio string `json:"aspectRatio,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type generationConfig struct {
|
||||||
|
ResponseModalities []string `json:"responseModalities"`
|
||||||
|
ImagenConfig *imagenConfig `json:"imagenConfig,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type generateResponse struct {
|
||||||
|
Candidates []struct {
|
||||||
|
Content struct {
|
||||||
|
Parts []struct {
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
InlineData *inlineData `json:"inlineData,omitempty"`
|
||||||
|
} `json:"parts"`
|
||||||
|
} `json:"content"`
|
||||||
|
} `json:"candidates"`
|
||||||
|
Error *struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(apiKey string) *Client {
|
||||||
|
return &Client{
|
||||||
|
APIKey: apiKey,
|
||||||
|
http: &http.Client{Timeout: 180 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat sends a system prompt + user message and returns the text response.
|
||||||
|
// model: e.g. gemini.TextModel ("gemini-2.5-flash")
|
||||||
|
func (c *Client) Chat(ctx context.Context, model, systemPrompt, userMsg string) (string, error) {
|
||||||
|
type chatContent struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Parts []part `json:"parts"`
|
||||||
|
}
|
||||||
|
type chatRequest struct {
|
||||||
|
SystemInstruction *chatContent `json:"system_instruction,omitempty"`
|
||||||
|
Contents []chatContent `json:"contents"`
|
||||||
|
GenerationConfig generationConfig `json:"generationConfig"`
|
||||||
|
}
|
||||||
|
|
||||||
|
req := chatRequest{
|
||||||
|
SystemInstruction: &chatContent{
|
||||||
|
Parts: []part{{Text: systemPrompt}},
|
||||||
|
},
|
||||||
|
Contents: []chatContent{
|
||||||
|
{Role: "user", Parts: []part{{Text: userMsg}}},
|
||||||
|
},
|
||||||
|
GenerationConfig: generationConfig{
|
||||||
|
ResponseModalities: []string{"TEXT"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("serializar request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
delays := []time.Duration{2 * time.Second, 4 * time.Second, 8 * time.Second}
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
for attempt := 0; attempt <= len(delays); attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", ctx.Err()
|
||||||
|
case <-time.After(delays[attempt-1]):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/%s:generateContent?key=%s", BaseURL, model, c.APIKey)
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("criar request: %w", err)
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.http.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = fmt.Errorf("HTTP: %w", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
lastErr = fmt.Errorf("ler resposta: %w", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == 429 || resp.StatusCode >= 500 {
|
||||||
|
lastErr = fmt.Errorf("status %d: %s", resp.StatusCode, string(body))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var gr generateResponse
|
||||||
|
if err := json.Unmarshal(body, &gr); err != nil {
|
||||||
|
return "", fmt.Errorf("parsear resposta: %w (body: %s)", err, string(body))
|
||||||
|
}
|
||||||
|
if gr.Error != nil {
|
||||||
|
return "", fmt.Errorf("gemini: %s", gr.Error.Message)
|
||||||
|
}
|
||||||
|
if len(gr.Candidates) == 0 || len(gr.Candidates[0].Content.Parts) == 0 {
|
||||||
|
return "", fmt.Errorf("gemini: resposta vazia (body: %s)", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range gr.Candidates[0].Content.Parts {
|
||||||
|
if p.Text != "" {
|
||||||
|
return p.Text, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("gemini: sem texto na resposta")
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("gemini: falha após %d tentativas: %w", len(delays)+1, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateImage sends a text prompt and returns raw PNG bytes.
|
||||||
|
func (c *Client) GenerateImage(ctx context.Context, model, prompt string) ([]byte, error) {
|
||||||
|
return c.generate(ctx, model, prompt, generationConfig{
|
||||||
|
ResponseModalities: []string{"IMAGE"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateImageSquare generates a 1:1 aspect-ratio image using imagenConfig.
|
||||||
|
// Use with gemini-2.5-flash-preview-image-generation.
|
||||||
|
func (c *Client) GenerateImageSquare(ctx context.Context, model, prompt string) ([]byte, error) {
|
||||||
|
return c.generate(ctx, model, prompt, generationConfig{
|
||||||
|
ResponseModalities: []string{"IMAGE"},
|
||||||
|
ImagenConfig: &imagenConfig{AspectRatio: "1:1"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) generate(ctx context.Context, model, prompt string, cfg generationConfig) ([]byte, error) {
|
||||||
|
reqBody := generateRequest{
|
||||||
|
Contents: []content{
|
||||||
|
{Parts: []part{{Text: prompt}}},
|
||||||
|
},
|
||||||
|
GenerationConfig: cfg,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("serializar request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
delays := []time.Duration{1 * time.Second, 2 * time.Second, 4 * time.Second}
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
for attempt := 0; attempt <= len(delays); attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-time.After(delays[attempt-1]):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/%s:generateContent?key=%s", BaseURL, model, c.APIKey)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("criar request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = fmt.Errorf("HTTP: %w", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
lastErr = fmt.Errorf("ler resposta: %w", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == 429 || resp.StatusCode >= 500 {
|
||||||
|
lastErr = fmt.Errorf("status %d: %s", resp.StatusCode, string(body))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var gr generateResponse
|
||||||
|
if err := json.Unmarshal(body, &gr); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsear resposta: %w (body: %s)", err, string(body))
|
||||||
|
}
|
||||||
|
if gr.Error != nil {
|
||||||
|
return nil, fmt.Errorf("gemini: %s", gr.Error.Message)
|
||||||
|
}
|
||||||
|
if len(gr.Candidates) == 0 {
|
||||||
|
return nil, fmt.Errorf("gemini: sem candidatos na resposta")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range gr.Candidates[0].Content.Parts {
|
||||||
|
if p.InlineData != nil {
|
||||||
|
imgBytes, err := base64.StdEncoding.DecodeString(p.InlineData.Data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decodificar imagem base64: %w", err)
|
||||||
|
}
|
||||||
|
return imgBytes, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("gemini: nenhuma imagem na resposta")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("gemini: falha após %d tentativas: %w", len(delays)+1, lastErr)
|
||||||
|
}
|
||||||
5
shared/go.mod
Normal file
5
shared/go.mod
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
module ldpost/shared
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require github.com/joho/godotenv v1.5.1
|
||||||
2
shared/go.sum
Normal file
2
shared/go.sum
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
207
shared/groq/client.go
Normal file
207
shared/groq/client.go
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
package groq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
BaseURL = "https://api.groq.com/openai/v1/chat/completions"
|
||||||
|
TextModel = "llama-3.3-70b-versatile"
|
||||||
|
VisionModel = "meta-llama/llama-4-scout-17b-16e-instruct"
|
||||||
|
|
||||||
|
DefaultTimeout = 120 * time.Second
|
||||||
|
DefaultMaxTokens = 4096
|
||||||
|
)
|
||||||
|
|
||||||
|
type GroqClient struct {
|
||||||
|
APIKey string
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type Message struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content any `json:"content"` // string or []ContentPart
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContentPart struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
ImageURL *ImageURL `json:"image_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageURL struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Constructor ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func NewGroqClient(apiKey string) *GroqClient {
|
||||||
|
return &GroqClient{
|
||||||
|
APIKey: apiKey,
|
||||||
|
HTTPClient: &http.Client{Timeout: DefaultTimeout},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Chat sends a chat completion and returns the text response.
|
||||||
|
// temp=0 uses Groq default; maxTokens=0 uses DefaultMaxTokens.
|
||||||
|
func (c *GroqClient) Chat(model string, messages []Message, temp float64, maxTokens int) (string, error) {
|
||||||
|
if maxTokens == 0 {
|
||||||
|
maxTokens = DefaultMaxTokens
|
||||||
|
}
|
||||||
|
body := chatRequest{
|
||||||
|
Model: model,
|
||||||
|
Messages: messages,
|
||||||
|
Temperature: temp,
|
||||||
|
MaxTokens: maxTokens,
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("serializar request: %w", err)
|
||||||
|
}
|
||||||
|
return c.doRequest(context.Background(), data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatJSON sends a chat completion with response_format=json_object and
|
||||||
|
// unmarshals the response text into dest.
|
||||||
|
func (c *GroqClient) ChatJSON(model string, messages []Message, temp float64, dest any) error {
|
||||||
|
if temp == 0 {
|
||||||
|
temp = 0.1 // low temp for structured JSON
|
||||||
|
}
|
||||||
|
body := chatRequest{
|
||||||
|
Model: model,
|
||||||
|
Messages: messages,
|
||||||
|
Temperature: temp,
|
||||||
|
MaxTokens: DefaultMaxTokens,
|
||||||
|
ResponseFormat: &respFormat{Type: "json_object"},
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("serializar request: %w", err)
|
||||||
|
}
|
||||||
|
text, err := c.doRequest(context.Background(), data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(text), dest); err != nil {
|
||||||
|
return fmt.Errorf("unmarshal JSON response: %w (text: %s)", err, text)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageMessage builds a Message with text + base64-encoded image file.
|
||||||
|
// Useful for vision requests.
|
||||||
|
func ImageMessage(role, text, imagePath string) (Message, error) {
|
||||||
|
imgData, err := os.ReadFile(imagePath)
|
||||||
|
if err != nil {
|
||||||
|
return Message{}, fmt.Errorf("ler imagem %s: %w", imagePath, err)
|
||||||
|
}
|
||||||
|
b64 := base64.StdEncoding.EncodeToString(imgData)
|
||||||
|
dataURL := fmt.Sprintf("data:image/png;base64,%s", b64)
|
||||||
|
return Message{
|
||||||
|
Role: role,
|
||||||
|
Content: []ContentPart{
|
||||||
|
{Type: "text", Text: text},
|
||||||
|
{Type: "image_url", ImageURL: &ImageURL{URL: dataURL}},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextMessages is a convenience helper that builds the typical system+user pair.
|
||||||
|
func TextMessages(system, user string) []Message {
|
||||||
|
return []Message{
|
||||||
|
{Role: "system", Content: system},
|
||||||
|
{Role: "user", Content: user},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Internal ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type chatRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []Message `json:"messages"`
|
||||||
|
Temperature float64 `json:"temperature,omitempty"`
|
||||||
|
MaxTokens int `json:"max_tokens,omitempty"`
|
||||||
|
ResponseFormat *respFormat `json:"response_format,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type respFormat struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatResponse struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
Error *struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GroqClient) doRequest(ctx context.Context, body []byte) (string, error) {
|
||||||
|
delays := []time.Duration{1 * time.Second, 2 * time.Second, 4 * time.Second}
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
for attempt := 0; attempt <= len(delays); attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
log.Printf("[WARN] Groq retry %d/%d: %v", attempt, len(delays), lastErr)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", ctx.Err()
|
||||||
|
case <-time.After(delays[attempt-1]):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, BaseURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("criar request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||||
|
|
||||||
|
resp, err := c.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = fmt.Errorf("HTTP: %w", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
lastErr = fmt.Errorf("ler resposta: %w", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry on rate limit and server errors
|
||||||
|
if resp.StatusCode == 429 || resp.StatusCode == 500 || resp.StatusCode == 503 {
|
||||||
|
lastErr = fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var gr chatResponse
|
||||||
|
if err := json.Unmarshal(respBody, &gr); err != nil {
|
||||||
|
return "", fmt.Errorf("parsear resposta groq: %w (body: %s)", err, string(respBody))
|
||||||
|
}
|
||||||
|
if gr.Error != nil {
|
||||||
|
return "", fmt.Errorf("groq api: %s", gr.Error.Message)
|
||||||
|
}
|
||||||
|
if len(gr.Choices) == 0 {
|
||||||
|
return "", fmt.Errorf("groq: resposta sem choices")
|
||||||
|
}
|
||||||
|
return gr.Choices[0].Message.Content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("groq: falha após %d tentativas: %w", len(delays)+1, lastErr)
|
||||||
|
}
|
||||||
92
shared/history/history.go
Normal file
92
shared/history/history.go
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
package history
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
histRelPath = "_history/format-history.json"
|
||||||
|
maxEntries = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
type HistoryEntry struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Formato string `json:"formato"`
|
||||||
|
Data string `json:"data"` // YYYY-MM-DD
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormatHistory struct {
|
||||||
|
History []HistoryEntry `json:"history"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadHistory reads _history/format-history.json from workspacePath.
|
||||||
|
// Returns empty history if file doesn't exist.
|
||||||
|
func LoadHistory(workspacePath string) (*FormatHistory, error) {
|
||||||
|
path := filepath.Join(workspacePath, histRelPath)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return &FormatHistory{}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ler format-history: %w", err)
|
||||||
|
}
|
||||||
|
var h FormatHistory
|
||||||
|
if err := json.Unmarshal(data, &h); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsear format-history: %w", err)
|
||||||
|
}
|
||||||
|
return &h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveHistory writes _history/format-history.json atomically.
|
||||||
|
func SaveHistory(workspacePath string, h *FormatHistory) error {
|
||||||
|
dir := filepath.Join(workspacePath, "_history")
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("criar _history/: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(h, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("serializar history: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(workspacePath, histRelPath)
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
|
||||||
|
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||||||
|
return fmt.Errorf("escrever tmp: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, path); err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return fmt.Errorf("rename tmp: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastN returns the last n formats used (most recent first).
|
||||||
|
func (h *FormatHistory) LastN(n int) []string {
|
||||||
|
var out []string
|
||||||
|
for i, e := range h.History {
|
||||||
|
if i >= n {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out = append(out, e.Formato)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddEntry prepends a new entry and trims to maxEntries.
|
||||||
|
func (h *FormatHistory) AddEntry(slug, formato string) {
|
||||||
|
entry := HistoryEntry{
|
||||||
|
Slug: slug,
|
||||||
|
Formato: formato,
|
||||||
|
Data: time.Now().Format("2006-01-02"),
|
||||||
|
}
|
||||||
|
h.History = append([]HistoryEntry{entry}, h.History...)
|
||||||
|
if len(h.History) > maxEntries {
|
||||||
|
h.History = h.History[:maxEntries]
|
||||||
|
}
|
||||||
|
}
|
||||||
169
shared/state/state.go
Normal file
169
shared/state/state.go
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
package state
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Status constants ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusWaitingEvaluator = "waiting_evaluator"
|
||||||
|
StatusWaitingRedator = "waiting_redator"
|
||||||
|
StatusWaitingEditor = "waiting_editor"
|
||||||
|
StatusWaitingArt = "waiting_art"
|
||||||
|
StatusWaitingDirector = "waiting_director"
|
||||||
|
StatusWaitingPublisher = "waiting_publisher"
|
||||||
|
StatusPublished = "published"
|
||||||
|
StatusRejected = "rejected"
|
||||||
|
|
||||||
|
EtapaDone = "done"
|
||||||
|
EtapaPending = "pending"
|
||||||
|
EtapaWaiting = "waiting"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Schema ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Etapas struct {
|
||||||
|
Evaluator string `json:"evaluator"`
|
||||||
|
Redator string `json:"redator"`
|
||||||
|
Editor string `json:"editor"`
|
||||||
|
Art string `json:"art"`
|
||||||
|
Director string `json:"director"`
|
||||||
|
Publisher string `json:"publisher"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Aprovacao struct {
|
||||||
|
Aprovado bool `json:"aprovado"`
|
||||||
|
Timestamp time.Time `json:"timestamp,omitempty"`
|
||||||
|
Ciclos int `json:"ciclos,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Aprovacoes struct {
|
||||||
|
Tema Aprovacao `json:"tema"`
|
||||||
|
Texto Aprovacao `json:"texto"`
|
||||||
|
Imagens Aprovacao `json:"imagens"`
|
||||||
|
Final Aprovacao `json:"final,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PostState struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Categoria string `json:"categoria"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Formato string `json:"formato"`
|
||||||
|
TemaEscolhido string `json:"tema_escolhido"`
|
||||||
|
TrendReferencia string `json:"trend_referencia"`
|
||||||
|
FunilTag string `json:"funil_tag,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
Etapas Etapas `json:"etapas"`
|
||||||
|
Aprovacoes Aprovacoes `json:"aprovacoes"`
|
||||||
|
PollingActive bool `json:"polling_active,omitempty"`
|
||||||
|
DirectorMessageID int `json:"director_message_id,omitempty"`
|
||||||
|
LinkedInPostID string `json:"linkedin_post_id,omitempty"`
|
||||||
|
PublishedAt *time.Time `json:"published_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── I/O ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// LoadState reads work/state.json relative to postPath.
|
||||||
|
// postPath is the post root: <workspace>/<categoria>/<slug>
|
||||||
|
func LoadState(postPath string) (*PostState, error) {
|
||||||
|
path := stateFilePath(postPath)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ler state.json (%s): %w", path, err)
|
||||||
|
}
|
||||||
|
var s PostState
|
||||||
|
if err := json.Unmarshal(data, &s); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsear state.json: %w", err)
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveState writes work/state.json atomically (tmp + rename).
|
||||||
|
// Updates UpdatedAt automatically.
|
||||||
|
func SaveState(postPath string, s *PostState) error {
|
||||||
|
s.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(s, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("serializar state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
workDir := filepath.Join(postPath, "work")
|
||||||
|
if err := os.MkdirAll(workDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("criar work/: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := stateFilePath(postPath)
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
|
||||||
|
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||||||
|
return fmt.Errorf("escrever tmp: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, path); err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return fmt.Errorf("rename tmp→state.json: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Methods ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// SetEtapa updates a specific agent's step status and bumps UpdatedAt.
|
||||||
|
func (s *PostState) SetEtapa(agente, status string) {
|
||||||
|
s.UpdatedAt = time.Now()
|
||||||
|
switch agente {
|
||||||
|
case "evaluator":
|
||||||
|
s.Etapas.Evaluator = status
|
||||||
|
case "redator":
|
||||||
|
s.Etapas.Redator = status
|
||||||
|
case "editor":
|
||||||
|
s.Etapas.Editor = status
|
||||||
|
case "art":
|
||||||
|
s.Etapas.Art = status
|
||||||
|
case "director":
|
||||||
|
s.Etapas.Director = status
|
||||||
|
case "publisher":
|
||||||
|
s.Etapas.Publisher = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsStatus reports whether the current Status matches expected.
|
||||||
|
func (s *PostState) IsStatus(expected string) bool {
|
||||||
|
return s.Status == expected
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Constructor ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func NewPostState(slug, categoria, formato, tema, trend string) *PostState {
|
||||||
|
now := time.Now()
|
||||||
|
return &PostState{
|
||||||
|
Slug: slug,
|
||||||
|
Categoria: categoria,
|
||||||
|
Status: StatusWaitingEvaluator,
|
||||||
|
Formato: formato,
|
||||||
|
TemaEscolhido: tema,
|
||||||
|
TrendReferencia: trend,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
Etapas: Etapas{
|
||||||
|
Evaluator: EtapaPending,
|
||||||
|
Redator: EtapaPending,
|
||||||
|
Editor: EtapaPending,
|
||||||
|
Art: EtapaPending,
|
||||||
|
Director: EtapaPending,
|
||||||
|
Publisher: EtapaPending,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Internal ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func stateFilePath(postPath string) string {
|
||||||
|
return filepath.Join(postPath, "work", "state.json")
|
||||||
|
}
|
||||||
111
shared/state/state_test.go
Normal file
111
shared/state/state_test.go
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
package state_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ldpost/shared/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSaveAndLoadState(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
postPath := filepath.Join(dir, "Codigo", "test-slug")
|
||||||
|
|
||||||
|
s := state.NewPostState("test-slug", "Codigo", "como", "Test tema", "Test trend")
|
||||||
|
s.Status = state.StatusWaitingRedator
|
||||||
|
s.SetEtapa("evaluator", state.EtapaDone)
|
||||||
|
|
||||||
|
if err := state.SaveState(postPath, s); err != nil {
|
||||||
|
t.Fatalf("SaveState: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := state.LoadState(postPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadState: %v", err)
|
||||||
|
}
|
||||||
|
if loaded.Slug != "test-slug" {
|
||||||
|
t.Errorf("slug: got %q, want %q", loaded.Slug, "test-slug")
|
||||||
|
}
|
||||||
|
if loaded.Status != state.StatusWaitingRedator {
|
||||||
|
t.Errorf("status: got %q, want %q", loaded.Status, state.StatusWaitingRedator)
|
||||||
|
}
|
||||||
|
if loaded.Etapas.Evaluator != state.EtapaDone {
|
||||||
|
t.Errorf("evaluator etapa: got %q, want %q", loaded.Etapas.Evaluator, state.EtapaDone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAtomicWrite(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
postPath := filepath.Join(dir, "Cat", "slug-atomic")
|
||||||
|
|
||||||
|
// Write initial state
|
||||||
|
s1 := state.NewPostState("slug-atomic", "Cat", "como", "Tema 1", "Trend 1")
|
||||||
|
if err := state.SaveState(postPath, s1); err != nil {
|
||||||
|
t.Fatalf("first SaveState: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify original file exists
|
||||||
|
stateFile := filepath.Join(postPath, "work", "state.json")
|
||||||
|
before, err := os.ReadFile(stateFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read state file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write second state
|
||||||
|
s2 := state.NewPostState("slug-atomic", "Cat", "erro", "Tema 2", "Trend 2")
|
||||||
|
if err := state.SaveState(postPath, s2); err != nil {
|
||||||
|
t.Fatalf("second SaveState: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
after, err := os.ReadFile(stateFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read state file after: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files should differ (second write changed the content)
|
||||||
|
if string(before) == string(after) {
|
||||||
|
t.Error("estado não mudou após segundo SaveState")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify no .tmp files left behind
|
||||||
|
tmpFile := stateFile + ".tmp"
|
||||||
|
if _, err := os.Stat(tmpFile); !os.IsNotExist(err) {
|
||||||
|
t.Error("arquivo .tmp deixado para trás após SaveState")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetEtapa(t *testing.T) {
|
||||||
|
s := state.NewPostState("slug", "Cat", "como", "tema", "trend")
|
||||||
|
|
||||||
|
agentes := []string{"evaluator", "redator", "editor", "art", "director", "publisher"}
|
||||||
|
for _, ag := range agentes {
|
||||||
|
before := s.UpdatedAt
|
||||||
|
time.Sleep(time.Millisecond) // ensure time advances
|
||||||
|
s.SetEtapa(ag, state.EtapaDone)
|
||||||
|
|
||||||
|
if s.UpdatedAt.Equal(before) || s.UpdatedAt.Before(before) {
|
||||||
|
t.Errorf("SetEtapa(%s): UpdatedAt não avançou", ag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.Etapas.Evaluator != state.EtapaDone {
|
||||||
|
t.Errorf("evaluator etapa: got %q", s.Etapas.Evaluator)
|
||||||
|
}
|
||||||
|
if s.Etapas.Publisher != state.EtapaDone {
|
||||||
|
t.Errorf("publisher etapa: got %q", s.Etapas.Publisher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsStatus(t *testing.T) {
|
||||||
|
s := state.NewPostState("slug", "Cat", "como", "tema", "trend")
|
||||||
|
s.Status = state.StatusWaitingEditor
|
||||||
|
|
||||||
|
if !s.IsStatus(state.StatusWaitingEditor) {
|
||||||
|
t.Error("IsStatus deveria retornar true")
|
||||||
|
}
|
||||||
|
if s.IsStatus(state.StatusPublished) {
|
||||||
|
t.Error("IsStatus deveria retornar false")
|
||||||
|
}
|
||||||
|
}
|
||||||
399
shared/telegram/bot.go
Normal file
399
shared/telegram/bot.go
Normal file
@ -0,0 +1,399 @@
|
|||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
apiBase = "https://api.telegram.org/bot"
|
||||||
|
msgLimit = 4000
|
||||||
|
pollTimeout = 30 // seconds for long polling
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Bot struct {
|
||||||
|
Token string
|
||||||
|
ChatID string
|
||||||
|
Client *http.Client
|
||||||
|
offset int // persistent across WaitFor* calls — avoids re-delivering consumed updates
|
||||||
|
}
|
||||||
|
|
||||||
|
type InlineButton struct {
|
||||||
|
Text string
|
||||||
|
CallbackData string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaFile struct {
|
||||||
|
Path string
|
||||||
|
Caption string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Constructor ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func NewBot(token, chatID string) *Bot {
|
||||||
|
return &Bot{
|
||||||
|
Token: token,
|
||||||
|
ChatID: chatID,
|
||||||
|
Client: &http.Client{Timeout: 35 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sending ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// SendMessage sends text with HTML parse mode.
|
||||||
|
// Long messages are split at newlines.
|
||||||
|
func (b *Bot) SendMessage(text string) (int, error) {
|
||||||
|
chunks := splitText(text, msgLimit)
|
||||||
|
var lastID int
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
id, err := b.sendText(chunk, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
lastID = id
|
||||||
|
}
|
||||||
|
return lastID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMessageWithKeyboard sends HTML text with an inline keyboard.
|
||||||
|
func (b *Bot) SendMessageWithKeyboard(text string, buttons [][]InlineButton) (int, error) {
|
||||||
|
var rows [][]tgButton
|
||||||
|
for _, row := range buttons {
|
||||||
|
var tgRow []tgButton
|
||||||
|
for _, btn := range row {
|
||||||
|
tgRow = append(tgRow, tgButton{Text: btn.Text, CallbackData: btn.CallbackData})
|
||||||
|
}
|
||||||
|
rows = append(rows, tgRow)
|
||||||
|
}
|
||||||
|
return b.sendText(text, &inlineKeyboard{InlineKeyboard: rows})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendPhoto sends a single image file with optional caption.
|
||||||
|
func (b *Bot) SendPhoto(filePath, caption string) error {
|
||||||
|
f, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("abrir imagem: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
w := multipart.NewWriter(&body)
|
||||||
|
_ = w.WriteField("chat_id", b.ChatID)
|
||||||
|
if caption != "" {
|
||||||
|
_ = w.WriteField("caption", caption)
|
||||||
|
}
|
||||||
|
fw, err := w.CreateFormFile("photo", filepath.Base(filePath))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(fw, f); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.Close()
|
||||||
|
|
||||||
|
return b.postMultipart("sendPhoto", w.FormDataContentType(), &body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMediaGroup sends up to 10 images as an album.
|
||||||
|
func (b *Bot) SendMediaGroup(files []MediaFile) error {
|
||||||
|
switch len(files) {
|
||||||
|
case 0:
|
||||||
|
return nil
|
||||||
|
case 1:
|
||||||
|
return b.SendPhoto(files[0].Path, files[0].Caption)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
w := multipart.NewWriter(&body)
|
||||||
|
_ = w.WriteField("chat_id", b.ChatID)
|
||||||
|
|
||||||
|
type inputMedia struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Media string `json:"media"`
|
||||||
|
Caption string `json:"caption,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var mediaJSON []inputMedia
|
||||||
|
for i, mf := range files {
|
||||||
|
fieldName := fmt.Sprintf("file%d", i)
|
||||||
|
f, err := os.Open(mf.Path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("abrir %s: %w", mf.Path, err)
|
||||||
|
}
|
||||||
|
fw, err := w.CreateFormFile(fieldName, filepath.Base(mf.Path))
|
||||||
|
if err != nil {
|
||||||
|
f.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = io.Copy(fw, f)
|
||||||
|
f.Close()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mediaJSON = append(mediaJSON, inputMedia{
|
||||||
|
Type: "photo",
|
||||||
|
Media: "attach://" + fieldName,
|
||||||
|
Caption: mf.Caption,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaBytes, _ := json.Marshal(mediaJSON)
|
||||||
|
_ = w.WriteField("media", string(mediaBytes))
|
||||||
|
w.Close()
|
||||||
|
|
||||||
|
return b.postMultipart("sendMediaGroup", w.FormDataContentType(), &body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Polling ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// WaitForCallback polls getUpdates until one of validCallbacks arrives.
|
||||||
|
// timeout=0 means wait indefinitely.
|
||||||
|
// Uses b.offset so consumed updates are not re-delivered to subsequent WaitFor* calls.
|
||||||
|
func (b *Bot) WaitForCallback(validCallbacks []string, timeout time.Duration) (string, error) {
|
||||||
|
valid := make(map[string]bool, len(validCallbacks))
|
||||||
|
for _, c := range validCallbacks {
|
||||||
|
valid[c] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var ctx context.Context
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
if timeout > 0 {
|
||||||
|
ctx, cancel = context.WithTimeout(context.Background(), timeout)
|
||||||
|
} else {
|
||||||
|
ctx, cancel = context.WithCancel(context.Background())
|
||||||
|
}
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
for {
|
||||||
|
updates, err := b.getUpdates(ctx, b.offset)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return "", fmt.Errorf("timeout aguardando callback: %w", ctx.Err())
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, u := range updates {
|
||||||
|
b.offset = u.UpdateID + 1
|
||||||
|
if u.CallbackQuery == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.answerCallback(u.CallbackQuery.ID)
|
||||||
|
if valid[u.CallbackQuery.Data] {
|
||||||
|
return u.CallbackQuery.Data, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Extended polling ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Event represents either a Telegram callback or a free-text message.
|
||||||
|
type Event struct {
|
||||||
|
IsCallback bool
|
||||||
|
Text string // CallbackData or message text
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitForAny polls until either a CallbackQuery or a text Message arrives.
|
||||||
|
// Uses b.offset so consumed updates are not re-delivered to subsequent WaitFor* calls.
|
||||||
|
func (b *Bot) WaitForAny(timeout time.Duration) (*Event, error) {
|
||||||
|
var ctx context.Context
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
if timeout > 0 {
|
||||||
|
ctx, cancel = context.WithTimeout(context.Background(), timeout)
|
||||||
|
} else {
|
||||||
|
ctx, cancel = context.WithCancel(context.Background())
|
||||||
|
}
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
for {
|
||||||
|
updates, err := b.getUpdates(ctx, b.offset)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, fmt.Errorf("timeout: %w", ctx.Err())
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, u := range updates {
|
||||||
|
b.offset = u.UpdateID + 1
|
||||||
|
if u.CallbackQuery != nil {
|
||||||
|
b.answerCallback(u.CallbackQuery.ID)
|
||||||
|
return &Event{IsCallback: true, Text: u.CallbackQuery.Data}, nil
|
||||||
|
}
|
||||||
|
if u.Message != nil && u.Message.Text != "" {
|
||||||
|
return &Event{IsCallback: false, Text: u.Message.Text}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── EscapeMarkdown ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// EscapeMarkdown escapes all MarkdownV2 special characters:
|
||||||
|
// _ * [ ] ( ) ~ ` > # + - = | { } . !
|
||||||
|
func EscapeMarkdown(s string) string {
|
||||||
|
special := `\_*[]()~` + "`" + `>#+-=|{}.!`
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(s) + 16)
|
||||||
|
for _, r := range s {
|
||||||
|
if strings.ContainsRune(special, r) {
|
||||||
|
b.WriteRune('\\')
|
||||||
|
}
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Internal types ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type tgButton struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
CallbackData string `json:"callback_data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type inlineKeyboard struct {
|
||||||
|
InlineKeyboard [][]tgButton `json:"inline_keyboard"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tgUpdate struct {
|
||||||
|
UpdateID int `json:"update_id"`
|
||||||
|
Message *tgMessage `json:"message,omitempty"`
|
||||||
|
CallbackQuery *tgCallbackQuery `json:"callback_query,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tgMessage struct {
|
||||||
|
MessageID int `json:"message_id"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tgCallbackQuery struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
Message *tgMessage `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (b *Bot) sendText(text string, markup *inlineKeyboard) (int, error) {
|
||||||
|
chatIDInt, err := strconv.ParseInt(b.ChatID, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("chat_id inválido %q: %w", b.ChatID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reqBody := map[string]any{
|
||||||
|
"chat_id": chatIDInt,
|
||||||
|
"text": text,
|
||||||
|
"parse_mode": "HTML",
|
||||||
|
}
|
||||||
|
if markup != nil {
|
||||||
|
reqBody["reply_markup"] = markup
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(reqBody)
|
||||||
|
resp, err := b.call("sendMessage", data)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Result struct {
|
||||||
|
MessageID int `json:"message_id"`
|
||||||
|
} `json:"result"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(resp, &result); err != nil {
|
||||||
|
return 0, fmt.Errorf("parsear sendMessage: %w", err)
|
||||||
|
}
|
||||||
|
if !result.OK {
|
||||||
|
return 0, fmt.Errorf("telegram sendMessage: %s", result.Description)
|
||||||
|
}
|
||||||
|
return result.Result.MessageID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) postMultipart(method, contentType string, body *bytes.Buffer) error {
|
||||||
|
url := apiBase + b.Token + "/" + method
|
||||||
|
resp, err := http.Post(url, contentType, body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", method, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
rb, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("%s status %d: %s", method, resp.StatusCode, string(rb))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) getUpdates(ctx context.Context, offset int) ([]tgUpdate, error) {
|
||||||
|
reqBody := map[string]any{"offset": offset, "timeout": pollTimeout}
|
||||||
|
data, _ := json.Marshal(reqBody)
|
||||||
|
|
||||||
|
url := apiBase + b.Token + "/getUpdates"
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
longClient := &http.Client{Timeout: time.Duration(pollTimeout+10) * time.Second}
|
||||||
|
resp, err := longClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Result []tgUpdate `json:"result"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsear getUpdates: %w", err)
|
||||||
|
}
|
||||||
|
return result.Result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) answerCallback(callbackID string) {
|
||||||
|
data, _ := json.Marshal(map[string]string{"callback_query_id": callbackID})
|
||||||
|
_, _ = b.call("answerCallbackQuery", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) call(method string, body []byte) ([]byte, error) {
|
||||||
|
url := apiBase + b.Token + "/" + method
|
||||||
|
resp, err := b.Client.Post(url, "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("telegram %s: %w", method, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
return io.ReadAll(resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitText(text string, limit int) []string {
|
||||||
|
if len(text) <= limit {
|
||||||
|
return []string{text}
|
||||||
|
}
|
||||||
|
var chunks []string
|
||||||
|
for len(text) > limit {
|
||||||
|
cut := limit
|
||||||
|
if idx := strings.LastIndex(text[:limit], "\n"); idx > 0 {
|
||||||
|
cut = idx
|
||||||
|
}
|
||||||
|
chunks = append(chunks, text[:cut])
|
||||||
|
text = text[cut:]
|
||||||
|
}
|
||||||
|
return append(chunks, text)
|
||||||
|
}
|
||||||
76
shared/telegram/bot_test.go
Normal file
76
shared/telegram/bot_test.go
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
package telegram_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"ldpost/shared/telegram"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEscapeMarkdown(t *testing.T) {
|
||||||
|
// All MarkdownV2 special chars must be escaped with backslash
|
||||||
|
special := []rune{'_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'}
|
||||||
|
|
||||||
|
for _, ch := range special {
|
||||||
|
input := string(ch)
|
||||||
|
got := telegram.EscapeMarkdown(input)
|
||||||
|
want := "\\" + input
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("EscapeMarkdown(%q) = %q, want %q", input, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEscapeMarkdown_PlainText(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"hello world", "hello world"},
|
||||||
|
{"", ""},
|
||||||
|
{"abc123", "abc123"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := telegram.EscapeMarkdown(c.input)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("EscapeMarkdown(%q) = %q, want %q", c.input, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEscapeMarkdown_MixedContent(t *testing.T) {
|
||||||
|
input := "Hello, world! How are you?"
|
||||||
|
got := telegram.EscapeMarkdown(input)
|
||||||
|
// '!' and '.' and '?' (not in special) — only '!' should be escaped
|
||||||
|
// ',' not special, '!' is special, '.' is special, '?' not special
|
||||||
|
// expected: "Hello, world\! How are you?"
|
||||||
|
want := `Hello, world\! How are you?`
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("EscapeMarkdown(%q)\n got: %q\n want: %q", input, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEscapeMarkdown_CodeBlock(t *testing.T) {
|
||||||
|
// Typical tech content
|
||||||
|
input := "Use fmt.Println(\"hello\") in Go"
|
||||||
|
got := telegram.EscapeMarkdown(input)
|
||||||
|
// '.' is special → escaped
|
||||||
|
// '(' and ')' are special → escaped
|
||||||
|
// '"' is not special
|
||||||
|
want := `Use fmt\.Println\("hello"\) in Go`
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("EscapeMarkdown(%q)\n got: %q\n want: %q", input, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewBot(t *testing.T) {
|
||||||
|
bot := telegram.NewBot("test-token", "123456")
|
||||||
|
if bot == nil {
|
||||||
|
t.Fatal("NewBot retornou nil")
|
||||||
|
}
|
||||||
|
if bot.Token != "test-token" {
|
||||||
|
t.Errorf("Token: got %q, want %q", bot.Token, "test-token")
|
||||||
|
}
|
||||||
|
if bot.ChatID != "123456" {
|
||||||
|
t.Errorf("ChatID: got %q, want %q", bot.ChatID, "123456")
|
||||||
|
}
|
||||||
|
}
|
||||||
32
test-cleanup.sh
Normal file
32
test-cleanup.sh
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# test-cleanup.sh — remove todo o workspace de teste
|
||||||
|
# Executa depois de test-pipeline.sh para desfazer completamente.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
TEST_WS="$SCRIPT_DIR/workspace-test"
|
||||||
|
|
||||||
|
if [[ ! -d "$TEST_WS" ]]; then
|
||||||
|
echo "Nada a limpar — $TEST_WS não existe."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Removendo workspace de teste: $TEST_WS"
|
||||||
|
echo "Conteúdo atual:"
|
||||||
|
find "$TEST_WS" -mindepth 2 -maxdepth 3 -name "state.json" | while read -r f; do
|
||||||
|
slug=$(basename "$(dirname "$(dirname "$f")")")
|
||||||
|
status=$(grep '"status"' "$f" | head -1 | sed 's/.*: "\(.*\)".*/\1/')
|
||||||
|
echo " • $slug — $status"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
read -rp "Confirmar remoção? [s/N]: " confirm
|
||||||
|
if [[ "${confirm,,}" != "s" ]]; then
|
||||||
|
echo "Cancelado."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf "$TEST_WS"
|
||||||
|
echo "✅ Workspace de teste removido."
|
||||||
89
test-pipeline.sh
Normal file
89
test-pipeline.sh
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# test-pipeline.sh — roda o pipeline completo sem postar no LinkedIn
|
||||||
|
# Usa workspace de teste separado para não contaminar o real.
|
||||||
|
#
|
||||||
|
# Uso: bash test-pipeline.sh [slug]
|
||||||
|
# slug: força um slug específico do _sugestoes.md (opcional)
|
||||||
|
#
|
||||||
|
# Para desfazer tudo depois: bash test-cleanup.sh
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
TEST_WS="$SCRIPT_DIR/workspace-test"
|
||||||
|
BINS="$SCRIPT_DIR/bin"
|
||||||
|
|
||||||
|
# Copiar _sugestoes.md para o workspace de teste
|
||||||
|
mkdir -p "$TEST_WS/_inbox"
|
||||||
|
cp "$SCRIPT_DIR/workspace/_inbox/_sugestoes.md" "$TEST_WS/_inbox/_sugestoes.md"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "══════════════════════════════════════════════"
|
||||||
|
echo " ldpost — PIPELINE DE TESTE"
|
||||||
|
echo " Workspace: $TEST_WS"
|
||||||
|
echo "══════════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── 1. Evaluator ──────────────────────────────────────────────────────────────
|
||||||
|
echo "▶ [1/5] Evaluator — sorteio + aprovação no Telegram..."
|
||||||
|
if [[ -n "${1:-}" ]]; then
|
||||||
|
EVALUATOR_FLAGS="--force-slug $1"
|
||||||
|
else
|
||||||
|
EVALUATOR_FLAGS=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
SLUG=$("$BINS/ldpost-evaluator.exe" \
|
||||||
|
--workspace "$TEST_WS" \
|
||||||
|
$EVALUATOR_FLAGS \
|
||||||
|
2>&1 | tee /dev/stderr | tail -1)
|
||||||
|
|
||||||
|
if [[ -z "$SLUG" ]]; then
|
||||||
|
echo "❌ Evaluator não retornou slug. Abortando."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Slug criado: $SLUG"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── 2. Redator ────────────────────────────────────────────────────────────────
|
||||||
|
echo "▶ [2/5] Redator — gerando rascunho via Groq..."
|
||||||
|
"$BINS/ldpost-redator.exe" \
|
||||||
|
--workspace "$TEST_WS" \
|
||||||
|
--post "$SLUG"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── 3. Editor ─────────────────────────────────────────────────────────────────
|
||||||
|
echo "▶ [3/5] Editor — formatando para LinkedIn..."
|
||||||
|
echo " (modo não-interativo — use --no-interactive para pular loop)"
|
||||||
|
"$BINS/ldpost-editor.exe" \
|
||||||
|
--workspace "$TEST_WS" \
|
||||||
|
--post "$SLUG" \
|
||||||
|
--no-interactive
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── 4. Director ───────────────────────────────────────────────────────────────
|
||||||
|
echo "▶ [4/5] Director — revisão final no Telegram (sem imagens)..."
|
||||||
|
"$BINS/ldpost-director.exe" \
|
||||||
|
--workspace "$TEST_WS" \
|
||||||
|
--post "$SLUG" \
|
||||||
|
--skip-images
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── 5. Publisher (manual — não posta) ─────────────────────────────────────────
|
||||||
|
echo "▶ [5/5] Publisher — modo manual (NÃO posta no LinkedIn)"
|
||||||
|
echo " Quando pedir URL do post, pressione Enter para pular."
|
||||||
|
echo ""
|
||||||
|
"$BINS/ldpost-publisher.exe" \
|
||||||
|
--workspace "$TEST_WS" \
|
||||||
|
--post "$SLUG" \
|
||||||
|
--manual
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "══════════════════════════════════════════════"
|
||||||
|
echo " Pipeline de teste concluído!"
|
||||||
|
echo " Para limpar: bash test-cleanup.sh"
|
||||||
|
echo "══════════════════════════════════════════════"
|
||||||
Loading…
Reference in New Issue
Block a user