<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Frank | AkitaOnRails.com</title><link>https://akitaonrails.github.io/tags/frank/</link><description>Blog do Fabio Akita do Canal do YouTube 'Akitando' falando sobre tecnologia, carreira e coisas geek.</description><generator>Hugo -- gohugo.io</generator><language>pt-BR</language><lastBuildDate>Mon, 19 Oct 2026 23:20:48 +0000</lastBuildDate><atom:link href="https://akitaonrails.github.io/tags/frank/index.xml" rel="self" type="application/rss+xml"/><item><title>Frank GO: brincando de GO com IA</title><link>https://akitaonrails.github.io/2026/07/06/frank-go-brincando-de-go-com-ia/</link><guid isPermaLink="true">https://akitaonrails.github.io/2026/07/06/frank-go-brincando-de-go-com-ia/</guid><pubDate>Mon, 06 Jul 2026 12:00:00 -0300</pubDate><description>&lt;p&gt;&lt;img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/07/06/frank-go/key-art-hikaru-no-go.webp" alt="Arte oficial do anime Hikaru no Go: o Hikaru no centro estendendo uma pedra de Go pra câmera, com o fantasma Sai de trajes Heian e leque atrás dele, cercados pelo elenco (Akira, Waya, Isumi, Akari e companhia) e pelo logo da Shonen Jump." loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;Eu comecei a gostar do milenar jogo de Go por causa do mangá &lt;strong&gt;Hikaru no Go&lt;/strong&gt;. Cheguei a escrever sobre isso &lt;a href="https://akitaonrails.github.io/2008/07/15/off-topic-por-que-programadores-devem-jogar-go/"&gt;em 2008, num post que ainda está no ar&lt;/a&gt;, defendendo por que programadores deveriam jogar Go. De lá pra cá pouca coisa mudou no meu caso de uso: eu tenho um goban físico em casa, mas nunca tive a menor intenção de entrar em grupo de estudo, federação ou competição. Eu só gosto de jogar sozinho, resolver uns tsumego (os quebra-cabeças de vida e morte do Go), e carregar partidas famosas pra estudar, como os lendários jogos do Lee Sedol contra o AlphaGo em 2016.&lt;/p&gt;</description><content:encoded><![CDATA[<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/07/06/frank-go/key-art-hikaru-no-go.webp" alt="Arte oficial do anime Hikaru no Go: o Hikaru no centro estendendo uma pedra de Go pra câmera, com o fantasma Sai de trajes Heian e leque atrás dele, cercados pelo elenco (Akira, Waya, Isumi, Akari e companhia) e pelo logo da Shonen Jump."  loading="lazy" /></p>
<p>Eu comecei a gostar do milenar jogo de Go por causa do mangá <strong>Hikaru no Go</strong>. Cheguei a escrever sobre isso <a href="/2008/07/15/off-topic-por-que-programadores-devem-jogar-go/">em 2008, num post que ainda está no ar</a>, defendendo por que programadores deveriam jogar Go. De lá pra cá pouca coisa mudou no meu caso de uso: eu tenho um goban físico em casa, mas nunca tive a menor intenção de entrar em grupo de estudo, federação ou competição. Eu só gosto de jogar sozinho, resolver uns tsumego (os quebra-cabeças de vida e morte do Go), e carregar partidas famosas pra estudar, como os lendários jogos do Lee Sedol contra o AlphaGo em 2016.</p>
<p>Pra quem não conhece o mangá: Hikaru no Go (roteiro de Yumi Hotta, arte de Takeshi Obata, o mesmo de Death Note) rodou na Shonen Jump entre 1998 e 2003 e causou um boom de interesse por Go no Japão inteiro. E tem um detalhe que pouca gente sabe: praticamente todas as partidas que aparecem no mangá são baseadas em jogos históricos reais, recriados quadro a quadro. Guarda essa informação, porque ela volta daqui a pouco.</p>
<h2>SGF e o problema do material espalhado<span class="hx:absolute hx:-mt-20" id="sgf-e-o-problema-do-material-espalhado"></span>
    <a href="#sgf-e-o-problema-do-material-espalhado" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Existem vários programas open source muito bons pra carregar partidas de Go ou até jogar online. Partidas de Go são gravadas num formato chamado <strong>SGF</strong> (Smart Game Format), um formato de texto puro que existe desde o fim dos anos 80: cada jogada vira uma coordenada (<code>;B[pd]</code> é uma pedra preta no ponto 4-4), e o formato suporta árvores de variação, comentários e metadados da partida. É o padrão de facto do mundo do Go: todo cliente lê SGF, todo servidor exporta SGF, e existe um acervo gigantesco de partidas históricas e problemas nesse formato acumulado ao longo de décadas.</p>
<p>E aí mora o problema: esse acervo está <strong>espalhado pela internet inteira</strong>. Um site tem as coleções clássicas de tsumego, outro tem as partidas históricas, um repositório do GitHub tem os problemas comentados, outro tem os kifu (registros de partida) profissionais. Eu não estava com o menor humor de baixar tudo manualmente, organizar pasta por pasta e configurar cliente por cliente. Eu queria um app bom o suficiente, bonito o suficiente, com tudo embutido.</p>
<h2>2008: a era GNU Go<span class="hx:absolute hx:-mt-20" id="2008-a-era-gnu-go"></span>
    <a href="#2008-a-era-gnu-go" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Aqui vale o contexto histórico, porque ele é a parte divertida. Em 2008, quando escrevi aquele post, a única &ldquo;IA&rdquo; de Go acessível era o <a href="https://www.gnu.org/software/gnugo/"target="_blank" rel="noopener">GNU Go</a>. E olha, a gente nem pode chamar aquilo de IA: era um monte de heurísticas empilhadas. E era terrível. Até um amador como eu conseguia ver que ele fazia jogadas sem fluxo nenhum, sem sentido de direção, pedras soltas que não conversavam entre si. Ganhava de iniciante por pura tática local e só.</p>
<p>O que aconteceu nos 18 anos seguintes é uma das histórias mais bonitas da computação. Primeiro vieram os métodos de Monte Carlo Tree Search, que já melhoraram bastante o nível.</p>
<p>Aí em março de 2016 o <strong>AlphaGo</strong> do DeepMind venceu Lee Sedol, um dos maiores jogadores da história, por 4 a 1. Foi a série da famosa jogada 37 da partida 2 (o &ldquo;ombro&rdquo; que nenhum humano jogaria) e da resposta à altura do Lee Sedol: a jogada 78 da partida 4, a &ldquo;mão de Deus&rdquo; que lhe deu sua única vitória. Se você nunca viu, existe um <a href="https://www.netflix.com/title/80190844"target="_blank" rel="noopener">documentário excelente sobre essa série na Netflix</a>, que vale a pena até pra quem não sabe nada de Go.</p>
<p>E depois que o DeepMind publicou os papers, a comunidade open source correu atrás: primeiro o Leela Zero reimplementando o AlphaZero, e depois o KataGo indo além. Hoje qualquer um tem um jogador sobre-humano de Go rodando de graça no próprio computador. Eu queria ver de perto o quanto essas IAs amadureceram em 20 anos.</p>
<p>Foi dessa vontade que nasceu meu novo projeto de brinquedo: o <strong><a href="https://github.com/akitaonrails/frank_go"target="_blank" rel="noopener">Frank GO</a></strong>.</p>
<h2>Sabaki + KataGo<span class="hx:absolute hx:-mt-20" id="sabaki--katago"></span>
    <a href="#sabaki--katago" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Pedi pro Claude Code pesquisar os apps e engines open source de Go mais populares (a pesquisa inteira está documentada <a href="https://github.com/akitaonrails/frank_go/tree/main/docs/research"target="_blank" rel="noopener">no próprio repositório</a>, como manda o figurino). No fim escolhi dois projetos como base:</p>
<p>O <strong><a href="https://sabaki.yichuanshen.de/"target="_blank" rel="noopener">Sabaki</a></strong>, do Yichuan Shen, é o front-end. É provavelmente o editor de SGF e tabuleiro de Go open source mais bonito e bem cuidado que existe, feito em Electron, com suporte a árvores de variação, análise e engines via protocolo GTP. Elegante e minimalista.</p>
<p>O <strong><a href="https://github.com/lightvector/KataGo"target="_blank" rel="noopener">KataGo</a></strong>, do David Wu, é a IA. É o motor open source mais forte do mundo, sobre-humano, herdeiro da linhagem do AlphaZero mas com uma diferença que importa demais pro meu caso: além da taxa de vitória, o KataGo estima <strong>pontuação e domínio de território ponto a ponto</strong>. Ele responde &ldquo;quem está ganhando, por quantos pontos, e quais áreas do tabuleiro pertencem a quem&rdquo;. É exatamente o tipo de informação que um iniciante precisa e que os engines antigos escondiam.</p>
<p>O Frank GO é um fork do Sabaki que amarra o KataGo pra sessões de jogo solo, e embute tudo aquilo que eu queria ter num lugar só:</p>
<ul>
<li><strong>Mais de 4.700 tsumego</strong> das coleções clássicas (a Enciclopédia do Cho Chikun, o Gokyo Shumyo, o Xuanxuan Qijing), transcritas pelo Vít Brunner no <a href="https://tsumego.tasuki.org/"target="_blank" rel="noopener">tsumego.tasuki.org</a> e convertidas pra SGF pelo projeto <a href="https://github.com/Seon82/tasuki2sgf"target="_blank" rel="noopener">tasuki2sgf</a>, mais os 420 problemas comentados do <a href="https://github.com/gogameguru/go-problems"target="_blank" rel="noopener">go-problems do Go Game Guru</a> (do An Younggil, profissional 8-dan), organizados em 10 níveis de dificuldade. Resolve 5 seguidos e sobe de nível. Um seletor de foco filtra por vida e morte, tesuji, ko ou semeai (corrida de capturas).</li>
<li><strong>Os puzzles revidam</strong>: com o KataGo instalado, ele responde suas jogadas dentro do problema, e quando ele desiste da área o app julga o resultado e marca o problema como resolvido automaticamente.</li>
<li><strong>Partidas históricas famosas pra estudar</strong>, do Jogo da Orelha Vermelha (1846, do lendário Honinbo Shusaku) até AlphaGo vs Lee Sedol, curadas do <a href="https://homepages.cwi.nl/~aeb/go/games/"target="_blank" rel="noopener">banco de partidas do Andries Brouwer no CWI</a>, cada uma com a história por trás, e as do AlphaGo com comentário jogada a jogada. Dá pra assistir em auto-play ou ligar o modo &ldquo;adivinhe a jogada&rdquo; e tentar achar o lance do profissional.</li>
<li><strong>As partidas do Hikaru no Go</strong>: lembra que os jogos do mangá são reais? Pois é, 15 deles estão embutidos com referência de capítulo e curiosidades, usando o <a href="https://senseis.xmp.net/?HikaruNoGoGames"target="_blank" rel="noopener">mapeamento capítulo-a-partida que a comunidade compilou na Sensei&rsquo;s Library</a>. A partida de internet do Sai contra o Toya Meijin, por exemplo, é um jogo real decidido por meio ponto.</li>
<li><strong>Dicionário de joseki</strong> (o <a href="https://waterfire.us/joseki.htm"target="_blank" rel="noopener">Kogo&rsquo;s Joseki Dictionary</a>, referência clássica de aberturas de canto), drills de escada, drill de &ldquo;quem está ganhando?&rdquo;, e um teste de rank de dez problemas que estima seu nível e te larga direto na prática certa.</li>
</ul>
<p>Lembra do problema do material espalhado? Essas coleções aí de cima são exatamente ele: cada uma vivia num canto da internet. A procedência e o licenciamento de cada conjunto estão documentados no <a href="https://github.com/akitaonrails/frank_go/blob/main/data/SOURCES.md"target="_blank" rel="noopener">data/SOURCES.md</a> do repositório, porque acervo dos outros se usa dando o devido crédito.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/07/06/frank-go/tsumego-practice.jpg" alt="Prática de tsumego no Frank GO: um problema de canto no tabuleiro com o overlay de pintura de área ligado, e o painel lateral mostrando nível 1, foco, e o aviso de que o KataGo responde as jogadas."  loading="lazy" /></p>
<h2>Overlays pra quem está começando<span class="hx:absolute hx:-mt-20" id="overlays-pra-quem-está-começando"></span>
    <a href="#overlays-pra-quem-est%c3%a1-come%c3%a7ando" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A parte em que eu mais mexi no Sabaki foi criar sobreposições visuais amigáveis pra iniciante. A principal é a <strong>pintura de área</strong>: um overlay que pinta a influência de cada jogador como um gradiente suave e o território consolidado em cor sólida, então você literalmente <strong>vê</strong> o que as pedras estão fazendo, quais áreas estão mortas e quais estão em disputa.</p>
<p>Junto vem o <strong>placar em tempo real</strong> considerando o komi (a compensação de pontos do branco por jogar em segundo), que avisa quando você está irremediavelmente atrás ou quando o jogo já está decidido. E passando o mouse no tabuleiro aparecem os nomes das jogadas (&ldquo;Hane&rdquo;, &ldquo;One-Point Jump&rdquo;), o vocabulário que os livros usam e que iniciante demora a absorver.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/07/06/frank-go/study-mode.jpg" alt="Modo de estudo do Frank GO: a partida de aposentadoria do Honinbo Shusai (1938) carregada no tabuleiro, com a pintura de área escurecendo as pedras mortas e o painel lateral contando a história do jogo imortalizado no romance “O Mestre de Go” do Kawabata."  loading="lazy" /></p>
<p>Esse era o meu problema com todo software de Go que já testei: são ferramentas feitas por jogadores fortes pra jogadores fortes. Winrate em porcentagem não diz nada pra quem está aprendendo. &ldquo;Você está 12 pontos atrás e o canto inferior esquerdo morreu&rdquo; diz tudo. O KataGo sempre soube calcular isso; faltava alguém colocar na tela de um jeito que iniciante entende.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/07/06/frank-go/practice-panel.jpg" alt="Painel de prática do Frank GO dando boas-vindas e sugerindo o teste de rank de dez problemas pra encontrar seu nível."  loading="lazy" /></p>
<p>A ideia é ser um balcão único, sem configuração nenhuma, pra iniciante e estudante aprender Go no próprio ritmo. E tem build pra todo lugar. No Arch Linux é <code>yay -S frank-go</code> via AUR. No <strong>Windows</strong> tem instalador e versão portátil nos <a href="https://github.com/akitaonrails/frank_go/releases"target="_blank" rel="noopener">Releases</a>; o instalador não tem assinatura digital, então o SmartScreen reclama na primeira execução, é só clicar em &ldquo;Mais informações → Executar assim mesmo&rdquo; (<a href="/2026/05/30/boas-praticas-projetos-codigo-aberto-llm-o-minimo/">eu explico por que não assino build de Windows no post de boas práticas</a>). No <strong>macOS</strong> é <code>brew install --cask akitaonrails/tap/frank-go</code>, ou o <code>.dmg</code> pra Apple Silicon e Intel; esse build é assinado com Developer ID e notarizado pela Apple, abre sem drama nenhum. E em qualquer plataforma dá pra rodar do fonte com <code>npm install</code>.</p>
<p>Em todos os casos o app baixa um engine de CPU pequeno com um clique, sem GPU, sem conta, sem servidor, totalmente offline. A exceção é o macOS, onde o KataGo não tem binário oficial: um <code>brew install katago</code> destrava o jogo contra a IA, e todo o resto (tsumego, estudo, drills, pintura de área) funciona sem ele. O progresso fica na sua máquina.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/07/06/frank-go/study-and-drills.jpg" alt="Menu de estudo e drills do Frank GO: estudar partida famosa, partidas do Hikaru no Go, dicionário de joseki, drill de quem está ganhando, drill de escada e teste de rank."  loading="lazy" /></p>
<h2>E quando você quiser jogar contra gente<span class="hx:absolute hx:-mt-20" id="e-quando-você-quiser-jogar-contra-gente"></span>
    <a href="#e-quando-voc%c3%aa-quiser-jogar-contra-gente" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O Frank GO é deliberadamente um app de estudo solo. Quando você aprender e quiser jogar contra humanos, o caminho natural são os servidores online, e aí os apps mais avançados servem melhor. O <a href="https://online-go.com/"target="_blank" rel="noopener">OGS (Online Go Server)</a> é o mais amigável hoje, roda direto no navegador. O <a href="https://www.gokgs.com/"target="_blank" rel="noopener">KGS</a> e o <a href="https://www.pandanet-igs.com/"target="_blank" rel="noopener">Pandanet IGS</a> (o Internet Go Server original, no ar desde o começo dos anos 90) são os veteranos, e os servidores asiáticos como Fox e Tygem concentram os jogadores mais fortes. O mundo do Go organizado gira em torno da <a href="https://www.intergofed.org/"target="_blank" rel="noopener">IGF (International Go Federation)</a> e das associações nacionais, com rankings e torneios pra quem quiser levar a sério.</p>
<p>Eu não quero. E esse é o ponto do projeto inteiro: como eu disse lá em cima, não tenho nenhuma vontade de competir. Eu queria, há literalmente anos, um jeito de jogar e me divertir com Go no meu ritmo, com material de estudo decente num lugar só, contra uma IA que joga bonito em vez de cuspir pedras sem sentido como o GNU Go de 2008. Acho que finalmente consegui.</p>
<p>O código é MIT, o repositório é <a href="https://github.com/akitaonrails/frank_go"target="_blank" rel="noopener">github.com/akitaonrails/frank_go</a>, e contribuições são bem-vindas. Tem inclusive uma <a href="https://github.com/akitaonrails/frank_go/issues/6"target="_blank" rel="noopener">issue aberta pedindo retratos dos personagens</a> pro modo Hikaru no Go, se alguém quiser desenhar. O Sai agradece.</p>
]]></content:encoded><category>frank</category><category>llms</category><category>inteligencia-artificial</category><category>games</category></item><item><title>Solução Completa de Mangá: Frank Manga+, Frank Yomik, extensão Prettify-Manga</title><link>https://akitaonrails.github.io/2026/05/30/manga-plus-shueisha-desktop-frank-manga-plus/</link><guid isPermaLink="true">https://akitaonrails.github.io/2026/05/30/manga-plus-shueisha-desktop-frank-manga-plus/</guid><pubDate>Sat, 30 May 2026 09:00:00 -0300</pubDate><description>&lt;p&gt;&lt;img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/logo.png" alt="Logo do Frank Manga&amp;#43;: o nome estilizado em vermelho e branco sobre fundo preto." loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;Eu sou assinante do app &lt;strong&gt;MANGA Plus by Shueisha&lt;/strong&gt; na Google Play Store do Android. Pra quem não conhece, é o canal oficial da Shueisha (editora japonesa por trás da Shonen Jump) pra ler os mangás dela legalmente, capítulo lançando semanalmente quase no mesmo dia da publicação no Japão. Por dois dólares por mês dá acesso ao catálogo inteiro, com tradução oficial em inglês.&lt;/p&gt;</description><content:encoded><![CDATA[<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/logo.png" alt="Logo do Frank Manga&#43;: o nome estilizado em vermelho e branco sobre fundo preto."  loading="lazy" /></p>
<p>Eu sou assinante do app <strong>MANGA Plus by Shueisha</strong> na Google Play Store do Android. Pra quem não conhece, é o canal oficial da Shueisha (editora japonesa por trás da Shonen Jump) pra ler os mangás dela legalmente, capítulo lançando semanalmente quase no mesmo dia da publicação no Japão. Por dois dólares por mês dá acesso ao catálogo inteiro, com tradução oficial em inglês.</p>
<p>Tenho um Samsung ZFold 7, que é uma das poucas exceções de celular que serve bem pra ler mangá e Kindle (já comentei sobre isso e sobre o <a href="/2026/05/14/terminando-maratona-ia-sucesso-ou-fracasso/">Frank Yomik</a> no ranking final da maratona de IA). O Fold aberto dá uma tela maior que celular normal e ajuda muito. Mas tem dia que meu olho já está cansado de telinha pequena, e o que eu queria mesmo era ler num monitor OLED de 32 polegadas que está bem na minha frente, perfeito pra leituras longas.</p>
<p>O problema é que <strong>MANGA Plus não tem site oficial pra leitura</strong>. A assinatura está presa ao Android. Existe uma versão pra iOS também, mas web não tem. Tablet ajuda, mas nada bate o conforto de uma tela grande e estática quando você quer ler um arco inteiro de One Piece numa tacada só.</p>
<p>Pra contexto, ultimamente coloquei muita coisa em dia: finalmente fechei Chainsaw Man, Dan Da Dan, Jujutsu Kaisen, Kagurabachi, Akane-banashi, Sakamoto Days e, claro, One Piece. Tem muito mais que eu quero acompanhar, e o conforto do monitor grande virou um problema real pra resolver.</p>
<p>O foco começa no <strong>Frank Manga+</strong>, que resolve o buraco da Shueisha no desktop. No fim também falo de dois complementos do meu setup: <a href="#prettify-manga">Prettify Manga Reader</a>, pra transformar sites de fansub e Kindle Web em leitores decentes, e <a href="#frank-yomik">Frank Yomik no navegador</a>, pra furigana/tradução em Kindle Japan e Naver.</p>
<h2>Decompilando o app<span class="hx:absolute hx:-mt-20" id="decompilando-o-app"></span>
    <a href="#decompilando-o-app" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Resolvi atacar. Sentei com o Claude Code e baixamos o xapk oficial do MANGA Plus. Decompilamos pra fonte Java com <a href="https://github.com/skylot/jadx"target="_blank" rel="noopener">jadx</a> e fomos navegando até entender como o app se autentica com a API da Shueisha.</p>
<p>A conclusão é direta: a &ldquo;assinatura&rdquo; do usuário, do ponto de vista do app, é um <strong>deviceSecret</strong> de 32 caracteres hexadecimais guardado num arquivo XML simples dentro dos shared preferences do Android. O app gera esse segredo uma vez quando você loga, e a partir daí toda chamada à API (<code>jumpg-api.tokyo-cdn.com</code>) é assinada com ele. Sem DRM elaborado, sem token rotativo, sem nada disso. É um segredo único e estático por aparelho.</p>
<p>O detalhe é que <strong>extrair esse segredo do app dá trabalho</strong>, porque por padrão você não tem acesso ao <code>/data/data/jp.co.shueisha.mangaplus/shared_prefs/config.xml</code> num Android sem root. Existem três caminhos:</p>
<ol>
<li><strong>Celular rooteado</strong>: roda <code>adb shell su -c &quot;cat /data/data/jp.co.shueisha.mangaplus/shared_prefs/config.xml&quot;</code> e procura o <code>&lt;string name=&quot;secret&quot;&gt;...&lt;/string&gt;</code>.</li>
<li><strong>Emulador com root</strong>: instala o Android Studio, cria um AVD com Google Play x86_64, aplica o <a href="https://gitlab.com/newbit/rootAVD"target="_blank" rel="noopener">rootAVD</a> pra ter root via Magisk, loga na sua conta Google que tem a assinatura, instala o MANGA Plus pela Play Store, e roda o mesmo <code>adb shell</code> de cima.</li>
<li><strong>Captura de rede com mitmproxy</strong> (mais avançado): roda o emulador root, injeta o CA do mitmproxy via Conscrypt APEX, e intercepta o segredo na primeira chamada autenticada do app.</li>
</ol>
<p>Os três caminhos estão documentados em detalhe nos <a href="https://github.com/akitaonrails/frank_mangaplus/blob/main/docs/install.md"target="_blank" rel="noopener">docs/install.md</a> e <a href="https://github.com/akitaonrails/frank_mangaplus/blob/main/docs/debugging.md"target="_blank" rel="noopener">docs/debugging.md</a> do repositório. Não vou repetir aqui passo a passo porque é mais ferramenta do que técnica em si, e quem topa o trabalho vai querer ler o original. O importante é que <strong>funciona</strong>, e que basta fazer <strong>uma vez</strong>: depois de extraído, o segredo serve pro app desktop pelo tempo que durar sua assinatura.</p>
<p>Sim, é chato. Mas é o preço de ter um único ponto de configuração que destrava o resto.</p>
<h2>O app desktop<span class="hx:absolute hx:-mt-20" id="o-app-desktop"></span>
    <a href="#o-app-desktop" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Com o segredo na mão, o resto foi fácil. Montei o <strong>Frank Manga+</strong>, um app nativo em <strong>Rust + Tauri</strong>, com WebView SvelteKit no frontend e cliente Rust com <code>reqwest</code> no backend conversando direto com a API da Shueisha. Pra carregar imagens dos capítulos sem vazar URL nem ter dor de cabeça com CORS, criei um esquema custom <code>mpimg://</code> interceptado pelo Tauri, que baixa, descriptografa (a API entrega as páginas com XOR simples) e devolve pro WebView.</p>
<p>O app abre numa visão de biblioteca com busca:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/library.png" alt="Frank Manga&#43; aberto na aba Library/Search mostrando uma grade de capas de mangá: One Piece, Sakamoto Days, Kagurabachi, Jujutsu Kaisen, Akane-banashi, Chainsaw Man, Dan Da Dan, entre outros."  loading="lazy" /></p>
<p>Clicando num título você vai pra página de detalhe com a lista completa de capítulos e um botão &ldquo;Continue&rdquo; que retoma do último capítulo lido:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/title-detail.png" alt="Página de detalhe do Kagurabachi no Frank Manga&#43;: capa grande à esquerda, sinopse, e lista virtualizada de capítulos numerados à direita com botão Continue no topo."  loading="lazy" /></p>
<p>O leitor abre em página única, com snap por página e as zonas de clique no estilo mangá: clica na <strong>metade esquerda</strong> da página pra avançar e na <strong>direita</strong> pra voltar (sentido de leitura japonês, da direita pra esquerda). Quem prefere teclado tem seta, espaço, <code>j</code>/<code>k</code> e PageUp/PageDown, tudo funciona. Quando você chega no fim de um capítulo, o próximo já vem em prefetch e é anexado no scroll, então não precisa voltar pra lista de capítulos toda hora. E o resume é por capítulo: se você sai no meio, da próxima vez que abrir aquele capítulo cai exatamente na página onde parou. Pra séries gigantes como One Piece (1100+ capítulos), a lista é virtualizada, então abre instantâneo:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/reader.png" alt="Leitor do Frank Manga&#43; em página única mostrando página 12 de 19 do capítulo de Kagurabachi: cena dramática em preto e branco do Chihiro empunhando a espada, com indicador de página no topo e barra de progresso no rodapé."  loading="lazy" /></p>
<p>Num monitor de 32 polegadas, página única desperdiça metade da tela. Por isso tem o <strong>modo página dupla</strong>: aperta <code>D</code> (ou clica no ícone de layout no cabeçalho) e o leitor junta as páginas que formam par: a dobra inteira preenche a tela do jeito que o autor desenhou. É como mangá foi feito pra ser lido.</p>
<p>E dá pra ver como isso importa quando compara com encadernação barata. Paperback ruim engole o meio da dobra, exatamente onde a arte deveria se conectar. No digital, quando o leitor junta direito, a cena respira:</p>
<table>
  <thead>
      <tr>
          <th>Frank Manga+ juntando a página dupla</th>
          <th>Paperback barato engolindo a dobra</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/reader-double-page.png" alt="Frank Manga&#43; em modo página dupla num monitor widescreen: uma cena dramática em preto e branco preenche a tela inteira, com uma personagem de maria-chiquinha chorando e os balões “I….” e “…WANT TO GET STRONGER.”."  loading="lazy" /></td>
          <td><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/kikoru-paperback-spread.jpeg" alt="Paperback de Kaiju No. 8 aberto numa página dupla da Kikoru, com a arte sendo engolida e distorcida pela lombada no centro."  loading="lazy" /></td>
      </tr>
  </tbody>
</table>
<p>O <code>D</code> cicla por três layouts: <strong>single</strong> (uma página por vez), <strong>double</strong> (pares sequenciais a partir da página 1) e <strong>double-cover</strong> (a primeira página de cada capítulo sozinha e o resto em pares, que é como mangá impresso encaderna a capa solta antes da primeira dobra). A escolha persiste entre capítulos e sessões, então você configura uma vez e esquece.</p>
<p>Tem também um filtro de leitura noturna. Aperta <code>F</code> (ou clica no ícone de lua no cabeçalho) e os brancos da página esquentam pro sépia. Branco de LCD castiga a vista no escuro, e isso suaviza sem achatar o contraste:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/reader-eye-protection.png" alt="Frank Manga&#43; com o filtro de leitura noturna ativo: uma página dupla em preto e branco ganha um tom âmbar quente, com vários quadros e balões de diálogo em inglês."  loading="lazy" /></p>
<p>O <code>F</code> cicla por quatro níveis: <strong>off → low → med → high → off</strong>. O botão fica âmbar e mostra de um a três pontinhos pro nível ativo, e a escolha fica salva no <code>localStorage</code> igual às outras preferências do leitor. Por baixo é um filtro CSS de <code>sepia + brightness + saturate</code> na pilha inteira de páginas: o sépia empurra a cor pro âmbar mas preserva a faixa de luminância, então preto continua preto e o contraste da arte fica intacto.</p>
<p>Estado de leitura (último capítulo, página corrente) fica no <code>localStorage</code> do WebView. Cache de imagens em <code>~/.cache/mangaplus-reader/</code>. O <code>deviceSecret</code> extraído fica em <code>~/.config/mangaplus-reader/secret</code> (Linux/macOS) ou <code>%APPDATA%\mangaplus-reader\secret</code> (Windows). Dá pra sobrescrever com a variável de ambiente <code>MANGAPLUS_SECRET</code> se preferir.</p>
<h2>Instalação<span class="hx:absolute hx:-mt-20" id="instalação"></span>
    <a href="#instala%c3%a7%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Tem build pronto pra todas as plataformas principais:</p>
<ul>
<li><strong>Linux</strong>: AppImage, <code>.deb</code>, <code>.rpm</code>, ou via AUR (<code>yay -S mangaplus-reader-bin</code>).</li>
<li><strong>macOS</strong>: <code>.dmg</code> separado pra Apple Silicon (aarch64) e Intel (x64).</li>
<li><strong>Windows</strong>: instalador <code>.exe</code>. Não está assinado, então o Windows SmartScreen vai reclamar — clica em &ldquo;More info → Run anyway&rdquo;.</li>
</ul>
<p>Todos os artefatos saem nos <a href="https://github.com/akitaonrails/frank_mangaplus/releases"target="_blank" rel="noopener">Releases do GitHub</a>.</p>
<h2>Status e contribuições<span class="hx:absolute hx:-mt-20" id="status-e-contribuições"></span>
    <a href="#status-e-contribui%c3%a7%c3%b5es" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Isso é <strong>beta</strong>. Funciona pro meu uso (leio várias séries por semana sem dor de cabeça), mas tem features que faltam: filtros de gênero na busca, modo offline real (hoje guarda em cache só o que já foi lido), e o caminho de extração do segredo pra usuários menos técnicos.</p>
<p>Se você assina o MANGA Plus e topa testar, manda issue ou PR. O código é Rust + Svelte, MIT, contribuição bem-vinda: <a href="https://github.com/akitaonrails/frank_mangaplus"target="_blank" rel="noopener">github.com/akitaonrails/frank_mangaplus</a>.</p>
<p>Agora finalmente leio One Piece no monitor grande, sem precisar segurar celular ou ficar de pescoço torto. Já valeu o trabalho.</p>
<p><a id="prettify-manga"></a></p>
<h2>Bônus: Prettify Manga Reader pra sites de fansub<span class="hx:absolute hx:-mt-20" id="bônus-prettify-manga-reader-pra-sites-de-fansub"></span>
    <a href="#b%c3%b4nus-prettify-manga-reader-pra-sites-de-fansub" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Além do app desktop, mantenho uma extensão mais simples: <a href="https://github.com/akitaonrails/prettify-manga"target="_blank" rel="noopener">Prettify Manga Reader</a>. Ela não traduz nada. O objetivo é arrumar a leitura.</p>
<p>A maioria dos sites de fansub é feia, cheia de banner, e claramente pensada pra celular: uma página embaixo da outra, scroll vertical infinito, boa sorte. Isso é prático pra ler no telefone, mas mangá não foi desenhado como feed de Instagram. Mangá foi pensado pra virar página. Duas páginas abertas ao mesmo tempo. O ritmo de leitura, a virada, o respiro entre quadros, tudo considera esse par.</p>
<p>E vários autores usam isso pra fazer spread de duas páginas. <strong>Kaiju No. 8</strong> faz várias vezes. Se o site joga uma página embaixo da outra, você até lê os balões, mas perde a composição inteira. Você está vendo metade da arte de cada vez e achando que viu tudo.</p>
<p>O normal nesses sites é isso:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/prettify-manga-reader-before.webp" alt="Página típica de fansub antes de ativar a extensão, com a página de mangá cercada pelo layout original do site."  loading="lazy" /></p>
<p>O Prettify abre um overlay escuro, encaixa a arte na tela e junta páginas no centro quando está em <strong>Book</strong> ou <strong>Double</strong>. O spread volta a parecer uma página dupla de verdade:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/prettify-manga-reader-spread.webp" alt="Prettify Manga Reader mostrando um spread de duas páginas corretamente unido no centro, em tela cheia e fundo escuro."  loading="lazy" /></p>
<p>Ele tem três modos: <strong>Single</strong> (uma página por tela), <strong>Double</strong> (pares desde o começo) e <strong>Book</strong> (primeira página sozinha, depois pares, que é o jeito de encadernação). Abre em Book por padrão. Se a imagem já é horizontal, mantém como spread em tela cheia. Tem scroll snap pra cada página parar no lugar certo, atalho de teclado, controles pequenos de mouse, ajuda com <code>?</code>, e um cartão no fim do capítulo tentando achar próximo/anterior quando o site deixa isso claro.</p>
<p>Também tem filtro noturno em três níveis. Aperta <code>N</code> e ele esquenta a página com sepia, brilho e contraste, sem transformar o desenho num cinza morto. Página branca no monitor grande de noite é uma lanterna na cara. Isso resolve.</p>
<p>Pra instalar, vai no <a href="https://github.com/akitaonrails/prettify-manga"target="_blank" rel="noopener">GitHub do Prettify Manga Reader</a>, baixa o zip em <strong>Releases</strong>, descompacta numa pasta permanente, abre <code>chrome://extensions</code> ou <code>brave://extensions</code>, liga <strong>Developer mode</strong>, clica em <strong>Load unpacked</strong> e escolhe a pasta descompactada. Se quiser montar do fonte, clona o repo, roda <code>npm test</code>, depois <code>npm run package</code>, descompacta o zip gerado em <code>dist/</code> e carrega essa pasta.</p>
<p>No Kindle Web Reader, o Prettify faz menos de propósito. Em URLs tipo <code>read.amazon.com/manga/...</code>, <code>read.amazon.co.jp/manga/...</code>, <code>read.amazon.co.uk/manga/...</code> e domínios <code>read.kindle.*</code>, não tenta reconstruir o livro numa overlay própria. O leitor da Amazon já resolve conta, página e DRM; a extensão só injeta uma barrinha discreta no canto inferior direito e atalhos de teclado por cima do reader nativo.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/prettify-manga-reader-kindle.webp" alt="Kindle Web Reader com a barra discreta do Prettify Manga Reader no canto inferior direito, mostrando o botão de Night ativo pra suavizar a página de mangá."  loading="lazy" /></p>
<p>Espaço, PageDown e setas avançam página; Shift+Space, PageUp e setas opostas voltam; Home/End vão pro começo ou fim quando o reader nativo aceita, com fallback de scroll; <code>N</code> cicla o filtro noturno. Não guarda credenciais da Amazon, não mexe em sessão, não precisa token no código. É só conforto visual e teclado em cima do Kindle Web que já existe.</p>
<p><a id="frank-yomik"></a></p>
<h2>Bônus 2: Frank Yomik agora também no desktop via extensão<span class="hx:absolute hx:-mt-20" id="bônus-2-frank-yomik-agora-também-no-desktop-via-extensão"></span>
    <a href="#b%c3%b4nus-2-frank-yomik-agora-tamb%c3%a9m-no-desktop-via-extens%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Quem leu o <a href="/2026/05/14/terminando-maratona-ia-sucesso-ou-fracasso/">balanço da maratona de IA</a> lembra do <a href="https://github.com/akitaonrails/FrankYomik"target="_blank" rel="noopener">Frank Yomik</a>, meu projeto self-hosted que pega uma página de mangá ou webtoon, detecta os balões com RT-DETR-v2, faz OCR, manda pro Ollama local (<code>qwen3:14b</code>) traduzir, e devolve a página com o texto traduzido no lugar. Já expliquei a história e os detalhes técnicos no post <a href="/2026/03/05/meu-primeiro-fracasso-com-vibe-code-e-como-consertei-frank-yomik/">Meu primeiro fracasso com Vibe Code e como consertei | Frank Yomik</a>. Aqui o ponto é o pedaço novo no desktop.</p>
<p>Originalmente tinha só cliente Flutter pra Android e Linux, que abria Kindle Japan e Naver Webtoon dentro de um WebView. Acontece que rodar app Flutter dentro de WebView no desktop pra ler Kindle é meio canhão pra mosca. No desktop você já tem Chromium aberto. O que faltava era uma extensão de navegador.</p>
<p>Nas últimas semanas adicionei isso. Extensão Manifest V3 pra Chromium/Brave/Edge, conversando com o mesmo servidor self-hosted do Yomik. Roda em <a href="https://read.amazon.co.jp"target="_blank" rel="noopener">read.amazon.co.jp</a>, <a href="https://read.kindle.co.jp"target="_blank" rel="noopener">read.kindle.co.jp</a>, <a href="https://comic.naver.com"target="_blank" rel="noopener">comic.naver.com</a> e na versão mobile do Naver. A página fica visualmente idêntica ao original, sem botão injetado nem HUD na cara do leitor. Toda configuração mora no popup:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/30/manga-plus/yomik-extension-popup.png" alt="Popup do Frank Yomik aberto sobre uma página do Kindle Japan mostrando Initial D em japonês: campos pra API base URL, auth token, checkboxes pra Kindle reader e Naver Webtoon, dropdown de manga pipeline em Furigana, target language English, e bloco de webtoon prefetch."  loading="lazy" /></p>
<p>O content script pega a imagem da página atual e manda pro servidor. O bearer token fica no service worker, então não vaza pro JS da página. Quando a tradução volta, a imagem é trocada na hora no lugar da original. Tem cache local pra não retraduzir página já vista, botão de &ldquo;force reprocess&rdquo; pra quando precisa reportar bug, e autosave de configuração. Detalhes no <a href="https://github.com/akitaonrails/FrankYomik/tree/main/extension"target="_blank" rel="noopener">README da extensão</a>, e o zip pra Load Unpacked sai nos Releases do repo.</p>
<p>Na prática, hoje o monitor de 32&quot; virou minha mesa de leitura. Frank Manga+ abre o catálogo oficial da Shueisha em inglês (One Piece, Chainsaw Man, e o que mais a Shonen Jump publicar). Quando é fansub ou Kindle Web e só quero conforto visual, o Prettify resolve reader, teclado e filtro noturno. Quando quero ler coisa japonesa sem tradução oficial, abro Kindle Japan numa aba e o Yomik manda furigana ou tradução LLM em cima da página. Pra webtoon coreano da Naver, mesma história, mesma extensão.</p>
<p>A parte boa é que cada projeto resolve um pedaço diferente e eles se completam. Frank Manga+ existe porque a Shueisha não tem site. Prettify deixa readers de navegador menos sofridos. O Yomik existe porque o resto do mangá que eu leio não tem tradução oficial decente em tempo hábil. Pra quem é doente de mangá igual eu, é o setup que eu queria há anos.</p>
]]></content:encoded><category>frank-yomik</category><category>frank</category><category>llms</category><category>inteligencia-artificial</category></item><item><title>Transformando YouTube num App de Karaoke | Frank Karaoke</title><link>https://akitaonrails.github.io/2026/04/05/transformando-youtube-num-app-de-karaoke-frank-karaoke/</link><guid isPermaLink="true">https://akitaonrails.github.io/2026/04/05/transformando-youtube-num-app-de-karaoke-frank-karaoke/</guid><pubDate>Sun, 05 Apr 2026 12:00:00 -0300</pubDate><description>&lt;p&gt;Projeto no GitHub: &lt;a href="https://github.com/akitaonrails/frank_karaoke"target="_blank" rel="noopener"&gt;github.com/akitaonrails/frank_karaoke&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/frank-karaoke/scoring-in-action.jpg" alt="Pontuação em tempo real sobre um vídeo de karaokê do YouTube" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;Eu sempre gostei de karaokê. De vez em quando saio pra cantar com família ou amigos. Em São Paulo tem bons lugares na Liberdade e no Bom Retiro, por exemplo, com cabines no estilo japonês. Se você nunca foi num karaokê desses: você aluga uma sala privada por hora, tem um catálogo enorme de músicas, dois microfones, e um sistema de pontuação que avalia seu canto em tempo real. Os melhores sistemas são japoneses, como o &lt;a href="https://www.joysound.com/"target="_blank" rel="noopener"&gt;Joysound&lt;/a&gt; e o &lt;a href="https://www.clubdam.com/"target="_blank" rel="noopener"&gt;DAM&lt;/a&gt;. Nota acima de 90 (de 100) é considerada avançada. O DAM, na série LIVE DAM Ai, até usa IA pra dar notas mais &amp;ldquo;humanas&amp;rdquo;.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Projeto no GitHub: <a href="https://github.com/akitaonrails/frank_karaoke"target="_blank" rel="noopener">github.com/akitaonrails/frank_karaoke</a></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/frank-karaoke/scoring-in-action.jpg" alt="Pontuação em tempo real sobre um vídeo de karaokê do YouTube"  loading="lazy" /></p>
<p>Eu sempre gostei de karaokê. De vez em quando saio pra cantar com família ou amigos. Em São Paulo tem bons lugares na Liberdade e no Bom Retiro, por exemplo, com cabines no estilo japonês. Se você nunca foi num karaokê desses: você aluga uma sala privada por hora, tem um catálogo enorme de músicas, dois microfones, e um sistema de pontuação que avalia seu canto em tempo real. Os melhores sistemas são japoneses, como o <a href="https://www.joysound.com/"target="_blank" rel="noopener">Joysound</a> e o <a href="https://www.clubdam.com/"target="_blank" rel="noopener">DAM</a>. Nota acima de 90 (de 100) é considerada avançada. O DAM, na série LIVE DAM Ai, até usa IA pra dar notas mais &ldquo;humanas&rdquo;.</p>
<p>Mas nem todo lugar tem esse nível.</p>
<h2>O problema com karaokê no Brasil<span class="hx:absolute hx:-mt-20" id="o-problema-com-karaokê-no-brasil"></span>
    <a href="#o-problema-com-karaok%c3%aa-no-brasil" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>No Brasil a gente cresceu com o <a href="https://www.videoland.com.br/wwwroot/historia.asp"target="_blank" rel="noopener">Videokê</a>, a marca que o coreano Seok Ha Hwang trouxe pro país em 1996, importando equipamento da Coreia. Virou febre nos anos 90 e 2000, apareceu em todo bar, churrasco e festa de aniversário. O problema é que esses aparelhos pararam no tempo. Os modelos atuais, como o VSK 5.0, vêm com uns 12-13 mil músicas no catálogo, que você expande comprando cartuchos ou pacotes de música. Na prática, o repertório é velho, a interface é dos anos 2000, e se a música que você quer cantar saiu depois de 2015, boa sorte.</p>
<p>A solução que muitos bares adotaram foi permitir Chromecast ou espelhamento de tela pra que os clientes busquem músicas direto no YouTube. Faz sentido: no YouTube você encontra karaokê de qualquer música. Versão com letra, versão instrumental, versão com guia vocal.</p>
<p>Mas tem um downgrade: você perde a pontuação. Uma das coisas mais divertidas do karaokê é a competição. Ver sua nota subindo, comparar com os amigos, tentar bater o recorde da noite. Se você está só cantando em cima de um vídeo do YouTube, não tem feedback nenhum. É como jogar boliche sem placar.</p>
<p>E comprar um sistema profissional pra casa? Importar um Joysound F1 sai por mais de US$ 2.000 só o hardware, fora a assinatura mensal do catálogo. Pra uso casual não faz sentido.</p>
<h2>A ideia: YouTube com pontuação em tempo real<span class="hx:absolute hx:-mt-20" id="a-ideia-youtube-com-pontuação-em-tempo-real"></span>
    <a href="#a-ideia-youtube-com-pontua%c3%a7%c3%a3o-em-tempo-real" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O <a href="https://github.com/akitaonrails/frank_karaoke"target="_blank" rel="noopener">Frank Karaoke</a> surgiu dessa frustração. Se o YouTube já tem todas as músicas, por que não fazer um app que funciona como wrapper do YouTube com overlay de pontuação em tempo real? Você busca qualquer vídeo de karaokê, canta junto, e o app analisa sua voz pelo microfone e mostra uma nota ao vivo.</p>
<p>É um app Flutter pra Android. Internamente ele carrega o YouTube numa webview e injeta um overlay em HTML/CSS/JavaScript direto na página. O display de score, o trail de pitch, o painel de configurações, o seletor de modo, tudo renderizado dentro da webview via injeção de JS.</p>
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/frank-karaoke/karaoke-full-dark.png">
  <img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/frank-karaoke/karaoke-full-light.png" alt="Frank Karaoke">
</picture>
<h2>Scoring sem referência<span class="hx:absolute hx:-mt-20" id="scoring-sem-referência"></span>
    <a href="#scoring-sem-refer%c3%aancia" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Agora, o problema real. Todos os sistemas profissionais de karaokê dependem de arquivos de referência pré-fabricados pra cada música. Todos.</p>
<p>O <a href="https://en.wikipedia.org/wiki/SingStar"target="_blank" rel="noopener">SingStar</a> da Sony, que vendeu mais de 12 milhões de cópias entre 2004 e o fim do PS3, tinha um track de notas feito à mão pra cada música. Cada nota, cada sílaba, tudo mapeado manualmente. O mecanismo comparava o pitch do cantor via FFT contra essa referência em tempo real. Detalhe que eu achei esperto: a oitava era ignorada. Se a nota certa era um Dó, tanto faz se você cantou Dó3 ou Dó4. Homens cantam músicas femininas sem problema.</p>
<p>O Joysound e o DAM no Japão vão além e avaliam três dimensões separadas: precisão de pitch (音感), ritmo/timing (リズム感) e expressividade/dinâmica de volume (表現力). Tudo baseado em dados MIDI do servidor do operador. O formato open source equivalente é o UltraStar, onde cada música tem um arquivo <code>.txt</code> assim:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>: 12 4 5 Hel-    (TipoNota BeatInicial Duração Pitch Sílaba)</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>Pitch 5</code> = MIDI 65 (F4). A pontuação compara o pitch do cantor com o da nota, módulo oitava, com tolerância de 1 semitom.</p>
<p>O Frank Karaoke funciona com qualquer vídeo do YouTube. Não tem arquivo de referência. Não tem MIDI. Não tem anotação de melodia. Zero metadata sobre qual nota você deveria estar cantando.</p>
<p>Eu não sei nada de pontuação de karaokê. Não sei nada de processamento de áudio, detecção de pitch, teoria musical aplicada a software. Nada. Então pedi pro Claude Code fazer uma pesquisa extensiva sobre o assunto. O que ele trouxe de volta está documentado em <a href="https://github.com/akitaonrails/frank_karaoke/blob/main/docs/scoring.md"target="_blank" rel="noopener"><code>docs/scoring.md</code></a> no repositório, e é bastante coisa: papers acadêmicos sobre avaliação de canto (Nakano et al. 2006, Tsai &amp; Lee 2012, Molina et al. 2013), patentes (a Yamaha tem uma de 1999, US5889224A, que detalha pontuação por MIDI com 3 faixas de tolerância), e o código fonte de projetos open source como UltraStar Deluxe, AllKaraoke, Vocaluxe e Nightingale.</p>
<p>A conclusão da pesquisa: sem referência por música, você precisa avaliar qualidade vocal de forma genérica. Medir <em>como</em> a pessoa canta, não <em>o que</em> ela deveria estar cantando. E como não existe uma métrica única que funcione pra todos os casos, decidimos implementar quatro modos de pontuação diferentes, cada um medindo uma dimensão diferente da qualidade vocal.</p>
<h2>O problema do microfone do celular<span class="hx:absolute hx:-mt-20" id="o-problema-do-microfone-do-celular"></span>
    <a href="#o-problema-do-microfone-do-celular" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Antes dos modos de pontuação, preciso explicar um problema mais fundamental que a pesquisa revelou: o microfone do celular.</p>
<p>Quando você canta karaokê com o celular, o microfone capta três coisas ao mesmo tempo: sua voz, a música saindo do alto-falante, e ruído ambiente da sala. Sua voz está fisicamente mais perto do microfone, então domina o sinal. Mas não o suficiente pra uma separação limpa.</p>
<p>Tentei várias abordagens pra isolar a voz:</p>
<p>Subtração espectral usando o áudio de referência do YouTube. Abandonei. O CDN do YouTube bloqueia extração direta de áudio por user-agent não-browser, e mesmo com o áudio de referência, a equalização do alto-falante, a reverberação da sala e o delay de Bluetooth tornam o sinal diferente demais do que o microfone capta. Subtração simples gera artefatos piores que nenhuma subtração.</p>
<p>Pré-ênfase + center clipping. Abandonei também. O center clipping destrói a forma de onda que o algoritmo YIN precisa pra autocorrelação, e a pré-ênfase amplifica ruído tanto quanto voz.</p>
<p>O que funciona é um filtro bandpass de 200-3500 Hz: um filtro IIR de segunda ordem (Butterworth, Q=0.707) em cascata. O high-pass em 200 Hz elimina baixo, bumbo, bass guitar do bleed do alto-falante. O low-pass em 3500 Hz elimina pratos, hi-hats, ruído de alta frequência. Os fundamentais da voz humana (85-300 Hz) e os formantes (300-3000 Hz) passam pelo filtro. Não é isolamento perfeito, mas melhora bastante a razão voz/música pra detecção de pitch.</p>
<p>Mas o bandpass sozinho não resolve tudo. Guitarras, sintetizadores e piano produzem sinais periódicos na mesma faixa de frequência da voz, e o YIN detecta pitch neles também. Pra lidar com isso, o app faz uma calibração adaptativa: nos primeiros 5 segundos de warmup (quando ninguém está cantando), ele coleta amostras de RMS do sinal pra estabelecer um baseline do nível do alto-falante. Durante a música, mantém esse baseline atualizado (percentil 25 dos últimos ~4 segundos de frames). Pra um frame ser pontuado, o RMS precisa estar pelo menos 1.3x acima do baseline. A voz está mais perto do microfone, então empurra o RMS acima do nível do alto-falante. A melodia instrumental fica perto do baseline e é filtrada. Nos testes, o cantor original saindo pelo alto-falante marcava uns 37 pontos com dots esparsos no trail, enquanto alguém cantando de verdade marcava ~59 com dots densos.</p>
<p>Outro detalhe chato: no Android, especificamente nos Samsung, o <code>AutomaticGainControl</code> (AGC) do DSP atenua o sinal em vez de amplificar. Nos Galaxy, habilitar AGC reduz o pico do microfone de ~0.06 pra ~0.003. Silêncio pra detecção de pitch. Então o app desabilita AGC, echo cancellation e noise suppression. Quando o pico fica abaixo de 0.01, aplica ganho por software (até 30x) pra trazer o sinal a níveis usáveis.</p>
<h2>O algoritmo YIN<span class="hx:absolute hx:-mt-20" id="o-algoritmo-yin"></span>
    <a href="#o-algoritmo-yin" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Pra detectar o pitch da voz eu uso o <a href="http://audition.ens.fr/adc/pdf/2002_JASA_YIN.pdf"target="_blank" rel="noopener">YIN</a>, de Alain de Cheveigné (IRCAM-CNRS) e Hideki Kawahara (Universidade de Wakayama). É um estimador de frequência fundamental no domínio do tempo. A ideia central é a Cumulative Mean Normalized Difference Function (CMNDF), que basicamente mede o quão periódico é o sinal em cada lag, normaliza pra reduzir falsos positivos, e usa interpolação parabólica pra refinar o resultado. É leve o suficiente pra rodar em tempo real no celular, o que importa aqui.</p>
<p>No app, o threshold do YIN é 0.70 (ajustado pra sinais mistos de voz + música), e frames com confiança abaixo de 0.3 são descartados. Abaixo disso, provavelmente é ruído ou instrumento.</p>
<h2>Os 4 modos de pontuação<span class="hx:absolute hx:-mt-20" id="os-4-modos-de-pontuação"></span>
    <a href="#os-4-modos-de-pontua%c3%a7%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Cada modo avalia um aspecto diferente da qualidade vocal. Todos compartilham o mesmo pipeline de áudio (bandpass → YIN → gate de confiança). A diferença é como interpretam o pitch detectado.</p>
<h3>Pitch Match<span class="hx:absolute hx:-mt-20" id="pitch-match"></span>
    <a href="#pitch-match" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Mede o quão limpo você sustenta as notas. Usa decaimento gaussiano baseado no desvio padrão dos valores MIDI numa janela rolante de ~15 frames. Notas firmes (desvio &lt; 0.3 semitons) pontuam 85-100%. Voz tremendo (desvio &gt; 2 semitons) pontua perto de zero. Bom pra músicas que você já conhece bem.</p>
<h3>Contour<span class="hx:absolute hx:-mt-20" id="contour"></span>
    <a href="#contour" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Mede a forma melódica do seu canto. Não importa qual nota exata você acerta, só a direção e o fluxo. Avalia a amplitude do pitch e movimentos melódicos (saltos &gt; 0.5 semitom) numa janela rolante. Canto monótono pontua ~10%. Movimento melódico suave com amplitude de 2-6 semitons pontua 70-100%. Bom pra quando você está aprendendo uma música nova.</p>
<h3>Intervals<span class="hx:absolute hx:-mt-20" id="intervals"></span>
    <a href="#intervals" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Mede a qualidade musical dos saltos entre notas consecutivas. Tom inteiro (2 semitons) pontua mais alto. Terças e quartas pontuam bem. Saltos descontrolados de uma oitava ou mais pontuam baixo. Usa uma curva gaussiana centrada no tom inteiro. Funciona quando você está cantando numa tonalidade diferente da original.</p>
<h3>Streak<span class="hx:absolute hx:-mt-20" id="streak"></span>
    <a href="#streak" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>É o Pitch Match com multiplicador combo. Cada frame consecutivo com score acima de 0.4 incrementa o contador de streak. O streak adiciona pontos bônus (até +0.4 em streak de 30+). Quebrar um streak &gt; 5 frames empurra uma penalidade de 0.05 no EMA. Silêncio congela o streak, então pausas instrumentais não te prejudicam. O modo mais divertido pra festas.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/frank-karaoke/score-display-detail.jpg" alt="Detalhe do display de pontuação: score ao vivo, score geral, trail de pitch com grid de notas (C3-G5)"  loading="lazy" /></p>
<p>A lógica por trás desses quatro modos veio da pesquisa que o Claude fez nos papers acadêmicos. Cada um mede uma dimensão diferente: afinação, contorno melódico, fraseado e consistência. Nenhum deles sozinho é suficiente, mas juntos cobrem razoavelmente bem o que dá pra avaliar sem ter a melodia de referência da música.</p>
<h2>O Pitch Oracle<span class="hx:absolute hx:-mt-20" id="o-pitch-oracle"></span>
    <a href="#o-pitch-oracle" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Além dos quatro modos puramente vocais, o app tem o que eu chamo de Pitch Oracle. A ideia: em vez de avaliar sua voz isoladamente, o app baixa o áudio de referência do vídeo via <code>youtube_explode_dart</code>, decodifica pra PCM, roda o YIN nele, e constrói um timeline de pitch com timestamp da música inteira. Durante a pontuação, se o pitch do microfone bate com o pitch da referência naquele momento do vídeo, provavelmente é bleed do alto-falante, e é ignorado. Se difere, é sua voz, e é pontuado.</p>
<p>A sincronização funciona pelo <code>currentTime</code> do elemento de vídeo HTML5, enviado pro Dart via um listener JS de <code>timeupdate</code> a cada ~250ms. O oracle consulta o pitch de referência na posição exata do playback, levando em conta pause, seek e mudança de velocidade.</p>
<p>Na primeira vez que você toca uma música, o oracle leva uns 5-15 segundos pra baixar e analisar o áudio. Mas o timeline é salvo como JSON no cache local do app (<code>pitch_oracle/&lt;videoId&gt;.json</code>). Se você tocar a mesma música de novo, carrega instantaneamente do cache, sem request de rede. Isso também resolve o problema de rate limiting do YouTube pras músicas que você mais canta.</p>
<p>Com o oracle ativo, os modos mudam de comportamento. Pitch Match compara a classe de pitch do cantor contra a da referência (agnóstico à oitava, como o SingStar). Contour usa correlação cruzada entre o movimento de pitch do cantor e o da referência. Intervals compara os saltos em semitons contra os da referência.</p>
<p>Quando o YouTube bloqueia o download por rate limiting (acontece depois de muitos requests seguidos do mesmo IP, limpa em 15-30 minutos), o oracle falha silenciosamente e os modos voltam pra análise puramente vocal.</p>
<h2>O caminho até aqui<span class="hx:absolute hx:-mt-20" id="o-caminho-até-aqui"></span>
    <a href="#o-caminho-at%c3%a9-aqui" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O app que você vê agora passou por muita iteração antes de chegar nesse estado.</p>
<p>Primeiro, eu tentei fazer uma versão desktop Linux pra facilitar o debug. Faz sentido, né? Testa no desktop, itera rápido, depois porta pro celular. O problema é que o Flutter não tem backend de webview pra Linux desktop. O <code>webview_flutter</code> simplesmente não funciona. Tentei o <code>webview_cef</code>, que é baseado no Chromium Embedded Framework. O CEF spawna seu próprio processo de GPU, e no Hyprland (compositor Wayland baseado no wlroots) isso conflita com o pipeline de renderização do compositor. No meu setup com NVIDIA, a sessão inteira do Hyprland congelou. Tela travada, sem resposta a teclado, tive que matar pelo TTY. Fora que o CEF exige download de um binário de ~200MB na primeira build. Desisti do CEF e escrevi com o Claude uma bridge nativa em C++ usando WebKitGTK com method channels do Flutter. Funcionou, mas cada peculiaridade do YouTube exigia código separado pro Linux e pro Android. O <code>just_audio</code> também não tem implementação pra Linux desktop. No fim a versão Linux virou peso morto. Deletei ~1.500 linhas de código Linux-específico e foquei só no Android.</p>
<p>Depois veio a saga do microfone no Samsung. No meu Galaxy Z Fold, o microfone captava um sinal absurdamente baixo. Pico de ~0.005, basicamente silêncio pro detector de pitch. Fiquei umas duas horas tentando entender. Baixei thresholds, aumentei ganho por software até 50x, desabilitei preprocessadores de áudio. Nada funcionava direito. Até que descobri o problema real: o <code>AutomaticGainControl</code> do Android. O nome diz &ldquo;controle automático de ganho&rdquo;, o que sugere que ele <em>amplifica</em> sinais fracos. Na implementação DSP dos Samsung, ele faz o oposto. Ele <em>atenua</em> o sinal pra um nível de referência baixo, otimizado pra chamadas de voz. Com AGC ligado, o pico caía de ~0.06 pra ~0.003. Desligar o AGC resolveu. Mas aí o pacote <code>audio_session</code> religava o AGC por baixo dos panos. Removi ele também. Foram três rodadas de fix, cada uma achando mais uma camada do problema.</p>
<p>E a pontuação. A pontuação levou mais tempo que todo o resto junto. A primeira implementação usava média cumulativa, que deixava a nota travada num valor e não respondia ao canto em tempo real. Troquei pra janela rolante. Aí a nota ficava presa em ~50% por causa de um bug no peso da pontuação primária. Consertei, e ela passou a começar em 70% mesmo sem ninguém cantando. Consertei de novo. O modo Streak não resetava direito no silêncio. O chromatic snap dava nota alta pra qualquer coisa. O histórico de pitch não era limpo nos gaps de silêncio e os modos ficavam estagnados. Cada bug corrigido revelava outro. Foram mais de 25 commits só ajustando a pontuação, do primeiro protótipo até o estado atual.</p>
<p>O resultado não é perfeito. Eu sei. Mas funciona o suficiente pra ser divertido, que era o objetivo desde o começo.</p>
<h2>Configurações<span class="hx:absolute hx:-mt-20" id="configurações"></span>
    <a href="#configura%c3%a7%c3%b5es" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/frank-karaoke/settings-panel.jpg" alt="Painel de configurações: presets de microfone, pitch shift, calibração"  loading="lazy" /></p>
<p>O painel de configurações fica no ícone de engrenagem do overlay. Tem três presets de microfone pra diferentes ambientes (microfone externo limpo, sala normal, festa barulhenta), cada um ajustando thresholds de confiança e amplitude. Tem pitch shift pra quando a música é aguda demais pra sua extensão vocal. O ajuste muda tanto o áudio do vídeo quanto a pontuação ao mesmo tempo: usa o <code>playbackRate</code> do elemento HTML5 com <code>preservesPitch=false</code>, então +2 semitons acelera o áudio pra 1.12x (pitch sobe) e -2 semitons desacelera pra 0.89x (pitch desce). A pontuação compensa o offset, então você canta na sua faixa confortável e o sistema avalia corretamente. Tem calibração de microfone, um processo de 3 segundos que mede o ruído da sala e adapta os thresholds. E tem restart pra zerar a pontuação sem recarregar o vídeo.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/05/frank-karaoke/scoring-modes-selector.jpg" alt="Seletor de modos de pontuação"  loading="lazy" /></p>
<p>Pra trocar de modo de pontuação, toque na caixa de score durante a reprodução.</p>
<h2>Fluxo de uso<span class="hx:absolute hx:-mt-20" id="fluxo-de-uso"></span>
    <a href="#fluxo-de-uso" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><ol>
<li>Abra o app. O YouTube carrega dentro do app com o logo do Frank Karaoke.</li>
<li>Busque um vídeo de karaokê. Qualquer vídeo funciona, mas faixas instrumentais com letra na tela dão melhores resultados.</li>
<li>O vídeo pausa brevemente pra inicializar o microfone, baixar dados da música pro pitch oracle, e preparar o overlay. Na primeira vez com uma música nova isso leva uns 5-15 segundos. Se você já tocou essa música antes, carrega do cache instantaneamente.</li>
<li>Cante. O score &ldquo;live&rdquo; reflete sua performance atual (média exponencial com alpha 0.15, resposta em ~1 segundo). O score &ldquo;overall&rdquo; é a média cumulativa da música inteira.</li>
<li>Quando o vídeo pausa, a pontuação pausa junto (pra não pontuar ruído ambiente). Se fizer seek, o score zera e tem 5 segundos de warmup.</li>
</ol>
<h2>Como instalar<span class="hx:absolute hx:-mt-20" id="como-instalar"></span>
    <a href="#como-instalar" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O app ainda não está na Play Store, estou esperando o Google verificar minha identidade de desenvolvedor. Deve aparecer lá nos próximos dias. Enquanto isso, é um projeto aberto e dá pra instalar direto.</p>
<p>O jeito mais fácil é baixar o APK assinado direto da <a href="https://github.com/akitaonrails/frank_karaoke/releases"target="_blank" rel="noopener">página de releases do GitHub</a>. No celular ou tablet Android, baixe o <code>FrankKaraoke-0.2.0-android.apk</code>, abra e toque em Instalar. Se o Android reclamar de &ldquo;fontes desconhecidas&rdquo;, habilite em Configurações &gt; Segurança pro seu navegador. Na primeira execução o app pede permissão de microfone. Depois vá nas configurações (ícone de engrenagem) e calibre o microfone antes de cantar, são 3 segundos.</p>
<p>Se você quer compilar do fonte ou contribuir, o repositório está no <a href="https://github.com/akitaonrails/frank_karaoke"target="_blank" rel="noopener">GitHub</a>. Precisa de Flutter SDK 3.10+, Android SDK API 24+, e um dispositivo físico pra testar microfone (emulador não dá resultados representativos).</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git clone https://github.com/akitaonrails/frank_karaoke.git
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> frank_karaoke
</span></span><span class="line"><span class="cl">flutter pub get
</span></span><span class="line"><span class="cl">flutter run -d &lt;device_id&gt;</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O README tem o resto.</p>
<p>Stack: Flutter + Riverpod pra state management, <code>webview_flutter</code> pro YouTube, <code>youtube_explode_dart</code> pra extração de áudio, <code>record</code> pra captura PCM do microfone, <code>audio_decoder</code> pra decodificação de referência via Android MediaCodec, e o algoritmo YIN implementado em Dart puro.</p>
<p>A documentação técnica do sistema de pontuação está em <a href="https://github.com/akitaonrails/frank_karaoke/blob/master/docs/scoring.md"target="_blank" rel="noopener"><code>docs/scoring.md</code></a> no repositório. Cobre como SingStar, Joysound e DAM funcionam, os papers acadêmicos, a arquitetura do pitch oracle, os problemas de isolamento de voz no Android, e o roadmap.</p>
<h2>A pontuação é experimental<span class="hx:absolute hx:-mt-20" id="a-pontuação-é-experimental"></span>
    <a href="#a-pontua%c3%a7%c3%a3o-%c3%a9-experimental" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Preciso ser direto: o sistema de pontuação é experimental. Sem arquivos de referência por música, a avaliação é aproximada. O app mede se você está afinado, se segue um contorno melódico, se seus intervalos são musicais, se é consistente. Mas não te diz se você está cantando a melodia certa desta música específica (a menos que o pitch oracle consiga baixar o áudio, e nem sempre consegue).</p>
<p>Se você tem experiência com processamento de áudio, detecção de pitch, ou avaliação musical, o repositório está aberto e a documentação de pesquisa em <a href="https://github.com/akitaonrails/frank_karaoke/blob/main/docs/scoring.md"target="_blank" rel="noopener"><code>docs/scoring.md</code></a> detalha o que foi tentado, o que funciona e o que não funciona. Em particular: calibração dos thresholds dos modos, melhorias no isolamento de voz, e integração com o <a href="https://github.com/rakuri255/UltraSinger"target="_blank" rel="noopener">UltraSinger</a> (que gera arquivos de referência a partir de músicas usando Demucs + basic-pitch + WhisperX) são áreas onde contribuição de quem entende do assunto faria diferença. Apreciamos qualquer ajuda de especialistas na calibração desses sistemas.</p>
<p>Ah, e o nome. Frank Karaoke. É uma homenagem ao Sinatra. Quem mais?</p>
<p>Projeto no GitHub: <a href="https://github.com/akitaonrails/frank_karaoke"target="_blank" rel="noopener">github.com/akitaonrails/frank_karaoke</a></p>
]]></content:encoded><category>frank</category><category>llms</category><category>inteligencia-artificial</category><category>audio-e-video</category></item><item><title>Ensinando a questionar notícias | Frank Investigator</title><link>https://akitaonrails.github.io/2026/03/27/ensinando-a-questionar-noticias-frank-investigator/</link><guid isPermaLink="true">https://akitaonrails.github.io/2026/03/27/ensinando-a-questionar-noticias-frank-investigator/</guid><pubDate>Fri, 27 Mar 2026 10:00:00 -0300</pubDate><description>&lt;p&gt;Aviso: o &lt;a href="https://github.com/akitaonrails/frank_investigator"target="_blank" rel="noopener"&gt;Frank Investigator&lt;/a&gt; é um projeto experimental, em desenvolvimento ativo, e não pretende ser a palavra final sobre nenhuma matéria analisada. Ele não diz o que é verdade ou mentira. O que ele faz é perguntar o que o artigo se recusou a perguntar, identificar padrões retóricos conhecidos, e buscar fontes externas que o autor omitiu. Se você quiser ajudar, contribua no &lt;a href="https://github.com/akitaonrails/frank_investigator"target="_blank" rel="noopener"&gt;GitHub&lt;/a&gt; ou mande feedback. Se quiser acompanhar os resultados, a newsletter &lt;a href="https://themakitachronicles.com/"target="_blank" rel="noopener"&gt;The Makita Chronicles&lt;/a&gt; vai ter uma seção nova chamada &amp;ldquo;Notícias Duvidosas&amp;rdquo; onde vou publicar o resumo do investigator e o link pro relatório completo.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Aviso: o <a href="https://github.com/akitaonrails/frank_investigator"target="_blank" rel="noopener">Frank Investigator</a> é um projeto experimental, em desenvolvimento ativo, e não pretende ser a palavra final sobre nenhuma matéria analisada. Ele não diz o que é verdade ou mentira. O que ele faz é perguntar o que o artigo se recusou a perguntar, identificar padrões retóricos conhecidos, e buscar fontes externas que o autor omitiu. Se você quiser ajudar, contribua no <a href="https://github.com/akitaonrails/frank_investigator"target="_blank" rel="noopener">GitHub</a> ou mande feedback. Se quiser acompanhar os resultados, a newsletter <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The Makita Chronicles</a> vai ter uma seção nova chamada &ldquo;Notícias Duvidosas&rdquo; onde vou publicar o resumo do investigator e o link pro relatório completo.</p>
<p>Dito isso, deixa eu explicar por que eu fiz isso.</p>
<h2>O problema com a mídia brasileira<span class="hx:absolute hx:-mt-20" id="o-problema-com-a-mídia-brasileira"></span>
    <a href="#o-problema-com-a-m%c3%addia-brasileira" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Eu cansei.</p>
<p>Cansei de abrir o jornal e ter que fazer ginástica mental pra separar informação de narrativa. Cansei de publicações como Folha de São Paulo, UOL, Carta Capital, Brasil 247, O Globo e várias outras que usam manchetes enganosas, omitem contexto de propósito, transpõem evidências de outros países sem nenhuma ressalva, e criam uma aparência de consenso entre veículos que estão todos dizendo a mesma coisa porque seguem a mesma pauta coordenada.</p>
<p>Isso não é teoria da conspiração. É padrão editorial verificável. E o pior: a maioria dos leitores não tem tempo nem ferramentas pra perceber. Você lê o título, lê os dois primeiros parágrafos, e segue com a impressão que o artigo plantou na sua cabeça.</p>
<h2>O que o Frank Investigator faz<span class="hx:absolute hx:-mt-20" id="o-que-o-frank-investigator-faz"></span>
    <a href="#o-que-o-frank-investigator-faz" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Você dá uma URL de artigo de notícia. O sistema busca o artigo com Chromium headless (pra passar por paywalls e cookie walls), extrai o conteúdo filtrando propaganda e sidebar, e decompõe o texto em afirmações verificáveis. Aí começa a análise de verdade: divergência entre manchete e corpo, falácias retóricas (falsa causalidade, apelo a autoridade, espantalho, bait-and-pivot), distorção de fontes, manipulação temporal, citação seletiva, authority laundering. Expande os links citados no artigo pra verificar se as fontes dizem o que o autor afirma. Avalia cada claim com consenso de 3 modelos de IA (Claude Sonnet 4.6, GPT-5.4, Gemini 3.1 Pro) via OpenRouter. Detecta lacunas contextuais, campanha coordenada entre veículos, e mede a proporção entre paixão e evidência. São 15 etapas ao todo.</p>
<p>O princípio central é &ldquo;Verdade Acima do Consenso&rdquo;: uma fonte primária (dado oficial, documento de governo, estudo acadêmico original) veta qualquer quantidade de fontes secundárias repetindo a mesma informação. Dez jornais repetindo a mesma coisa sem fonte primária continuam valendo zero.</p>
<p>Deixa eu mostrar cinco exemplos reais.</p>
<h2>Exemplo 1: O caso Noelia Castillo (BBC)<span class="hx:absolute hx:-mt-20" id="exemplo-1-o-caso-noelia-castillo-bbc"></span>
    <a href="#exemplo-1-o-caso-noelia-castillo-bbc" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/noelia-original.png" alt="Artigo original na BBC"  loading="lazy" /></p>
<p>A <a href="https://www.bbc.com/portuguese/articles/clyxedlekleo"target="_blank" rel="noopener">BBC publicou</a> uma matéria com o título &ldquo;Morre Noelia Castillo: a luta de uma jovem de 25 anos na Justiça contra seu pai para receber a eutanásia na Espanha&rdquo;. À primeira vista parece uma história sobre direito à eutanásia e uma disputa familiar. Uma jovem tetraplégica que lutou contra a oposição do pai religioso pra exercer seu direito de morrer.</p>
<p>Só que ao comparar com outros veículos, como a <a href="https://veja.abril.com.br/comportamento/a-decisao-extrema-tomada-por-espabhola-que-ficou-paraplegica-apos-agressao-sexual/"target="_blank" rel="noopener">Veja</a>, aparecem fatos que a BBC omitiu quase completamente. E esses fatos mudam tudo.</p>
<p>Noelia foi retirada da família pelo governo espanhol aos 13 anos e colocada sob custódia do estado. Enquanto estava sob essa custódia, sofreu múltiplos estupros coletivos. A violência sexual resultou em danos psiquiátricos graves e um histórico de saúde mental que já somava 67% de grau de invalidez antes dos eventos de 2022. Quando tentou suicídio em outubro de 2022, jogando-se do quinto andar de um prédio, ficou paraplégica. O grau de invalidez subiu pra 74%.</p>
<p>O pedido de eutanásia foi aprovado pela Comissão de Garantia e Avaliação da Catalunha. A data marcada pro procedimento foi 2 de agosto de 2024, mas ficou suspensa por mais de 600 dias por causa dos recursos judiciais do pai. Cinco instâncias judiciais se pronunciaram. O Tribunal Constitucional descartou violação de direitos fundamentais. O Supremo Tribunal da Espanha recusou o recurso. O Tribunal Europeu de Direitos Humanos rejeitou o pedido de suspensão. Na sexta-feira, 26 de março de 2026, Noelia foi submetida à eutanásia no Hospital Residencial Sant Camil, na comarca catalã de Garraf.</p>
<p>Mas tem um detalhe que a Veja menciona e que é perturbador: Noelia teria manifestado dúvidas antes do procedimento. E o hospital teria acelerado o processo porque os órgãos já estavam comprometidos pra doação.</p>
<p>O <a href="https://investigator.themakitachronicles.com/investigations/e5a27e016c"target="_blank" rel="noopener">relatório do Frank Investigator</a> comparou a cobertura de vários veículos.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/noelia-contexto.png" alt="Contexto do evento e fatos omitidos"  loading="lazy" /></p>
<p>A análise cruzada entre os artigos analisados mostrou que alguns veículos, como a BBC, omitiram fatos que alteram a interpretação do caso inteiro. Outros, como a Veja, trouxeram o contexto completo. O episódio de violência sexual coletiva de outubro de 2022, a investigação criminal, o histórico psiquiátrico desde os 13 anos, tudo isso aparece em algumas coberturas mas está completamente ausente em outras. E entre os veículos que omitiram, nenhum tocou na questão ética de que o fundamento físico pro pedido de eutanásia deriva de uma tentativa de suicídio.</p>
<p>O enquadramento convergente entre os veículos é de &ldquo;batalha judicial&rdquo;, &ldquo;morte que pediu&rdquo;, &ldquo;deixar de sofrer&rdquo;, &ldquo;partir em paz&rdquo;, suavizando o caráter definitivo do procedimento e posicionando Noelia como protagonista heroica e o pai como antagonista obstrutivo. O pai, Gerônimo Castillo, e seus Advogados Cristãos são qualificados como &ldquo;ultracatólicos&rdquo; ou &ldquo;ultraconservadores&rdquo; sem fonte independente que sustente essa classificação editorial.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/noelia-coordenada.png" alt="Análise de narrativa coordenada - Noelia"  loading="lazy" /></p>
<p>A coordenação narrativa ficou em 55%. Não parece ser coordenação ativa entre redações, mas alinhamento editorial temático: todo mundo comprou a narrativa de autonomia individual sem questionar. O que eleva o score é a convergência de omissões. Nenhum veículo explica a distinção jurídica entre o TEDH &ldquo;autorizar ativamente&rdquo; a eutanásia e simplesmente recusar medidas cautelares provisórias pedidas pelo pai, que é o que de fato aconteceu. As implicações médicas e bioéticas de aprovar eutanásia em caso decorrente de tentativa de suicídio não aparecem em lugar nenhum. E qualquer voz crítica ao procedimento é automaticamente enquadrada como religiosa ou ideológica, nunca como médica ou jurídica.</p>
<p>É o tipo de caso onde a omissão é a manipulação. Os veículos que omitiram esses fatos não mentiram em nenhum momento. Mas ao enquadrar como &ldquo;disputa familiar sobre direito à eutanásia&rdquo; e omitir a cadeia causal (custódia do estado → estupros coletivos → dano psiquiátrico → tentativa de suicídio → paraplegia → eutanásia), o leitor fica com uma impressão radicalmente diferente da realidade. A comparação entre coberturas é exatamente o tipo de coisa que o investigator faz bem: expor o que cada veículo escolheu mostrar e o que escolheu esconder.</p>
<h2>Exemplo 2: &ldquo;Governo corta imposto de quase mil importados&rdquo; (UOL)<span class="hx:absolute hx:-mt-20" id="exemplo-2-governo-corta-imposto-de-quase-mil-importados-uol"></span>
    <a href="#exemplo-2-governo-corta-imposto-de-quase-mil-importados-uol" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/corte-original.png" alt="Artigo original no UOL"  loading="lazy" /></p>
<p>O <a href="https://economia.uol.com.br/noticias/redacao/2026/03/26/de-remedios-a-lupulo-governo-corta-imposto-de-mil-importados.ghtm"target="_blank" rel="noopener">UOL publicou</a> que o governo cortou impostos de importação de quase mil produtos, de remédios a lúpulo. Manchete positiva, tom de benefício ao consumidor. Vários outros veículos publicaram a mesma coisa com enquadramento parecido: o governo fez algo bom, preços vão cair.</p>
<p>Só que a <a href="https://www.gazetadopovo.com.br/economia/governo-aumenta-imposto-importacao-recua-fake-news/"target="_blank" rel="noopener">Gazeta do Povo</a> conta a outra metade da história. O governo não cortou impostos antigos. O que aconteceu foi: em algum momento anterior a fevereiro de 2025, o governo aumentou tarifas de importação de mais de 1.200 itens, uma medida que geraria R$ 14 bilhões em arrecadação estimada. Depois, sob pressão nas redes sociais e pressão popular público, recuou parcialmente. Zerou tarifas de uns 970 itens de capital, informática e telecomunicações. Reduziu impostos de 120 produtos de informática. E agora chama isso de &ldquo;corte de impostos&rdquo;.</p>
<p>Ou seja: aumentaram, tomaram pressão popular, voltaram atrás em parte, e reembalaram como se fosse uma concessão generosa. A maioria dos mais de 1.200 itens que tiveram tarifas elevadas continua com tarifas mais altas do que antes. Nenhum preço caiu pro consumidor final. Os preços voltaram ao que eram pra alguns produtos, e continuam mais altos pra maioria.</p>
<p>O <a href="https://investigator.themakitachronicles.com/investigations/f35bfe0176"target="_blank" rel="noopener">relatório do Frank Investigator</a> cruzou os artigos e expôs o que foi omitido.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/corte-contexto.png" alt="Contexto e fatos omitidos - corte de impostos"  loading="lazy" /></p>
<p>O que a comparação entre artigos expõe: nenhum dos veículos com enquadramento positivo menciona que a maioria dos 1.200+ itens com tarifas elevadas continua com tarifas mais altas após as duas rodadas de &ldquo;cortes&rdquo;. O impacto fiscal sobre a meta de arrecadação de R$ 14 bilhões prevista com os aumentos originais, ninguém calcula. Vozes da indústria nacional que pode ser prejudicada pela redução tarifária de concorrentes importados, nenhuma. Critérios objetivos do Gecex pra definir &ldquo;oferta insuficiente no mercado interno&rdquo;, não aparecem.</p>
<p>Os dois artigos analisados constroem o mesmo enquadramento positivo pro governo. O paradoxo de que os &ldquo;cortes&rdquo; são reversão parcial de aumentos feitos pelo mesmo governo no ano anterior fica enterrado ou simplesmente ausente.</p>
<p>É o tipo clássico de manipulação por reenquadramento. Ninguém mentiu. Mas &ldquo;governo corta imposto&rdquo; e &ldquo;governo recua de aumento após pressão popular&rdquo; descrevem o mesmo evento com impressões opostas. A escolha editorial de qual versão publicar já é a manipulação.</p>
<h2>Exemplo 3: &ldquo;Globo se desculpa por PowerPoint&rdquo; (Brasil 247)<span class="hx:absolute hx:-mt-20" id="exemplo-3-globo-se-desculpa-por-powerpoint-brasil-247"></span>
    <a href="#exemplo-3-globo-se-desculpa-por-powerpoint-brasil-247" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/globo-original.png" alt="Artigo original no Brasil 247"  loading="lazy" /></p>
<p>Esse caso explodiu nos últimos dias. A GloboNews mostrou um diagrama no programa Estúdio I conectando o presidente Lula, seus ministros e Daniel Vorcaro, dono do Banco Master, que está no centro de fraudes documentadas. A Globo depois se retratou publicamente, chamou o material de &ldquo;errôneo e incompleto&rdquo;, e demitiu uma editora.</p>
<p>O <a href="https://www.brasil247.com/brasil/globo-se-desculpa-por-powerpoint-que-tentou-jogar-o-caso-master-no-colo-de-lula"target="_blank" rel="noopener">Brasil 247</a> publicou uma matéria com o título &ldquo;Globo se desculpa por PowerPoint que tentou jogar o caso Master no colo de Lula&rdquo;.</p>
<p>O <a href="https://investigator.themakitachronicles.com/investigations/752d80653a"target="_blank" rel="noopener">relatório do Frank Investigator</a> expôs o que está acontecendo por baixo:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/globo-resumo.png" alt="Resumo da investigação - Globo"  loading="lazy" /></p>
<p>O fato central é real: a Globo pediu desculpas e demitiu alguém. Mas o enquadramento do Brasil 247 vai muito além do que os fatos sustentam. O título diz &ldquo;tentou jogar no colo de Lula&rdquo; atribuindo intenção deliberada onde os documentos apontam pra falha editorial. A retratação da Globo falou em material &ldquo;errôneo e incompleto&rdquo;, não em tentativa de incriminar ninguém.</p>
<p>O que chama atenção nesse caso é a campanha coordenada. O investigator deu 62% de coordenação narrativa.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/globo-coordenada.png" alt="Análise de narrativa coordenada - Globo"  loading="lazy" /></p>
<p>Vários veículos editorialmente alinhados ao governo usaram a expressão &ldquo;sem provas&rdquo; de forma convergente pra descrever a associação entre Lula e o caso Master. Todos focaram no erro da Globo como ponto central da narrativa, em vez de investigar as conexões reais. Nenhum veículo mencionou quais outros nomes políticos foram excluídos do PowerPoint original. Nenhum investigou as conexões documentadas de Vorcaro com diferentes esferas do poder. O foco é meta-jornalístico: criticam a emissora em vez de cobrir o escândalo.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/globo-retorica.png" alt="Análise retórica - Globo"  loading="lazy" /></p>
<p>As falácias detectadas: linguagem carregada (&ldquo;tentou jogar&rdquo;, &ldquo;sem provas&rdquo; usados pra enquadrar erro editorial como ataque político deliberado), falsa causalidade (a retratação da Globo não prova que as conexões são falsas), cherry-picking (destaca a omissão de nomes ligados ao governo Lula sem contextualizar quais outros nomes foram omitidos), e bait-and-pivot (usa o pedido de desculpas da Globo como gancho pra minimizar o escândalo do Banco Master).</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/globo-lacunas.png" alt="Lacunas contextuais - Globo"  loading="lazy" /></p>
<p>E as perguntas que nenhum desses veículos fez: quais nomes de ministros do Supremo e políticos de outros partidos também foram excluídos do PowerPoint? O caso Master tem conexões documentadas com figuras do governo Lula, ou se restringem a governos anteriores? O Brasil 247, que publica esse artigo, tem alinhamento editorial declarado com o governo Lula? Qual foi a reação do Conselho de Ética jornalístico?</p>
<p>Confiança geral: 13%. O artigo não fabrica fatos. Mas seleciona, enquadra e omite de forma a construir uma narrativa que os dados não sustentam.</p>
<h2>Exemplo 4: &ldquo;Por que combustível caro é bom&rdquo; (Folha)<span class="hx:absolute hx:-mt-20" id="exemplo-4-por-que-combustível-caro-é-bom-folha"></span>
    <a href="#exemplo-4-por-que-combust%c3%advel-caro-%c3%a9-bom-folha" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/combustivel-original.png" alt="Artigo original na Folha"  loading="lazy" /></p>
<p>O economista Bernardo Guimarães publicou uma <a href="https://www1.folha.uol.com.br/colunas/bernardo-guimaraes/2026/03/por-que-combustivel-caro-e-bom.shtml"target="_blank" rel="noopener">coluna na Folha</a> defendendo que combustível caro é bom pra sociedade porque estimula inovação em energia limpa. Ele cita artigos acadêmicos reais (Popp 2002, NBER) e tem credenciais acadêmicas verificáveis (doutorado em Yale, professor na FGV EESP). Parece sólido.</p>
<p>O <a href="https://investigator.themakitachronicles.com/investigations/e6cd2ac867"target="_blank" rel="noopener">relatório completo do Frank Investigator</a> mostra outro quadro.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/combustivel-summary.png" alt="Resumo da investigação - combustível"  loading="lazy" /></p>
<p>O artigo acerta ao citar estudos reais. Mas omite quem paga a conta. A coluna ignora completamente o impacto distributivo: populações de baixa renda, moradores de áreas periféricas e rurais, que dependem mais de veículos próprios e têm menos acesso a alternativas limpas. Pra um economista, ignorar efeitos distributivos é ou incompetência ou escolha editorial.</p>
<p>Tem um problema pior. As evidências empíricas que ele cita (patentes nos EUA, dados de veículos elétricos na Califórnia entre 2014-2017) são transpostas pro Brasil sem ressalva nenhuma. O Brasil tem uma matriz de etanol e infraestrutura flex-fuel que altera completamente o mecanismo causal. O artigo trata como se o consumidor brasileiro estivesse na mesma situação que o californiano, o que é falso.</p>
<p>E tem o contexto que o artigo menciona de passagem mas não desenvolve: a Guerra no Irã está fazendo os preços de combustível subirem no mundo todo. O Brasil deveria estar numa posição privilegiada por causa do pré-sal e do etanol. Mas décadas de má gestão e corrupção na Petrobras fizeram com que a gente pague o mesmo preço que o resto do mundo. Em vez de questionar isso, a coluna vende a ideia de que &ldquo;pelo menos vai estimular energia limpa&rdquo;. É uma racionalização de um problema que não deveria existir.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/combustivel-lacunas.png" alt="Lacunas contextuais - combustível"  loading="lazy" /></p>
<p>O investigator identificou 5 questões que o artigo se recusou a abordar, com 35% de completude contextual. As falácias detectadas incluem falso dilema (apresenta combustível caro como a única opção viável de política climática), cherry-picking (reconhece inelasticidade de curto prazo mas enfatiza apenas efeitos de longo prazo), e linguagem carregada (descreve alternativas como &ldquo;brincar de plantar uma mudinha&rdquo;).</p>
<p>Confiança geral: 25%. Não é desinformação fabricada. É opinião com seleção de evidências a favor da tese e omissão de contrapontos relevantes.</p>
<h2>Exemplo 5: &ldquo;Não existe cinema forte sem regulamentação do streaming&rdquo; (O Globo)<span class="hx:absolute hx:-mt-20" id="exemplo-5-não-existe-cinema-forte-sem-regulamentação-do-streaming-o-globo"></span>
    <a href="#exemplo-5-n%c3%a3o-existe-cinema-forte-sem-regulamenta%c3%a7%c3%a3o-do-streaming-o-globo" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/cinema-original.png" alt="Artigo original no O Globo"  loading="lazy" /></p>
<p>Renata Magalhães, presidente da Academia Brasileira de Cinema, deu uma <a href="https://oglobo.globo.com/blogs/miriam-leitao/post/2026/03/renata-magalhaes-nao-existe-cinema-forte-sem-regulamentacao-do-streaming.ghtml"target="_blank" rel="noopener">entrevista na coluna da Miriam Leitão no O Globo</a> defendendo que regulamentar o streaming é condição necessária pra fortalecer o cinema brasileiro.</p>
<p>O <a href="https://investigator.themakitachronicles.com/investigations/7e4f5605c5"target="_blank" rel="noopener">relatório do Frank Investigator</a> encontrou problemas sérios.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/cinema-resumo.png" alt="Resumo da investigação - cinema"  loading="lazy" /></p>
<p>Primeiro: a afirmação central de que &ldquo;muitos filmes têm baixa audiência&rdquo; aparece sem nenhum dado empírico. Nenhum número, nenhuma série histórica. É afirmação de autoridade pura, sem base analítica.</p>
<p>Segundo: tem uma contradição interna que o artigo não resolve. O texto abre dizendo que o cinema brasileiro está &ldquo;em destaque internacional&rdquo; (prêmios, festivais). E logo em seguida argumenta que a ausência de regulamentação impede o fortalecimento da indústria. Mas se o cinema brasileiro já está ganhando prêmios internacionais sem essa regulamentação, o argumento de que a regulamentação é condição necessária cai por terra. O artigo não endereça essa contradição.</p>
<p>E é aqui que entra o elefante na sala que nenhum desses artigos menciona: as pessoas simplesmente não querem assistir a maioria desses filmes. O cinema brasileiro premiado internacionalmente é feito pra competir em Cannes e no Oscar, não pra lotar sala de cinema no Brasil. Em vez de se perguntar por que o público brasileiro não se interessa, a indústria prefere pedir regulamentação do streaming pra forçar plataformas a financiar e exibir conteúdo que não tem audiência espontânea. É o modelo clássico: usa dinheiro público e regulação pra manter viva uma indústria que não se sustenta no mercado.</p>
<p>A entrevistada é presidente da Academia Brasileira de Cinema. Tem interesse institucional direto na regulamentação. O artigo não apresenta nenhuma voz contrária e não discute os custos pro consumidor: aumento de preço de assinatura, redução de catálogo. Uma fonte só, com conflito de interesse declarado, sem contraponto.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/blog/2026/03/27/frank-investigator/cinema-coordenada.png" alt="Análise de narrativa coordenada - cinema"  loading="lazy" /></p>
<p>E aqui vem a campanha coordenada, com 55% de coordenação narrativa. Múltiplos veículos reproduzem o mesmo enquadramento emocional: cinema &ldquo;em risco&rdquo;, regulamentação como &ldquo;essencial&rdquo;. Nenhuma das fontes identificadas discute evidências empíricas internacionais comparáveis sobre a eficácia de cotas de conteúdo (a Europa tem experiências com resultados contraditórios). Nenhuma menciona os conflitos de interesse da Academia Brasileira de Cinema. O fato de que as produções brasileiras premiadas internacionalmente foram feitas sem a regulamentação proposta é omitido convergentemente em todos os veículos. Apenas um site isolado (targethd.net) mencionou impactos negativos pro consumidor.</p>
<p>Confiança geral: 9%. O artigo é advocacy editorial legítimo, mas com falhas analíticas que limitam seu valor informativo a quase zero.</p>
<h2>O que o investigator analisa<span class="hx:absolute hx:-mt-20" id="o-que-o-investigator-analisa"></span>
    <a href="#o-que-o-investigator-analisa" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Os 5 exemplos acima mostram padrões diferentes, mas os critérios de análise são os mesmos.</p>
<p>O sinal mais forte de manipulação é a omissão. Não é o que o artigo diz que engana, é o que ele deixa de dizer. A análise de lacunas contextuais identifica as perguntas que o artigo deveria ter respondido e não respondeu, e busca contra-evidência pra cada uma. Nos exemplos acima, artigos que omitem a maior parte do contexto relevante não estão informando ninguém. E quando a comparação cruzada entre veículos mostra que uns cobriram os fatos e outros não, como no caso Noelia ou no corte de impostos, fica difícil argumentar que a omissão foi acidental.</p>
<p>Depois vem a detecção de campanha coordenada. Vários jornais cobrirem o mesmo assunto é normal. Todos usarem a mesma linguagem carregada, focarem nos mesmos pontos e omitirem os mesmos contrapontos ao mesmo tempo não é. O sinal mais forte de coordenação não é o que os veículos dizem em comum, mas o que omitem em comum.</p>
<p>Tem também o reenquadramento, que é mais sutil. No caso dos impostos, o governo aumentou tarifas, recuou sob pressão, e os veículos chamaram de &ldquo;corte&rdquo;. Ninguém mentiu tecnicamente, mas a escolha de enquadramento muda completamente a interpretação. Esse tipo de manipulação é mais difícil de detectar porque cada afirmação individual é defensável.</p>
<p>As falácias retóricas pegam construções específicas: falso dilema (&ldquo;ou regula ou o cinema morre&rdquo;), bait-and-pivot (abre com fato positivo e pivota pra narrativa de crise), linguagem carregada (&ldquo;sem provas&rdquo; usado pra atribuir intencionalidade). Cada falácia detectada vem com a citação exata do trecho e a explicação de por que aquela construção é problemática.</p>
<p>E tem o princípio que costura tudo: se 10 veículos repetem a mesma afirmação citando uns aos outros, o consenso de LLMs tem que refletir que a cadeia de evidência é circular, não que a afirmação é bem suportada. Volume de cobertura não é proxy pra verdade.</p>
<h2>Por que você não pode &ldquo;só perguntar pro ChatGPT&rdquo;<span class="hx:absolute hx:-mt-20" id="por-que-você-não-pode-só-perguntar-pro-chatgpt"></span>
    <a href="#por-que-voc%c3%aa-n%c3%a3o-pode-s%c3%b3-perguntar-pro-chatgpt" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A primeira reação de muita gente vai ser: &ldquo;por que eu preciso disso se eu posso colar o artigo no ChatGPT e pedir pra ele analisar?&rdquo;</p>
<p>Tenta. Pega um artigo político e pede pro ChatGPT criticar. Ele vai criticar. Pega o mesmo artigo e pede pra confirmar. Ele vai encontrar argumentos pra confirmar. O LLM não está buscando a verdade. Ele está prevendo qual resposta você mais provavelmente espera ouvir dado o enquadramento da sua pergunta. Se você pede &ldquo;analise os problemas desse artigo&rdquo;, o modelo vai achar problemas. Se você pede &ldquo;esse artigo está correto?&rdquo;, ele vai encontrar méritos. É viés de confirmação automatizado.</p>
<p>Tem outro problema. Os LLMs generalistas foram treinados pra ser agradáveis. A tendência sycophantic (de concordar com o usuário) é documentada em todos os modelos grandes. Se o seu histórico de conversa indica que você é de esquerda, o modelo tende a enquadrar as respostas de uma forma que agrade esse perfil. Se você é de direita, mesma coisa pro outro lado. Ele não está mentindo de propósito. Ele está otimizando pra satisfação do usuário, que é literalmente a métrica pela qual foi treinado via RLHF.</p>
<p>E o pior: LLMs alucinam. Se não têm evidência suficiente pra sustentar a resposta que acham que você quer ouvir, inventam. Fabricam citações e dados plausíveis, atribuem declarações a pessoas que nunca as fizeram. Se você pede pra ele criticar um artigo sobre combustível, ele pode inventar um estudo fictício que &ldquo;prova&rdquo; o contrário do artigo. Parece convincente. Mas não existe.</p>
<p>O Frank Investigator foi construído pra evitar exatamente esses problemas. A primeira decisão de design é que nenhum humano faz pergunta ao LLM. Não existe prompt aberto tipo &ldquo;analise esse artigo&rdquo;. Cada etapa do pipeline tem prompts estruturados que pedem análises específicas: &ldquo;liste as falácias retóricas neste trecho&rdquo;, &ldquo;identifique que informações contextuais estão ausentes&rdquo;, &ldquo;compare a manchete com o corpo do texto&rdquo;. O modelo não sabe se o operador concorda ou discorda do artigo, porque o operador não opina em nenhum momento. Isso elimina o viés de confirmação na raiz.</p>
<p>Pra lidar com alucinação, todo analisador que usa LLM inclui a instrução &ldquo;CRITICAL &ndash; NO HALLUCINATION: Only reference URLs, sources, claims, quotes, and data that are EXPLICITLY present in the input provided to you. Do not invent, guess, or fabricate any URL, source name, statistic, quote, or claim. If you cannot verify something from the provided text, mark it as unverifiable &ndash; never fill in details.&rdquo; Não elimina alucinação completamente, mas reduz muito. E como são 3 modelos de empresas diferentes (Anthropic, OpenAI, Google) respondendo as mesmas perguntas, quando um alucina os outros dois geralmente discordam. O consenso é ponderado por confiança, não por maioria simples. Se dois modelos dão &ldquo;supported&rdquo; com 70% de confiança e um dá &ldquo;mixed&rdquo; com 95%, o &ldquo;mixed&rdquo; pesa mais. Quanto mais distante a discordância, maior a penalização na confiança final. Se um modelo começa a dar respostas inconsistentes, ele é colocado em quarentena e os outros dois continuam.</p>
<p>Mas a salvaguarda que eu considero mais importante é o primary source veto. Se uma fonte primária (dado do IBGE, decisão judicial, estudo original) contradiz uma claim, a confiança é capada em 60% e o veredicto é forçado pra &ldquo;mixed&rdquo;, mesmo que os 3 LLMs digam &ldquo;supported&rdquo;. Dez artigos de jornal repetindo uma afirmação não superam um dado oficial que a contradiz. Na mesma linha, se 5 matérias &ldquo;confirmam&rdquo; uma claim mas todas são do mesmo grupo editorial (Folha/UOL, Globo/G1/Valor), o sistema sabe que são a mesma voz e reduz o peso. Volume não substitui independência.</p>
<p>Nada disso torna o sistema perfeito. Mas é categoricamente diferente de colar texto no ChatGPT e perguntar &ldquo;o que você acha?&rdquo;.</p>
<h2>Os números<span class="hx:absolute hx:-mt-20" id="os-números"></span>
    <a href="#os-n%c3%bameros" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><table>
  <thead>
      <tr>
          <th>Métrica</th>
          <th>Valor</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Commits</td>
          <td>129</td>
      </tr>
      <tr>
          <td>Dias de trabalho ativo</td>
          <td>~9</td>
      </tr>
      <tr>
          <td>Linhas de Ruby (código)</td>
          <td>19.444</td>
      </tr>
      <tr>
          <td>Linhas de teste</td>
          <td>9.190</td>
      </tr>
      <tr>
          <td>Arquivos de teste</td>
          <td>108</td>
      </tr>
      <tr>
          <td>Total de linhas (código)</td>
          <td>24.301</td>
      </tr>
      <tr>
          <td>Services (analisadores + serviços)</td>
          <td>80</td>
      </tr>
      <tr>
          <td>Analisadores de desinformação</td>
          <td>15</td>
      </tr>
      <tr>
          <td>Modelos ActiveRecord</td>
          <td>14</td>
      </tr>
      <tr>
          <td>Background jobs</td>
          <td>19</td>
      </tr>
      <tr>
          <td>Migrações de banco</td>
          <td>31</td>
      </tr>
      <tr>
          <td>Etapas do pipeline</td>
          <td>15</td>
      </tr>
      <tr>
          <td>Modelos de LLM em consenso</td>
          <td>3</td>
      </tr>
      <tr>
          <td>Locales</td>
          <td>2 (en, pt-BR)</td>
      </tr>
  </tbody>
</table>
<p>Stack: Ruby 4.0.1, Rails 8.1.2, SQLite com WAL mode, Solid Queue (jobs dentro do Puma), Solid Cable (WebSockets), Tailwind CSS v4, Chromium headless via Ferrum CDP, deploy com Kamal pro GitHub Container Registry. AGPL-3.0.</p>
<h2>O processo de desenvolvimento<span class="hx:absolute hx:-mt-20" id="o-processo-de-desenvolvimento"></span>
    <a href="#o-processo-de-desenvolvimento" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O projeto tem 129 commits em 9 dias de trabalho ativo (11-16 e 25-27 de março). O primeiro dia foi o mais pesado: mais de 60 commits só em 11 de março, começando do zero e chegando num sistema funcional com extração de conteúdo, decomposição de claims, avaliação por LLM, e interface web com live updates via Turbo Streams.</p>
<p>Os commits contam a história. Começou com a fundação:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>1e32d5a - Initial Frank Investigator foundation
564eb97 - Add recursive source crawling and RubyLLM scaffold
c8c5357 - Add Brazil source registry and authority connectors
3d30617 - Add U.S. authority profiles, source role modeling, and specialized connectors</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Depois vieram os analisadores de desinformação, um por um:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>a65fef7 - Add rhetorical fallacy analyzer for detecting narrative manipulation
5dcf99d - Add headline-body divergence detection and headline citation amplification
a113efd - Add smear campaign defense with circular citation and viral volume detection
56d501a - Add media ownership modeling, syndication detection, and independence analysis</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>A fase de hardening contra ruído e falsos positivos foi a que mais consumiu tempo:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>46e4bc5 - Add pre-fetch defenses: URL filtering, circuit breaker, fetch prioritization
168fea1 - Add post-fetch content gate, claim noise filter, and duplicate content skip
b2843d7 - Add paywall detection, pricing noise filter, and ofertas.* host rejection
b1b230d - Rewrite Chromium fetcher with Ferrum CDP for anti-bot evasion</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Depois veio a interface, deploy, e os analisadores mais avançados:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>c5afcea - Replace custom CSS with Tailwind CSS v4 and rewrite all templates
95a8ec4 - Add Docker deployment with bin/deploy script
ccfacc9 - Add coordinated narrative detection across media outlets
ba3e2f4 - Add 6 new misinformation detection analyzers with cross-analysis
fda984b - Add LLM-generated investigation summary with quality assessment
a4ebe78 - Add contextual gap analysis to detect manipulation through omission</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>E nos últimos commits, o consenso de 3 modelos simultâneos e otimização de testes:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>5cc9c05 - Enable 3-model LLM consensus: Sonnet 4.6, GPT-4.1 Mini, Gemini 2.5 Pro
cdf5fb5 - Batch 5 content analyzers into single LLM call, add anti-hallucination
28c305e - Add WebMock stubs for LLM and web search — tests run in 1.4s (was 540s)</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Os testes que rodavam em 9 minutos agora rodam em 1.4 segundo com WebMock stubbing de todas as chamadas de LLM e web search. Isso fez diferença enorme na velocidade de iteração.</p>
<h2>Limitações<span class="hx:absolute hx:-mt-20" id="limitações"></span>
    <a href="#limita%c3%a7%c3%b5es" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Fact-checking de notícias é um problema difícil. Um artigo sozinho não contém tudo que é necessário pra uma análise completa. O autor escolheu o que incluir e o que omitir, e essa escolha já é o primeiro nível de manipulação. As fontes citadas dentro do artigo foram selecionadas pelo autor pra sustentar a narrativa dele, não pra dar uma visão equilibrada.</p>
<p>O Frank Investigator não diz o que é verdade e o que é mentira. O resultado é um relatório com pontos fortes e fracos, não um selo de &ldquo;verdadeiro&rdquo; ou &ldquo;falso&rdquo;. Mesmo com todas as salvaguardas que descrevi acima, a contra-evidência buscada automaticamente pode não ser a mais relevante. As falácias detectadas podem ter falsos positivos. A análise de campanha coordenada depende do que o web search retorna no momento da busca.</p>
<p>Use os relatórios como ponto de partida pra formar sua própria opinião, não como veredicto final.</p>
<p>O projeto é open source, AGPL-3.0. Se quiser contribuir, testar, reportar bugs ou sugerir melhorias: <a href="https://github.com/akitaonrails/frank_investigator"target="_blank" rel="noopener">GitHub</a>. Se quiser acompanhar as análises, a newsletter <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The Makita Chronicles</a> vai ter a seção &ldquo;Notícias Duvidosas&rdquo; com resumo e link pro relatório completo de cada investigação.</p>
]]></content:encoded><category>frank</category><category>llms</category><category>inteligencia-artificial</category></item><item><title>Reescrevi o OpenClaw em Rust, funcionou? | FrankClaw</title><link>https://akitaonrails.github.io/2026/03/16/reescrevi-o-openclaw-em-rust-funcionou-frankclaw/</link><guid isPermaLink="true">https://akitaonrails.github.io/2026/03/16/reescrevi-o-openclaw-em-rust-funcionou-frankclaw/</guid><pubDate>Mon, 16 Mar 2026 08:00:00 -0300</pubDate><description>&lt;p&gt;Antes de tudo: o FrankClaw ainda está em alpha pesado. Funciona pra tarefas simples, mas não testei workflows complexos. Se você quer ajudar, abre Issues no &lt;a href="https://github.com/akitaonrails/frankclaw"target="_blank" rel="noopener"&gt;GitHub&lt;/a&gt; com o que encontrar. Tem muita coisa pra testar.&lt;/p&gt;
&lt;p&gt;Até &amp;ldquo;funciona&amp;rdquo;, mas esse projeto foi mais pelo exercício. Dito isso, deixa eu contar por que fiz isso.&lt;/p&gt;
&lt;h2&gt;O problema com o OpenClaw&lt;span class="hx:absolute hx:-mt-20" id="o-problema-com-o-openclaw"&gt;&lt;/span&gt;
&lt;a href="#o-problema-com-o-openclaw" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;O &lt;a href="https://github.com/openclaw/openclaw"target="_blank" rel="noopener"&gt;OpenClaw&lt;/a&gt; é um gateway que conecta canais de mensagem (Telegram, Discord, Slack, WhatsApp, etc.) a provedores de IA (OpenAI, Anthropic, Ollama). Você configura, sobe o servidor, e pode conversar com LLMs direto pelo Telegram ou qualquer outro canal. É um projeto popular e com bastante atividade.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Antes de tudo: o FrankClaw ainda está em alpha pesado. Funciona pra tarefas simples, mas não testei workflows complexos. Se você quer ajudar, abre Issues no <a href="https://github.com/akitaonrails/frankclaw"target="_blank" rel="noopener">GitHub</a> com o que encontrar. Tem muita coisa pra testar.</p>
<p>Até &ldquo;funciona&rdquo;, mas esse projeto foi mais pelo exercício. Dito isso, deixa eu contar por que fiz isso.</p>
<h2>O problema com o OpenClaw<span class="hx:absolute hx:-mt-20" id="o-problema-com-o-openclaw"></span>
    <a href="#o-problema-com-o-openclaw" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O <a href="https://github.com/openclaw/openclaw"target="_blank" rel="noopener">OpenClaw</a> é um gateway que conecta canais de mensagem (Telegram, Discord, Slack, WhatsApp, etc.) a provedores de IA (OpenAI, Anthropic, Ollama). Você configura, sobe o servidor, e pode conversar com LLMs direto pelo Telegram ou qualquer outro canal. É um projeto popular e com bastante atividade.</p>
<p>Atividade demais, na verdade.</p>
<p>Eu fiz um clone depth-1 do repositório e rodei um <code>tokei</code>:</p>
<table>
  <thead>
      <tr>
          <th>Métrica</th>
          <th>Valor</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Arquivos TypeScript (sem testes)</td>
          <td>3.794</td>
      </tr>
      <tr>
          <td>Linhas de código TypeScript</td>
          <td>~1.247.000</td>
      </tr>
      <tr>
          <td>Arquivos de teste</td>
          <td>2.799</td>
      </tr>
      <tr>
          <td>Dependências (package.json raiz)</td>
          <td>73</td>
      </tr>
  </tbody>
</table>
<p>Mais de um milhão de linhas de TypeScript. Os 2.799 arquivos de teste parecem bastante em número absoluto, mas proporcionalmente ao tamanho do codebase, a cobertura é baixa. A maior parte do código está em 29 pacotes de um monorepo com 21 extensões.</p>
<p>Fui buscar mais commits pra entender o ritmo de desenvolvimento. Nos 100 commits que consegui puxar, todos caíram em apenas 2 dias (9 e 10 de março). ~50 commits por dia, de 42 contribuidores diferentes. Vibe coding ao extremo.</p>
<p>A conclusão é a que você imagina: volumes enormes de código gerado por IA sendo despejados num repositório a uma velocidade que impossibilita review humano sério. E isso me incomodou o suficiente pra ir investigar mais a fundo.</p>
<h2>A auditoria de segurança<span class="hx:absolute hx:-mt-20" id="a-auditoria-de-segurança"></span>
    <a href="#a-auditoria-de-seguran%c3%a7a" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Pedi pro Claude fazer uma auditoria de segurança completa no código do OpenClaw. O <a href="https://github.com/akitaonrails/frankclaw/blob/master/docs/OPENCLAW_SECURITY_AUDIT.md"target="_blank" rel="noopener">relatório</a> encontrou:</p>
<table>
  <thead>
      <tr>
          <th>Severidade</th>
          <th>Quantidade</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CRITICAL</td>
          <td>7</td>
      </tr>
      <tr>
          <td>HIGH</td>
          <td>9</td>
      </tr>
      <tr>
          <td>MEDIUM</td>
          <td>12</td>
      </tr>
      <tr>
          <td>LOW</td>
          <td>6</td>
      </tr>
      <tr>
          <td>INFO</td>
          <td>5</td>
      </tr>
  </tbody>
</table>
<p>Sete vulnerabilidades críticas. Deixa eu listar algumas:</p>
<ul>
<li>Timing side-channel na comparação de tokens &ndash; o <code>safeEqualSecret()</code> faz um <code>return</code> antecipado no type-check, permitindo que um atacante distinga tokens malformados de tokens errados medindo latência.</li>
<li><code>eval()</code> no browser tool &ndash; execução arbitrária de JavaScript sem sandbox.</li>
<li>Shell sem allowlist &ndash; qualquer tool pode executar qualquer comando no sistema.</li>
<li>Webhooks do Slack sem verificação de assinatura nenhuma.</li>
<li>Transcripts e config em texto puro no disco, sem criptografia.</li>
<li>Sem rate limiting efetivo &ndash; IPs podem ser spoofados se o operador configura trusted proxies de forma ampla.</li>
</ul>
<p>Essas são só as que o Claude encontrou numa varredura automatizada. Provavelmente tem mais.</p>
<p>Eu não vou rodar isso na minha máquina. Nem dentro de um container Docker. Um gateway que recebe webhooks da internet, executa comandos shell, se conecta a APIs de IA com suas chaves, e guarda histórico de conversas, tudo isso com 7 vulnerabilidades críticas conhecidas? Não, obrigado.</p>
<p>Então eu fiz o que qualquer desenvolvedor racional faria: decidi construir o meu.</p>
<h2>A primeira tentativa: Claude Code<span class="hx:absolute hx:-mt-20" id="a-primeira-tentativa-claude-code"></span>
    <a href="#a-primeira-tentativa-claude-code" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Comecei do jeito mais direto. Clonei o OpenClaw, apontei o Claude Code pro código, e pedi: &ldquo;reescreve isso em Rust.&rdquo;</p>
<p>Não funcionou. O codebase é grande demais. Mais de um milhão de linhas de TypeScript espalhadas em 29 pacotes. O Claude não consegue manter tudo em contexto. O resultado inicial foi muito incompleto: muitos tipos criados mas sem implementação, <code>todo!()</code> por toda parte, boilerplate demais e funcionalidade de menos.</p>
<p>Troquei pro Codex 5.4 pra testar. Mesma coisa: pedi pra analisar e reescrever. Melhorou um pouco em certos aspectos, mas o problema fundamental é o mesmo. Nenhuma IA hoje pega um projeto desse tamanho e reescreve inteiro de uma vez. O contexto não cabe.</p>
<h2>A técnica que funciona<span class="hx:absolute hx:-mt-20" id="a-técnica-que-funciona"></span>
    <a href="#a-t%c3%a9cnica-que-funciona" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O que funciona é ir devagar. Um passo de cada vez.</p>
<p>Pede pro Claude (ou Codex) analisar o código original em etapas. Faz um plano longo detalhando cada feature. Depois implementa uma feature por vez em Rust, com testes, faz commit, e repete. É tedioso, mas é o que produz código que compila e funciona de verdade.</p>
<p>O motivo é simples: o código original é tão massivo que nenhum agente de IA (nem vários em paralelo) consegue manter tudo em contexto ao mesmo tempo. Você precisa decidir o que importa, implementar aquilo, validar, e seguir pro próximo.</p>
<p>E precisa decidir o que cortar. O OpenClaw tem 21 extensões de canal: Google Chat, iMessage, IRC, Teams, Matrix, Mattermost, Nostr, Twitch&hellip; Eu não preciso de nenhuma dessas. Mantive os canais mainstream: Web, Telegram, Discord, Slack, Signal, WhatsApp e Email. TTS? Fora. Polls? Fora. WhatsApp Web via Baileys? Fora, uso a Cloud API oficial. São features que adicionam complexidade sem valor proporcional.</p>
<h2>Descobrindo o IronClaw<span class="hx:absolute hx:-mt-20" id="descobrindo-o-ironclaw"></span>
    <a href="#descobrindo-o-ironclaw" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>No meio do desenvolvimento, descobri o <a href="https://github.com/nichochar/iron-claw"target="_blank" rel="noopener">IronClaw</a>, que se apresenta como &ldquo;OpenClaw em Rust&rdquo;. Ótimo, pensei. Vou ver o que eles fizeram.</p>
<p>Clonei o repositório e pedi pro Claude escrever um <a href="https://github.com/akitaonrails/frankclaw/blob/master/docs/IRONCLAW_COMPARISON.md"target="_blank" rel="noopener">relatório de comparação</a>. O IronClaw tem coisas boas. Adotei 12 features:</p>
<p>Circuit breaker com retry e backoff exponencial pra resiliência de providers LLM. Detecção de leak de credenciais no output. Cache de respostas LLM com SHA-256 da prompt. Cost tracking com budget guards (aviso em 80%, bloqueio em 100%). Extended thinking pra Claude 3.7+ e o1. MCP client pra tool servers externos. Lifecycle hooks em inbound, tool calls e outbound. Smart model routing que joga queries simples pra modelos mais baratos. Suporte a túneis (cloudflared, ngrok, tailscale). REPL interativo (<code>frankclaw chat</code>). Routines com event triggers além de cron. Job state machine com auto-repair.</p>
<p>Mas o IronClaw depende de PostgreSQL + pgvector, tem sandbox WASM (wasmtime adiciona ~10MB), e é parte do ecossistema NEAR AI. Eu quero um binário único com SQLite embarcado e zero dependências externas.</p>
<h2>O que o FrankClaw traz do OpenClaw<span class="hx:absolute hx:-mt-20" id="o-que-o-frankclaw-traz-do-openclaw"></span>
    <a href="#o-que-o-frankclaw-traz-do-openclaw" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O core do OpenClaw está lá: 7 canais de mensagem, multi-provider com failover, agent runtime, sistema de comandos, skills, subagents, browser automation via CDP, bash tool com allowlist, cron jobs, REPL interativo. Mais as 12 features do IronClaw que listei acima.</p>
<p>Mas vários pedaços precisaram ser reescritos de um jeito que o original não fez. A compactação de contexto, por exemplo, usa sliding window com estimativa de tokens, pruning de mensagens e repair automático de tool pairs que ficam órfãos quando o contexto é cortado. O failover entre providers agora é model-aware: se você pede um modelo Claude e o provider da vez é OpenAI, ele pula automaticamente em vez de dar erro. O canvas renderiza SVG, HTML e Markdown com detecção de conflitos de revisão. São coisas que no OpenClaw ou não existiam ou estavam pela metade.</p>
<p>Depois veio a fase de adicionar o que faltava pra paridade. O FrankClaw hoje tem 30+ LLM tools: <code>web_fetch</code> (SSRF-safe com HTML-to-text), <code>web_search</code> (Brave API), <code>file_read</code>/<code>file_write</code>/<code>file_edit</code> (sandboxed no workspace com proteção contra path traversal), <code>pdf_read</code>, <code>image_describe</code> (via modelos de vision), <code>audio_transcribe</code>, <code>sessions_list</code>/<code>sessions_history</code>/<code>sessions_delete</code>, <code>message_send</code>/<code>message_react</code>, <code>cron_list</code>/<code>cron_add</code>/<code>cron_remove</code>, <code>config_get</code> (auto-redação de secrets), <code>agents_list</code>, <code>memory_get</code>/<code>memory_search</code>, mais os browser tools (<code>browser_open</code>, <code>browser_extract</code>, <code>browser_snapshot</code>, <code>browser_click</code>, <code>browser_type</code>, <code>browser_wait</code>, <code>browser_press</code>, <code>browser_sessions</code>, <code>browser_close</code>, <code>browser_aria</code>).</p>
<p>Algumas adições que não existem no OpenClaw original: um sistema de memória/RAG com SQLite FTS5 e embeddings (OpenAI, Gemini, Voyage) que sincroniza arquivos do workspace automaticamente. Uma API compatível com OpenAI (<code>/v1/chat/completions</code> e <code>/v1/models</code>), então qualquer cliente que fala esse protocolo (Cursor, Continue, Open WebUI) pode usar o FrankClaw como backend sem adaptação. Uma TUI em <code>ratatui</code> pra quem prefere terminal. Aprovação interativa de tools destrutivos antes de executar.</p>
<p>Tem coisas menores que fazem diferença na prática. Você pode configurar múltiplas API keys por provider com round-robin e backoff automático, então se uma key estourar o rate limit, a próxima assume. O model catalog já sabe context windows e custos dos modelos OpenAI e Anthropic sem você ter que configurar. Extração de URLs de mensagens tem blocklist de IPs privados contra SSRF. O sistema de comandos aceita directives inline (<code>/think</code>, <code>/model</code>) além de aliases.</p>
<p>Do lado de operação: ACP (Agent Client Protocol) via JSON-RPC 2.0 sobre NDJSON pra quem quer integrar programaticamente. Sistema de plugins com manifests e lifecycle de enable/disable. i18n com 9 locales via <code>FRANKCLAW_LANG</code>. Workspace identity files (<code>SOUL.md</code>, <code>IDENTITY.md</code>) pra definir a personalidade do bot por projeto. Health monitor nos canais com auto-restart. WebSocket com ping keepalive que sobrevive timeouts de proxy e túnel. <code>frankclaw start/stop/status</code> pra quem quer rodar como daemon com PID tracking. E toda a configuração migrou de JSON pra TOML.</p>
<h2>Hardening: onde está o diferencial<span class="hx:absolute hx:-mt-20" id="hardening-onde-está-o-diferencial"></span>
    <a href="#hardening-onde-est%c3%a1-o-diferencial" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O <a href="https://github.com/akitaonrails/frankclaw/blob/master/docs/OPENCLAW_SECURITY_AUDIT.md"target="_blank" rel="noopener">relatório de auditoria</a> que rodamos no OpenClaw encontrou 7 vulnerabilidades críticas e 9 high. O FrankClaw resolve todas:</p>
<table>
  <thead>
      <tr>
          <th>Área</th>
          <th>OpenClaw</th>
          <th>FrankClaw</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Comparação de tokens</td>
          <td>SHA-256 + timingSafeEqual com early return que leaka timing</td>
          <td>Comparação constant-time byte a byte, sem early returns</td>
      </tr>
      <tr>
          <td>Shell execution</td>
          <td>Sem allowlist obrigatória</td>
          <td>Deny-all por padrão + binary allowlist + rejeição de metacaracteres + sandbox ai-jail opcional</td>
      </tr>
      <tr>
          <td>Browser tool</td>
          <td><code>eval()</code> sem sandbox</td>
          <td>CDP com timeout 15s, SSRF guard, crash recovery, ARIA inspection</td>
      </tr>
      <tr>
          <td>Webhook Slack</td>
          <td>Zero verificação de assinatura</td>
          <td>HMAC-SHA256 com proteção contra replay</td>
      </tr>
      <tr>
          <td>Webhook Discord</td>
          <td>Placeholder hardcoded</td>
          <td>Ed25519 com validação de timestamp</td>
      </tr>
      <tr>
          <td>Criptografia</td>
          <td>Plaintext no disco</td>
          <td>ChaCha20-Poly1305 em sessions e config</td>
      </tr>
      <tr>
          <td>Password hashing</td>
          <td>Sem autenticação por senha</td>
          <td>Argon2id (t=3, m=64MB, p=4)</td>
      </tr>
      <tr>
          <td>Permissões de arquivo</td>
          <td>0o644 (world-readable)</td>
          <td>0o600 (owner-only)</td>
      </tr>
      <tr>
          <td>Prompt injection</td>
          <td>Sanitização básica</td>
          <td>Unicode Cc/Cf stripping + boundary tags + limite de 2MB</td>
      </tr>
      <tr>
          <td>Malware scanning</td>
          <td>Nenhum</td>
          <td>VirusTotal opcional em uploads</td>
      </tr>
      <tr>
          <td>Validação de input</td>
          <td>Sem limites</td>
          <td>IDs de 255 bytes, session keys de 800 bytes, frames WS configuráveis</td>
      </tr>
      <tr>
          <td>SSRF</td>
          <td>Proteção parcial</td>
          <td>Blocklist completa (RFC 1918, loopback, CGNAT, link-local) + DNS rebinding defense</td>
      </tr>
      <tr>
          <td>Tool execution</td>
          <td>Sem confirmação do usuário</td>
          <td>Aprovação interativa pra tools mutating/destructive</td>
      </tr>
  </tbody>
</table>
<p>O FrankClaw compila com <code>#![forbid(unsafe_code)]</code> em todos os 13 crates. Zero blocos unsafe.</p>
<p>E a auditoria não parou no OpenClaw. Fizemos uma <a href="https://github.com/akitaonrails/frankclaw/blob/master/docs/AUDIT_PLAN.md"target="_blank" rel="noopener">auditoria por componente</a> em 14 fases comparando cada parte do FrankClaw contra o original: canais, providers, runtime, tools, sessions, crypto, cron, webhooks. Tudo documentado.</p>
<h2>Deploy: como instalar<span class="hx:absolute hx:-mt-20" id="deploy-como-instalar"></span>
    <a href="#deploy-como-instalar" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O FrankClaw roda com Docker Compose. Três containers: gateway, Chromium headless (pra browser tools), e Cloudflare tunnel (pra receber webhooks).</p>
<h3>1. Clonar e configurar<span class="hx:absolute hx:-mt-20" id="1-clonar-e-configurar"></span>
    <a href="#1-clonar-e-configurar" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git clone https://github.com/akitaonrails/frankclaw.git
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> frankclaw
</span></span><span class="line"><span class="cl">cp .env.docker.example .env.docker</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Edite o <code>.env.docker</code> com suas chaves:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Providers de IA (preencha os que usar)</span>
</span></span><span class="line"><span class="cl"><span class="nv">OPENAI_API_KEY</span><span class="o">=</span>
</span></span><span class="line"><span class="cl"><span class="nv">ANTHROPIC_API_KEY</span><span class="o">=</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Canais (preencha os que quiser usar)</span>
</span></span><span class="line"><span class="cl"><span class="nv">TELEGRAM_BOT_TOKEN</span><span class="o">=</span>         <span class="c1"># via @BotFather</span>
</span></span><span class="line"><span class="cl"><span class="nv">WHATSAPP_TOKEN</span><span class="o">=</span>             <span class="c1"># Meta Business Platform</span>
</span></span><span class="line"><span class="cl"><span class="nv">WHATSAPP_PHONE_ID</span><span class="o">=</span>
</span></span><span class="line"><span class="cl"><span class="nv">WHATSAPP_VERIFY_TOKEN</span><span class="o">=</span>
</span></span><span class="line"><span class="cl"><span class="nv">DISCORD_BOT_TOKEN</span><span class="o">=</span>
</span></span><span class="line"><span class="cl"><span class="nv">SLACK_BOT_TOKEN</span><span class="o">=</span>
</span></span><span class="line"><span class="cl"><span class="nv">SLACK_APP_TOKEN</span><span class="o">=</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Embedding providers (só se usar memória/RAG)</span>
</span></span><span class="line"><span class="cl"><span class="c1"># GEMINI_API_KEY=</span>
</span></span><span class="line"><span class="cl"><span class="c1"># VOYAGE_API_KEY=</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Opcional: criptografia de sessions (recomendado)</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Gere com: openssl rand -base64 32</span>
</span></span><span class="line"><span class="cl"><span class="nv">FRANKCLAW_MASTER_KEY</span><span class="o">=</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Opcional: scan de malware em uploads</span>
</span></span><span class="line"><span class="cl"><span class="nv">VIRUSTOTAL_API_KEY</span><span class="o">=</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>2. Configurar o gateway<span class="hx:absolute hx:-mt-20" id="2-configurar-o-gateway"></span>
    <a href="#2-configurar-o-gateway" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>O arquivo <code>frankclaw.toml</code> define agentes, modelos e canais. Use o wizard ou os exemplos:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Gerar config base com canal web</span>
</span></span><span class="line"><span class="cl">cargo run -- onboard --channel web
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Ou copiar dos exemplos</span>
</span></span><span class="line"><span class="cl">ls examples/channels/</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Pra cada canal, o CLI tem templates prontos:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cargo run -- config-example --channel telegram
</span></span><span class="line"><span class="cl">cargo run -- config-example --channel whatsapp</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>3. Cloudflare Tunnel (pra receber webhooks)<span class="hx:absolute hx:-mt-20" id="3-cloudflare-tunnel-pra-receber-webhooks"></span>
    <a href="#3-cloudflare-tunnel-pra-receber-webhooks" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Se você vai usar canais que precisam de webhook (Telegram, Discord, Slack, WhatsApp), precisa de um túnel público. O Docker Compose já inclui o cloudflared:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Copie suas credenciais do Cloudflare</span>
</span></span><span class="line"><span class="cl">cp docker/cloudflared/config.yml.example docker/cloudflared/config.yml
</span></span><span class="line"><span class="cl">cp ~/.cloudflared/&lt;tunnel-id&gt;.json docker/cloudflared/credentials.json
</span></span><span class="line"><span class="cl">cp ~/.cloudflared/cert.pem docker/cloudflared/cert.pem
</span></span><span class="line"><span class="cl"><span class="c1"># Edite config.yml com seu hostname</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>4. Subir<span class="hx:absolute hx:-mt-20" id="4-subir"></span>
    <a href="#4-subir" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose up -d</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O gateway sobe na porta 18789 (interna ao Docker). O cloudflared roteia o tráfego externo. O Chromium fica na rede interna pra browser tools.</p>
<p>Pra testar local sem Docker:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cargo run -- chat</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Abre o REPL interativo direto no terminal (agora também tem uma TUI em <code>ratatui</code> com dark mode e tabs). Sem gateway, sem webhook. Bom pra validar que o provider de IA está respondendo antes de configurar canais.</p>
<h3>Validação<span class="hx:absolute hx:-mt-20" id="validação"></span>
    <a href="#valida%c3%a7%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cargo run -- check     <span class="c1"># valida config</span>
</span></span><span class="line"><span class="cl">cargo run -- doctor    <span class="c1"># diagnóstico completo</span>
</span></span><span class="line"><span class="cl">cargo run -- audit     <span class="c1"># auditoria de segurança com severity ratings</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O <code>audit</code> é o que eu mais gosto. Ele verifica se você tem criptografia habilitada, se as permissões de arquivo estão corretas, se os webhooks têm verificação de assinatura, se o bash tool está em deny-all. Sai com exit code non-zero se encontra problemas críticos, então dá pra colocar no CI.</p>
<h2>O processo de desenvolvimento<span class="hx:absolute hx:-mt-20" id="o-processo-de-desenvolvimento"></span>
    <a href="#o-processo-de-desenvolvimento" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O projeto tem 178 commits em ~5 dias de trabalho (10-16 de março). Quase 57 mil linhas de Rust em 120 arquivos, organizados em 13 crates.</p>
<p>Os commits contam a história. As primeiras dezenas foram scaffold: estrutura do workspace, tipos básicos, o gateway HTTP/WebSocket, primeira versão dos channel adapters. Código gerado em massa, muita coisa incompleta.</p>
<p>Depois começaram os adapters de canal, um por um:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>236aa1c - Minimal Discord channel adapter
fd67017 - Minimal Slack channel adapter
9f51373 - Minimal Signal channel adapter
1052e47 - WhatsApp channel webhook adapter
035f86e - Email channel adapter (IMAP inbound, SMTP outbound)</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Quando os canais básicos estavam no lugar, veio a integração do IronClaw num commit grande:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>c87ab32 - IronClaw-derived features: circuit breaker, retry, leak detection, cache, cost tracking, extended thinking</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>E aí veio o hardening. Essa foi a fase que eu mais tive que intervir manualmente, porque o Claude gera código funcional mas não pensa em vetores de ataque sozinho:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>db34198 - Prompt injection sanitization, external content wrapping, prompt size limit
5719b34 - Optional VirusTotal malware scanning for file uploads
ccd2b2b - Harden input validation across all user-facing entry points
aa918ee - Optional ai-jail sandbox for bash tool
2d7b1df - Security audit command with severity-rated findings
d12cc97 - 3-tier ToolRiskLevel system replacing binary browser mutation flag
21e0c91 - Timing-safe token comparison in WhatsApp, crypto audit tests
e240c1b - Webhook replay prevention with timestamp verification
876a78c - Gateway &amp; media: SSRF redirect validation, filename hardening</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>22 commits de security hardening. Mais 10 de auditoria por componente. Cada achado virou um commit com fix.</p>
<p>Depois vieram as auditorias específicas por canal, cada uma descobrindo edge cases diferentes:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>43b085f - Discord audit: HELLO timeout, fatal close codes, message chunking
12c7cff - Telegram audit: caption overflow, parse fallback, edit idempotency
f515062 - WhatsApp audit: message type filtering, send error classification
3c42aff - Slack audit: fatal auth errors, send error classification</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Browser tools precisaram de atenção extra. Um headless Chrome que recebe URLs de um LLM é um vetor de ataque óbvio:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>3217a96 - Browser automation: CDP timeout, SSRF guard, session limits, crash recovery
d98a803 - Gate mutating browser tools behind operator approval
014f56e - Browser screenshot/ARIA tools for accessibility tree inspection</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Nos commits mais recentes, o projeto começou a divergir do OpenClaw:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>5d73c4d - OpenAI-compatible HTTP API (/v1/chat/completions, /v1/models)
d832c36 - Memory/RAG system with SQLite FTS5, embeddings, and file sync
2b05f47 - Interactive tool approval for mutating/destructive tools
49034eb - Web console: dark mode, 8 tabs, focus mode, tool sidebar
9f51a18 - TUI, Gemini/Voyage embeddings, plugin management, ACP protocol
3c0703b - Config migration from JSON to TOML
eb130ad - Channel health monitor with auto-restart and rate limiting
2c3ea7e - Workspace bootstrap files (SOUL.md, IDENTITY.md) to system prompt
9df61bf - Model-aware failover routing, canvas SVG rendering
c7ba108 - WebSocket ping keepalive and auto-reconnect to web console</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>A API compatível com OpenAI é a que eu mais uso no dia a dia. Cursor, Continue, Open WebUI, qualquer coisa que fala o protocolo OpenAI consegue usar o FrankClaw como backend sem mexer em nada do lado do cliente.</p>
<h2>Os números<span class="hx:absolute hx:-mt-20" id="os-números"></span>
    <a href="#os-n%c3%bameros" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><table>
  <thead>
      <tr>
          <th>Métrica</th>
          <th>Valor</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Commits</td>
          <td>178</td>
      </tr>
      <tr>
          <td>Dias de trabalho</td>
          <td>~5</td>
      </tr>
      <tr>
          <td>Linhas de Rust</td>
          <td>56.586</td>
      </tr>
      <tr>
          <td>Arquivos Rust</td>
          <td>120</td>
      </tr>
      <tr>
          <td>Crates</td>
          <td>13</td>
      </tr>
      <tr>
          <td>LLM tools</td>
          <td>30+</td>
      </tr>
      <tr>
          <td>Commits de security hardening</td>
          <td>22</td>
      </tr>
      <tr>
          <td>Commits de auditoria</td>
          <td>10</td>
      </tr>
      <tr>
          <td>Canais suportados</td>
          <td>7</td>
      </tr>
      <tr>
          <td>Providers de IA</td>
          <td>9 (OpenAI, Anthropic, Ollama, Google, OpenRouter, Copilot, Groq, Together, DeepSeek)</td>
      </tr>
      <tr>
          <td>Vulnerabilidades críticas do OpenClaw resolvidas</td>
          <td>7/7</td>
      </tr>
      <tr>
          <td>Vulnerabilidades high do OpenClaw resolvidas</td>
          <td>9/9</td>
      </tr>
      <tr>
          <td>Blocos unsafe no código</td>
          <td>0</td>
      </tr>
  </tbody>
</table>
<p>Comparado ao OpenClaw:</p>
<table>
  <thead>
      <tr>
          <th>Métrica</th>
          <th>OpenClaw (TS)</th>
          <th>FrankClaw (Rust)</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Linhas de código</td>
          <td>~1.247.000</td>
          <td>56.586</td>
      </tr>
      <tr>
          <td>Arquivos fonte</td>
          <td>3.794</td>
          <td>120</td>
      </tr>
      <tr>
          <td>Dependências runtime</td>
          <td>73</td>
          <td>~40 crates</td>
      </tr>
      <tr>
          <td>Canais</td>
          <td>28</td>
          <td>7</td>
      </tr>
      <tr>
          <td>Vulnerabilidades críticas</td>
          <td>7 conhecidas</td>
          <td>0</td>
      </tr>
  </tbody>
</table>
<p>Os números não são diretamente comparáveis. O OpenClaw tem 21 extensões de canal que eu cortei, uma UI web mais completa, e features de nicho que não portei. Mas o core (gateway, canais mainstream, providers, runtime, sessions, tools, memória) está lá com 22x menos código.</p>
<h2>Como ajudar (beta testing)<span class="hx:absolute hx:-mt-20" id="como-ajudar-beta-testing"></span>
    <a href="#como-ajudar-beta-testing" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O FrankClaw funciona pra conversa básica via Web e Telegram (testei ambos). WhatsApp funciona pra mensagens simples. Discord, Slack, Signal e Email estão implementados mas não tiveram teste extensivo. Não fizemos nenhum workflow complicado ainda.</p>
<p>Se você quer testar: clone o repositório, suba com Docker Compose, configure pelo menos um canal (Telegram é o mais fácil) e tente usar normalmente. Mande mensagens, teste tool calls, tente quebrar o sistema. Abra Issues no GitHub com o que encontrar.</p>
<p>O que eu sei que precisa de mais olhos: workflows com tools (browser, bash, MCP), subagent orchestration, failover entre providers, session persistence com criptografia, smart routing entre modelos, scheduled jobs, o sistema de memória/RAG, e a API OpenAI-compatible. Basicamente tudo que vai além de &ldquo;manda mensagem, recebe resposta&rdquo;.</p>
<p>Não precisa ser desenvolvedor Rust. O valor maior está em usar o sistema de formas que eu não pensei e encontrar os edge cases que só aparecem com uso real.</p>
<p>O FrankClaw não substitui o OpenClaw hoje. O OpenClaw tem mais canais, mais features, mais gente trabalhando nele. Mas carrega o peso de mais de um milhão de linhas de TypeScript geradas a 50 commits por dia por dezenas de contribuidores, com vulnerabilidades críticas documentadas. O FrankClaw é a alternativa pra quem olha pra isso e pensa &ldquo;eu não vou rodar esse código na minha máquina&rdquo;.</p>
<p>Mas vou ser honesto: apesar de ter sido divertido construir, eu pessoalmente não sei se preciso disso. O FrankClaw é um gateway genérico, feito pra ser flexível, conectar qualquer canal a qualquer provider, com runtime de agentes, tools, subagents, jobs, hooks. É muita infraestrutura.</p>
<p>O que eu descobri nos últimos meses é que eu consigo construir bots customizados pra tarefas específicas muito mais rápido. Em 1 dia eu tenho um bot funcionando, focado no que eu preciso, sem carregar o peso de um framework genérico. Foi o que eu fiz com o <a href="/2026/02/20/discord-como-admin-panel-bastidores-do-the-m-akita-chronicles/">Marvin</a> no projeto da newsletter, por exemplo. Um bot de Discord feito sob medida, que faz exatamente o que eu preciso e nada mais.</p>
<p>Um gateway genérico como o FrankClaw faz mais sentido pra quem quer uma interface unificada entre vários canais de chat e vários modelos de IA sem programar. Se esse é o seu caso, experimenta. Se você é desenvolvedor e sabe o que quer, talvez um bot customizado te sirva melhor. Fica a seu critério.</p>
<p>O <a href="https://github.com/akitaonrails/frankclaw"target="_blank" rel="noopener">repositório está aqui</a>. AGPL-3.0.</p>
]]></content:encoded><category>frank</category><category>llms</category><category>inteligencia-artificial</category><category>rust</category></item><item><title>Atacando fraudes via email | Frank FBI</title><link>https://akitaonrails.github.io/2026/03/09/atacando-fraudes-via-email-frank-fbi/</link><guid isPermaLink="true">https://akitaonrails.github.io/2026/03/09/atacando-fraudes-via-email-frank-fbi/</guid><pubDate>Mon, 09 Mar 2026 13:00:00 -0300</pubDate><description>&lt;p&gt;Esse fim de semana eu trabalhei em 3 projetos. Dois deles eu já publiquei: o &lt;a href="https://akitaonrails.github.io/2026/03/07/crystal-e-um-wrapper-inteligente-pro-ffmpeg-feito-em-3-horas-easy-ffmpeg/"&gt;easy-ffmpeg&lt;/a&gt;, um wrapper inteligente pro FFmpeg em Crystal, e o &lt;a href="https://akitaonrails.github.io/2026/03/07/portando-10-mil-linhas-de-python-pra-crystal-com-claude-easy-subtitle/"&gt;easy-subtitle&lt;/a&gt;, um port de 10 mil linhas de Python pra Crystal em menos de 40 minutos. Continuo melhorando os dois, adicionando features e corrigindo edge cases conforme uso no dia-a-dia.&lt;/p&gt;
&lt;p&gt;Mas o terceiro projeto é de outra natureza. É um projeto de segurança. E a motivação vem de uma dor antiga.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Esse fim de semana eu trabalhei em 3 projetos. Dois deles eu já publiquei: o <a href="/2026/03/07/crystal-e-um-wrapper-inteligente-pro-ffmpeg-feito-em-3-horas-easy-ffmpeg/">easy-ffmpeg</a>, um wrapper inteligente pro FFmpeg em Crystal, e o <a href="/2026/03/07/portando-10-mil-linhas-de-python-pra-crystal-com-claude-easy-subtitle/">easy-subtitle</a>, um port de 10 mil linhas de Python pra Crystal em menos de 40 minutos. Continuo melhorando os dois, adicionando features e corrigindo edge cases conforme uso no dia-a-dia.</p>
<p>Mas o terceiro projeto é de outra natureza. É um projeto de segurança. E a motivação vem de uma dor antiga.</p>
<h2>O problema: emails demais<span class="hx:absolute hx:-mt-20" id="o-problema-emails-demais"></span>
    <a href="#o-problema-emails-demais" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Como ex-YouTuber e criador de conteúdo, eu recebo uma quantidade absurda de emails. Convites pra eventos, propostas de collab, ofertas de patrocínio, pedidos de divulgação. Todo tipo de abordagem.</p>
<p>Eu deleto 100% deles. Não leio. A maioria eu marco como SPAM sem sequer abrir. A forma mais fácil de ser reportado é me mandar um email &ndash; eu não ligo, porque eu não preciso. Eu também não atendo telefone. Nunca. E bloqueio automaticamente qualquer pessoa que mande mensagem direto no meu WhatsApp, independente do conteúdo. Meu tempo é valioso demais pra eu ficar triando mensagem de desconhecido. Deleto tudo e sigo em frente.</p>
<p>Funciona pra mim. Mas eu sei que a maioria das pessoas não opera assim.</p>
<h2>O veneno é a VAIDADE<span class="hx:absolute hx:-mt-20" id="o-veneno-é-a-vaidade"></span>
    <a href="#o-veneno-%c3%a9-a-vaidade" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A maioria das pessoas que cai em phishing por email não cai por ignorância técnica. Cai por <strong>VAIDADE</strong>.</p>
<p>&ldquo;Olá, queremos te convidar pra nosso evento exclusivo.&rdquo; &ldquo;Sua marca foi selecionada para uma parceria especial.&rdquo; &ldquo;Parabéns, você foi indicado como referência no seu setor.&rdquo;</p>
<p>A dopamina bate antes da razão ter chance de intervir. Alguém reconheceu seu trabalho, alguém quer te dar dinheiro. É exatamente aí que o golpista te pega.</p>
<p>Não é o CEO de multinacional que cai no golpe do príncipe nigeriano. É o microinfluencer que recebe uma proposta de patrocínio &ldquo;boa demais pra ser verdade&rdquo;. É o pequeno empresário que recebe um convite pra um evento que parece legítimo. A vaidade desliga o senso crítico.</p>
<p>E os golpes estão cada vez mais sofisticados. Com LLMs, qualquer criminoso gera emails perfeitos em português, sem erros gramaticais, com formatação profissional, com domínios que imitam empresas reais. O velho &ldquo;olha os erros de português&rdquo; já não serve mais como filtro.</p>
<h2>A ideia: Frank FBI<span class="hx:absolute hx:-mt-20" id="a-ideia-frank-fbi"></span>
    <a href="#a-ideia-frank-fbi" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Em vez de tentar ensinar todo mundo a identificar phishing (o que não funciona, porque a vaidade é mais forte que o treinamento), por que não criar uma ferramenta que faça isso automaticamente?</p>
<p>Recebeu um email suspeito? Encaminha pra um endereço dedicado. Em poucos minutos, recebe de volta um relatório detalhado com tudo que a ferramenta conseguiu descobrir sobre aquele email.</p>
<p>Assim nasceu o <a href="https://github.com/akitaonrails/frank_fbi"target="_blank" rel="noopener">Frank FBI</a> &ndash; Fraud Bureau of Investigation.</p>
<h2>Como funciona<span class="hx:absolute hx:-mt-20" id="como-funciona"></span>
    <a href="#como-funciona" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Você configura uma conta Gmail dedicada (pode ser qualquer Gmail com App Password). Cadastra os emails autorizados a usar o serviço. A partir daí, o fluxo é:</p>
<ol>
<li>Recebe email suspeito na sua caixa pessoal</li>
<li>Encaminha pro endereço do Frank FBI</li>
<li>O sistema analisa automaticamente</li>
<li>Recebe a resposta na mesma thread, com o relatório completo</li>
</ol>
<p>Pra dar uma ideia do resultado, aqui está um relatório de um email legítimo (cold outreach de uma empresa real):</p>
<p><img src="https://raw.githubusercontent.com/akitaonrails/frank_fbi/master/docs/ok-email.png" alt="Relatório de email legítimo - score 80/100"  loading="lazy" /></p>
<p>E aqui um email suspeito, onde o domínio do remetente tenta se passar por outra empresa:</p>
<p><img src="https://raw.githubusercontent.com/akitaonrails/frank_fbi/master/docs/suspect-email.png" alt="Relatório de email suspeito - score 40/100"  loading="lazy" /></p>
<p>O Frank FBI roda 6 camadas de análise, cada uma com peso específico na pontuação final:</p>
<h3>Camada 1 &ndash; Autenticação de headers (peso 15%)<span class="hx:absolute hx:-mt-20" id="camada-1--autenticação-de-headers-peso-15"></span>
    <a href="#camada-1--autentica%c3%a7%c3%a3o-de-headers-peso-15" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>SPF, DKIM, DMARC. O Reply-To bate com o From? Tem headers antispam suspeitos? Essa camada responde a pergunta mais básica: o email realmente veio de quem diz que veio?</p>
<h3>Camada 2 &ndash; Reputação do remetente (peso 15%)<span class="hx:absolute hx:-mt-20" id="camada-2--reputação-do-remetente-peso-15"></span>
    <a href="#camada-2--reputa%c3%a7%c3%a3o-do-remetente-peso-15" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Consulta a idade do domínio via WHOIS (domínio registrado semana passada já é um sinal), verifica se o IP está em listas negras de DNS (DNSBL), e mantém um banco de reputação local que melhora conforme mais emails são analisados. Se o remetente finge ser uma empresa mas manda de um Gmail, isso pesa.</p>
<h3>Camada 3 &ndash; Análise de conteúdo (peso 15%)<span class="hx:absolute hx:-mt-20" id="camada-3--análise-de-conteúdo-peso-15"></span>
    <a href="#camada-3--an%c3%a1lise-de-conte%c3%bado-peso-15" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Aqui entra o pattern matching: urgência artificial (&ldquo;sua conta será bloqueada em 24h&rdquo;), pedidos de dados pessoais, impersonação de autoridade, ofertas financeiras. Também detecta URL shorteners e links onde o texto exibido não bate com o href real &ndash; aquele clássico &ldquo;clique aqui&rdquo; que aponta pra um domínio completamente diferente. Verifica anexos perigosos (.exe, .scr, macros em Office).</p>
<h3>Camada 4 &ndash; APIs externas (peso 15%)<span class="hx:absolute hx:-mt-20" id="camada-4--apis-externas-peso-15"></span>
    <a href="#camada-4--apis-externas-peso-15" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>O URLhaus (abuse.ch) mantém um banco de URLs maliciosas conhecido. O VirusTotal agrega resultados de dezenas de antivírus. Se alguma URL do email já foi flagada como malware ou phishing por essas bases, o score sobe. Resultados ficam em cache com TTL pra não estourar os rate limits das APIs gratuitas.</p>
<h3>Camada 5 &ndash; Verificação de entidade (peso 10%)<span class="hx:absolute hx:-mt-20" id="camada-5--verificação-de-entidade-peso-10"></span>
    <a href="#camada-5--verifica%c3%a7%c3%a3o-de-entidade-peso-10" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Essa camada eu acho a mais interessante. O Frank FBI faz OSINT &ndash; Open Source Intelligence. Usa o Brave Search pra verificar se o remetente ou empresa realmente existe. Faz WHOIS do domínio diretamente. Captura screenshot do site com Chrome headless. Cruza tudo pra tentar responder: &ldquo;essa entidade é real e é quem diz ser?&rdquo;</p>
<p>Olha um exemplo real dessa camada em ação, analisando um email que tentava se passar por uma empresa legítima:</p>
<p><img src="https://raw.githubusercontent.com/akitaonrails/frank_fbi/master/docs/verificacao-identidade.png" alt="Verificação de identidade - OSINT detalhado"  loading="lazy" /></p>
<p>O domínio tinha 83 dias de vida, registrado via Namecheap, sem presença online verificável. O sistema encontrou divergências entre o nome no email e os registros públicos, e identificou que o domínio legítimo da empresa real era outro.</p>
<h3>Camada 6 &ndash; Análise por LLM (peso 30%)<span class="hx:absolute hx:-mt-20" id="camada-6--análise-por-llm-peso-30"></span>
    <a href="#camada-6--an%c3%a1lise-por-llm-peso-30" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Consulta 3 modelos de IA em paralelo: Claude Sonnet (Anthropic), GPT-4o (OpenAI) e Grok 4 (xAI), via OpenRouter. Cada modelo analisa o email de forma independente. Um sistema de consenso com peso por confiança combina os resultados. Se os 3 concordam que é fraude, a confiança é alta. Se divergem, o sistema pondera. Se todos os LLMs falharem, cai pra um score neutro de 50 em vez de adivinhar &ndash; melhor admitir ignorância do que alucinar.</p>
<p>A pontuação final é uma média ponderada com ajuste de confiança. Vereditos possíveis: Legítimo (0-25), Suspeito OK (26-50), Suspeito Fraude (51-75) ou Fraudulento (76-100). Existe ainda uma política de escalação de risco que impõe pisos mínimos: se indicadores críticos foram detectados (URLs maliciosas confirmadas, DKIM falhou), o score não pode ficar abaixo de certos limiares, mesmo que outras camadas não tenham achado nada.</p>
<h2>Self-hosted: seus dados ficam com você<span class="hx:absolute hx:-mt-20" id="self-hosted-seus-dados-ficam-com-você"></span>
    <a href="#self-hosted-seus-dados-ficam-com-voc%c3%aa" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O Frank FBI é self-hosted. Você roda no seu próprio servidor. Seus emails não passam por nenhum serviço de terceiro (exceto as APIs de verificação como VirusTotal, que recebem apenas URLs, não o conteúdo do email).</p>
<p>Dá pra instalar no seu home server, como eu fiz, ou dentro da infraestrutura da sua empresa pra que seus funcionários usem. O deploy é via Docker Compose com 4 containers: aplicação Rails, worker de jobs em background, poller IMAP, e um setup inicial de banco. Sobe tudo com um <code>docker compose up -d</code> e está funcionando.</p>
<h2>Reportando fraudes pra comunidade<span class="hx:absolute hx:-mt-20" id="reportando-fraudes-pra-comunidade"></span>
    <a href="#reportando-fraudes-pra-comunidade" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Além de analisar emails pra você, o Frank FBI pode opcionalmente reportar fraudes confirmadas pra bancos de dados comunitários. Isso só acontece quando o score é &gt;= 85 e o veredito é &ldquo;fraudulento&rdquo;. É opt-in.</p>
<p>O ThreatFox (abuse.ch) é um banco de dados aberto de indicadores de comprometimento (IOCs) mantido pela comunidade de segurança. Quando você reporta uma URL ou domínio malicioso lá, firewalls, filtros de email e SIEMs ao redor do mundo podem consumir essa informação pra bloquear a ameaça.</p>
<p>O AbuseIPDB é a mesma ideia, mas pra IPs. Se o IP do remetente do email fraudulento é reportado aqui, provedores de email e administradores de rede podem bloquear tráfego malicioso antes que chegue aos usuários.</p>
<p>E o SpamCop é um dos serviços mais antigos de reporting de spam. O Frank FBI encaminha o email completo pro SpamCop, que analisa os headers e reporta pros provedores responsáveis. É denunciar direto pra quem pode agir.</p>
<p>Cada report é uma contribuição pra que outras pessoas não caiam no mesmo golpe. E é automatizado: se o email é claramente fraudulento, o report sai sem intervenção manual.</p>
<p>Mas reportar coisas erradas causa dano. Por isso o sistema tem proteções anti-envenenamento: uma lista de ~40 domínios conhecidos (Microsoft, Apple, Google, Amazon, PayPal, governos) que nunca são reportados; domínios com scan limpo são excluídos; IPs de infraestrutura de cloud (Google, Microsoft, Cloudflare) são filtrados; domínios de email gratuito são ignorados. Só IOCs genuinamente maliciosos chegam aos bancos comunitários.</p>
<h2>Deploy: como colocar pra rodar<span class="hx:absolute hx:-mt-20" id="deploy-como-colocar-pra-rodar"></span>
    <a href="#deploy-como-colocar-pra-rodar" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Vou explicar como eu fiz o deploy no meu home server. Se você tem um VPS, NAS ou qualquer máquina Linux com Docker, o processo é o mesmo.</p>
<h3>1. Clonar e buildar a imagem<span class="hx:absolute hx:-mt-20" id="1-clonar-e-buildar-a-imagem"></span>
    <a href="#1-clonar-e-buildar-a-imagem" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Você precisa de um registry privado pra guardar a imagem Docker. Eu uso <a href="https://gitea.io/"target="_blank" rel="noopener">Gitea</a> no meu home server &ndash; é um GitHub self-hosted leve que inclui container registry. Se você já tem um GitHub privado, pode usar o GitHub Container Registry (ghcr.io) em vez disso.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Clonar o repositório</span>
</span></span><span class="line"><span class="cl">git clone https://github.com/akitaonrails/frank_fbi.git
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> frank_fbi
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Buildar a imagem Docker</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Se usa Gitea (troque pelo IP/porta do seu registry):</span>
</span></span><span class="line"><span class="cl">docker build -t seu-servidor:3007/frank_fbi:latest .
</span></span><span class="line"><span class="cl">docker push seu-servidor:3007/frank_fbi:latest
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Se usa GitHub Container Registry:</span>
</span></span><span class="line"><span class="cl">docker build -t ghcr.io/seu-usuario/frank_fbi:latest .
</span></span><span class="line"><span class="cl">docker push ghcr.io/seu-usuario/frank_fbi:latest</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>2. Configurar o .env<span class="hx:absolute hx:-mt-20" id="2-configurar-o-env"></span>
    <a href="#2-configurar-o-env" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Copie o <code>.env.example</code> e preencha. As variáveis obrigatórias:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Gere com: ruby -rsecurerandom -e &#39;puts SecureRandom.hex(64)&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">SECRET_KEY_BASE</span><span class="o">=</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Gere com: bin/rails db:encryption:init (roda local, copia os 3 valores)</span>
</span></span><span class="line"><span class="cl"><span class="nv">ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY</span><span class="o">=</span>
</span></span><span class="line"><span class="cl"><span class="nv">ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY</span><span class="o">=</span>
</span></span><span class="line"><span class="cl"><span class="nv">ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT</span><span class="o">=</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Gmail dedicado pro Frank FBI (crie uma conta só pra isso)</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Ative 2FA e gere um App Password em https://myaccount.google.com/apppasswords</span>
</span></span><span class="line"><span class="cl"><span class="nv">GMAIL_USERNAME</span><span class="o">=</span>seu-frank-fbi@gmail.com
</span></span><span class="line"><span class="cl"><span class="nv">GMAIL_PASSWORD</span><span class="o">=</span>xxxx-xxxx-xxxx-xxxx
</span></span><span class="line"><span class="cl"><span class="nv">GMAIL_IMAP_HOST</span><span class="o">=</span>imap.gmail.com
</span></span><span class="line"><span class="cl"><span class="nv">GMAIL_SMTP_HOST</span><span class="o">=</span>smtp.gmail.com
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Senha do ingress do Action Mailbox (qualquer string aleatória)</span>
</span></span><span class="line"><span class="cl"><span class="nv">RAILS_INBOUND_EMAIL_PASSWORD</span><span class="o">=</span>uma-senha-qualquer-longa
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># LLM via OpenRouter (obrigatório pra Camada 6)</span>
</span></span><span class="line"><span class="cl"><span class="nv">OPENROUTER_API_KEY</span><span class="o">=</span>sua-chave-openrouter
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Seu email de admin (pra gerenciar remetentes autorizados)</span>
</span></span><span class="line"><span class="cl"><span class="nv">ADMIN_EMAIL</span><span class="o">=</span>seu-email@pessoal.com</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>As APIs externas são opcionais mas recomendadas. Sem elas, as camadas correspondentes simplesmente não rodam:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># VirusTotal (grátis, 4 requests/min) - https://virustotal.com</span>
</span></span><span class="line"><span class="cl"><span class="nv">VIRUSTOTAL_API_KEY</span><span class="o">=</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># WhoisXML (grátis, 500 requests/mês) - https://whoisxmlapi.com</span>
</span></span><span class="line"><span class="cl"><span class="nv">WHOISXML_API_KEY</span><span class="o">=</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Brave Search (grátis, 1 req/s) - https://brave.com/search/api/</span>
</span></span><span class="line"><span class="cl"><span class="nv">BRAVE_SEARCH_API_KEY</span><span class="o">=</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>E o community reporting, que é totalmente opt-in. Deixe em branco se não quiser reportar:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">THREATFOX_AUTH_KEY</span><span class="o">=</span>
</span></span><span class="line"><span class="cl"><span class="nv">ABUSEIPDB_API_KEY</span><span class="o">=</span>
</span></span><span class="line"><span class="cl"><span class="nv">SPAMCOP_SUBMISSION_ADDRESS</span><span class="o">=</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>3. Docker Compose no servidor<span class="hx:absolute hx:-mt-20" id="3-docker-compose-no-servidor"></span>
    <a href="#3-docker-compose-no-servidor" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Crie o <code>docker-compose.yml</code> no seu servidor. Troque a imagem pelo seu registry:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">setup</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">seu-servidor:3007/frank_fbi:latest </span><span class="w"> </span><span class="c"># ou ghcr.io/seu-usuario/frank_fbi:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;./bin/rails&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;db:prepare&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env_file</span><span class="p">:</span><span class="w"> </span><span class="l">.env</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">RAILS_ENV=production</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./storage:/rails/storage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">seu-servidor:3007/frank_fbi:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env_file</span><span class="p">:</span><span class="w"> </span><span class="l">.env</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">RAILS_ENV=production</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./storage:/rails/storage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./emails:/rails/emails</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">setup</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_completed_successfully</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;curl&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-f&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;http://localhost:3000/up&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">10s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">timeout</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">start_period</span><span class="p">:</span><span class="w"> </span><span class="l">15s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">worker</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">seu-servidor:3007/frank_fbi:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;./bin/jobs&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env_file</span><span class="p">:</span><span class="w"> </span><span class="l">.env</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">RAILS_ENV=production</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./storage:/rails/storage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">setup</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_completed_successfully</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">mail_fetcher</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">seu-servidor:3007/frank_fbi:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;./bin/rails&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;frank_fbi:fetch_mail&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env_file</span><span class="p">:</span><span class="w"> </span><span class="l">.env</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">RAILS_ENV=production</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">APP_HOST=http://app:3000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./storage:/rails/storage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>4. Subir e cadastrar o primeiro usuário<span class="hx:absolute hx:-mt-20" id="4-subir-e-cadastrar-o-primeiro-usuário"></span>
    <a href="#4-subir-e-cadastrar-o-primeiro-usu%c3%a1rio" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose up -d</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O <code>setup</code> roda as migrations e sai. O <code>app</code> sobe o Rails, o <code>worker</code> processa os jobs em background, e o <code>mail_fetcher</code> fica fazendo polling IMAP a cada 30 segundos.</p>
<p>Pra cadastrar remetentes autorizados, mande um email do seu ADMIN_EMAIL pro endereço do Frank FBI com o assunto <code>add email1@exemplo.com, email2@exemplo.com</code>. Pra listar quem está cadastrado, mande com assunto <code>list</code>. Pra ver estatísticas, <code>stats</code>.</p>
<p>A partir daí, qualquer remetente cadastrado pode encaminhar emails suspeitos e receber os relatórios de análise.</p>
<h2>O processo de desenvolvimento<span class="hx:absolute hx:-mt-20" id="o-processo-de-desenvolvimento"></span>
    <a href="#o-processo-de-desenvolvimento" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Esse projeto começou como uma ideia simples: &ldquo;e se eu pudesse encaminhar emails suspeitos pra algum lugar e receber uma análise?&rdquo;. Mas projetos de segurança não são como projetos normais.</p>
<p>Num projeto comum, um bug é inconveniente. Num projeto de segurança, um bug pode ser uma vulnerabilidade. Se o sistema diz que um email fraudulento é legítimo, alguém pode perder dinheiro. Se reporta incorretamente um domínio legítimo como malicioso, pode prejudicar uma empresa inocente. Se um email malicioso consegue explorar o próprio analisador, o atacante ganha acesso ao servidor.</p>
<p>Isso muda o mindset de desenvolvimento. Cada decisão precisa considerar: &ldquo;como alguém mal-intencionado poderia abusar disso?&rdquo;</p>
<h3>Evolução via commits<span class="hx:absolute hx:-mt-20" id="evolução-via-commits"></span>
    <a href="#evolu%c3%a7%c3%a3o-via-commits" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Deixa eu mostrar como o projeto evoluiu olhando os commits mais relevantes.</p>
<p>O projeto começou com a estrutura básica de análise de email e logo precisou de controle de acesso:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>eb59474 - Add admin access control and allowed senders whitelist
036e9f1 - Harden access control against email spoofing and admin impersonation</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O primeiro adiciona a lista de remetentes autorizados. O segundo resolve o problema óbvio: e se alguém spoofar o email de um remetente autorizado? O sistema passou a verificar SPF/DKIM antes de aceitar qualquer submissão. Rate limiting por remetente veio junto, pra evitar abuso.</p>
<p>Depois vieram as preocupações com a qualidade do scoring:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>e891270 - Fix zero-dilution scoring bug and add critical alert UX
2cbb272 - Harden fraud scoring and reporting
50d96a1 - Move text pattern detection from regex to LLM consensus layer</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O bug de zero-dilution é sutil: quando uma camada falha e retorna score 0 com confiança baixa, ela diluía a média geral pra baixo, fazendo emails fraudulentos parecerem mais seguros do que eram. A correção implementou dampening que descarta camadas de baixa qualidade em vez de deixá-las puxar o score pra baixo.</p>
<p>A mudança de regex pra LLM no pattern detection foi pragmática. Padrões de fraude em linguagem natural são difíceis de capturar com regex. Falsos positivos demais. LLMs entendem contexto e intenção de um jeito que regex não consegue.</p>
<p>As race conditions apareceram quando o pipeline começou a rodar camadas em paralelo:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>2e4276d - Fix race conditions across pipeline and add Brave Search rate limiting
ac433d9 - Fix WHOIS race condition and Brave Search gzip error logging</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Jobs concorrentes tentando escrever no mesmo registro de email, consultas WHOIS pisando uma na outra, rate limits de APIs externas sendo estourados. O clássico &ldquo;funciona no teste sequencial, quebra em produção&rdquo;.</p>
<p>A prevenção de alucinação dos LLMs mereceu um commit dedicado:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>e08da9e - Prevent LLM hallucination in fraud reports</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>LLMs inventam coisas. Se o modelo diz &ldquo;este domínio foi registrado ontem&rdquo; sem evidência nos dados, o relatório fica comprometido. Esse commit implementou validação cruzada: claims dos LLMs são verificados contra os dados concretos das camadas determinísticas. Se o LLM afirma algo que contradiz os fatos, a informação é descartada ou sinalizada.</p>
<p>E quando o community reporting foi implementado:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>a01d769 - Add community threat intelligence reporting
1b64eee - Harden community reporting against poisoning and add rate limiting</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>O primeiro commit implementa a funcionalidade. O segundo adiciona as proteções anti-envenenamento. Se um atacante percebe que o Frank FBI reporta automaticamente, ele pode tentar enviar emails que contenham IOCs de empresas legítimas como &ldquo;fraude&rdquo;, fazendo o sistema reportar domínios inocentes pras bases comunitárias. Isso é IOC poisoning, e é um ataque real contra sistemas de threat intelligence.</p>
<h3>Hardening contínuo<span class="hx:absolute hx:-mt-20" id="hardening-contínuo"></span>
    <a href="#hardening-cont%c3%adnuo" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>9430c63 - Harden risky attachment analysis and warnings
bdc503d - Harden screenshot capture with failure recovery and pipeline timeout
125e73a - Separate suspect content from submitter signature in forwarded emails
6f7a522 - Handle forwarded message fidelity</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Anexos maliciosos que poderiam explorar o parser. Screenshots de sites que poderiam travar o Chrome headless. Assinaturas do remetente que estavam sendo confundidas com conteúdo do email suspeito. Emails encaminhados que perdiam fidelidade no forward. Cada um desses resolve um vetor de ataque ou edge case diferente.</p>
<p>Segurança não é uma feature que você implementa uma vez. É um processo contínuo de &ldquo;o que eu não pensei que poderia dar errado?&rdquo;</p>
<h2>Os números<span class="hx:absolute hx:-mt-20" id="os-números"></span>
    <a href="#os-n%c3%bameros" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><table>
  <thead>
      <tr>
          <th>Métrica</th>
          <th>Valor</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Commits</td>
          <td>38</td>
      </tr>
      <tr>
          <td>Horas de trabalho (~)</td>
          <td>17</td>
      </tr>
      <tr>
          <td>Linhas de Ruby</td>
          <td>14.616</td>
      </tr>
      <tr>
          <td>Arquivos Ruby</td>
          <td>161</td>
      </tr>
      <tr>
          <td>Código da aplicação (app/)</td>
          <td>8.217 linhas</td>
      </tr>
      <tr>
          <td>Código de testes (test/)</td>
          <td>5.312 linhas</td>
      </tr>
      <tr>
          <td>Ratio teste/código</td>
          <td>0.65</td>
      </tr>
      <tr>
          <td>Camadas de análise</td>
          <td>6</td>
      </tr>
      <tr>
          <td>Jobs assíncronos</td>
          <td>20 classes</td>
      </tr>
      <tr>
          <td>Modelos de dados</td>
          <td>9 tabelas</td>
      </tr>
      <tr>
          <td>Commits/hora</td>
          <td>~2.2</td>
      </tr>
      <tr>
          <td>Linhas/hora</td>
          <td>~860</td>
      </tr>
  </tbody>
</table>
<p>860 linhas por hora. Obviamente desenvolvimento assistido por IA. Mas olha os commits de hardening: nenhum deles veio do LLM sugerindo &ldquo;ei, vamos proteger contra spoofing&rdquo;. Fui eu que parei e pensei &ldquo;espera, e se alguém spoofar o remetente autorizado?&rdquo;, &ldquo;e se um atacante usar IOC poisoning contra o meu sistema de reporting?&rdquo;. Esse tipo de pergunta o LLM não faz sozinho. Ele implementa quando você pede, mas quem precisa perceber o buraco é você.</p>
<h2>Um aviso sério: NÃO ofereça isso como serviço<span class="hx:absolute hx:-mt-20" id="um-aviso-sério-não-ofereça-isso-como-serviço"></span>
    <a href="#um-aviso-s%c3%a9rio-n%c3%a3o-ofere%c3%a7a-isso-como-servi%c3%a7o" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Se você olhou pro Frank FBI e pensou &ldquo;legal, vou oferecer isso como SaaS pra outras pessoas&rdquo;, eu tenho um conselho: <strong>NÃO faça isso</strong>.</p>
<p>Eu consigo pensar em várias formas de explorar um serviço desses oferecido ao público. O operador teria acesso a todos os emails encaminhados pelos usuários &ndash; informações pessoais, dados corporativos, correspondência sensível. Um serviço centralizado vira um alvo de alto valor: comprometa o servidor e você tem acesso a um fluxo contínuo de emails confidenciais de pessoas que já estão em situação vulnerável.</p>
<p>Eu conheço gente o suficiente pra saber que quem deployaria isso como serviço não ia se preocupar com criptografia em repouso nem com destruir os emails após análise. E quem garante que o operador não vai ler os emails dos usuários? Ninguém. É o tipo de coisa que parece um serviço útil mas na prática cria um honeypot de dados sensíveis gerenciado por alguém sem incentivo pra protegê-los.</p>
<p>O Frank FBI foi feito pra ser self-hosted. Pra rodar no seu servidor, sob seu controle, com seus dados ficando com você. Ou na infraestrutura da sua empresa, gerenciado pela sua equipe de TI.</p>
<p>E o projeto está licenciado sob AGPL-3.0. Se você usar meu código e oferecer como serviço de rede, você é obrigado por lei a abrir todo o código derivado. Sem exceções. Eu escolhi AGPL por esse motivo &ndash; pra garantir que ninguém pegue o projeto, adicione tracking e telemetria, e ofereça como &ldquo;serviço gratuito de verificação de email&rdquo; enquanto coleta dados de usuários por baixo dos panos.</p>
<p>O <a href="https://github.com/akitaonrails/frank_fbi"target="_blank" rel="noopener">repositório está aqui</a>. AGPL-3.0.</p>
]]></content:encoded><category>frank</category><category>llms</category><category>inteligencia-artificial</category><category>seguranca</category></item><item><title>Meu primeiro fracasso com Vibe Code e como Consertei | Frank Yomik</title><link>https://akitaonrails.github.io/2026/03/05/meu-primeiro-fracasso-com-vibe-code-e-como-consertei-frank-yomik/</link><guid isPermaLink="true">https://akitaonrails.github.io/2026/03/05/meu-primeiro-fracasso-com-vibe-code-e-como-consertei-frank-yomik/</guid><pubDate>Thu, 05 Mar 2026 08:00:00 -0300</pubDate><description>&lt;p&gt;Antes que alguém pergunte, todo o código está &lt;a href="https://github.com/akitaonrails/FrankYomik/"target="_blank" rel="noopener"&gt;neste repositório&lt;/a&gt;. E tem binários pré-compilados do client na &lt;a href="https://github.com/akitaonrails/FrankYomik/releases"target="_blank" rel="noopener"&gt;página de releases&lt;/a&gt;. Mas só o app não adianta porque precisa do componente servidor, que roda numa máquina sua (local ou cloud) com GPU de pelo menos 16GB de VRAM, e que você precisa configurar - eu não vou manter um server público, só um pessoal pro meu uso privado.&lt;/p&gt;
&lt;p&gt;&amp;ndash;&lt;/p&gt;
&lt;p&gt;&lt;img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/screenshot-2026-03-05_07-52-11.png" alt="kindle library" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;Eu tenho uma coleção enorme de mangás comprados na Amazon.co.jp que leio pelo &lt;a href="read.amazon.co.jp"&gt;Kindle web&lt;/a&gt;. Mangás shounen normalmente têm furigana — aquele texto pequeno em hiragana ao lado dos kanjis — que me ajudam a ler, porque, apesar de ter estudado japonês, nunca fui treinado formalmente. Mas mangás para público adulto (seinen, não pornô) geralmente vêm sem furigana. São kanjis puros e minha leitura deles fica muito lenta.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Antes que alguém pergunte, todo o código está <a href="https://github.com/akitaonrails/FrankYomik/"target="_blank" rel="noopener">neste repositório</a>. E tem binários pré-compilados do client na <a href="https://github.com/akitaonrails/FrankYomik/releases"target="_blank" rel="noopener">página de releases</a>. Mas só o app não adianta porque precisa do componente servidor, que roda numa máquina sua (local ou cloud) com GPU de pelo menos 16GB de VRAM, e que você precisa configurar - eu não vou manter um server público, só um pessoal pro meu uso privado.</p>
<p>&ndash;</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/screenshot-2026-03-05_07-52-11.png" alt="kindle library"  loading="lazy" /></p>
<p>Eu tenho uma coleção enorme de mangás comprados na Amazon.co.jp que leio pelo <a href="read.amazon.co.jp">Kindle web</a>. Mangás shounen normalmente têm furigana — aquele texto pequeno em hiragana ao lado dos kanjis — que me ajudam a ler, porque, apesar de ter estudado japonês, nunca fui treinado formalmente. Mas mangás para público adulto (seinen, não pornô) geralmente vêm sem furigana. São kanjis puros e minha leitura deles fica muito lenta.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/screenshot-2026-03-05_07-53-26.png" alt="furigana"  loading="lazy" /></p>
<p>Faz anos que quero uma ferramenta que resolva isso. A ideia é simples: detectar os balões de fala numa página de mangá, extrair o texto com OCR, e ou adicionar furigana nos kanjis, ou traduzir direto pro inglês e renderizar de volta no balão. Parece fácil, certo?</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/screenshot-2026-03-05_08-07-43.png" alt="no-furigana"  loading="lazy" />
*(kanji sem legenda/furigana)</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/screenshot-2026-03-05_08-11-21.png" alt="com furigana"  loading="lazy" />
*(com furigana injetado em tempo real)</p>
<p>Pois é, parecia. E por saber que parecia fácil, mas que não seria, eu nunca tive paciência pra fazer. Eu sei fazer os 80% iniciais de qualquer projeto. O problema são os 20% finais — aquela fase de experimentação, tweaking, ajuste fino, tratar edge cases — que consomem mais tempo que todo o resto junto. E num projeto de visão computacional, esses 20% são especialmente traiçoeiros.</p>
<p>Mas aí chegou a era do vibe coding. E eu pensei: talvez agora os 20% sejam viáveis. Iniciei o projeto no dia 24 de fevereiro de 2026, às 23:10 da noite. E este se tornou um exemplo de como é muito fácil fazer grandes volumes de código <strong>inútil</strong>.</p>
<h2>A Ideia Original: OpenCV e Heurísticas<span class="hx:absolute hx:-mt-20" id="a-ideia-original-opencv-e-heurísticas"></span>
    <a href="#a-ideia-original-opencv-e-heur%c3%adsticas" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Meu plano original - o conceito que imaginei por anos - era: usar <a href="https://opencv.org/"target="_blank" rel="noopener">OpenCV</a> - que é uma biblioteca famosa e antiga de visão de computador - pra detectar os balões de fala. Balões de mangá são tipicamente áreas brancas com contorno preto dentro de painéis. Em teoria, basta fazer threshold da imagem pra pegar regiões brancas, encontrar contornos, filtrar por tamanho e formato, e pronto.</p>
<p>Em 24 horas eu já tinha uma prova de conceito funcional: detecção de balões, OCR com manga-ocr (modelo treinado especificamente em texto japonês de mangá), furigana com MeCab pra análise morfológica, tradução com Ollama rodando Qwen3:14b local, e renderização do texto de volta no balão. O commit inicial (<code>9169d73</code>) do dia 24/fev já fazia tudo isso.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/shounen6-debug.jpg" alt="shounen6 debug"  loading="lazy" /></p>
<p>No dia seguinte, 25 de fevereiro, eu já estava estendendo o pipeline com pipeline de tradução de webtoons coreanos. 27 commits nesse dia. Tudo parecia estar fluindo.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/057-debug.jpg" alt="webtoon debug"  loading="lazy" /></p>
<p>E aí começou o inferno.</p>
<h2>O Inferno dos Falsos Positivos<span class="hx:absolute hx:-mt-20" id="o-inferno-dos-falsos-positivos"></span>
    <a href="#o-inferno-dos-falsos-positivos" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O problema com detecção de balões via OpenCV é que o manga não é um documento padronizado. Cada artista tem traço próprio, a qualidade de scan varia enormemente entre eras de impressão, e páginas coloridas precisam de parâmetros completamente diferentes de páginas P&amp;B. E a coisa que mais se parece com um balão de fala branco numa página de mangá é&hellip; <strong>um rosto</strong>.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/screenshot-2026-03-05_09-15-17.png" alt="false positive face"  loading="lazy" /></p>
<p>Rostos de personagens são áreas claras, relativamente arredondadas, com contorno escuro. Exatamente como balões de fala. E não importa quantos filtros você empilhe, sempre aparece um caso onde o rosto de um personagem com cabelo azul passa por todos os seus filtros, ou onde um balão legítimo é rejeitado porque tem formato incomum.</p>
<p>Olha como ficou meu detector de balões no pico da complexidade — 551 linhas com 7 camadas de filtros de falso-positivo (versão resumida):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># --- False positive filters ---</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 1. Edge density</span>
</span></span><span class="line"><span class="cl"><span class="n">edge_pixels</span> <span class="o">=</span> <span class="n">cv2</span><span class="o">.</span><span class="n">countNonZero</span><span class="p">(</span><span class="n">cv2</span><span class="o">.</span><span class="n">bitwise_and</span><span class="p">(</span><span class="n">edges</span><span class="p">,</span> <span class="n">edges</span><span class="p">,</span> <span class="n">mask</span><span class="o">=</span><span class="n">mask</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="n">edge_density</span> <span class="o">=</span> <span class="n">edge_pixels</span> <span class="o">/</span> <span class="n">area</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">edge_density</span> <span class="o">&gt;</span> <span class="n">max_edge_density</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">continue</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 2. Bright pixel ratio</span>
</span></span><span class="line"><span class="cl"><span class="n">bright_pixels</span> <span class="o">=</span> <span class="n">cv2</span><span class="o">.</span><span class="n">countNonZero</span><span class="p">(</span><span class="n">cv2</span><span class="o">.</span><span class="n">bitwise_and</span><span class="p">(</span><span class="n">bright_thresh</span><span class="p">,</span> <span class="n">mask</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="n">bright_ratio</span> <span class="o">=</span> <span class="n">bright_pixels</span> <span class="o">/</span> <span class="n">area</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">bright_ratio</span> <span class="o">&lt;</span> <span class="n">min_bright_ratio</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">continue</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 3. Mid-tone ratio</span>
</span></span><span class="line"><span class="cl"><span class="n">mid_mask</span> <span class="o">=</span> <span class="n">cv2</span><span class="o">.</span><span class="n">inRange</span><span class="p">(</span><span class="n">gray</span><span class="p">,</span> <span class="mi">80</span><span class="p">,</span> <span class="mi">220</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">mid_pixels</span> <span class="o">=</span> <span class="n">cv2</span><span class="o">.</span><span class="n">countNonZero</span><span class="p">(</span><span class="n">cv2</span><span class="o">.</span><span class="n">bitwise_and</span><span class="p">(</span><span class="n">mid_mask</span><span class="p">,</span> <span class="n">mask</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="n">mid_ratio</span> <span class="o">=</span> <span class="n">mid_pixels</span> <span class="o">/</span> <span class="n">area</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">mid_ratio</span> <span class="o">&gt;</span> <span class="n">max_mid_ratio</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">continue</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 4. Contour circularity</span>
</span></span><span class="line"><span class="cl"><span class="n">circularity</span> <span class="o">=</span> <span class="mi">4</span> <span class="o">*</span> <span class="n">np</span><span class="o">.</span><span class="n">pi</span> <span class="o">*</span> <span class="n">area</span> <span class="o">/</span> <span class="p">(</span><span class="n">perimeter</span> <span class="o">*</span> <span class="n">perimeter</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">circularity</span> <span class="o">&lt;</span> <span class="mf">0.10</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">continue</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 5. Border darkness</span>
</span></span><span class="line"><span class="cl"><span class="n">border_mean</span> <span class="o">=</span> <span class="n">cv2</span><span class="o">.</span><span class="n">mean</span><span class="p">(</span><span class="n">gray</span><span class="p">,</span> <span class="n">mask</span><span class="o">=</span><span class="n">border_only</span><span class="p">)[</span><span class="mi">0</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">border_mean</span> <span class="o">&gt;</span> <span class="mi">160</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">continue</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 6. Background uniformity (white_std)</span>
</span></span><span class="line"><span class="cl"><span class="n">white_pixels</span> <span class="o">=</span> <span class="n">gray</span><span class="p">[(</span><span class="n">mask</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">)</span> <span class="o">&amp;</span> <span class="p">(</span><span class="n">gray</span> <span class="o">&gt;</span> <span class="mi">200</span><span class="p">)]</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="nb">float</span><span class="p">(</span><span class="n">np</span><span class="o">.</span><span class="n">std</span><span class="p">(</span><span class="n">white_pixels</span><span class="p">))</span> <span class="o">&gt;</span> <span class="mi">15</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">continue</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 7. Dark content analysis (text strokes)</span>
</span></span><span class="line"><span class="cl"><span class="n">very_dark</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">sum</span><span class="p">((</span><span class="n">inner_mask</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">)</span> <span class="o">&amp;</span> <span class="p">(</span><span class="n">gray</span> <span class="o">&lt;</span> <span class="mi">60</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="n">dark_ratio_60</span> <span class="o">=</span> <span class="n">very_dark</span> <span class="o">/</span> <span class="n">inner_area</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">dark_ratio_60</span> <span class="o">&lt;</span> <span class="n">min_dark_ratio</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">continue</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Cada um desses filtros foi adicionado em resposta a um falso-positivo específico. Edge density separava balões (que têm traços esparsos de texto) de rostos (que têm cabelo, olhos, nariz criando bordas densas). Bright pixel ratio verificava se a região era realmente branca. Circularity descartava formas muito irregulares. E por aí vai.</p>
<p>Mas o pior é que esses filtros interagiam entre si de formas imprevisíveis. Olha esse commit do dia 26/fev (<code>70c814a</code>):</p>
<blockquote>
  <p>&ldquo;Revert rect_dark, mid_ratio, and early-split changes that caused face FPs&rdquo;</p>

</blockquote>
<p>Eu tinha tentado relaxar dois thresholds — rect_dark de 0.10 pra 0.11, mid_ratio de 0.15 pra 0.16 — pra recuperar balões que estavam sendo perdidos. Resultado: rostos e regiões de corpo começaram a passar como falsos positivos em mangá do Adachi. Tive que reverter tudo.</p>
<p>Esse é o padrão que se repetiu durante dias: recuperar um balão perdido significava abrir porta pra falsos-positivos. Corrigir um falso-positivo significava perder um balão legítimo. Era um whack-a-mole infinito.</p>
<h2>Os Paliativos: CLAHE, Detecção por Borda, Watershed<span class="hx:absolute hx:-mt-20" id="os-paliativos-clahe-detecção-por-borda-watershed"></span>
    <a href="#os-paliativos-clahe-detec%c3%a7%c3%a3o-por-borda-watershed" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Quando os 7 filtros básicos não foram suficientes, comecei a empilhar passes adicionais.</p>
<p>Commit <code>294e785</code> (26/fev): adicionei CLAHE (<em>Contrast Limited Adaptive Histogram Equalization</em>) como segundo passe de detecção. Balões com brilho médio, perto do threshold de 200, estavam sendo perdidos. O CLAHE equalizava o contraste e revelava esses balões borderline.</p>
<p>Mas o CLAHE também fazia rostos passarem pelos filtros porque inflava artificialmente o brilho de pele. Então precisei adicionar uma função inteira de validação contra a imagem original:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">_validate_on_original</span><span class="p">(</span><span class="n">candidate</span><span class="p">,</span> <span class="n">gray_orig</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;Check if a CLAHE-detected candidate looks bubble-like on original.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">roi</span> <span class="o">=</span> <span class="n">gray_orig</span><span class="p">[</span><span class="n">y1</span><span class="p">:</span><span class="n">y2</span><span class="p">,</span> <span class="n">x1</span><span class="p">:</span><span class="n">x2</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="n">mean_brightness</span> <span class="o">=</span> <span class="n">roi</span><span class="o">.</span><span class="n">mean</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Already bright enough for pass 1 — rejected for good reason</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">mean_brightness</span> <span class="o">&gt;</span> <span class="mi">215</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">False</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Must have text strokes (dark pixels)</span>
</span></span><span class="line"><span class="cl">    <span class="n">dark_ratio</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">sum</span><span class="p">(</span><span class="n">roi</span> <span class="o">&lt;</span> <span class="mi">60</span><span class="p">)</span> <span class="o">/</span> <span class="n">roi</span><span class="o">.</span><span class="n">size</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">dark_ratio</span> <span class="o">&lt;</span> <span class="mf">0.07</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">False</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># White pixel variance: text creates high std, face skin is uniform</span>
</span></span><span class="line"><span class="cl">    <span class="n">white_pixels</span> <span class="o">=</span> <span class="n">roi</span><span class="p">[</span><span class="n">roi</span> <span class="o">&gt;</span> <span class="mi">200</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">white_pixels</span><span class="p">)</span> <span class="o">&gt;</span> <span class="mi">50</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="nb">float</span><span class="p">(</span><span class="n">np</span><span class="o">.</span><span class="n">std</span><span class="p">(</span><span class="n">white_pixels</span><span class="p">))</span> <span class="o">&lt;</span> <span class="mi">9</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span> <span class="kc">False</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="kc">True</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Commit <code>5dddb31</code> (1/mar): adicionei um terceiro passe inteiro de detecção baseado em bordas (<em>edge-based segmentation</em>). Pra pegar balões onde o interior branco se fundia com o fundo branco da página. Dilatava as bordas Canny, invertia, fazia AND com regiões brilhantes, e procurava contornos no resultado.</p>
<p>Commit <code>b695295</code> (26/fev): adicionei recuperação de balões pequenos via gradiente morfológico + validação por OCR. Se o OCR confirmava que tinha texto japonês válido na região, provavelmente era um balão real.</p>
<p>Cada paliativo adicionava 50-100 linhas de código e mais uma camada de complexidade. E cada um tinha seus próprios edge cases e falsos-positivos.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/shounen-debug.jpg" alt="shounen debug"  loading="lazy" /></p>
<h2>A Conta Final da v0.1<span class="hx:absolute hx:-mt-20" id="a-conta-final-da-v01"></span>
    <a href="#a-conta-final-da-v01" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>No dia 1 de março, marquei a tag v0.1. Nesse ponto eu tinha:</p>
<ul>
<li><strong>90 commits</strong> em 6 dias de desenvolvimento (24/fev a 1/mar)</li>
<li><strong>551 linhas</strong> só no <code>bubble_detector.py</code></li>
<li>7 camadas de filtro de falso-positivo, cada uma com thresholds empíricos</li>
<li>Dois passes de detecção completos (original + CLAHE)</li>
<li>Um terceiro passe baseado em bordas</li>
<li>Validação cruzada contra imagem original</li>
<li>Separação de watershed pra balões sobrepostos</li>
<li>Perfis separados de threshold pra páginas coloridas vs preto-e-branco</li>
<li>20+ magic numbers ajustados empiricamente contra uma amostra limitada de páginas</li>
<li>Testes de regressão travando cada falso-positivo específico (face de menina com cabelo azul, moldura de janela, tira fina horizontal, chão de concreto&hellip;)</li>
</ul>
<p>E mesmo com tudo isso, ainda não era confiável. Cada novo mangá que eu testava revelava algum pequeno caso que quebrava o detector. Era um monolito extremamente frágil.</p>
<blockquote>
  <p><strong>SINTOMA:</strong> você faz correção pra corrigir a correção que corrigia outra correção, e quando mexe num pedaço, quebra outro sem querer: significa que o código é <strong>brittle</strong>, frágil, uma casa de cartas que vai cair a qualquer momento. Esse é o ponto pra desistir e repensar!</p>

</blockquote>
<h2>A Decisão: Pesquisar Alternativas<span class="hx:absolute hx:-mt-20" id="a-decisão-pesquisar-alternativas"></span>
    <a href="#a-decis%c3%a3o-pesquisar-alternativas" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Na v0.1 eu parei e fiz a pergunta que deveria ter feito no começo: será que já existe alguém que treinou um modelo de ML pra fazer exatamente isso?</p>
<p>Pedi pro Claude pesquisar modelos disponíveis de detecção de balões em quadrinhos. A pesquisa resultou no documento <code>docs/yolo_bubble_detection_plan.md</code>, onde analisamos as alternativas.</p>
<p>A primeira que apareceu foi um <a href="https://medium.com/@beyzaakyildiz/what-is-yolov8-how-to-use-it-b3807d13c5ce"target="_blank" rel="noopener">YOLOv8 Medium do ogkalu</a> (<code>comic-speech-bubble-detector-yolov8m</code>), treinado em ~8.000 imagens de manga, webtoon, manhua e quadrinhos ocidentais. Detecta só uma classe (speech bubble). Mas pesquisando mais, achamos outro modelo do mesmo autor: <code>ogkalu/comic-text-and-bubble-detector</code>, um <a href="https://github.com/lyuwenyu/RT-DETR"target="_blank" rel="noopener">RT-DETR-v2</a> com backbone ResNet-50-vd (42.9M parâmetros), treinado em <strong>~11.000 imagens</strong>, com três classes: <code>bubble</code>, <code>text_bubble</code> e <code>text_free</code>. Ambos Apache 2.0.</p>
<p>Também avaliamos o comic-text-detector (DBNet + YOLOv5, ~13.000 imagens do Manga109), mas esse detectava regiões de texto e não balões. E como dados de treinamento futuro, havia o Roboflow com 4.492 imagens já etiquetadas, e o Manga109 dataset com 147.918 anotações em 21.142 páginas.</p>
<p>O RT-DETR-v2 com 3 classes era o mais promissor porque distinguia balões de fala, texto dentro de balões e texto livre (narração, SFX). Poderia substituir tanto o <code>bubble_detector.py</code> quanto o <code>text_detector.py</code> em uma única passada de inferência.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/026-en.jpg" alt="026 en"  loading="lazy" /></p>
<p>A conclusão do documento de pesquisa era direta:</p>
<blockquote>
  <p>&ldquo;Mesmo sem ajustes finos, esses modelos foram treinados com 8 a 11 mil imagens de diversos quadrinhos. Eles devem lidar com a diversidade de estilos artísticos que nossos filtros manuais têm dificuldade em processar. A cascata heurística de 7 filtros e seus números mágicos seriam completamente eliminados.&rdquo;</p>

</blockquote>
<p>E se não fosse suficiente, incluímos um plano de fine-tuning usando dados pareados (páginas originais em japonês vs fan translations em inglês) que geravam labels automaticamente por diff de imagem. Mas primeiro queríamos testar o baseline.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/shounen2-en.jpg" alt="shounen2 en"  loading="lazy" /></p>
<h2>Trocando Tudo: RT-DETR-v2<span class="hx:absolute hx:-mt-20" id="trocando-tudo-rt-detr-v2"></span>
    <a href="#trocando-tudo-rt-detr-v2" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>No dia 4 de março, fiz o commit <code>0df63f2</code>: &ldquo;Replace OpenCV heuristic detection with RT-DETR-v2, add bubble shape masking&rdquo;.</p>
<p>O diff fala por si:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>16 files changed, 732 insertions(&#43;), 1112 deletions(-)</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>1.112 linhas deletadas. Mais linhas removidas do que adicionadas. O detector de balões de 551 linhas foi substituído por 262 linhas — e dessas, a maior parte é extração de máscara de formato (contour mask) a partir do bbox detectado, não detecção em si.</p>
<p>O núcleo da detecção virou isso:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">MODEL_ID</span> <span class="o">=</span> <span class="s2">&#34;ogkalu/comic-text-and-bubble-detector&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">DEFAULT_CONFIDENCE</span> <span class="o">=</span> <span class="mf">0.35</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">detect_bubbles</span><span class="p">(</span><span class="n">img_cv</span><span class="p">,</span> <span class="n">confidence</span><span class="o">=</span><span class="n">DEFAULT_CONFIDENCE</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">model</span><span class="p">,</span> <span class="n">processor</span><span class="p">,</span> <span class="n">device</span> <span class="o">=</span> <span class="n">_get_model</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">img_pil</span> <span class="o">=</span> <span class="n">Image</span><span class="o">.</span><span class="n">fromarray</span><span class="p">(</span><span class="n">img_cv</span><span class="p">[:,</span> <span class="p">:,</span> <span class="p">::</span><span class="o">-</span><span class="mi">1</span><span class="p">])</span>  <span class="c1"># BGR→RGB</span>
</span></span><span class="line"><span class="cl">    <span class="n">inputs</span> <span class="o">=</span> <span class="n">processor</span><span class="p">(</span><span class="n">images</span><span class="o">=</span><span class="n">img_pil</span><span class="p">,</span> <span class="n">return_tensors</span><span class="o">=</span><span class="s2">&#34;pt&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">to</span><span class="p">(</span><span class="n">device</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="n">torch</span><span class="o">.</span><span class="n">no_grad</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">        <span class="n">outputs</span> <span class="o">=</span> <span class="n">model</span><span class="p">(</span><span class="o">**</span><span class="n">inputs</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">results</span> <span class="o">=</span> <span class="n">processor</span><span class="o">.</span><span class="n">post_process_object_detection</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">outputs</span><span class="p">,</span> <span class="n">target_sizes</span><span class="o">=</span><span class="n">target_sizes</span><span class="p">,</span> <span class="n">threshold</span><span class="o">=</span><span class="n">confidence</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ... map classes, deduplicate, sort</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Sem thresholds mágicos. Sem perfis separados pra cor vs P&amp;B. Sem CLAHE. Sem detecção por borda. Sem watershed. Sem 7 camadas de filtro. Um modelo que foi treinado em 11.000 imagens diversas de quadrinhos já sabe distinguir balão de rosto melhor do que meus montes de &ldquo;ifs&rdquo;.</p>
<p>O que também sumiu junto:</p>
<ul>
<li>O <code>text_detector.py</code> inteiro (387 linhas) — substituído pela classe <code>text_free</code> do RT-DETR</li>
<li>O sistema de feedback de falsos-positivos — RT-DETR detections são confiáveis o suficiente pra não precisar de marcação manual</li>
<li>Centenas de linhas de testes de regressão testando false-positives específicos</li>
</ul>
<p>No final, nem precisei tentar fazer fine tuning próprio. O modelo que já existe resolvia mais de 99% dos casos, o que pra mim já era excelente.</p>
<p>Quem não entende estatística tem dificuldade de entender isso. Com meu procedimento &ldquo;manual&rdquo; de opencv eu já conseguia pegar 80%, talvez mais. Mas isso é muito pouco. Se em toda página um rosto fica com um balão em cima, isso é péssimo.</p>
<p>Mesmo se com muito esforço (mais quinhentos &ldquo;ifs&rdquo; diferentes) conseguisse chegar em 95%, ainda assim não é suficiente. Chegar em 80% é fácil. os últimos 20% custam exponencialmente mais, e o último 1% pode ser impossível em muitos casos. É assim que as coisas funcionam. Todo mundo pára nos 80%.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/shounen10-debug.jpg" alt="shounen10 debug"  loading="lazy" /></p>
<h2>As Outras Dores: Flutter no Linux<span class="hx:absolute hx:-mt-20" id="as-outras-dores-flutter-no-linux"></span>
    <a href="#as-outras-dores-flutter-no-linux" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Enquanto o backend em Python e Go foi relativamente estável, o cliente Flutter no Linux foi um capítulo à parte de sofrimento.</p>
<p>O Flutter usa WebKitGTK pro WebView no Linux, e esse componente tem particularidades dolorosas. Commit <code>8e5d168</code> (28/fev) conta a história: WebKitGTK não consegue resolver Promise return values de JavaScript assíncrono, gerando PlatformException. Tive que reescrever todos os overlays como IIFEs síncronas com <code>decode().then()</code> e um nudge de <code>opacity: 0.999</code> pra forçar refresh da textura no compositor GPU.</p>
<p>No meu setup com NVIDIA + Wayland, o WebView era inutilizável em resolução full. Commit <code>a36e1eb</code> (27/fev) tentou resolver forçando CPU rendering com <code>WEBKIT_SKIA_ENABLE_CPU_RENDERING=1</code> e desabilitando compositing acelerado. Depois tive que reverter isso (commit <code>8e5d168</code>) e forçar o Mesa da iGPU AMD via <code>__EGL_VENDOR_LIBRARY_FILENAMES</code> pra impedir que o WebKitGTK pegasse a dGPU NVIDIA.</p>
<p>Cada uma dessas descobertas custou horas de debugging de coisas que simplesmente não tinham documentação. No final consegui deixar a versão Linux razoável, mas não é nada espetacular. Não sei se estou perdendo alguma coisa óbvia, mas Flutter no Linux eu achei uma droga, especialmente tendo acabado de fazer um app nativo com Rust/Tauri (muito melhor). Mas como eu queria um app que funcionasse em Linux e Android, não tinha tanta opção.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/078-en.jpg" alt="078 en"  loading="lazy" /></p>
<h2>O Prefetch do Kindle que Morreu<span class="hx:absolute hx:-mt-20" id="o-prefetch-do-kindle-que-morreu"></span>
    <a href="#o-prefetch-do-kindle-que-morreu" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Uma ideia que parecia genial e que deu errado: criar um segundo WebView escondido no Flutter que compartilhava cookies/sessão com o WebView principal e ficava navegando páginas à frente pra pré-processar.</p>
<p>Isso porque o website do Kindle só carrega uma página de cada vez. Ele não carrega tudo de uma vez ou partes, só uma página. Então não dá pra processar páginas pra frente ou pra trás. Quando o servidor ainda era lento pra processar páginas, eu queria deixar tudo já pré-cacheado, pra quem virar a página já tivesse a tradução.</p>
<p>Commit <code>a36e1eb</code> (27/fev) implementou o KindlePrefetchManager inteiro: 406 linhas de Dart, com batched prefetch (3 páginas à frente), eventos GDK confiáveis pra virar página (isTrusted=true), rate limiting com pacing humano, janela com sensitive=FALSE pra não roubar foco.</p>
<p>Nos dias seguintes, vieram os consertos:</p>
<ul>
<li><code>cfe08eb</code>: melhorar confiabilidade do prefetch e matching de overlay</li>
<li><code>4461b5f</code>: harden na seleção de overlay e recovery do Kindle</li>
<li><code>f141b2e</code>: parar de destruir o background webview a cada virada de página</li>
</ul>
<p>E no final (commit <code>2b93e99</code>, 4/mar), deletei tudo. 657 linhas removidas, substituídas por um spinner simples na toolbar.</p>
<p>Com a mudança pro modelo RT-DETR, e paralelizando a tradução dos balões, a tradução ficou &ldquo;quase real-time&rdquo;, em menos de 10 segundos já carregava a tradução, então não é mais um dor tão grande esperar vir a página, e dá pra pedir um de cada vez. O prefetch adicionava complexidade demais pra um ganho marginal, e o modo certo de resolver era simplesmente processar a página sob demanda com feedback visual adequado.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/adult2-furigana.jpg" alt="adult2 furigana"  loading="lazy" /></p>
<h2>Os Números<span class="hx:absolute hx:-mt-20" id="os-números"></span>
    <a href="#os-n%c3%bameros" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>No total, o projeto Frank Yomik tem hoje:</p>
<ul>
<li><strong>111 commits</strong> em 9 dias de desenvolvimento (com 2 dias de intervalo no meio)</li>
<li><strong>29.428 linhas</strong> de código em 181 arquivos</li>
<li><strong>~13K linhas</strong> de Python (pipeline de processamento + worker)</li>
<li><strong>~6K linhas</strong> de Dart (cliente Flutter)</li>
<li><strong>~3.4K linhas</strong> de Go (API server)</li>
<li><strong>345 testes unitários</strong> + 34 testes de integração</li>
<li>Suporte a manga japonês (Kindle) e webtoons coreanos (Naver/Webtoons)</li>
</ul>
<p>O pipeline hoje funciona assim:</p>
<ol>
<li>O cliente Flutter (Android ou Linux desktop) abre o site do Kindle ou Webtoons num WebView</li>
<li>Captura a imagem da página (captura do blob de imagem na página do Kindle, fetch do <code>&lt;img&gt;</code> pra webtoons)</li>
<li>Envia pra API Go que enfileira no Redis Streams com dedup por SHA256</li>
<li>Worker Python processa: RT-DETR-v2 detecta balões → manga-ocr ou EasyOCR extrai texto → Ollama qwen3:14b traduz → text_renderer renderiza de volta na imagem</li>
<li>Resultado volta via WebSocket, overlay substitui a imagem original</li>
</ol>
<p>Pra furigana: fugashi (wrapper do MeCab) faz análise morfológica e gera a leitura em hiragana de cada kanji. Troquei de pykakasi pro fugashi porque o pykakasi não considera contexto da frase (「人」 virava にん em vez de ひと).</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/adult4-furigana.jpg" alt="adult4 furigana"  loading="lazy" /></p>
<p>Se eu soubesse o que sei agora — que o modelo RT-DETR-v2 já existia e resolvia o problema de detecção com um threshold de confiança — teria eliminado a fase inteira de OpenCV. A parte de OCR, tradução, rendering e Flutter já estava razoavelmente estabilizada. A detecção era o gargalo, e era exatamente a parte que eu poderia ter economizado, se tivesse desistido antes.</p>
<h2>O que eu aprendi errando<span class="hx:absolute hx:-mt-20" id="o-que-eu-aprendi-errando"></span>
    <a href="#o-que-eu-aprendi-errando" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Eu gastei 5 dias polindo um detector de balões em OpenCV que teve 20+ commits de ajuste fino, 7 camadas de filtro, 3 passes de detecção, e que no final foi substituído por um wrapper de 50 linhas ao redor de um modelo pré-treinado.</p>
<p>Foram 1.112 linhas deletadas num único commit. Mas não foi tempo perdido. Aqueles 5 dias me ensinaram exatamente porque heurísticas falham em visão computacional. Eu entendi o problema, a cascata onde mexer num threshold quebra outro, e foi esse entendimento que me fez reconhecer a hora de parar e pesquisar alternativas.</p>
<p>E aqui entra o papel real do vibe coding nessa história.</p>
<blockquote>
  <p><strong>Prompting não substitui pensar.</strong></p>

</blockquote>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/shounen8-en.jpg" alt="shounen8 en"  loading="lazy" /></p>
<p>Eu gerei código muito rápido com Claude, mas o problema não era velocidade de escrita, era a abordagem. Nenhum prompt do mundo transforma 7 camadas de filtro heurístico numa solução robusta. A solução certa era mudar de abordagem completamente.</p>
<p>Mas o vibe coding tornou o fracasso barato. Nos tempos pré-AI, aqueles 5 dias de OpenCV teriam talvez sido 2-3 semanas. O custo de errar seria alto o suficiente pra dificultar a decisão de jogar fora. Com vibe coding, 5 dias foram descartados sem dor porque eu sabia que podia reconstruir rápido. E de fato, a integração do RT-DETR-v2 e reestruturação do projeto inteiro foram feitos em <strong>um único dia</strong> (4/mar, 15 commits).</p>
<p>A pergunta que fica: se eu tivesse feito a pesquisa do <code>yolo_bubble_detection_plan.md</code> no dia 1 em vez do dia 5, provavelmente teria chegado no estado atual em 2 dias. A diferença entre semanas de trabalho e um fim de semana era uma busca no HuggingFace. Pesquisar antes de implementar parece óbvio quando você olha pra trás, mas no calor do momento a tentação de resolver na mão é forte.</p>
<p>O projeto agora é open source. Eu inicialmente não sabia se queria abrir o código, tinha muita gambiarra e aquele detector de 551 linhas que eu tinha vergonha. Mas depois da refatoração, o código ficou limpo o suficiente pra compartilhar. É a versão que eu gostaria de ter construído desde o início, mas que só consegui construir porque errei primeiro.</p>
<p>A dor de cabeça maior foi o modelo de detecção e substituição de balões, mas ainda tem vários outros pontos que não detalhei: notaram que Webtoons são coloridos e os próprios balões tem arte? Precisei usar um modelo de imagem pra fazer <strong>in-painting</strong> e mandar a IA redesenhar o balão antes de colocar o texto em cima.</p>
<p>Outra dor de cabeça que acho que não vou resolver: <strong>coerência da tradução</strong>. Hoje ele traduz cada balão isolado, sem contexto da história antes ou depois. Em japonês, não existe diferença de gênero em palavras. Então o capitão estava falando da Nami mas o balão fala &ldquo;Ele&rdquo; em vez de &ldquo;Ela&rdquo;. Não tem como saber se não ler o texto anterior. Pra isso funcionar melhor, assim como num chat de GPT, precisa adicionar parte do texto anterior, pra saber quando usar o gênero correto ou, pior, quando tem trocadilhos que apareceram volumes atrás e é referenciado no futuro (coisa que um Oda adora fazer). Essas sutilezas todas se perdem se traduzir só um balão de cada vez.</p>
<p>Imagino que é por isso que ainda ninguém fez algo parecido com o que eu fiz, de traduzir em tempo quase real, porque pra realmente ficar bom, o trabalho de tradução seria exponencial pra cada capítulo mais pra frente na história.</p>
<p>Enfim, o nome <strong>Frank Yomik</strong> vem de &ldquo;yomi&rdquo; (読み, leitura em japonês) e &ldquo;ik-da&rdquo; (읽다, ler em coreano). Frank é referência à tradução franca, direta. O app lê nas duas línguas.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/03/shounen9-en.jpg" alt="shounen9 en"  loading="lazy" /></p>
<p>Pra quem quiser experimentar: o <a href="https://github.com/akitaonrails/FrankYomik"target="_blank" rel="noopener">repositório está no GitHub</a>. O server precisa de uma GPU com pelo menos 8GB VRAM pro modelo de detecção + OCR + tradução. O cliente Flutter roda em Android e Linux desktop. E se você, como eu, tem uma pilha de mangás japoneses que gostaria de ler mais fluentemente — agora pode.</p>
]]></content:encoded><category>frank-yomik</category><category>frank</category><category>llms</category><category>inteligencia-artificial</category></item><item><title>Software Nunca Está 'Pronto' — 4 Projetos, a Vida Pós-Deploy, e Por Que One-Shot Prompt É Mito</title><link>https://akitaonrails.github.io/2026/03/01/software-nunca-esta-pronto-4-projetos-a-vida-pos-deploy-e-por-que-one-shot-prompt-e-mito/</link><guid isPermaLink="true">https://akitaonrails.github.io/2026/03/01/software-nunca-esta-pronto-4-projetos-a-vida-pos-deploy-e-por-que-one-shot-prompt-e-mito/</guid><pubDate>Sun, 01 Mar 2026 12:00:00 -0300</pubDate><description>&lt;p&gt;E não deixe de assinar minha newsletter &lt;a href="https://themakitachronicles.com/"target="_blank" rel="noopener"&gt;The M.Akita Chronicles&lt;/a&gt;!&lt;/p&gt;
&lt;p&gt;&amp;ndash;&lt;/p&gt;
&lt;p&gt;Publiquei o &amp;ldquo;post final&amp;rdquo; da série &lt;a href="https://akitaonrails.github.io/2026/02/20/do-zero-a-pos-producao-em-1-semana-como-usar-ia-em-projetos-de-verdade-bastidores-do-the-m-akita-chronicles/"&gt;Bastidores do The M.Akita Chronicles&lt;/a&gt; há 10 dias. 274 commits, 1.323 testes, deploy em produção. Escrevi as lições, fiz a conclusão, coloquei a citação no final. Done.&lt;/p&gt;
&lt;p&gt;125 commits de pós-produção depois, posso confirmar: &lt;strong&gt;software &lt;em&gt;&amp;ldquo;done&amp;rdquo;&lt;/em&gt; não existe.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Hoje o repositório do M.Akita Chronicles tem 335 commits e 1.422 testes. Amanhã, segunda-feira, os assinantes recebem a 3ª newsletter consecutiva — gerada, revisada, e enviada por um sistema que não para de evoluir. Enquanto isso, o &lt;a href="https://akitaonrails.github.io/2026/02/23/vibe-code-fiz-um-indexador-inteligente-de-imagens-com-ia-em-2-dias/"&gt;Frank Sherlock&lt;/a&gt; saiu de 50 commits e v0.1 pra 103 commits e v0.7 com face detection. O &lt;a href="https://akitaonrails.github.io/2026/02/01/vibe-code-fiz-um-editor-de-markdown-do-zero-com-claude-code-frankmd-part-1/"&gt;FrankMD&lt;/a&gt; recebeu 3 contribuidores externos e lançou a v0.2.0. E até o &lt;a href="https://akitaonrails.github.io/2026/02/21/vibe-code-fiz-um-clone-do-mega-em-rails-em-1-dia-pro-meu-home-server/"&gt;FrankMega&lt;/a&gt; — um projeto construído em 1 dia — precisou de correções quando usuários reais apareceram.&lt;/p&gt;</description><content:encoded><![CDATA[<p>E não deixe de assinar minha newsletter <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The M.Akita Chronicles</a>!</p>
<p>&ndash;</p>
<p>Publiquei o &ldquo;post final&rdquo; da série <a href="/2026/02/20/do-zero-a-pos-producao-em-1-semana-como-usar-ia-em-projetos-de-verdade-bastidores-do-the-m-akita-chronicles/">Bastidores do The M.Akita Chronicles</a> há 10 dias. 274 commits, 1.323 testes, deploy em produção. Escrevi as lições, fiz a conclusão, coloquei a citação no final. Done.</p>
<p>125 commits de pós-produção depois, posso confirmar: <strong>software <em>&ldquo;done&rdquo;</em> não existe.</strong></p>
<p>Hoje o repositório do M.Akita Chronicles tem 335 commits e 1.422 testes. Amanhã, segunda-feira, os assinantes recebem a 3ª newsletter consecutiva — gerada, revisada, e enviada por um sistema que não para de evoluir. Enquanto isso, o <a href="/2026/02/23/vibe-code-fiz-um-indexador-inteligente-de-imagens-com-ia-em-2-dias/">Frank Sherlock</a> saiu de 50 commits e v0.1 pra 103 commits e v0.7 com face detection. O <a href="/2026/02/01/vibe-code-fiz-um-editor-de-markdown-do-zero-com-claude-code-frankmd-part-1/">FrankMD</a> recebeu 3 contribuidores externos e lançou a v0.2.0. E até o <a href="/2026/02/21/vibe-code-fiz-um-clone-do-mega-em-rails-em-1-dia-pro-meu-home-server/">FrankMega</a> — um projeto construído em 1 dia — precisou de correções quando usuários reais apareceram.</p>
<p>Ao longo de fevereiro, publiquei mais de uma dezena de posts detalhando a construção de cada projeto. Este post é diferente. Não é sobre construir do zero, já cobri isso. É sobre o que acontece <strong>depois</strong>. E o que acontece depois destrói a narrativa do one-shot prompt. Software precisa de um humano experiente no volante. Desenvolvimento iterativo é a única forma que funciona. Quem discorda não botou sistema em produção ainda.</p>
<p>Vou provar com <code>git log</code>.</p>
<h2>A Vida Depois do Deploy<span class="hx:absolute hx:-mt-20" id="a-vida-depois-do-deploy"></span>
    <a href="#a-vida-depois-do-deploy" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><table>
  <thead>
      <tr>
          <th>Projeto</th>
          <th>Commits pós-publicação</th>
          <th>Destaque</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>The M.Akita Chronicles</td>
          <td>56</td>
          <td>3ª semana em produção, features novas, 13 bug fixes, prompt tuning</td>
      </tr>
      <tr>
          <td>Frank Sherlock</td>
          <td>53</td>
          <td>De v0.3 a v0.7: video support, face detection, AUR publish</td>
      </tr>
      <tr>
          <td>FrankMD</td>
          <td>14</td>
          <td>3 contribuidores externos, 4 PRs merged, v0.2.0</td>
      </tr>
      <tr>
          <td>FrankMega</td>
          <td>2</td>
          <td>MIME types que ninguém previu</td>
      </tr>
      <tr>
          <td><strong>Total</strong></td>
          <td><strong>125</strong></td>
          <td></td>
      </tr>
  </tbody>
</table>
<p>Vamos aos detalhes.</p>
<h2>The M.Akita Chronicles: 3 Semanas em Produção<span class="hx:absolute hx:-mt-20" id="the-makita-chronicles-3-semanas-em-produção"></span>
    <a href="#the-makita-chronicles-3-semanas-em-produ%c3%a7%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O sistema de newsletter está no ar desde 16 de fevereiro. Já enviou 2 newsletters e amanhã envia a terceira. 56 commits aconteceram desde o &ldquo;post final&rdquo;:</p>
<table>
  <thead>
      <tr>
          <th>Data</th>
          <th>Commits</th>
          <th>O que aconteceu</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>20/fev (qui)</td>
          <td>16</td>
          <td>Steam Gaming (seção nova inteira), bug fixes em paywall detection</td>
      </tr>
      <tr>
          <td>21/fev (sex)</td>
          <td>12</td>
          <td>/preview command, YouTube collage, notificações de /rerun</td>
      </tr>
      <tr>
          <td>22/fev (sáb)</td>
          <td>1</td>
          <td>Fix: /preflight mandando resultado pro channel errado</td>
      </tr>
      <tr>
          <td>23/fev (dom)</td>
          <td>10</td>
          <td>Publicação da 2ª newsletter, Gmail clipping warning, TTS revert</td>
      </tr>
      <tr>
          <td>24/fev (seg)</td>
          <td>1</td>
          <td>Ajuste de prompt do podcast</td>
      </tr>
      <tr>
          <td>28/fev (sáb)</td>
          <td>7</td>
          <td>Marvin prompt tuning, rate limiting, /rerun comments</td>
      </tr>
      <tr>
          <td>01/mar (dom)</td>
          <td>9</td>
          <td>Mood system, switch pra Grok-4, QA pipeline, centralização de config</td>
      </tr>
  </tbody>
</table>
<h3>Features que Ninguém Previu<span class="hx:absolute hx:-mt-20" id="features-que-ninguém-previu"></span>
    <a href="#features-que-ningu%c3%a9m-previu" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><strong>Steam Gaming.</strong> Não existia no plano original. Nasceu porque a seção de entretenimento só tinha anime — e leitores gamers ficaram de fora. Resultado: 6 commits só no dia de lançamento. O primeiro commit (<code>bf41e25</code>) adicionou a seção completa — serviço de API Steam, job de geração, testes, shortcode Hugo. Os 5 seguintes corrigiram: parsing de datas em português, offset pro ranking não repetir o top 10, filtro de wishlist pra releases, dark theme. Nenhum spec do mundo prevê que a API do Steam retorna datas com abreviações em português (<code>&quot;fev&quot;</code>, <code>&quot;mar&quot;</code>, <code>&quot;abr&quot;</code>) quando você pede <code>l=brazilian</code>. Isso você descobre quando o parser quebra em produção.</p>
<p><strong>Sistema de preview.</strong> O <code>/preview</code> nasceu porque eu precisava ver como o conteúdo auto-gerado ficava <strong>antes</strong> de publicar a newsletter inteira. Parece óbvio em retrospecto, mas na v1 a única forma de validar era gerar tudo e olhar o resultado final. 5 commits pra construir um sistema de preview com 9 seções, aliases (<code>hn</code> pra hacker_news, <code>steam</code> pra steam_gaming), modo de preview de comentários separado, e rescue quando uma seção falha sem derrubar as outras (<code>d23c725</code> — porque uma seção com erro estava matando o preview inteiro).</p>
<p><strong>Moods do Marvin.</strong> O Marvin (a persona sarcástica do bot) sofre de um problema crônico de LLMs: suavização. Sem intervenção, todo comentário vira o mesmo tom morno. A solução iterativa foi um sistema de moods — 9 modos que o operador escolhe por story:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="no">MARVIN_MOODS</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;sarcastic&#34;</span>   <span class="o">=&gt;</span> <span class="s2">&#34;Be extra sarcastic and biting in your commentary.&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;ironic&#34;</span>      <span class="o">=&gt;</span> <span class="s2">&#34;Use irony and dark humor to make your point.&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;grounded&#34;</span>    <span class="o">=&gt;</span> <span class="s2">&#34;Be more neutral and journalist-like, factual and measured.&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;provocative&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;Be provocative and controversial, challenge assumptions.&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;counter&#34;</span>     <span class="o">=&gt;</span> <span class="s2">&#34;Take the opposite position from Akita&#39;s comment.&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;insightful&#34;</span>  <span class="o">=&gt;</span> <span class="s2">&#34;Find a non-obvious angle -- a historical parallel, a second-order consequence.&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;positive&#34;</span>    <span class="o">=&gt;</span> <span class="s2">&#34;Find the genuinely positive angle.&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;hopeful&#34;</span>     <span class="o">=&gt;</span> <span class="s2">&#34;Be cautiously optimistic. Acknowledge the good potential.&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;negative&#34;</span>    <span class="o">=&gt;</span> <span class="s2">&#34;Be extra pessimistic and bleak.&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span><span class="o">.</span><span class="n">freeze</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Nada disso surgiu de uma spec. Surgiu de 3 semanas lendo comentários genéricos e pensando <em>&ldquo;isso não tá bom&rdquo;</em>. São 7 commits só de prompt tuning ao longo de 10 dias. Bani padrões formulaicos (<code>d4bd3a6</code>). Eliminei o &ldquo;Ah,&rdquo; como abertura (<code>4171628</code>). Removi o Marvin de seções inteiras do podcast porque ele tava contaminando o tom (<code>246cc60</code>). Forcei substância ao invés de trocadilhos (<code>8bc47c4</code>). Cada commit de prompt é uma micro-correção que só faz sentido depois de ler o output anterior.</p>
<h3>Bugs que Só Existem em Produção<span class="hx:absolute hx:-mt-20" id="bugs-que-só-existem-em-produção"></span>
    <a href="#bugs-que-s%c3%b3-existem-em-produ%c3%a7%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Dos 56 commits, 13 são bug fixes. Alguns favoritos:</p>
<ul>
<li>
<p><strong>Gmail clipping</strong> (<code>3258f5b</code>): Gmail silenciosamente corta emails maiores que 102KB. Descobri quando a 2ª newsletter saiu mais longa e leitores no Gmail não viram o final. Resultado: um check de tamanho no content preflight que avisa antes de publicar. Reduzi as seções de história de 10 pra 5 itens (<code>51ce528</code>) pra caber no limite.</p>
</li>
<li>
<p><strong>False paywall detection</strong> (<code>1fd8739</code>): o scraper marcava sites como paywall quando encontrava texto de bloqueio acidental num rodapé genérico. Só apareceu quando a lista de stories cresceu pra dezenas de fontes reais.</p>
</li>
<li>
<p><strong>t.co link mangling</strong> (<code>6bd056f</code>): o Twitter encurta URLs com t.co, mas também auto-linka nomes de arquivo que parecem domínios. <code>config.yml</code> vira um link pro domínio <code>config.yml</code>. 158 linhas de fix pra tratar corretamente textos de tweet.</p>
</li>
<li>
<p><strong>TTS language revert</strong> (<code>7803ad9</code> -&gt; <code>c1dd668</code>): tentei trocar o idioma do TTS de &ldquo;Portuguese&rdquo; pra &ldquo;Auto&rdquo;. O modelo gerou sotaque misturado. Revertido no mesmo dia.</p>
</li>
<li>
<p><strong>Podcast section ordering</strong> (<code>5bef18c</code>): seções do podcast saíram fora de ordem. Fix de 3 linhas + untrack de 1.504 linhas de conteúdo gerado que foi commitado por acidente.</p>
</li>
<li>
<p><strong>Elementor sites com múltiplos <code>&lt;article&gt;</code></strong> (<code>e70b756</code>): sites construídos com Elementor usam a tag <code>&lt;article&gt;</code> de forma não-semântica. O extrator de conteúdo pegava o bloco errado. 171 linhas de fix + testes.</p>
</li>
</ul>
<p>Nenhum desses bugs poderia ter sido previsto numa spec. Eles só existem porque o sistema tá no ar, processando dados reais, batendo em APIs reais, sendo lido por gente real.</p>
<h3>De 1.323 pra 1.422 Testes<span class="hx:absolute hx:-mt-20" id="de-1323-pra-1422-testes"></span>
    <a href="#de-1323-pra-1422-testes" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>A suíte de testes continuou crescendo junto:</p>
<table>
  <thead>
      <tr>
          <th>App</th>
          <th>Testes (20/fev)</th>
          <th>Testes (01/mar)</th>
          <th>Crescimento</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>marvin-bot</td>
          <td>970</td>
          <td>1.060</td>
          <td>+90</td>
      </tr>
      <tr>
          <td>newsletter</td>
          <td>353</td>
          <td>362</td>
          <td>+9</td>
      </tr>
      <tr>
          <td><strong>Total</strong></td>
          <td><strong>1.323</strong></td>
          <td><strong>1.422</strong></td>
          <td><strong>+99</strong></td>
      </tr>
  </tbody>
</table>
<p>Feature nova? Vem com teste. Bug fix? Vem com teste de regressão. Sem isso, os 56 commits de pós-produção seriam 56 oportunidades de quebrar algo que funcionava ontem. TDD não é fase, é hábito.</p>
<h3>O Modelo Mudou: De Claude pra Grok-4<span class="hx:absolute hx:-mt-20" id="o-modelo-mudou-de-claude-pra-grok-4"></span>
    <a href="#o-modelo-mudou-de-claude-pra-grok-4" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Commit <code>8f9d11c</code>: <code>Switch default model to x-ai/grok-4</code>. A arquitetura isolou o modelo LLM numa variável de ambiente desde o início. Mas o nome do modelo ainda estava referenciado em código por 24 arquivos. Resultado: commit <code>c8c688e</code> — <code>Centralize LLM model config</code> — 24 arquivos tocados pra garantir que trocar de modelo seja uma mudança de 1 linha de config.</p>
<p>Esse tipo de refactoring só surge na operação. Quando você começa o projeto, não sabe que vai trocar de modelo. Quando troca, quer que seja trivial. E a limpeza pra tornar trivial? Só acontece quando a dor aparece.</p>
<h2>Frank Sherlock: De v0.1 a v0.7 em 4 Dias<span class="hx:absolute hx:-mt-20" id="frank-sherlock-de-v01-a-v07-em-4-dias"></span>
    <a href="#frank-sherlock-de-v01-a-v07-em-4-dias" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O <a href="/2026/02/23/vibe-code-fiz-um-indexador-inteligente-de-imagens-com-ia-em-2-dias/">post sobre o Frank Sherlock</a> cobriu os primeiros 2 dias e ~50 commits: a pesquisa de benchmarks, o scaffold do app Tauri (Rust + React), a pipeline de classificação com Ollama, e o lançamento da v0.1.0 com binários pra Linux, macOS e Windows.</p>
<p>O que o post não cobriu: os 53 commits seguintes, em 4 dias, que levaram o projeto da v0.3 pra v0.7:</p>
<table>
  <thead>
      <tr>
          <th>Versão</th>
          <th>Data</th>
          <th>Destaque</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>v0.4.0</td>
          <td>24/fev</td>
          <td>Duplicate detection (SHA-256 + dHash), PDF password manager</td>
      </tr>
      <tr>
          <td>v0.5.0</td>
          <td>24/fev</td>
          <td>Auto-update, scan performance 2x, checkpoint resume</td>
      </tr>
      <tr>
          <td>v0.6.0</td>
          <td>25/fev</td>
          <td>Video support (11 formatos), directory tree, FTS stemmer</td>
      </tr>
      <tr>
          <td>v0.7.0</td>
          <td>27/fev</td>
          <td><strong>Face detection</strong> com ONNX nativo, person management</td>
      </tr>
  </tbody>
</table>
<h3>Face Detection: A Feature que Nasceu da Iteração<span class="hx:absolute hx:-mt-20" id="face-detection-a-feature-que-nasceu-da-iteração"></span>
    <a href="#face-detection-a-feature-que-nasceu-da-itera%c3%a7%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>A v0.1 era um indexador de imagens com classificação por LLM. A v0.7 tem detecção de rostos com embeddings de 512 dimensões, clustering por similaridade de cosseno, e busca por pessoa (<code>face:alice</code>). Nada disso tava no plano original. Surgiu porque comecei a usar o app com fotos de verdade e pensei <em>&ldquo;quero encontrar todas as fotos de fulano&rdquo;</em> A feature nasceu do uso, não da spec.</p>
<p>O caminho até lá foi metódico:</p>
<ol>
<li><strong><code>f34132f</code></strong>: A/B testing framework pra face detection — benchmarkear antes de implementar, não depois</li>
<li><strong><code>ef3be82</code></strong>: Resultados do A/B (SCRFD + ArcFace venceu)</li>
<li><strong><code>6d9174a</code></strong>: Implementação nativa com ONNX Runtime — sem Python, sem dependência externa</li>
<li><strong><code>3b25eaf</code></strong>: Clustering, person management, FacesView completa</li>
<li><strong><code>a02d67f</code></strong>: Refactoring — extrair helpers, deletar código morto, compartilhar CSS</li>
</ol>
<p>Benchmark -&gt; implementar -&gt; refatorar. O mesmo ciclo de sempre. E algo que nenhum one-shot prompt produz.</p>
<h3>Video Support: Outro Emergente<span class="hx:absolute hx:-mt-20" id="video-support-outro-emergente"></span>
    <a href="#video-support-outro-emergente" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>O app foi pensado pra imagens. Mas pastas reais de mídia têm vídeos. Commit <code>a67a2f9</code> adicionou suporte completo: scanning de 11 formatos (MP4, MKV, AVI, MOV, WebM&hellip;), extração de keyframe pra classificação pelo LLM, skip de frames pretos, parsing de legendas .srt pro índice full-text, e preview inline com streaming HTTP Range.</p>
<p>1.626 linhas adicionadas num commit. Uma feature inteira que nasceu do uso real, não de uma especificação.</p>
<h3>7 Releases, 3 Plataformas, AUR Automático<span class="hx:absolute hx:-mt-20" id="7-releases-3-plataformas-aur-automático"></span>
    <a href="#7-releases-3-plataformas-aur-autom%c3%a1tico" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Em 7 dias, o Frank Sherlock teve 7 releases. Cada um com binários compilados pra Linux (AppImage), macOS (DMG com notarização e code signing), e Windows (MSI). A pipeline de CI/CD (<code>release.yml</code>) faz tudo automaticamente. A partir da v0.5.0, um workflow adicional (<code>aur-publish.yml</code>) publica automaticamente no AUR — o repositório de pacotes do Arch Linux.</p>
<p>No final: 17.210 linhas de Rust, 10.863 linhas de TypeScript, 621 testes (322 Rust + 299 frontend). Cross-platform. Releases publicados e instaláveis por qualquer usuário. Uma semana, um dev, um agente de IA.</p>
<p>A parte de CI/CD pra cross-platform é onde a fantasia do <em>&ldquo;funciona na minha máquina&rdquo;</em> morre. Foram 17 commits só de build: Windows UNC paths, macOS signing com notarização, Linux AppImage, permissões de release workflow, macOS Intel target removido. Nenhum LLM do mundo sabe que o macOS exige entitlements específicos pra hardened runtime ou que paths no Windows começam com <code>\\?\</code>. Esse tipo de trabalho de deploy exige alguém que já passou por essa dor antes.</p>
<h2>FrankMD: Open Source de Verdade<span class="hx:absolute hx:-mt-20" id="frankmd-open-source-de-verdade"></span>
    <a href="#frankmd-open-source-de-verdade" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O FrankMD foi o primeiro projeto desta saga, um editor Markdown self-hosted com Rails 8. Os <a href="/2026/02/01/vibe-code-fiz-um-editor-de-markdown-do-zero-com-claude-code-frankmd-part-1/">posts de fevereiro</a> cobriram a construção. O que aconteceu depois é mais interessante: outras pessoas começaram a contribuir.</p>
<p>14 commits desde 20 de fevereiro. 3 contribuidores externos. 4 PRs merged. Release v0.2.0 no dia 28:</p>
<table>
  <thead>
      <tr>
          <th>PR</th>
          <th>Autor</th>
          <th>Tipo</th>
          <th>O que fez</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>#34</td>
          <td>@murilo-develop</td>
          <td>Bug fix</td>
          <td>Integração Ollama: ModelNotFoundError + API base URL</td>
      </tr>
      <tr>
          <td>#36</td>
          <td>@rafaself</td>
          <td>Bug fix</td>
          <td>Comportamento de table hints no editor (3 commits iterativos)</td>
      </tr>
      <tr>
          <td>#38</td>
          <td>@LuccaRomanelli</td>
          <td>Feature</td>
          <td>Auto-sync de tema com o desktop environment Omarchy</td>
      </tr>
      <tr>
          <td>#39</td>
          <td>@LuccaRomanelli</td>
          <td>Feature</td>
          <td>&ldquo;New Folder&rdquo; no menu de contexto do explorer</td>
      </tr>
  </tbody>
</table>
<h3>O Padrão do Maintainer<span class="hx:absolute hx:-mt-20" id="o-padrão-do-maintainer"></span>
    <a href="#o-padr%c3%a3o-do-maintainer" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>O mais revelador não são as contribuições em si, mas o que eu fiz depois de cada merge.</p>
<p>Quando o @LuccaRomanelli submeteu o PR do Omarchy theme sync (+308 linhas), eu mergeei e imediatamente commitei <code>fix: harden Omarchy theme integration and fix broken tests</code> — <strong>+163 linhas</strong> de fixes e testes no commit seguinte. O contribuidor implementou a feature. O maintainer endureceu.</p>
<p>Quando o @rafaself submeteu o fix de table hints, foram 3 commits iterativos no mesmo dia — <code>enhance</code>, <code>improve</code>, <code>streamline</code> — mostrando o mesmo padrão de refinamento progressivo que eu faço com o agente de IA. No dia seguinte, ele mandou um commit separado atualizando faraday, nokogiri e rack por security advisories.</p>
<p>No dia 28, sentei e fiz tudo de uma vez: mergeei as 4 PRs, commitei o hardening, atualizei 5 gems (brakeman, bootsnap, selenium-webdriver, web-console, mocha), e publiquei as release notes da v0.2.0. Um <em>&ldquo;release day&rdquo;</em> clássico.</p>
<p>O FrankMD hoje tem 226 commits, 1.804 testes (425 Ruby + 1.379 JavaScript), e contribuidores ativos. Não é mais <em>&ldquo;meu projetinho pessoal&rdquo;</em>. Virou software com comunidade. E comunidade não aparece pra protótipo sem testes que <em>&ldquo;funciona na minha máquina&rdquo;</em>. Aparece pra projeto com CI verde, documentação, releases versionados, e código que dá pra ler.</p>
<h2>FrankMega: Até o Menor Projeto Precisa de Pós-Produção<span class="hx:absolute hx:-mt-20" id="frankmega-até-o-menor-projeto-precisa-de-pós-produção"></span>
    <a href="#frankmega-at%c3%a9-o-menor-projeto-precisa-de-p%c3%b3s-produ%c3%a7%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O FrankMega foi construído em <a href="/2026/02/21/vibe-code-fiz-um-clone-do-mega-em-rails-em-1-dia-pro-meu-home-server/">1 dia</a>. 26 commits, file sharing seguro com Rails 8, 210 testes, deploy via Docker + Cloudflare Tunnel. Post publicado no mesmo dia. Pronto, certo?</p>
<p>Três dias depois, 2 commits:</p>
<ul>
<li><code>db4bb705</code> — Add macOS, Linux, and Windows package MIME types to seed defaults</li>
<li><code>b0c4829a</code> — Fix MIME types to match Marcel detection, add normalizes strip</li>
</ul>
<p>Usuários tentaram compartilhar arquivos <code>.dmg</code>, <code>.deb</code>, e <code>.msi</code>. O MIME type detection (via gem Marcel) não reconhecia esses formatos porque não estavam nos defaults do seed. Dois commits. 22 linhas. 15 minutos.</p>
<p>Mas sem eles, o FrankMega não servia pra compartilhar pacotes de instalação — que é exatamente o caso de uso mais comum num home server de devs.</p>
<blockquote>
  <p>Nenhum prompt no mundo prevê que seus usuários vão querer compartilhar <code>.deb</code> no primeiro dia.</p>

</blockquote>
<p>O projeto mais simples de todos, com a pós-produção mais curta. E mesmo assim, precisou de iteração.</p>
<h2>Os Números Consolidados<span class="hx:absolute hx:-mt-20" id="os-números-consolidados"></span>
    <a href="#os-n%c3%bameros-consolidados" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><table>
  <thead>
      <tr>
          <th>Projeto</th>
          <th>Commits (total)</th>
          <th>Testes</th>
          <th>Pós-produção</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>The M.Akita Chronicles</td>
          <td>335</td>
          <td>1.422</td>
          <td>56 commits, 10 dias em produção</td>
      </tr>
      <tr>
          <td>Frank Sherlock</td>
          <td>103</td>
          <td>621</td>
          <td>53 commits, 4 releases extras</td>
      </tr>
      <tr>
          <td>FrankMD</td>
          <td>226</td>
          <td>1.804</td>
          <td>14 commits, 3 contribuidores</td>
      </tr>
      <tr>
          <td>FrankMega</td>
          <td>28</td>
          <td>210</td>
          <td>2 commits, MIME fixes</td>
      </tr>
      <tr>
          <td><strong>Total</strong></td>
          <td><strong>692</strong></td>
          <td><strong>4.057</strong></td>
          <td><strong>125 commits pós-publicação</strong></td>
      </tr>
  </tbody>
</table>
<p>692 commits. 4.057 testes. 4 projetos em produção. Fevereiro de 2026. Eu e um agente de IA.</p>
<h2>Por Que One-Shot Prompt É Mito<span class="hx:absolute hx:-mt-20" id="por-que-one-shot-prompt-é-mito"></span>
    <a href="#por-que-one-shot-prompt-%c3%a9-mito" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Olha os 125 commits de pós-produção e me diz qual deles poderia ter sido previsto por uma especificação.</p>
<p>Gmail corta emails maiores que 102KB, e você só descobre quando manda o primeiro email longo. A Steam API retorna datas com abreviações em português (<code>&quot;fev&quot;</code>, <code>&quot;abr&quot;</code>), e você só descobre quando o parser quebra no domingo de geração. Usuários de macOS tentam compartilhar <code>.dmg</code> e não conseguem porque o MIME type não tá no seed. O Marvin abre todo comentário com <em>&ldquo;Ah,&rdquo;</em> e você só percebe quando lê 30 seguidos e quer arrancar os olhos. O modelo LLM precisa ser trocável com 1 linha de config, mas tá espalhado em 24 arquivos. Windows usa UNC paths que começam com <code>\\?\</code>, e o CI explode na sua cara. E o TTS em modo &ldquo;Auto&rdquo;? Gera um sotaque que parece português de Lisboa tentando ser carioca.</p>
<p>Nada disso é &ldquo;debugar&rdquo; no sentido tradicional. É navegar um espaço de problemas que só se revela quando o software encontra a realidade. Cada um desses foi uma decisão tomada em tempo real por alguém com contexto.</p>
<p>A fantasia do one-shot prompt é que, se você escrever uma especificação suficientemente detalhada, a IA produz o software perfeito. Mas a especificação perfeita exigiria que você soubesse de antemão tudo que vai dar errado. E se você soubesse de antemão tudo que vai dar errado, você não precisaria da especificação — já teria o software pronto na cabeça.</p>
<p>Software bom é o resultado de centenas de micro-decisões tomadas com o sistema rodando. Não de uma macro-decisão tomada antes de escrever a primeira linha.</p>
<h2>O Papel do Desenvolvedor Experiente<span class="hx:absolute hx:-mt-20" id="o-papel-do-desenvolvedor-experiente"></span>
    <a href="#o-papel-do-desenvolvedor-experiente" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A IA acelerou tudo isso. Sem ela, 692 commits em um mês seria impossível pra uma pessoa só. Mas aceleração sem direção é só entropia mais rápida.</p>
<p>Em cada projeto houve decisões que a IA não teria como tomar sozinha. Trocar de Claude pra Grok-4 porque o modelo anterior tava fraco num domínio específico. Benchmark de SCRFD vs alternativas <em>antes</em> de implementar face detection, porque a escolha errada ia custar dias. O hardening de +163 linhas que eu commitei imediatamente depois de mergear o PR do Omarchy, porque eu vi onde ia quebrar. O revert do TTS em modo Auto no mesmo dia, porque eu sei que &ldquo;Auto&rdquo; num modelo de TTS pra português brasileiro vai produzir sotaque misturado.</p>
<p>O caso do circuit breaker é o mais ilustrativo. Quando adicionei rate limiting, a Brave Search API começou a retornar 429 de vez em quando. Se eu pedisse, o agente implementaria um retry com backoff exponencial. Mas eu não pedi retry. Pedi circuit breaker:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">WebSearcher</span>
</span></span><span class="line"><span class="cl">  <span class="no">CIRCUIT_BREAKER_SECONDS</span> <span class="o">=</span> <span class="mi">120</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">search</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="ss">max_results</span><span class="p">:</span> <span class="mi">5</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="o">[]</span> <span class="k">if</span> <span class="n">circuit_open?</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="n">brave_search</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="n">max_results</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">response</span><span class="o">.</span><span class="n">code</span> <span class="o">==</span> <span class="mi">429</span>
</span></span><span class="line"><span class="cl">      <span class="n">trip_circuit!</span>
</span></span><span class="line"><span class="cl">      <span class="no">Rails</span><span class="o">.</span><span class="n">logger</span><span class="o">.</span><span class="n">warn</span><span class="p">(</span><span class="s2">&#34;WebSearcher rate limited (429)&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="k">return</span> <span class="o">[]</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">rescue</span> <span class="no">Net</span><span class="o">::</span><span class="no">OpenTimeout</span><span class="p">,</span> <span class="no">Net</span><span class="o">::</span><span class="no">ReadTimeout</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">    <span class="n">trip_circuit!</span>
</span></span><span class="line"><span class="cl">    <span class="o">[]</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Se a API retorna 429 ou timeout, para de tentar por 120 segundos. Sem retry, sem backoff, sem fila. Por quê? Porque eu sei que o cron roda todo dia às 8h e que nos domingos de geração de newsletter o volume de queries triplica. Uma manada de retries atrasando tudo é pior que resultados vazios numa seção.</p>
<p>A IA implementa um circuit breaker quando você pede. Mas ela não vai pedir pra implementar um circuit breaker. Ela não tem o contexto operacional. Isso é conhecimento que vem de rodar sistemas em produção por décadas.</p>
<blockquote>
  <p>O agente escreve o código. Eu decido qual código escrever. E essa decisão exige experiência que nenhum prompt substitui.</p>

</blockquote>
<h2>As Lições<span class="hx:absolute hx:-mt-20" id="as-lições"></span>
    <a href="#as-li%c3%a7%c3%b5es" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><strong>1. Software em produção diverge da spec em horas, não em meses.</strong> Os primeiros bugs do M.Akita Chronicles surgiram na primeira newsletter real. Os MIME types do FrankMega quebraram nos primeiros uploads. Quem acha que deploya e acabou tá vivendo num mundo que não existe.</p>
<p><strong>2. Pós-produção não é &ldquo;manutenção&rdquo;.</strong> 56 commits em 10 dias no M.Akita Chronicles não são patches. São features novas (Steam Gaming, preview, moods), refactoring de arquitetura (centralização de config do LLM), hardening de segurança (rate limiting). Isso é desenvolvimento. O software não parou de evoluir só porque eu disse <em>&ldquo;done&rdquo;</em> num blog post.</p>
<p><strong>3. TDD protege a evolução.</strong> 99 novos testes em 10 dias. Os 1.804 testes do FrankMD permitiram mergear 4 PRs de contribuidores externos sem medo. Sem testes, cada merge é roleta russa.</p>
<p><strong>4. Small releases mantêm a sanidade.</strong> 7 releases do Frank Sherlock em 7 dias. CI verde, binários compilados, release notes. Deu problema? Reverte uma versão. Compara com &ldquo;6 meses + big bang release&rdquo; e me conta qual funciona melhor.</p>
<p><strong>5. Comunidade aparece pra projetos reais.</strong> Ninguém manda PR pra protótipo sem testes que <em>&ldquo;funciona na minha máquina&rdquo;</em>. O FrankMD recebeu 3 contribuidores porque tem CI verde, documentação, e releases versionados.</p>
<p><strong>6. A experiência do desenvolvedor é o gargalo, não a velocidade da IA.</strong> 692 commits em um mês. Mas cada commit que importa exigiu décadas de experiência pra saber que era necessário. A IA digita rápido. Eu sei o que digitar.</p>
<p><strong>7. One-shot é pra demo. Iteração é pra produção.</strong> Se o objetivo é um vídeo de 10 minutos mostrando um <em>&ldquo;SaaS pronto&rdquo;</em>, one-shot serve. Se o objetivo é software que sobrevive ao contato com usuários reais, só iteração funciona. E iteração sustentável exige disciplina: TDD, CI, small releases, refactoring contínuo. Sem atalho.</p>
<h2>Pro Sênior que Ainda Tá de Braços Cruzados<span class="hx:absolute hx:-mt-20" id="pro-sênior-que-ainda-tá-de-braços-cruzados"></span>
    <a href="#pro-s%c3%aanior-que-ainda-t%c3%a1-de-bra%c3%a7os-cruzados" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Até aqui eu bati nos amadores que acham que podem mandar um prompt e a IA cospe um sistema pronto. Justo. Mas tem um outro grupo que me preocupa tanto quanto: o desenvolvedor sênior que viu a IA errar três vezes, declarou que <em>&ldquo;isso não serve pra nada&rdquo;</em>, e voltou a fazer tudo na mão.</p>
<p>Eu entendo o raciocínio. A IA alucina, gera código com bugs sutis, sugere soluções over-engineered. Tudo verdade, e eu documentei cada um desses problemas nos posts anteriores. Mas me diz uma coisa: você nunca fez exatamente a mesma coisa? Você nunca passou 2 horas lendo documentação pra descobrir que era um typo na config? A diferença é que quando a IA erra, você pega nos testes e corrige em minutos. Quando você erra sozinho, leva o mesmo tempo pra cometer o erro e mais tempo ainda pra achar, porque você confia no próprio código.</p>
<p>Vamos olhar pros números concretos do que eu entreguei em fevereiro, uma pessoa e um agente de IA:</p>
<table>
  <thead>
      <tr>
          <th>Projeto</th>
          <th>Tempo</th>
          <th>Resultado</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>M.Akita Chronicles</td>
          <td>8 dias</td>
          <td>4 apps (Rails + Python + Hugo), 335 commits, 1.422 testes, em produção há 3 semanas</td>
      </tr>
      <tr>
          <td>Frank Sherlock</td>
          <td>7 dias</td>
          <td>App desktop Tauri (Rust + React), 103 commits, 621 testes, 7 releases, binários pra 3 OS</td>
      </tr>
      <tr>
          <td>FrankMD</td>
          <td>~4 dias</td>
          <td>Editor Markdown Rails 8, 226 commits, 1.804 testes, 3 contribuidores externos</td>
      </tr>
      <tr>
          <td>FrankMega</td>
          <td>1 dia</td>
          <td>File sharing Rails 8, 28 commits, 210 testes, deploy Docker + Cloudflare</td>
      </tr>
  </tbody>
</table>
<p>Agora faz a conta de cabeça. Quanto tempo você levaria pra fazer o Frank Sherlock sozinho? Um app Tauri do zero, com pipeline de classificação por LLM em Rust, OCR via Python, full-text search com FTS5, detecção de duplicatas por perceptual hash, face detection com ONNX nativo, suporte a vídeo com extração de keyframe, CI pra 3 plataformas com code signing e notarização no macOS, auto-update, e publicação automática no AUR. Com 621 testes. Em Rust, que não perdoa.</p>
<p>Sendo honesto: sem IA, um dev sênior bom levaria no mínimo 3-4 semanas nisso, provavelmente mais. Eu fiz em 7 dias, sozinho, e cada release tá publicado com binários que qualquer pessoa pode baixar e instalar.</p>
<p>O M.Akita Chronicles eu já estimei no <a href="/2026/02/20/do-zero-a-pos-producao-em-1-semana-como-usar-ia-em-projetos-de-verdade-bastidores-do-the-m-akita-chronicles/">post anterior</a>: ~200 user stories. Em Scrum com equipe sênior de 2-3 devs, sem impedimentos, seriam 10-15 semanas. Fiz em 8 dias. Hoje, 3 semanas depois, o sistema continua rodando, evoluindo, com 99 testes a mais do que quando <em>&ldquo;acabou&rdquo;</em>.</p>
<p>O FrankMega é mais modesto, mas file sharing seguro com Rails 8, I18n em 2 idiomas, 22 issues de segurança corrigidos, deploy via Docker, 210 testes. Fiz em 1 dia. Um sênior bom, sem IA, faria em 1-2 semanas na melhor das hipóteses.</p>
<p>E não estou falando de protótipos descartáveis. Gente de fora manda PR pro FrankMD. Assinantes reais recebem a newsletter do M.Akita Chronicles toda segunda-feira. Qualquer pessoa pode baixar o Frank Sherlock no GitHub Releases ou no AUR. CI verde, Brakeman zerado, testes reais, deploys automatizados. Isso é software de produção, não demo de conferência.</p>
<p><em>&ldquo;Ah, mas eu faço código melhor sem IA.&rdquo;</em> Talvez. Mas em quanto tempo? O que eu mostrei aqui não é que a IA faz código perfeito. Longe disso. É que com TDD, CI, pair programming com agente, e refactoring contínuo, o resultado final é código de produção com qualidade. 4.057 testes estão lá pra provar. Brakeman zerado. 125 commits de pós-produção mostram que o código aguenta evolução sem virar uma bola de lama.</p>
<p>E usar IA de vez em quando pra gerar um trecho de código aqui e ali, tipo autocomplete glorificado, também não é a resposta. Você tá deixando 90% do ganho na mesa. O que eu fiz em fevereiro foi pair programming full-time com um agente. Do primeiro commit ao deploy em produção. Com a mesma disciplina que eu usaria com um par humano. Resultado: 4 projetos em produção em 1 mês, com qualidade que eu coloco meu nome. Porque eu coloquei.</p>
<p>Se você é sênior e ainda tá esperando a IA <em>&ldquo;melhorar&rdquo;</em> pra começar a usar de verdade, eu digo o seguinte: ela já tá boa o suficiente. Os 692 commits estão aí pra provar. O gargalo agora é você aprender a trabalhar com ela.</p>
<h2>Conclusão<span class="hx:absolute hx:-mt-20" id="conclusão"></span>
    <a href="#conclus%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Em fevereiro de 2026, eu construí 4 projetos do zero à produção com IA. Mas a construção é a parte fácil. O que separa software de verdade de demo são os 125 commits que vieram depois, quando os bugs que ninguém previu apareceram, quando contribuidores externos mandaram PRs que precisaram de hardening, quando features novas surgiram do uso real e não de uma planilha de requisitos.</p>
<p>A IA me tornou absurdamente mais produtivo. Sem ela, o Frank Sherlock não teria face detection em 7 dias. Sem ela, o M.Akita Chronicles não estaria na 3ª semana de operação com 1.422 testes. A velocidade é real.</p>
<p>Mas nenhuma das decisões que importam veio da IA. Trocar de modelo, revertar o TTS no mesmo dia, olhar pro PR do contribuidor e ver que ia quebrar, pedir circuit breaker ao invés de retry. Tudo isso fui eu. A IA executou. As decisões foram minhas.</p>
<p>A IA é o acelerador. As técnicas de Extreme Programming (TDD, small releases, pair programming, refactoring contínuo) são o freio e a direção. Sem disciplina, IA produz código rápido que acumula dívida técnica mais rápido ainda. Com disciplina, IA produz software que evolui de verdade, semana após semana.</p>
<p>692 commits. 4.057 testes. 4 projetos em produção. E amanhã, segunda-feira, às 7h, os assinantes do The M.Akita Chronicles recebem a 3ª newsletter. Gerada, revisada, e enviada por um sistema que nunca vai estar <em>&ldquo;pronto&rdquo;</em>. Porque software pronto é software morto.</p>
<blockquote>
  <p>&ldquo;Software pronto é software morto. Software vivo itera.&rdquo;</p>

</blockquote>
]]></content:encoded><category>frank</category><category>llms</category><category>engenharia-de-software</category><category>off-topic</category></item><item><title>Vibe Code: Fiz um Indexador Inteligente de Imagens com IA em 2 dias | Frank Sherlock</title><link>https://akitaonrails.github.io/2026/02/23/vibe-code-fiz-um-indexador-inteligente-de-imagens-com-ia-em-2-dias/</link><guid isPermaLink="true">https://akitaonrails.github.io/2026/02/23/vibe-code-fiz-um-indexador-inteligente-de-imagens-com-ia-em-2-dias/</guid><pubDate>Mon, 23 Feb 2026 18:34:34 +0000</pubDate><description>&lt;p&gt;Nas últimas 48 horas, construí do zero um aplicativo desktop completo, com binários publicados para Linux, macOS e Windows. 50 commits, ~26 horas de trabalho efetivo, 8.359 linhas de Rust, 5.842 de TypeScript, 338 testes automatizados. Se você me dissesse isso 2 anos atrás, eu chamaria de mentira.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://github.com/akitaonrails/FrankSherlock/raw/master/docs/frank_sherlock.png" alt="frank sherlock" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;O nome é &lt;a href="https://github.com/akitaonrails/FrankSherlock"target="_blank" rel="noopener"&gt;&lt;strong&gt;Frank Sherlock&lt;/strong&gt;&lt;/a&gt; — um sistema local de catalogação e busca de imagens usando IA. Você aponta pra uma pasta (pode ser um NAS com terabytes), ele escaneia tudo, classifica cada arquivo usando um LLM de visão rodando localmente na sua GPU, e te dá busca full-text sobre o conteúdo. Não é cloud, não manda nada pra fora, roda 100% na sua máquina.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Nas últimas 48 horas, construí do zero um aplicativo desktop completo, com binários publicados para Linux, macOS e Windows. 50 commits, ~26 horas de trabalho efetivo, 8.359 linhas de Rust, 5.842 de TypeScript, 338 testes automatizados. Se você me dissesse isso 2 anos atrás, eu chamaria de mentira.</p>
<p><img src="https://github.com/akitaonrails/FrankSherlock/raw/master/docs/frank_sherlock.png" alt="frank sherlock"  loading="lazy" /></p>
<p>O nome é <a href="https://github.com/akitaonrails/FrankSherlock"target="_blank" rel="noopener"><strong>Frank Sherlock</strong></a> — um sistema local de catalogação e busca de imagens usando IA. Você aponta pra uma pasta (pode ser um NAS com terabytes), ele escaneia tudo, classifica cada arquivo usando um LLM de visão rodando localmente na sua GPU, e te dá busca full-text sobre o conteúdo. Não é cloud, não manda nada pra fora, roda 100% na sua máquina.</p>
<p>Eis alguns exemplos de textos que ele extraiu de algumas das minhas imagens:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-23_16-08-03.png" alt="game boy example"  loading="lazy" /></p>
<p>E olha os detalhes do Surya OCR: ele leu direitinho o texto na tela do Game Boy:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-23_16-08-14.png" alt="gameboy screen ocr"  loading="lazy" /></p>
<p>Mais do que isso, eu tenho diretórios de screenshots de comprovantes de pagamento. Nunca mais ia achar nada ali, mas agora:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-23_16-07-37.png" alt="santander comprovante"  loading="lazy" /></p>
<p>Embora ele faça alguns erros, claro, animes obscuros (ele fica achando que tudo que ele não reconhece é Evangelion 😂). Mas muitos ele surpreendentemente acerta também. Mas as descrições em si já ajudam muito.</p>
<p>Vai precisar de um mínimo de GPU (eu só testei na minha 5090, mas é pra baixar modelos menores pra GPUs menores, e teoricamente suporta AMD e Mac também, mas ainda não testei - aceito Issues e Pull Requests pra quem quiser fazer beta-testing em Mac e Windows). Basta ter o Ollama instalado e rodando; opcionalmente, Python (pra ter o Surya OCR, que é opcional, mas é o melhor).</p>
<p>Vou colocar como &ldquo;1.0&rdquo; quando tiver mais gente testado em Windows/macOS e eu tiver os certificados pra assinar os executáveis direitinho. Esta é uma versão &ldquo;beta&rdquo; ainda! Compilem e rodem vocês mesmos em suas máquinas, tá tudo explicado no <a href="https://github.com/akitaonrails/FrankSherlock/blob/master/README.md"target="_blank" rel="noopener">README</a>.</p>
<p>E eu fiz isso com meu <a href="2026/02/20/do-zero-a-pos-producao-em-1-semana-como-usar-ia-em-projetos-de-verdade-bastidores-do-the-m-akita-chronicles/">&ldquo;Agile Vibe Coding&rdquo;</a> — basicamente, programar em parceria com um LLM (no caso, Claude Code).</p>
<blockquote>
  <p><strong>Agile Vibe Coding funciona. E funciona muito bem. Mas a ideia é só 10% do trabalho. Os outros 90% são engenharia.</strong></p>

</blockquote>
<p>Engenharia requer experiência, julgamento, e saber fazer as perguntas certas. O LLM é um executor excelente. Mas quem decide o que executar, em que ordem, e por quê, esse continua sendo o trabalho do desenvolvedor.</p>
<p>Tem um discurso crescente de que qualquer pessoa com uma boa ideia pode construir software agora. Num certo sentido, sim, você pode colocar um protótipo no ar rápido. Mas a distância entre &ldquo;roda na minha máquina&rdquo; e &ldquo;software que funciona em 3 sistemas operacionais, sobrevive a cancelamentos no meio do processamento, não corrompe dados, e escala de 94 arquivos de teste pra 500 mil em produção&rdquo; continua sendo enorme. Esse gap é engenharia, e engenharia ainda exige alguém que saiba o que está fazendo.</p>
<p>Vou contar a história completa: da pesquisa inicial ao release, passando por benchmarks, prova de conceito, decisões de arquitetura, CI/CD multi-plataforma, e tudo que ficou entre &ldquo;eu tenho uma ideia&rdquo; e &ldquo;aqui está o AppImage, o DMG e o MSI&rdquo;.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-23_15-47-30.png" alt="4 image preview"  loading="lazy" /></p>
<h2>A Ideia Original<span class="hx:absolute hx:-mt-20" id="a-ideia-original"></span>
    <a href="#a-ideia-original" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Tudo começou com uma pergunta simples: será que LLMs open-source de visão conseguem classificar conteúdo de verdade? Não estou falando de &ldquo;mulher na praia&rdquo; — isso qualquer modelo faz. Estou falando de olhar pra uma imagem e dizer &ldquo;isso é a Ranma, do anime Ranma 1/2 da Rumiko Takahashi, numa cena do OVA The Battle for Miss Beachside&rdquo;. Será que chegamos nesse nível? (TL;DR não, mas o suficiente)</p>
<p>E se chegamos, dá pra fazer um catálogo inteligente de arquivos? Algo que eu aponte pro meu NAS com terabytes de mídia acumulada ao longo de décadas e consiga buscar por conteúdo, não por nome de arquivo? Quem tem um NAS em casa sabe: depois de alguns anos, os arquivos acumulam e a organização de diretórios simplesmente para de escalar. Você sabe que tem aquele comprovante de pagamento de 2019 em algum lugar, mas o arquivo se chama <code>IMG_20190315_142301.jpg</code> e está num diretório com mais 3 mil fotos.</p>
<p>Meu hardware: AMD 7850X3D, RTX 5090, Arch Linux. Restrição absoluta: nada de APIs remotas, nada de OpenAI, nada de cloud. Tudo open-source, tudo local. Se eu vou processar terabytes de arquivos pessoais, incluindo documentos financeiros e fotos privadas, não quero mandar nada pra servidor de terceiros. Fora que o custo de API cloud pra esse volume seria proibitivo.</p>
<p>Mas primeiro: pesquisa. Sem saber se a tecnologia entrega o que eu preciso, não faz sentido construir nada. Scripts pequenos, protótipos rápidos, modelos diferentes. Ver o que funciona antes de escrever a primeira linha de código do app de verdade. Esse é um padrão que eu sigo há anos: valide a suposição mais arriscada primeiro. Se o LLM de visão não conseguir classificar decentemente, todo o resto é desperdício.</p>
<h2>Pesquisa A/B: Benchmark Driven Development<span class="hx:absolute hx:-mt-20" id="pesquisa-ab-benchmark-driven-development"></span>
    <a href="#pesquisa-ab-benchmark-driven-development" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Essa é a parte que a maioria das pessoas pula, e é exatamente onde a experiência faz diferença. A tentação de pular direto pra implementação é enorme. <em>&ldquo;Vou usar o modelo X porque li num blog que é bom.&rdquo;</em> Não. Antes de escolher modelo, framework, ou arquitetura, eu montei um <a href="https://github.com/akitaonrails/FrankSherlock/tree/master/_research_ab_test"target="_blank" rel="noopener">benchmark formal</a>.</p>
<p>Montei um corpus de teste com 94 arquivos: 60 imagens (fotos, screenshots, anime, documentos, receipts), 9 áudios, 13 vídeos e 12 documentos. Pra cada arquivo, criei um ground truth em JSON com a classificação correta — tipo, descrição, série (quando aplicável). Esse ground truth é o que permite medir accuracy de verdade, não <em>&ldquo;olhei o resultado e pareceu OK&rdquo;</em>.</p>
<p>O benchmark tem 6 fases, cada uma respondendo a uma pergunta específica:</p>
<ol>
<li><strong>Metadata</strong>: quanto custa extrair metadata básica? (resposta: barato, 0.07s/arquivo)</li>
<li><strong>Imagens</strong>: qual modelo de visão é melhor? Qual OCR?</li>
<li><strong>Áudio</strong>: Whisper funciona? Qual tamanho de modelo?</li>
<li><strong>Vídeo</strong>: classificação por frames funciona?</li>
<li><strong>Catálogo unificado</strong>: o pipeline completo integrado funciona?</li>
<li><strong>Projeção de custo</strong>: quanto tempo e dinheiro pra processar um NAS real?</li>
</ol>
<h3>Fase 2: Visão — O Resultado Que Ninguém Esperava<span class="hx:absolute hx:-mt-20" id="fase-2-visão--o-resultado-que-ninguém-esperava"></span>
    <a href="#fase-2-vis%c3%a3o--o-resultado-que-ningu%c3%a9m-esperava" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Testei <code>qwen2.5vl:7b</code>, <code>llava:13b</code> e <code>minicpm-v:8b</code> em 30 imagens rotuladas. O resultado:</p>
<table>
  <thead>
      <tr>
          <th>Modelo</th>
          <th style="text-align: center">Type Accuracy</th>
          <th style="text-align: center">Series Accuracy</th>
          <th style="text-align: center">JSON Valid</th>
          <th style="text-align: center">Latência/img</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>qwen2.5vl:7b</td>
          <td style="text-align: center"><strong>0.80</strong></td>
          <td style="text-align: center"><strong>0.14</strong></td>
          <td style="text-align: center"><strong>0.87</strong></td>
          <td style="text-align: center">0.55s</td>
      </tr>
      <tr>
          <td>minicpm-v:8b</td>
          <td style="text-align: center">0.50</td>
          <td style="text-align: center">0.00</td>
          <td style="text-align: center">0.83</td>
          <td style="text-align: center">1.63s</td>
      </tr>
      <tr>
          <td>llava:13b</td>
          <td style="text-align: center">0.33</td>
          <td style="text-align: center">0.06</td>
          <td style="text-align: center">0.83</td>
          <td style="text-align: center">1.62s</td>
      </tr>
  </tbody>
</table>
<p>O modelo de 7B parâmetros ganhou de lavada dos maiores. Não é typo. O <code>qwen2.5vl:7b</code> bateu o <code>llava:13b</code> (quase o dobro do tamanho) em todas as métricas, e ainda foi 3x mais rápido. Isso contradiz a intuição de <em>&ldquo;modelo maior = modelo melhor&rdquo;</em>. Na prática, depende do task e do prompt.</p>
<p>Naturalmente, a próxima pergunta é: e o 32B? Mesmo modelo, versão gigante. A gente deve conseguir bem mais, certo? Errado:</p>
<table>
  <thead>
      <tr>
          <th>Modelo</th>
          <th style="text-align: center">Type Accuracy</th>
          <th style="text-align: center">Series Accuracy</th>
          <th style="text-align: center">Latência/img</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>qwen2.5vl:7b</td>
          <td style="text-align: center">0.87</td>
          <td style="text-align: center">0.19</td>
          <td style="text-align: center"><strong>0.77s</strong></td>
      </tr>
      <tr>
          <td>qwen2.5vl:32b</td>
          <td style="text-align: center">0.87</td>
          <td style="text-align: center">0.25</td>
          <td style="text-align: center">22.46s</td>
      </tr>
  </tbody>
</table>
<p>O 32B deu +0.06 em <em>series accuracy</em> (literalmente 1 acerto a mais em 16 itens rotulados) e custou <strong>29x mais tempo</strong>. Pra quem vai processar centenas de milhares de arquivos, essa troca não fecha. 29x mais lento significa que um job de 6 horas vira um job de uma semana.</p>
<p>Aqui faço um comentário sobre ferramenta: fiz a primeira rodada com Claude Code e tinha pedido pra ele escolher os modelos que achava melhores. Mas depois decidi ir pro GPT Codex e ele fez outras sugestões que achei interessante testar. Em resumo: tenho achado Codex muito melhor pra <strong>experimentação</strong> e <strong>código exploratório</strong>, pra pesquisa mesmo. Claude eu acho melhor quando já sabemos melhor exatamente o que queremos.</p>
<p>Com o Codex, testei os novos candidatos <code>qwen3-vl:8b</code> e <code>qwen3-vl:30b-a3b</code> com 3 repetições para fins de significância estatística. Resultado? Ambos <em>piores</em> que o <code>qwen2.5vl:7b</code> — <code>type_accuracy</code> de 0.55 contra 0.89 do incumbent, com intervalo de confiança de 95% que nem se aproxima. E ainda mais lentos: 2x e 2.2x respectivamente. Modelo mais novo nem sempre é modelo melhor pro seu caso de uso. O qwen3-vl frequentemente retornava JSON truncado ou mal-formado — uma regressão real em robustez.</p>
<h3>OCR: Surya vs Ollama Vision<span class="hx:absolute hx:-mt-20" id="ocr-surya-vs-ollama-vision"></span>
    <a href="#ocr-surya-vs-ollama-vision" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Para extração de texto (documentos escaneados, receipts, screenshots com texto), testei dois engines:</p>
<table>
  <thead>
      <tr>
          <th>Engine</th>
          <th style="text-align: center">Cobertura</th>
          <th style="text-align: center">Similaridade</th>
          <th style="text-align: center">Latência/img</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Surya</td>
          <td style="text-align: center">54/65 arquivos</td>
          <td style="text-align: center">0.9455</td>
          <td style="text-align: center">8.15s</td>
      </tr>
      <tr>
          <td>Ollama Vision</td>
          <td style="text-align: center">38/65 arquivos</td>
          <td style="text-align: center">0.9419</td>
          <td style="text-align: center">1.73s</td>
      </tr>
  </tbody>
</table>
<p>Surya encontra texto em muito mais arquivos (83% vs 58%), mas é 5x mais lento. Quando encontra texto, a qualidade é praticamente igual (similarity &gt; 0.94 nos dois). Solução óbvia: abordagem híbrida. Usa Surya quando precisa de cobertura máxima, Ollama Vision como fallback rápido. O design do pipeline ficou: tenta Surya → se falhar ou não encontrar texto → fallback pra Ollama Vision. Por isso falei no começo que Surya é opcional, se não quiser instalar.</p>
<h3>Projeção de Custo<span class="hx:absolute hx:-mt-20" id="projeção-de-custo"></span>
    <a href="#proje%c3%a7%c3%a3o-de-custo" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>A fase 6 fez algo que raramente vejo em projetos open-source: projeção de custo pra uso real. Com os timings medidos por tipo de arquivo, extrapolamos pra cenários de NAS:</p>
<table>
  <thead>
      <tr>
          <th>Cenário</th>
          <th style="text-align: center">Arquivos</th>
          <th style="text-align: center">Tempo</th>
          <th style="text-align: center">Custo Elétrico</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Corpus teste</td>
          <td style="text-align: center">94</td>
          <td style="text-align: center">24 min</td>
          <td style="text-align: center">$0.02</td>
      </tr>
      <tr>
          <td>NAS pequeno</td>
          <td style="text-align: center">~5K</td>
          <td style="text-align: center">6.6 h</td>
          <td style="text-align: center">$0.36</td>
      </tr>
      <tr>
          <td>NAS médio</td>
          <td style="text-align: center">~50K</td>
          <td style="text-align: center">2.6 dias</td>
          <td style="text-align: center">$3.37</td>
      </tr>
      <tr>
          <td>NAS grande</td>
          <td style="text-align: center">~500K</td>
          <td style="text-align: center">26 dias</td>
          <td style="text-align: center"><strong>$33.70</strong></td>
      </tr>
  </tbody>
</table>
<p>Leve esses números com algum ceticismo porque é conta em papel de pão. $34 de eletricidade pra classificar 500 mil arquivos com GPU local. Tenta fazer isso com API do GPT-4 Vision — a $0.01 por imagem (conservador), são $5.000. O preço do meu setup (GPU + eletricidade) se paga no primeiro uso grande.</p>
<p>A lição: <strong>faça benchmark, não adivinhe</strong>. Eu poderia ter assumido que o modelo maior seria melhor, ou que o mais novo superaria o antigo. Os dados mostraram o contrário. ~2 horas de benchmarks me pouparam de escolhas erradas que custariam dias de retrabalho (antigamente eu diria &ldquo;semanas&rdquo;).</p>
<h2>Prova de Conceito em Python<span class="hx:absolute hx:-mt-20" id="prova-de-conceito-em-python"></span>
    <a href="#prova-de-conceito-em-python" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Com os benchmarks em mãos, o próximo passo foi validar o pipeline completo antes de partir pro Rust. Não o pipeline de benchmark (que testa cada modelo isoladamente), mas o pipeline de classificação real — a sequência exata de chamadas que o app final vai fazer pra cada arquivo.</p>
<p>Criei um protótipo em Python — 754 linhas em um único arquivo (<a href="https://github.com/akitaonrails/FrankSherlock/blob/master/_classification/run_classification.py"target="_blank" rel="noopener"><code>classification/run_classification.py</code></a>) — que implementava a estratégia vencedora dos benchmarks.</p>
<p>O pipeline tem 4 estágios de enriquecimento:</p>
<ol>
<li><strong>Classificação primária</strong>: manda a imagem pro <code>qwen2.5vl:7b</code> com prompt estruturado, pede JSON com tipo, descrição, tags, confidence</li>
<li><strong>Enriquecimento anime</strong>: se o tipo primário for anime/manga/cartoon, faz uma segunda passada com prompt especializado pedindo série, personagem, cena, artista</li>
<li><strong>Documento/OCR</strong>: se for documento ou receipt, extrai texto com Surya e/ou Ollama Vision, depois pede dados estruturados (datas, valores, IDs de transação)</li>
<li><strong>Output</strong>: grava resultado em YAML espelhando a estrutura de diretórios de origem</li>
</ol>
<p>A parte mais crítica é o parsing de JSON do LLM. Quem já trabalhou com output de LLMs sabe que eles são&hellip; criativos com formatação. Às vezes vem com markdown fences (<code>json ... </code>). Às vezes tem texto antes e depois do JSON. Às vezes o JSON está quase certo mas falta fechar uma chave. O protótipo implementou uma cascata de 3 tentativas que depois virou a regra do projeto inteiro:</p>
<ol>
<li><strong>Parse direto</strong>: <code>json.loads(response)</code> — funciona em ~70% dos casos</li>
<li><strong>Extração por balanceamento de chaves</strong>: encontra o primeiro <code>{</code>, conta chaves abertas e fechadas, extrai o substring — pega mais ~20%</li>
<li><strong>Regex field salvage</strong>: se tudo mais falhar, usa regex pra extrair campos individuais (&ldquo;type&rdquo;: &ldquo;&hellip;&rdquo;, &ldquo;description&rdquo;: &ldquo;&hellip;&rdquo;) — salva os últimos ~10%</li>
</ol>
<p>Essa cascata ficou no código Rust praticamente idêntica. PoC bem feito encurta o caminho pra produção.</p>
<p>Resultado: 60 imagens processadas, zero erros, 6.29s/imagem de média. O pipeline funcionava de ponta a ponta. Hora de construir o app de verdade.</p>
<h2>Construindo o App Tauri<span class="hx:absolute hx:-mt-20" id="construindo-o-app-tauri"></span>
    <a href="#construindo-o-app-tauri" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Nesse ponto, tentei continuar com o Codex, mas aí ele se engasgou em tentar converter o Poc em Python pra Tauri. Mas como agora as escolhas já foram feita, voltei pro Claude Code, e ele não teve problemas em mapear do Python pra Rust.</p>
<p>Aqui é onde o vibe coding mostra a que veio. Vou contar a timeline real, commit por commit, pra você ter noção do ritmo. Os timestamps são do git log, então são precisos.</p>
<h3>Sábado 21/02 (19:29 → 21:08) — 6 commits, 1h39<span class="hx:absolute hx:-mt-20" id="sábado-2102-1929--2108--6-commits-1h39"></span>
    <a href="#s%c3%a1bado-2102-1929--2108--6-commits-1h39" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Tudo começou com <a href="https://github.com/akitaonrails/FrankSherlock/tree/master/_research_ab_test"target="_blank" rel="noopener">research</a>. Seis commits de setup, scripts de benchmark, análise de resultados:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>f57221c 19:29 Phase 0: Project setup with uv, environment verification
4977497 19:45 Implement all phases: metadata, image/audio/video classification
af6e3aa 19:49 Add research results report
41d6c2a 20:12 Add per-file timing, OCR phase, cost estimation
0cd1b10 21:02 Fix Surya OCR, re-run on 94-file corpus
25b3ace 21:08 Add conclusions, cost analysis, recommended pipeline</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Sábado à noite, só pesquisa pura. Nenhuma linha de código do app. Mas quando fui dormir, sabia qual modelo usar, qual abordagem de OCR, e quanto ia custar em tempo e eletricidade.</p>
<h3>Domingo 22/02 (13:05 → 23:30) — 14 commits, ~10h25<span class="hx:absolute hx:-mt-20" id="domingo-2202-1305--2330--14-commits-10h25"></span>
    <a href="#domingo-2202-1305--2330--14-commits-10h25" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>O domingo foi o dia de construção pesada. Acordei, almocei, e sentei pra trabalhar.</p>
<p><strong>13:05</strong> — Primeiro commit do dia: o protótipo Python de classificação. 749 linhas validando o pipeline completo de classificação, a cascata de JSON parsing, e o enriquecimento condicional. Esse protótipo não era throwaway — ele era o design document executável do pipeline Rust que viria depois.</p>
<p>Dei uma pausa pra pra dar uma saída, passear, tomar um vinho, depois voltei e continuei:</p>
<p><strong>17:31</strong> — Benchmarks atualizados com os novos candidatos (qwen3-vl:8b e qwen3-vl:30b-a3b). Três repetições cada, intervalos de confiança. Confirmaram que o qwen2.5vl:7b era a escolha certa — não por pouco, mas por uma margem enorme.</p>
<p><strong>17:56</strong> — O &ldquo;big bang&rdquo;: scaffold do app Tauri. <strong>9.631 linhas inseridas</strong> num único commit. A estrutura inteira do app: Rust backend com SQLite + FTS5, React frontend, config system, scanner de arquivos, modelos de dados, query parser pra busca natural. Nesse ponto o app já buscava no banco. Não tinha classificação ainda, mas a fundação estava sólida — e é exatamente o ponto. O scaffold veio com testes, tipos TypeScript, e a arquitetura de diretórios que eu defini. O Claude gerou o código, mas a estrutura de módulos (config, db, scan, models, query_parser) veio de como eu queria organizar as responsabilidades.</p>
<p><strong>19:04</strong> — O commit mais pesado do projeto: <strong>4.186 linhas</strong>. Classification pipeline em Rust (1.069 linhas de <code>classify.rs</code> — praticamente a tradução do PoC Python), geração de thumbnails (Lanczos3, 300px, JPEG 80%), scanning incremental com fingerprint, o script Surya OCR em Python, runtime detection pra Ollama, e a expansão brutal do banco de dados com upsert, touch, delete e FTS indexing. Num commit. Com 47 testes. Eu tive que tomar as decisões de arquitetura (como o cache espelha o rel_path, como o scan é dividido em duas fases, como os erros propagam), mas o Claude escreveu o grosso do código e os testes que vieram junto.</p>
<p><strong>19:55</strong> — Redesign de UI, cancellation de scan, auto-cleanup de classificações órfãs, reorganização do repo inteiro (movi tudo de research pra <code>_research_ab_test/</code>). CI e release workflow já configurados nesse commit — eu sabia que ia precisar deles no dia seguinte.</p>
<p><strong>21:02</strong> — DB resilience (WAL mode, backup, health check), gerenciamento de roots (adicionar, remover, listar diretórios monitorados), zoom, redesign do sidebar com tree view, fix de thumbnails. Quatro features num commit. Num workflow manual, cada uma dessas seria um PR separado com review de dias.</p>
<p><strong>21:14 → 21:39</strong> — Read-only database mode pra filesystems sandbox, resume de scans interrompidos no startup, redesign de grid tiles com hover overlay, modelo de seleção, infinite scroll. Três commits em 25 minutos. O Claude estava num ritmo absurdo.</p>
<p><strong>22:41</strong> — Multi-select com Ctrl/Shift click, preview de collage com os itens selecionados, Ctrl+C copia pro clipboard do OS.</p>
<p><strong>23:30</strong> — O grande refactor: frontend monolítico (tudo no <code>App.tsx</code> - se não explicar, Claude sempre faz isso) desmembrado em 15 componentes + 10 hooks + 84 testes frontend. Esse é o tipo de coisa que normalmente leva um dia inteiro de trabalho tedioso. Com vibe coding, foi um commit de uma hora. O Claude extraiu cada componente, criou os hooks, montou os testes com mocks adequados, e manteve tudo funcionando. Eu só precisei dizer <em>&ldquo;refatora esse monolito em componentes e hooks, e escreve testes pra cada um&rdquo;</em>.</p>
<h3>Segunda-feira 23/02 (00:09 → 14:33) — 30 commits, ~14h<span class="hx:absolute hx:-mt-20" id="segunda-feira-2302-0009--1433--30-commits-14h"></span>
    <a href="#segunda-feira-2302-0009--1433--30-commits-14h" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>A segunda foi de polimento, robustez e a maratona de cross-platform.</p>
<p><strong>00:09</strong> — Abstração multi-OS: todo código específico de plataforma isolado no módulo <code>platform/</code>. Essa decisão, tomada cedo, salvou horas de dor no CI. Quando o Windows precisou de tratamento especial pra paths UNC, a mudança ficou contida em <code>platform/process.rs</code> em vez de espalhada em 8 arquivos.</p>
<p><strong>00:12 → 01:18</strong> — Sequência rápida: cargo audit no CI, remoção de duplicações, licença GPL-3.0, help dialog (F1) com exemplos de query syntax, sort controls pra resultados, sistema de migração SQLite formal, context menu (copy, delete, rename). Seis commits em pouco mais de uma hora.</p>
<p><strong>09:38</strong> — Após dormir umas 8 horas, o primeiro commit da manhã: extração do módulo LLM. O monolito de chamadas ao Ollama que vivia dentro de <code>classify.rs</code> foi separado em <code>llm/client.rs</code> (chamadas HTTP, parsing JSON), <code>llm/management.rs</code> (download, listagem, cleanup de modelos) e <code>llm/model_selection.rs</code> (seleção hardware-aware por tier). Atomic file ops pra não corromper cache se o processo morrer no meio de uma escrita.</p>
<p><strong>10:54</strong> — Extração de localização EXIF (GPS coordinates → endereço legível), modal de edição de metadata, setup instructions por OS (cada OS tem dependências diferentes pro Ollama e Python).</p>
<p><strong>11:34</strong> — Suporte a PDF: scanning, indexing e preview usando PDFium. Não é trivial — PDFium precisa de binário nativo por plataforma, renderização de páginas em imagem, detecção de páginas em branco (pra não gerar thumbnail de uma capa vazia), montagem de thumbnail com as 2 primeiras páginas com conteúdo, e extração de texto nativo como alternativa mais rápida ao OCR.</p>
<p><strong>12:08</strong> — Albums e Smart Folders. Albums são coleções manuais (o usuário arrasta arquivos pra dentro), Smart Folders são queries salvas que aparecem no sidebar e atualizam automaticamente. Duas migrações de banco, novo componente de sidebar, drag-and-drop. Em 34 minutos.</p>
<p><strong>12:11 → 12:52</strong> — Ícones SVG macOS-inspired no sidebar, suporte a argumento CLI (<code>sherlock /path/to/folder</code>), copy description/OCR text no context menu, fix de path do PDFium em produção, ícones e screenshots no README, script <code>tauri</code> no npm, sidebar toggle, titlebar dinâmico, fix de compilação Windows (imports Unix-only). Dez commits em 41 minutos. A maioria desses foram issues que apareceram no CI ou durante teste manual.</p>
<p><strong>13:14 → 13:54</strong> — A maratona de CI fixes. Auto-provision do venv Surya OCR com barra de progresso no SetupModal, regeneração de ícones com alpha channel, <code>cargo fmt</code> + clippy + fix de UNC paths no Windows, testes pros exemplos do help dialog, rescan de pasta individual, fix de assertions Windows, fix de permissions do release workflow. Sete commits de hardening. Cada um resolvendo um bug real que aparecia no CI matrix ou em teste.</p>
<p><strong>14:26</strong> — Drag-and-drop pra reordenar roots no sidebar, cancelamento de scan antes de deletar um root (pra não deixar o scan rodando em background num diretório que o usuário removeu — edge case sutil que poderia causar crash).</p>
<p><strong>14:33</strong> — Último commit: fix de responsividade no cancelamento de scan. Check do cancel flag após cada classificação, poll imediato em vez de esperar o próximo tick. Detalhe pequeno, impacto grande na UX.</p>
<h2>Decisões de Arquitetura<span class="hx:absolute hx:-mt-20" id="decisões-de-arquitetura"></span>
    <a href="#decis%c3%b5es-de-arquitetura" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Aqui é o que separa <em>&ldquo;deixar o LLM escrever código&rdquo;</em> de &ldquo;construir software de verdade&rdquo;. Nenhuma dessas decisões veio de prompt. O Claude não sugeriu nenhuma espontaneamente. Eu tive que pedir cada uma.</p>
<h3>Princípio Read-Only<span class="hx:absolute hx:-mt-20" id="princípio-read-only"></span>
    <a href="#princ%c3%adpio-read-only" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>O app <strong>nunca</strong> escreve nos diretórios escaneados. Tudo — thumbnails, cache de classificação, banco de dados — fica em <code>~/.local/share/frank_sherlock/</code>. Isso não é só boa prática, é respeito pelo dado do usuário. Se alguém aponta pro NAS da empresa, o app não pode sair criando <code>.sherlock/</code> em cada subdiretório. Se o diretório é montado como read-only via NFS, o app precisa funcionar normalmente. Parece óbvio, mas muitos apps de catalogação que você conhece criam caches e thumbnails <strong>dentro</strong> dos diretórios de origem. (cof Synology @eaDir cof)</p>
<h3>Scanning Incremental<span class="hx:absolute hx:-mt-20" id="scanning-incremental"></span>
    <a href="#scanning-incremental" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Escanear terabytes de dados toda vez que o app abre seria insano. O scan é incremental em dois sentidos:</p>
<ol>
<li><strong>Fase de descoberta</strong> (rápida): caminha o filesystem comparando mtime + tamanho de cada arquivo. Se nada mudou desde o último scan, nem lê o conteúdo — apenas atualiza o marcador de &ldquo;visto neste scan&rdquo;. Pra um NAS com 500K arquivos onde 99% não mudou, essa fase leva segundos, não horas.</li>
<li><strong>Fase de processamento</strong> (pesada): só pra arquivos novos ou modificados. Calcula fingerprint (SHA-256 dos primeiros 64KB), gera thumbnail, classifica com o LLM. E aqui entra a detecção de movidos: se um arquivo mudou de path mas o fingerprint é o mesmo, o app preserva toda a classificação já feita e apenas atualiza o path. Você reorganizou 10 mil fotos em pastas novas? O app detecta e não reclassifica nenhuma.</li>
</ol>
<p>O checkpoint é por arquivo. Se o scan for interrompido (o app crashou, o usuário fechou, acabou a luz), na próxima vez ele retoma do último arquivo processado, não do zero. Isso é implementado via scan job persistence no banco: o cursor do scan fica salvo em <code>scan_jobs</code>.</p>
<h3>Cancelamento Cooperativo<span class="hx:absolute hx:-mt-20" id="cancelamento-cooperativo"></span>
    <a href="#cancelamento-cooperativo" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>O scan roda numa thread separada via <code>tokio::spawn_blocking</code>. Para cancelar, uso <code>AtomicBool</code> compartilhado entre a thread de scan e o frontend. O flag é checado:</p>
<ul>
<li>Antes de cada arquivo na fase de descoberta</li>
<li>Antes de cada classificação na fase de processamento</li>
<li>Após cada classificação (pro caso de a chamada ao Ollama demorar)</li>
</ul>
<p>Isso garante que o cancelamento responde em no máximo o tempo de uma classificação (~1 segundo), não no tempo do scan inteiro. Sem esse design, cancelar um scan de 500K arquivos poderia levar minutos — ou simplesmente não funcionar.</p>
<h3>Resiliência do Banco<span class="hx:absolute hx:-mt-20" id="resiliência-do-banco"></span>
    <a href="#resili%c3%aancia-do-banco" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>SQLite com WAL mode (permite leituras concorrentes durante escrita), health check no startup, backup automático antes de migrações, sistema de migração formal via <code>rusqlite_migration</code>. Cinco migrações no total:</p>
<ol start="0">
<li>Schema inicial (tabelas de arquivos, roots, scan_jobs, e a virtual table FTS5 pra busca)</li>
<li>Coluna <code>location_text</code> pra endereço EXIF</li>
<li>Rebuild do índice FTS (necessário após mudar a tokenização)</li>
<li>Tabelas <code>albums</code> e <code>album_files</code> pra coleções manuais</li>
<li>Tabela <code>smart_folders</code> pra queries salvas</li>
</ol>
<p>As migrações são identificadas por posição e nunca podem ser editadas ou reordenadas depois de publicadas. Isso é o tipo de regra que você aprende depois de corromper o banco de produção de alguém uma vez. A regra está codificada no CLAUDE.md do projeto pra que futuras sessões de vibe coding não a violem.</p>
<h3>Seleção de Modelo Hardware-Aware<span class="hx:absolute hx:-mt-20" id="seleção-de-modelo-hardware-aware"></span>
    <a href="#sele%c3%a7%c3%a3o-de-modelo-hardware-aware" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Nem todo mundo tem uma RTX 5090. O app detecta a GPU no startup e escolhe o modelo adequado:</p>
<ul>
<li><strong>GPU fraca ou sem GPU</strong>: <code>qwen2.5vl:3b</code> (tier small — roda em qualquer coisa)</li>
<li><strong>GPU com &gt;= 6GB VRAM</strong>: <code>qwen2.5vl:7b</code> (tier medium, o default dos benchmarks)</li>
<li><strong>Apple Silicon com &gt;= 48GB unificados</strong>: <code>qwen2.5vl:32b</code> (tier large, só onde a memória unificada permite sem swap)</li>
</ul>
<p>A detecção usa <code>nvidia-smi</code> no Linux/Windows, <code>system_profiler</code> no macOS, e <code>sysinfo</code> como fallback pra RAM do sistema. O resultado é cacheado no <code>AppState</code> do Tauri pra não ficar executando subprocesso toda hora.</p>
<h2>Multi-OS e CI/CD<span class="hx:absolute hx:-mt-20" id="multi-os-e-cicd"></span>
    <a href="#multi-os-e-cicd" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Se tem uma parte do projeto que justifica ter experiência, é essa. Fazer software que compila é fácil. Fazer software que compila e passa todos os testes em Linux, macOS e Windows ao mesmo tempo te ensina humildade.</p>
<h3>Módulo Platform<span class="hx:absolute hx:-mt-20" id="módulo-platform"></span>
    <a href="#m%c3%b3dulo-platform" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Todo código específico de OS vive em <code>src-tauri/src/platform/</code>:</p>
<ul>
<li><code>gpu.rs</code>: detecção de GPU (NVIDIA via nvidia-smi, AMD via sysfs/rocm-smi, Apple via system_profiler)</li>
<li><code>clipboard.rs</code>: cópia de imagem pro clipboard (xclip no Linux, pbcopy no macOS, PowerShell no Windows)</li>
<li><code>python.rs</code>: localização de Python (python3 vs python no PATH), caminhos de venv por OS</li>
<li><code>process.rs</code>: abstração de execução de subprocesso com tratamento de output</li>
</ul>
<p>Isso significa que <code>classify.rs</code>, <code>scan.rs</code>, <code>thumbnail.rs</code> — nenhum deles sabe em qual OS está rodando. Eles pedem pra plataforma e a plataforma resolve. Quando o Windows precisou de tratamento especial pra paths UNC (aqueles que começam com <code>\\?\</code>), a mudança ficou contida em <code>platform/</code>. Quando o macOS precisou de um import condicional, idem. O resto do codebase não foi tocado.</p>
<h3>GitHub Actions Matrix<span class="hx:absolute hx:-mt-20" id="github-actions-matrix"></span>
    <a href="#github-actions-matrix" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Dois workflows:</p>
<ul>
<li><strong>CI</strong> (push + PR): build e teste em Linux, macOS e Windows. Cada push roda <code>cargo test</code> + <code>npm test</code> nos 3 OS. Inclui <code>cargo fmt --check</code>, <code>cargo clippy -- -D warnings</code> e <code>cargo audit</code>. Se qualquer plataforma falhar, o PR não passa.</li>
<li><strong>Release</strong> (tags v*): build via <code>tauri-action</code>, gera AppImage (Linux), DMG (macOS arm64), MSI (Windows), e cria um Draft Release no GitHub com os binários anexados.</li>
</ul>
<p>Os 10+ commits de CI fix na segunda de manhã, foram a parte menos glamorosa do projeto. Coisas como:</p>
<ul>
<li><code>#[cfg(not(target_os = &quot;windows&quot;))]</code> em imports que usam <code>std::os::unix::fs::PermissionsExt</code></li>
<li><code>dunce::canonicalize</code> no lugar de <code>std::fs::canonicalize</code> porque o Windows gera paths com prefixo <code>\\?\</code> que quebram comparações de string</li>
<li>Instalar <code>rustfmt</code> e <code>clippy</code> explicitamente no runner porque nem sempre vêm no toolchain default da GitHub Actions</li>
<li>Remover o target macOS Intel do release workflow (Apple Silicon only — não vale o custo de manter dois targets Mac)</li>
</ul>
<p>Ninguém posta essas coisas no X. Mas sem elas, seu app não builda em 2 dos 3 targets.</p>
<h2>Integrações Externas<span class="hx:absolute hx:-mt-20" id="integrações-externas"></span>
    <a href="#integra%c3%a7%c3%b5es-externas" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>O app depende de 3 sistemas externos. Cada um trouxe seus próprios problemas.</p>
<h3>Ollama<span class="hx:absolute hx:-mt-20" id="ollama"></span>
    <a href="#ollama" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>O Ollama serve os modelos de visão via API REST local (porta 11434). O app faz:</p>
<ul>
<li><strong>Verificação de status</strong>: checa se o Ollama está rodando e lista modelos instalados/carregados</li>
<li><strong>Download de modelo</strong>: se o modelo recomendado não está instalado, oferece download com barra de progresso via streaming da API</li>
<li><strong>Geração</strong>: manda imagem em base64 + prompt, recebe JSON (com a cascata de parsing de 3 níveis)</li>
<li><strong>Cleanup</strong>: descarrega modelos da VRAM quando não está classificando, pra não ficar monopolizando a GPU pras outras aplicações do usuário</li>
</ul>
<p>O Ollama é o único hard requirement. Sem ele, a classificação não funciona. O SetupModal guia o usuário pela instalação e download do modelo.</p>
<h3>Surya OCR<span class="hx:absolute hx:-mt-20" id="surya-ocr"></span>
    <a href="#surya-ocr" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Surya é um engine de OCR em Python que roda localmente. O problema: o app é Rust e não pode depender de uma instalação Python do sistema. A solução:</p>
<ul>
<li>O app mantém um Python venv isolado em <code>~/.local/share/frank_sherlock/surya_venv/</code></li>
<li>O script <code>surya_ocr.py</code> é bundled como resource do Tauri (empacotado no binário)</li>
<li>No primeiro uso, o <code>SetupModal</code> oferece provisionar o venv automaticamente (encontra Python, cria venv, pip install surya-ocr + dependências)</li>
<li>A classificação chama o script via subprocess, passa a imagem como argumento, lê o texto extraído do stdout</li>
</ul>
<p>O Surya é um <strong>soft requirement</strong>: se não estiver instalado, o app funciona normalmente — só não vai ter OCR dedicado. O pipeline degrada gracefully pra usar Ollama Vision como fallback, que é pior em cobertura mas funciona. O usuário vê um aviso no setup, não um erro que bloqueia o uso.</p>
<h3>PDFium<span class="hx:absolute hx:-mt-20" id="pdfium"></span>
    <a href="#pdfium" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Pra PDFs, eu precisava de extração de texto nativo e renderização de páginas pra thumbnail. PDFium é o engine do Chrome pra PDFs, e tem bindings Rust via <code>pdfium-render</code>.</p>
<p>O binário do PDFium é baixado por um script (<code>scripts/download-pdfium.sh</code>) e bundled via <code>lib/</code> nos resources do Tauri. Cada plataforma recebe o binário correto (.so, .dylib, .dll). O <code>lib/</code> é gitignored — os binários são baixados no build, não versionados.</p>
<p>O pipeline de PDF:</p>
<ol>
<li>Tenta extrair texto nativo (sem OCR) — muitos PDFs já têm text layer</li>
<li>Se tiver texto suficiente, usa direto pra indexação (mais rápido e mais preciso que OCR)</li>
<li>Se não, renderiza a página e manda pro pipeline de imagem (Ollama Vision)</li>
<li>Detecta páginas em branco, encontra a primeira página com conteúdo real</li>
<li>Gera thumbnail como montagem das 2 primeiras páginas com conteúdo (dá uma noção melhor do documento do que só a capa)</li>
</ol>
<h2>O que Agile Vibe Coding Realmente É<span class="hx:absolute hx:-mt-20" id="o-que-agile-vibe-coding-realmente-é"></span>
    <a href="#o-que-agile-vibe-coding-realmente-%c3%a9" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>OK, agora o ponto que realmente importa. O motivo de eu estar escrevendo isso.</p>
<h3>O Que o Claude Fez<span class="hx:absolute hx:-mt-20" id="o-que-o-claude-fez"></span>
    <a href="#o-que-o-claude-fez" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><ul>
<li>Escreveu a maior parte do código Rust e TypeScript</li>
<li>Gerou 166 testes Rust e 172 testes frontend</li>
<li>Implementou o parsing de JSON com 3 níveis de fallback</li>
<li>Configurou CI/CD com matrix de 3 OS</li>
<li>Fez refactors massivos (monolito → 15 componentes + 10 hooks)</li>
<li>Lidou com edge cases de encoding, paths Unicode, e formatos de arquivo exóticos</li>
<li>Escreveu queries SQL, migrações, índices FTS5</li>
<li>Implementou detecção de GPU, clipboard por OS, resolução de Python</li>
<li>Criou o setup flow com barra de progresso e download de modelo</li>
<li>Debugou e fixou dezenas de issues de compilação cross-platform</li>
</ul>
<p>A velocidade é difícil de descrever sem parecer exagero. O commit das 19:04 de sábado, aquele de 4.186 linhas com 47 testes, levou cerca de uma hora incluindo minha revisão. Um dev humano, mesmo bom, levaria um dia inteiro pra escrever isso com a mesma cobertura de testes.</p>
<h3>O Que Eu Fiz<span class="hx:absolute hx:-mt-20" id="o-que-eu-fiz"></span>
    <a href="#o-que-eu-fiz" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><ul>
<li>Decidi fazer benchmarks antes de escrever qualquer código de app</li>
<li>Escolhi Tauri em vez de Electron (menor footprint, Rust nativo, sem Node runtime em prod)</li>
<li>Defini o princípio read-only como regra inviolável</li>
<li>Projetei o scan incremental com detecção de move por fingerprint</li>
<li>Decidi por cooperative cancellation com AtomicBool</li>
<li>Exigi migração formal de schema (nada de ALTER TABLE ad-hoc em scripts soltos)</li>
<li>Insisti em abstração de plataforma desde o primeiro commit multi-OS</li>
</ul>
<p>E principalmente: fiz as perguntas chatas. <em>&ldquo;E se o scan for cancelado no meio?&rdquo;</em> virou o sistema de checkpoint. <em>&ldquo;E se o banco corromper?&rdquo;</em> virou WAL + backup + health check. <em>&ldquo;E se a pessoa não tiver GPU boa?&rdquo;</em> virou model selection por tier. <em>&ldquo;E se o Surya não estiver instalado?&rdquo;</em> virou soft requirement com fallback. <em>&ldquo;E se o usuário deletar um root que está no meio de um scan?&rdquo;</em> virou cancel-before-delete. <em>&ldquo;E se o arquivo mudar de lugar mas o conteúdo for o mesmo?&rdquo;</em> virou detecção de move.</p>
<p>Ah, e decidi a hora de parar de adicionar features e publicar.</p>
<p>Essa última é subestimada. A tentação de ficar adicionando &ldquo;só mais uma coisa&rdquo; é enorme quando o custo marginal de implementar é baixo. O Claude implementa qualquer feature que eu pedir em minutos. Mas software que nunca é publicado não serve pra ninguém. Saber a hora de parar é uma skill que nenhum LLM vai te dar.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-23_16-37-22.jpg" alt="github releases"  loading="lazy" /></p>
<h3>O Padrão Real<span class="hx:absolute hx:-mt-20" id="o-padrão-real"></span>
    <a href="#o-padr%c3%a3o-real" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Agile Vibe coding não é <em>&ldquo;pedir pro LLM fazer um app&rdquo;</em>. É pair programming com um parceiro que é rápido pra caramba, tem memória perfeita, e nunca reclama de refactor. Você diz o que quer, ele implementa, você revisa e ajusta a direção. Mas se você não sabe pra onde ir, velocidade não ajuda — você só chega mais rápido no lugar errado.</p>
<p>As perguntas que eu fiz — sobre resiliência, cancelamento, multi-OS, degradação graceful, detecção de edge cases — nenhuma delas veio do LLM. Vieram de anos construindo software que precisa funcionar em ambientes reais, com usuários reais, em hardware diverso. O Claude não vai te perguntar <em>&ldquo;e se o usuário puxar o cabo de rede no meio do download do modelo?&rdquo;</em>. Mas se você perguntar, ele implementa o tratamento em minutos.</p>
<p>É tentador olhar pra esse projeto e concluir que qualquer pessoa com uma boa ideia poderia ter feito o mesmo. Mas tente imaginar: sem a decisão de fazer benchmarks, eu teria escolhido o modelo errado. Sem o PoC em Python, teria descoberto os problemas de JSON parsing em produção. Sem a abstração de plataforma, estaria debugando issues de Windows espalhadas por 15 arquivos. Sem o checkpoint de scan, usuários perderiam horas de processamento a cada crash. Sem a migração formal de schema, a primeira atualização quebraria o banco de todo mundo.</p>
<p>Pense num arquiteto com uma equipe de construção absurdamente eficiente. O arquiteto não precisa levantar cada parede, mas precisa saber onde elas vão e o que acontece se você tirar uma. A equipe executa rápido, trabalha de noite, não reclama. Mas alguém precisa ter desenhado a planta. Sem planta, é só um monte de tijolos empilhados rápido.</p>
<h2>Números Finais<span class="hx:absolute hx:-mt-20" id="números-finais"></span>
    <a href="#n%c3%bameros-finais" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Pra quem gosta de números concretos:</p>
<table>
  <thead>
      <tr>
          <th>Métrica</th>
          <th>Valor</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Commits</td>
          <td>50</td>
      </tr>
      <tr>
          <td>Horas de trabalho efetivo</td>
          <td>~26h</td>
      </tr>
      <tr>
          <td>Linhas de Rust</td>
          <td>8.359</td>
      </tr>
      <tr>
          <td>Linhas de TypeScript</td>
          <td>5.842</td>
      </tr>
      <tr>
          <td>Linhas de CSS</td>
          <td>1.649</td>
      </tr>
      <tr>
          <td>Testes Rust</td>
          <td>166</td>
      </tr>
      <tr>
          <td>Testes frontend</td>
          <td>172</td>
      </tr>
      <tr>
          <td>Testes total</td>
          <td>338</td>
      </tr>
      <tr>
          <td>Plataformas</td>
          <td>3 (Linux, macOS, Windows)</td>
      </tr>
      <tr>
          <td>Migrações de banco</td>
          <td>5</td>
      </tr>
      <tr>
          <td>Módulos Rust</td>
          <td>13+</td>
      </tr>
      <tr>
          <td>Componentes React</td>
          <td>15+</td>
      </tr>
      <tr>
          <td>Hooks React</td>
          <td>10+</td>
      </tr>
  </tbody>
</table>
<p>O primeiro commit foi sexta-feira 21/02 às 19:29. O último foi segunda-feira 23/02 às 14:33. Descontando sono (~8h na noite de sábado/domingo, ~8h na madrugada de domingo/segunda) e pausas, são ~26 horas de trabalho distribuídas em um fim de semana.</p>
<p>De zero — sem um único arquivo no repositório — a binários publicados para 3 sistemas operacionais, com testes automatizados rodando no CI a cada push. Incluindo a fase de pesquisa, que sozinha justificaria um sprint.</p>
<h2>Conclusão<span class="hx:absolute hx:-mt-20" id="conclusão"></span>
    <a href="#conclus%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Agile Vibe coding funciona. Mas funciona como qualquer ferramenta poderosa: nas mãos de quem sabe o que está fazendo.</p>
<p>A ideia do Frank Sherlock caberia num tweet: <em>&ldquo;classificar imagens com LLM local&rdquo;</em>. Mas transformar isso em software real exigiu: pesquisa benchmarked com ground truth formal, prova de conceito validando o pipeline completo, arquitetura incremental, tratamento de erros em 3 níveis, cancelamento cooperativo, migração formal de schema, abstração de plataforma, CI/CD com matrix de 3 OS, integração com 3 sistemas externos com graceful degradation, e 338 testes pra garantir que nada disso quebra quando alguém fizer cargo update.</p>
<p>O LLM acelerou tudo isso absurdamente. Mas não substituiu a necessidade de saber o que fazer. Se eu tivesse pedido <em>&ldquo;faz um app que classifica imagens&rdquo;</em> sem as 2 horas de benchmark, sem a prova de conceito, sem as decisões de arquitetura, sem as perguntas chatas sobre edge cases, o resultado seria um protótipo que funciona na minha máquina e quebra em qualquer outro lugar. E provavelmente eu nem perceberia até alguém reclamar.</p>
<p>O vibe precisa de um condutor experiente. Por enquanto, esse condutor ainda é humano.</p>
<p>Código em <a href="https://github.com/akitaonrails/FrankSherlock"target="_blank" rel="noopener">github.com/akitaonrails/FrankSherlock</a>. GPL-3.0, local-only, open source.</p>
]]></content:encoded><category>frank</category><category>llms</category><category>inteligencia-artificial</category><category>vibe-coding</category></item><item><title>Do Zero à Pós-Produção em 1 Semana - Como usar IA em Projetos de Verdade | Bastidores do The M.Akita Chronicles</title><link>https://akitaonrails.github.io/2026/02/20/do-zero-a-pos-producao-em-1-semana-como-usar-ia-em-projetos-de-verdade-bastidores-do-the-m-akita-chronicles/</link><guid isPermaLink="true">https://akitaonrails.github.io/2026/02/20/do-zero-a-pos-producao-em-1-semana-como-usar-ia-em-projetos-de-verdade-bastidores-do-the-m-akita-chronicles/</guid><pubDate>Fri, 20 Feb 2026 12:49:58 +0000</pubDate><description>&lt;p&gt;Este post vai fazer parte de uma série; acompanhe pela tag &lt;a href="https://akitaonrails.github.io/tags/the-m-akita-chronicles/"&gt;the-m-akita-chronicles&lt;/a&gt;. Esta é a parte 9 e Final!&lt;/p&gt;
&lt;p&gt;E não deixe de assinar minha nova newsletter &lt;a href="https://themakitachronicles.com/"target="_blank" rel="noopener"&gt;The M.Akita Chronicles&lt;/a&gt;!&lt;/p&gt;
&lt;p&gt;&amp;ndash;&lt;/p&gt;
&lt;p&gt;274 commits. 8 dias. 4 aplicações. 1.323 testes. Um sistema completo em produção — com assinantes reais recebendo uma newsletter toda segunda-feira às 7h, um podcast gerado por IA no Spotify, um blog estático no Netlify, e um bot no Discord que é minha interface operacional.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Este post vai fazer parte de uma série; acompanhe pela tag <a href="/tags/the-m-akita-chronicles/">the-m-akita-chronicles</a>. Esta é a parte 9 e Final!</p>
<p>E não deixe de assinar minha nova newsletter <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The M.Akita Chronicles</a>!</p>
<p>&ndash;</p>
<p>274 commits. 8 dias. 4 aplicações. 1.323 testes. Um sistema completo em produção — com assinantes reais recebendo uma newsletter toda segunda-feira às 7h, um podcast gerado por IA no Spotify, um blog estático no Netlify, e um bot no Discord que é minha interface operacional.</p>
<p>Fiz isso com um parceiro: Claude Code, um agente de IA rodando no terminal. E a lição mais importante que tirei não é que &ldquo;IA programa sozinha&rdquo; — é que <strong>IA programando sozinha é a receita pro desastre</strong>. O que funciona é algo que já tem nome e décadas de história: Extreme Programming. Só que agora o par no pair programming não é humano.</p>
<blockquote>
  <p>Eu preciso que todos vocês passem por uma experiência: uma <strong>IMERSÃO DE 1 SEMANA</strong> completamente <strong>DESAPEGADO</strong> do código, usando a IA como pair programming, indo do zero até produção em qualquer projetinho pequeno. Vai por mim, até vocês fazerem pelo menos, não vão entender. Mas depois muda tudo.</p>

</blockquote>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/pair-programming.jpg" alt="pair programming"  loading="lazy" /></p>
<h2>O Mito do &ldquo;One-Shot Prompt&rdquo;<span class="hx:absolute hx:-mt-20" id="o-mito-do-one-shot-prompt"></span>
    <a href="#o-mito-do-one-shot-prompt" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Tem uma narrativa crescente de que &ldquo;vibe coding&rdquo; é escrever uma spec detalhada, dar pro agente de IA, e ele entrega o sistema pronto. Tipo aqueles vídeos de &ldquo;criei um SaaS em 10 minutos com o Cursor&rdquo;. Parece mágico até você olhar o que foi realmente entregue: um protótipo sem testes, sem segurança, sem tratamento de erros, sem deploy, sem nada que faça um sistema sobreviver ao contato com usuários reais.</p>
<p>A realidade dos meus 274 commits conta outra história:</p>
<table>
  <thead>
      <tr>
          <th>Categoria</th>
          <th>Commits</th>
          <th>%</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Features novas (<code>Add ...</code>)</td>
          <td>101</td>
          <td>37%</td>
      </tr>
      <tr>
          <td>Bug fixes (<code>Fix ...</code>)</td>
          <td>45</td>
          <td>16%</td>
      </tr>
      <tr>
          <td>Refactoring/hardening (<code>Harden</code>, <code>Extract</code>, <code>DRY</code>, <code>Rework</code>, <code>Replace</code>)</td>
          <td>27</td>
          <td>10%</td>
      </tr>
      <tr>
          <td>Segurança</td>
          <td>21</td>
          <td>8%</td>
      </tr>
      <tr>
          <td>Deploy/infra</td>
          <td>30</td>
          <td>11%</td>
      </tr>
      <tr>
          <td>Testes/CI</td>
          <td>44</td>
          <td>16%</td>
      </tr>
      <tr>
          <td>Documentação</td>
          <td>27</td>
          <td>10%</td>
      </tr>
  </tbody>
</table>
<p><strong>Só 37% dos commits são features.</strong> O resto é o trabalho que faz um software de verdade: corrigir bugs, endurecer contra falhas, proteger contra ataques, deployar, testar, documentar. Quem mostra só a parte das features está vendendo uma ilusão.</p>
<p>E mais: muitas das features surgiram <strong>porque</strong> a iteração revelou que eram necessárias. <code>ContentPreflight</code> não estava no plano original — nasceu quando a segunda newsletter saiu com uma seção vazia porque um job falhou silenciosamente. <code>RecoverStaleDeliveriesJob</code> nasceu quando percebi que um crash no meio do envio deixava emails num limbo eterno. <code>StealthBrowser</code> nasceu quando o Morningstar bloqueou o HTTParty. Nenhum spec do mundo prevê isso. <strong>O sistema correto emerge da iteração, não da especificação.</strong></p>
<h2>Pair Programming Com Um Agente de IA<span class="hx:absolute hx:-mt-20" id="pair-programming-com-um-agente-de-ia"></span>
    <a href="#pair-programming-com-um-agente-de-ia" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Eu não uso o Claude Code como um gerador de código. Uso como um par de pair programming.</p>
<p>A diferença é fundamental. Um gerador de código recebe uma spec e devolve output. Um par de pair programming <strong>conversa</strong>. Eu interrompo quando ele toma um caminho errado. Dou contexto que ele não tem. Questiono decisões. Peço alternativas. Às vezes digo &ldquo;para, isso tá ficando complicado demais&rdquo; e ele simplifica.</p>
<p>Alguns exemplos reais dos 274 commits:</p>
<p><strong>Interrupção por excesso de engenharia.</strong> Quando pedi o sistema de email delivery, a primeira proposta tinha uma state machine com 8 estados, retry queues separadas, e dead letter handling. Interrompi: &ldquo;Simplifica. Quatro estados: pending, sending, sent, unknown. Na dúvida, não reenvia.&rdquo; O resultado foi o <code>SendSingleEmailJob</code> com a variável <code>ses_accepted</code> como pivô — 40 linhas que resolvem o problema inteiro.</p>
<p><strong>Contexto que o agente não tem.</strong> Quando estávamos no Yahoo Finance, o Claude tentou usar HTTParty. Expliquei: &ldquo;Yahoo faz TLS fingerprinting, vai bloquear qualquer HTTP client que não seja browser real.&rdquo; Resultado: headless Chromium com crumb authentication. Isso não está em nenhum tutorial — é o tipo de conhecimento que vem de tentar e falhar.</p>
<p><strong>Decisão de arquitetura conjunta.</strong> O podcast pipeline poderia ser um monolito Python. Sugeri: &ldquo;Rails orquestra, Python serve TTS via HTTP, conteúdo integra por filesystem.&rdquo; O resultado — <code>QwenTtsClient</code> no Ruby chamando <code>podcast-tts</code> em Python — mantém cada linguagem no que faz melhor.</p>
<p><strong>Prompt refinement iterativo.</strong> As primeiras newsletters saíam genéricas. Os comentários do Akita e do Marvin soavam iguais. Fui ajustando prompts com ele: &ldquo;Akita nunca diz &rsquo;talvez&rsquo;. Marvin usa &lsquo;bom&hellip;&rsquo; e &rsquo;enfim&rsquo;. O LLM vai suavizar opiniões se você não for explícito.&rdquo; Cada ajuste era um commit de prompt, testado com dados reais.</p>
<p>O padrão é: eu trago o <strong>quê</strong> e o <strong>porquê</strong>. O agente traz o <strong>como</strong> — e o como é frequentemente melhor do que eu faria sozinho. Mas o como sem o quê produz código correto que resolve o problema errado. E o quê sem o como produz specs que nunca saem do papel.</p>
<h2>Extreme Programming Não É Moda dos Anos 2000<span class="hx:absolute hx:-mt-20" id="extreme-programming-não-é-moda-dos-anos-2000"></span>
    <a href="#extreme-programming-n%c3%a3o-%c3%a9-moda-dos-anos-2000" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/agile-lifecycle-development-process-diagram-vector-31188796.jpg" alt="agile lifecycle development process diagram"  loading="lazy" /></p>
<p>Tudo que fizemos tem nome no XP. Não porque planejei — porque são as práticas que naturalmente emergem quando você programa com feedback rápido:</p>
<h3>Pair Programming<span class="hx:absolute hx:-mt-20" id="pair-programming"></span>
    <a href="#pair-programming" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Já expliquei. O par é o agente. A dinâmica é a mesma: um pilota, outro navega. Eu navego mais do que piloto — defino direção, questiono decisões, corrijo rota. O agente pilota mais do que navega — escreve código, roda testes, propõe soluções. Quando inverte (eu ditando código exato, ele apenas digitando), o resultado sempre piora.</p>
<h3>Small Releases<span class="hx:absolute hx:-mt-20" id="small-releases"></span>
    <a href="#small-releases" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Dos 274 commits, <strong>cada um passa no CI</strong>. Sem exceção. Não existe &ldquo;commit quebrando que vai ser consertado no próximo&rdquo;. Cada commit em master é production-ready.</p>
<p>O CI roda em cada commit: RuboCop (estilo) → bundler-audit (vulnerabilidades de gems) → Brakeman (segurança estática) → testes completos. São ~22 segundos. Parece pouco, mas multiplicado por 274 commits são 100+ minutos de validação automatizada que eu não precisei fazer manualmente.</p>
<p>A distribuição de commits por dia mostra o ritmo:</p>
<table>
  <thead>
      <tr>
          <th>Data</th>
          <th>Commits</th>
          <th>Destaque</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>10/fev (seg)</td>
          <td>4</td>
          <td>Scaffold inicial, Discord bot, blog</td>
      </tr>
      <tr>
          <td>11/fev (ter)</td>
          <td>41</td>
          <td>Explosão de features: 7 seções de newsletter, temas, X polling</td>
      </tr>
      <tr>
          <td>12/fev (qua)</td>
          <td>49</td>
          <td>Podcast pipeline, deploy, segurança, market data real</td>
      </tr>
      <tr>
          <td>13/fev (qui)</td>
          <td>29</td>
          <td>Daily digest, scraping resiliente, hardening</td>
      </tr>
      <tr>
          <td>14/fev (sex)</td>
          <td>30</td>
          <td>Integração end-to-end, email hardening, blog deploy</td>
      </tr>
      <tr>
          <td>15/fev (sáb)</td>
          <td>40</td>
          <td>Podcast fine-tuning, cross-app, story management</td>
      </tr>
      <tr>
          <td>16/fev (dom)</td>
          <td>26</td>
          <td>Primeira newsletter real enviada para assinantes</td>
      </tr>
      <tr>
          <td>17/fev (seg)</td>
          <td>31</td>
          <td>Refactoring, Application Commands, anime fix</td>
      </tr>
      <tr>
          <td>18/fev (ter)</td>
          <td>24</td>
          <td>DRY shortcodes, backup job, consolidação</td>
      </tr>
  </tbody>
</table>
<p>A &ldquo;explosão&rdquo; do dia 2 não é caos — são small releases empilhadas. Cada uma adiciona uma feature, roda CI, está pronta pra deploy. Não é waterfall de 2 semanas seguido de big bang merge. É integração contínua de verdade.</p>
<h3>Test-Driven Development<span class="hx:absolute hx:-mt-20" id="test-driven-development"></span>
    <a href="#test-driven-development" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>1.323 testes. 970 no marvin-bot (6 segundos em paralelo), 353 no newsletter (1 segundo em paralelo). Ratio:</p>
<table>
  <thead>
      <tr>
          <th>App</th>
          <th>Código (linhas)</th>
          <th>Testes (linhas)</th>
          <th>Ratio teste/código</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>marvin-bot</td>
          <td>9.348</td>
          <td>13.929</td>
          <td>1.49x</td>
      </tr>
      <tr>
          <td>newsletter</td>
          <td>3.397</td>
          <td>5.391</td>
          <td>1.59x</td>
      </tr>
      <tr>
          <td><strong>Total</strong></td>
          <td><strong>12.745</strong></td>
          <td><strong>19.320</strong></td>
          <td><strong>1.52x</strong></td>
      </tr>
  </tbody>
</table>
<p><strong>Mais linhas de teste do que de código.</strong> Em ambas as aplicações. E isso não é vanidade — é a infraestrutura que permite velocidade.</p>
<p>Coverage por SimpleCov:</p>
<table>
  <thead>
      <tr>
          <th>App</th>
          <th>Line Coverage</th>
          <th>Branch Coverage</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>marvin-bot</td>
          <td>82.4%</td>
          <td>73.1%</td>
      </tr>
      <tr>
          <td>newsletter</td>
          <td>86.8%</td>
          <td>70.5%</td>
      </tr>
  </tbody>
</table>
<p>Não é 100% — e nunca será. Os 17-18% não cobertos são majoritariamente integração com APIs externas (Discord, SES, OpenRouter) que são mockadas nos testes. O coverage real em lógica de negócio está acima de 95%.</p>
<p>A pergunta que importa: <strong>esses testes são úteis?</strong> A prova empírica: em 274 commits, eu vi o CI falhar e pegar um bug real mais de 50 vezes. Sem esses testes, cada um desses bugs teria ido pra produção. O agente propõe uma mudança, o teste pega a regressão, a mudança é corrigida antes de commitar. Sem testes, o agente te entrega código plausível que silenciosamente quebra algo que funcionava ontem.</p>
<p>TDD com um agente de IA é multiplicativo. O agente é bom em gerar testes — sabe os padrões, sabe os edge cases comuns, sabe a estrutura do Minitest. E os testes que ele gera viram a rede de segurança das mudanças que ele mesmo faz depois. É um ciclo virtuoso: <strong>testes permitem velocidade, velocidade gera mais testes</strong>.</p>
<h3>Refactoring Contínuo<span class="hx:absolute hx:-mt-20" id="refactoring-contínuo"></span>
    <a href="#refactoring-cont%c3%adnuo" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>27 commits explícitos de refactoring. Alguns exemplos:</p>
<ul>
<li><strong><code>Extract StealthBrowser wrapper</code></strong> — O código de headless browser com stealth patches estava duplicado em 4 clients. Extraiu pra um serviço, 4 clientes ficaram com 5 linhas cada.</li>
<li><strong><code>DRY section rendering: collapse 5 bubble methods + 7 Hugo shortcodes</code></strong> — 5 métodos de renderização de bolha no <code>MarkdownToHtml</code> que eram copy-paste com cores diferentes colapsados em 1 método parametrizado. 7 shortcodes Hugo delegando pra 1 partial.</li>
<li><strong><code>Replace market recap LLM generation with Yahoo Finance real data</code></strong> — O LLM inventava números de mercado. Substituí por dados reais do Yahoo Finance + commentary do LLM. Melhor resultado, menor custo.</li>
<li><strong><code>Replace DuckDuckGo with Brave Search API</code></strong> — DuckDuckGo não tem API estável. Brave Search tem API oficial com 2.000 queries/mês grátis. Troca limpa.</li>
<li><strong><code>Rework ArticleFetcher: browser-first with anti-detection</code></strong> — Duas reescrituras completas. A primeira tentou HTTP-first com fallback pra browser. A segunda inverteu: browser-first porque a maioria dos links de leitores são SPAs. Cada reescritura manteve os testes passando.</li>
</ul>
<p>O refactoring é onde o par de pair programming brilha. Eu digo &ldquo;esse código tá duplicado, extrai um concern&rdquo; e o agente faz em 2 minutos o que me levaria 20. Mas <strong>eu</strong> decido o quê extrair e <strong>como</strong> a interface deveria ser. O agente sozinho não refatora — ele empilha código novo em cima do existente.</p>
<h3>Integração Contínua<span class="hx:absolute hx:-mt-20" id="integração-contínua"></span>
    <a href="#integra%c3%a7%c3%a3o-cont%c3%adnua" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>O CI já descrito acima: rubocop + bundler-audit + brakeman + testes. Roda em cada commit, em ambas as apps. Brakeman merece destaque:</p>
<table>
  <thead>
      <tr>
          <th>App</th>
          <th>Controllers</th>
          <th>Models</th>
          <th>Warnings</th>
          <th>Ignored</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>marvin-bot</td>
          <td>6</td>
          <td>8</td>
          <td>0</td>
          <td>0</td>
      </tr>
      <tr>
          <td>newsletter</td>
          <td>10</td>
          <td>5</td>
          <td>0</td>
          <td>2</td>
      </tr>
  </tbody>
</table>
<p>Zero warnings de segurança em ambos. As 2 ignored no newsletter são false positives documentados (SQL interpolation com paths de config, não user input).</p>
<p>Brakeman pegou problemas reais durante o desenvolvimento: SQL injection numa query de busca, path traversal no controller de imagens, redirect aberto no unsubscribe. Cada um corrigido antes de ir pra produção, no mesmo commit que o CI flaggou.</p>
<h2>A Anatomia de Uma Feature: Do Pedido ao Deploy<span class="hx:absolute hx:-mt-20" id="a-anatomia-de-uma-feature-do-pedido-ao-deploy"></span>
    <a href="#a-anatomia-de-uma-feature-do-pedido-ao-deploy" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Vou pegar um exemplo concreto pra ilustrar como o fluxo funciona na prática. O <code>List-Unsubscribe</code> (commit <code>5950ccc</code>).</p>
<p><strong>Contexto</strong>: emails de confirmação não chegavam no Gmail. Tudo verde — DKIM, SPF, DMARC, SES aceitando. Mas Gmail jogava no spam silenciosamente.</p>
<p><strong>Investigação</strong> (eu + agente): pesquisamos. Desde fevereiro de 2024, Gmail exige <code>List-Unsubscribe</code> + <code>List-Unsubscribe-Post</code> em todo email de bulk sender. Sem eles, penaliza o <strong>domínio inteiro</strong> — incluindo emails transacionais.</p>
<p><strong>Implementação</strong> (agente executa, eu valido): header no <code>SesMailer</code>, endpoint RFC 8058 no <code>UnsubscriptionsController</code> (skip CSRF pra email clients, retorna 200 ao invés de redirect), testes cobrindo ambos os flows.</p>
<p><strong>CI</strong>: rubocop OK, brakeman pegou o <code>skip_forgery_protection</code> como warning (correto — validamos que é condicional). Testes passam.</p>
<p><strong>Deploy</strong>: <code>kamal deploy</code>. Em 2 minutos, a correção está em produção. Reputação do domínio leva 1-2 dias pra se recuperar. No dia seguinte, emails de confirmação voltam a chegar.</p>
<p><strong>Tempo total</strong>: ~25 minutos do &ldquo;por que o Gmail tá rejeitando?&rdquo; ao deploy em produção. Sem o agente, seriam horas de leitura de RFC, implementação manual, e esperança de que não esqueci nada.</p>
<h2>O Que a IA Faz Bem (E o Que Faz Mal)<span class="hx:absolute hx:-mt-20" id="o-que-a-ia-faz-bem-e-o-que-faz-mal"></span>
    <a href="#o-que-a-ia-faz-bem-e-o-que-faz-mal" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Depois de 274 commits com um agente de IA, tenho uma noção bem clara dos pontos fortes e fracos:</p>
<h3>Faz bem:<span class="hx:absolute hx:-mt-20" id="faz-bem"></span>
    <a href="#faz-bem" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><ul>
<li><strong>Boilerplate e scaffolding</strong>: jobs, services, testes, migrations — o agente produz na velocidade de digitação</li>
<li><strong>Testes</strong>: surpreendentemente bom em identificar edge cases e gerar testes abrangentes</li>
<li><strong>Refactoring mecânico</strong>: renomear, extrair métodos, mover código entre arquivos — rápido e preciso</li>
<li><strong>Pesquisa contextual</strong>: &ldquo;como funciona RFC 8058?&rdquo; produz resposta acionável em segundos</li>
<li><strong>Consistência</strong>: segue padrões estabelecidos no projeto (concerns, naming conventions, test structure) sem esquecer</li>
</ul>
<h3>Faz mal:<span class="hx:absolute hx:-mt-20" id="faz-mal"></span>
    <a href="#faz-mal" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><ul>
<li><strong>Decisões de arquitetura</strong>: tende a over-engineer. Precisa de freio humano constante</li>
<li><strong>Conhecimento de domínio específico</strong>: não sabe que o Yahoo faz TLS fingerprinting ou que o VoiceDesign do Qwen produz sotaque europeu</li>
<li><strong>Opiniões</strong>: suaviza tudo. Prompts de personalidade viram mush genérico sem instruções explícitas</li>
<li><strong>Segurança proativa</strong>: implementa o que você pede, mas raramente sugere proteções que você não pediu (SSRF, rate limiting, encryption at rest)</li>
<li><strong>Priorização</strong>: executa qualquer coisa com igual entusiasmo. Não diz &ldquo;isso é perda de tempo&rdquo; ou &ldquo;faça X antes de Y&rdquo;</li>
</ul>
<p>IA não programa sozinha, e não é inútil. <strong>É um multiplicador do programador, não um substituto</strong>. Um programador medíocre com IA produz código medíocre mais rápido. Um programador experiente com IA produz código bom na velocidade que antes só era possível com equipes.</p>
<h2>Os Números que Importam<span class="hx:absolute hx:-mt-20" id="os-números-que-importam"></span>
    <a href="#os-n%c3%bameros-que-importam" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Vamos ao tamanho real do que foi construído em 8 dias:</p>
<h3>Código<span class="hx:absolute hx:-mt-20" id="código"></span>
    <a href="#c%c3%b3digo" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><table>
  <thead>
      <tr>
          <th>Componente</th>
          <th>Linhas</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>marvin-bot app (Ruby)</td>
          <td>9.348</td>
      </tr>
      <tr>
          <td>marvin-bot tests (Ruby)</td>
          <td>13.929</td>
      </tr>
      <tr>
          <td>newsletter app (Ruby)</td>
          <td>3.397</td>
      </tr>
      <tr>
          <td>newsletter tests (Ruby)</td>
          <td>5.391</td>
      </tr>
      <tr>
          <td>podcast-tts (Python)</td>
          <td>1.770</td>
      </tr>
      <tr>
          <td>Hugo templates (HTML)</td>
          <td>193</td>
      </tr>
      <tr>
          <td>Blog CSS</td>
          <td>261</td>
      </tr>
      <tr>
          <td>AI prompts (Markdown)</td>
          <td>795</td>
      </tr>
      <tr>
          <td>CLAUDE.md (guia do projeto)</td>
          <td>702</td>
      </tr>
  </tbody>
</table>
<p><strong>Total de código funcional: ~16.000 linhas. Total com testes: ~36.000 linhas.</strong></p>
<h3>Componentes<span class="hx:absolute hx:-mt-20" id="componentes"></span>
    <a href="#componentes" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><ul>
<li><strong>43 services</strong> no marvin-bot (scrapers, AI, parsers, validators, uploaders)</li>
<li><strong>38 jobs</strong> no marvin-bot (geração de conteúdo, daily digest, podcast, infraestrutura)</li>
<li><strong>12 services</strong> no newsletter (email delivery, publishing, assembly)</li>
<li><strong>11 jobs</strong> no newsletter (sending, recovery, backup, blog publishing)</li>
<li><strong>23 prompt templates</strong> de IA (personalidade, geração, podcast, tools)</li>
<li><strong>17 shortcodes</strong> Hugo (renderização de seções no blog)</li>
<li><strong>3 RubyLLM tools</strong> (image generation, web search, web fetch)</li>
</ul>
<h3>Pipeline<span class="hx:absolute hx:-mt-20" id="pipeline"></span>
    <a href="#pipeline" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><ul>
<li><strong>10 seções</strong> de conteúdo geradas por semana (anime, hacker news, youtube, market, world events, history x3, holidays, closing remarks)</li>
<li><strong>10 daily digest jobs</strong> postando no Discord às 8h</li>
<li><strong>1 podcast</strong> de ~30 minutos gerado por semana (script + TTS + assembly + S3)</li>
<li><strong>1 newsletter</strong> montada, publicada no blog, e enviada por email toda segunda</li>
</ul>
<h2>Isso É 200 User Stories?<span class="hx:absolute hx:-mt-20" id="isso-é-200-user-stories"></span>
    <a href="#isso-%c3%a9-200-user-stories" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/66d7f28718285e260f53fe6e_018b4d67589e6f4d2fdf9130502016fb4a75e73a-1200x674.jpg" alt="sprint planning"  loading="lazy" /></p>
<p>Olhei os 274 commits e tentei mapear pra user stories no sentido ágil. Nem todo commit é uma story — muitos são fixes dentro da mesma feature, ajustes de prompt, ou cleanup de CI. Mas a contagem conservadora de features distintas, cada uma com critérios de aceitação implícitos (funciona, tem teste, passa no CI, deploya):</p>
<blockquote>
  <p><strong>Estimativa: ~180-200 user stories.</strong></p>

</blockquote>
<p>Isso inclui:</p>
<ul>
<li>Cada seção de newsletter (10 geradores distintos com scraping, AI, validação, fallback)</li>
<li>O pipeline de email (delivery tracking, claiming atômico, recovery, List-Unsubscribe)</li>
<li>O pipeline de podcast (script LLM, TTS dual-model, assembly, S3, RSS)</li>
<li>O Discord bot (15+ comandos, embeds, context, multi-turn /ask, daily digest)</li>
<li>O blog (Hugo + Hextra, 17 shortcodes, RSS podcast, SEO, GA)</li>
<li>A subscription page (confirmação, temas, LGPD, termos de serviço)</li>
<li>Infraestrutura (Kamal deploy, Cloudflare, SES config, RunPod, backups)</li>
<li>21 commits de segurança (SSRF, CSP, rate limiting, encryption, path traversal)</li>
</ul>
<p>Em Scrum tradicional com uma equipe de 2-3 devs, uma story bem feita (com code review, QA, deploy) leva em média 1-2 dias. 200 stories × 1.5 dias / 2 devs = <strong>~150 dias de trabalho, ou ~30 semanas em sprints de 2 semanas</strong> (contando planning, review, retro, e o overhead inevitável de coordenação entre pessoas).</p>
<p>Sendo generoso — equipe sênior, stories bem definidas, sem impedimentos — talvez 10-15 semanas.</p>
<blockquote>
  <p>Nós fizemos em 8 dias. Uma pessoa e um agente de IA.</p>

</blockquote>
<p>Mas — e isso é crucial — <strong>eu não sou um dev qualquer e isso não é um projeto qualquer</strong>. Eu tenho 30 anos de experiência em software. Sei quais decisões importam e quais não. Sei quando parar de engenheirar e shipar. Sei quando o agente está errado antes de ele terminar de gerar o código. E o projeto é solo-developer, sem code review externo, sem QA separado, sem coordenação entre equipes.</p>
<p>O multiplicador real do agente não é &ldquo;10x mais código&rdquo;. É <strong>eliminar o tempo morto</strong>. Sem agente, 70% do meu tempo seria digitando boilerplate, lendo docs de API, escrevendo testes mecânicos, debugando typos. Com agente, esse tempo vira zero e eu foco 100% em decisões de arquitetura, domínio, e qualidade.</p>
<h2>O CLAUDE.md: A Spec que Evolui<span class="hx:absolute hx:-mt-20" id="o-claudemd-a-spec-que-evolui"></span>
    <a href="#o-claudemd-a-spec-que-evolui" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Uma coisa que funciona excepcionalmente bem com agentes de IA é ter um documento vivo que descreve o projeto. O <code>CLAUDE.md</code> do projeto tem 702 linhas e cobre:</p>
<ul>
<li>Visão geral da arquitetura</li>
<li>Stack tecnológico completo</li>
<li>Todas as variáveis de ambiente</li>
<li>Estrutura do diretório de conteúdo</li>
<li>Serviços, jobs e models de cada app</li>
<li>12 &ldquo;common hurdles&rdquo; com soluções documentadas</li>
<li>14 design patterns do projeto</li>
<li>Pipeline semanal completo com horários</li>
<li>Checklist pós-implementação</li>
</ul>
<p>Esse documento não foi escrito de uma vez. Ele <strong>evoluiu com o projeto</strong>. Cada vez que descobríamos um hurdle (Yahoo Finance precisa de Chromium, HN RSS muda de formato, LLM suaviza opiniões), documentávamos no CLAUDE.md. Na próxima sessão, o agente já sabe.</p>
<p>É o equivalente do onboarding doc de uma equipe — mas o &ldquo;novo membro da equipe&rdquo; é o agente, e ele lê o doc inteiro em 2 segundos antes de cada interação. O investimento em documentação retorna exponencialmente com agentes de IA porque <strong>eles realmente leem</strong>.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_12-59-38.jpg" alt="claude.md"  loading="lazy" /></p>
<h2>As Lições<span class="hx:absolute hx:-mt-20" id="as-lições"></span>
    <a href="#as-li%c3%a7%c3%b5es" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><strong>1. Vibe coding sem disciplina é protótipo descartável.</strong> 274 commits com CI, testes, security scanning, e small releases é o oposto de vibe coding. É engenharia de software com um copiloto que digita rápido.</p>
<p><strong>2. TDD é mais importante com IA, não menos.</strong> O agente modifica código com confiança porque tem 1.323 testes como rede de segurança. Sem testes, cada mudança é uma aposta.</p>
<p><strong>3. O humano decide o quê. O agente decide o como.</strong> Inverta e o resultado piora dramaticamente.</p>
<p><strong>4. Refactoring contínuo é obrigatório.</strong> O agente empilha código. Se você não poda regularmente — extrair concerns, DRY duplicações, simplificar interfaces — a base de código vira um monólito de 500 linhas por arquivo.</p>
<p><strong>5. Documentação é investimento com retorno imediato.</strong> Cada hora documentando no CLAUDE.md economiza horas de contexto perdido em sessões futuras.</p>
<p><strong>6. Small releases são a chave.</strong> Cada commit é production-ready. Se algo der errado, reverte um commit. Se a feature era má ideia, nunca dependeu de outra. Isso só funciona com CI automático.</p>
<p><strong>7. Segurança não é fase — é hábito.</strong> 21 commits de segurança espalhados por 8 dias, não concentrados numa &ldquo;sprint de segurança&rdquo; no final. Brakeman roda em todo commit. Vulnerabilidades são corrigidas no momento que aparecem.</p>
<p><strong>8. O agente nunca diz &ldquo;não&rdquo;.</strong> Isso é um bug, não uma feature. Se você pede algo over-engineered, ele implementa com entusiasmo. Se pede algo inseguro, ele implementa sem reclamar. <strong>Você</strong> é o freio. <strong>Você</strong> é o code review. <strong>Você</strong> é o adulto na sala.</p>
<h2>O Contra-Exemplo: FrankMD<span class="hx:absolute hx:-mt-20" id="o-contra-exemplo-frankmd"></span>
    <a href="#o-contra-exemplo-frankmd" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Tudo que descrevi acima pode soar teórico. <em>&ldquo;Claro que TDD e refactoring contínuo são importantes&rdquo;</em> — todo mundo concorda em tese. Então vou provar empiricamente com um contra-exemplo do <strong>mesmo desenvolvedor, com o mesmo agente de IA, duas semanas antes</strong>.</p>
<p>Antes do M.Akita Chronicles, eu construí o <a href="https://github.com/akitaonrails/FrankMD"target="_blank" rel="noopener">FrankMD</a> — um editor Markdown em Rails, estilo Obsidian/VSCode. 212 commits em 19 dias. Mesmo setup: eu + Claude Code como par de pair programming.</p>
<p><img src="https://camo.githubusercontent.com/4d7ffcd26d22eb57334017e3b946a10eeb59cacf609edcc8caebf68d03d528b4/68747470733a2f2f6e65772d75706c6f6164732d616b6974616f6e7261696c732e73332e75732d656173742d322e616d617a6f6e6177732e636f6d2f6672616e6b6d642f323032362f30322f73637265656e73686f742d323032362d30322d30315f31342d31332d32392e6a7067" alt="frankmd"  loading="lazy" /></p>
<p>A diferença: no FrankMD, eu fiz o que qualquer desenvolvedor faz quando &ldquo;é só um projetinho pessoal&rdquo;. Foquei em features. Testes? Depois. Refactoring? Quando necessário. CI? Roda quando lembrar.</p>
<h3>A Espiral do <code>app_controller.js</code><span class="hx:absolute hx:-mt-20" id="a-espiral-do-app_controllerjs"></span>
    <a href="#a-espiral-do-app_controllerjs" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>O FrankMD é um SPA-like com Stimulus. No início, todo o JavaScript vivia em um único arquivo: <code>app_controller.js</code>. Aqui está o que aconteceu com ele ao longo dos commits:</p>
<table>
  <thead>
      <tr>
          <th>Momento</th>
          <th>LOC</th>
          <th>O que aconteceu</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Commit #1 (inicial)</td>
          <td>506</td>
          <td>Scaffold limpo, editor básico</td>
      </tr>
      <tr>
          <td>Commit #44 (~10 features depois)</td>
          <td>~4.372</td>
          <td>File finder, themes, typewriter, image picker, emoji&hellip;</td>
      </tr>
      <tr>
          <td>Commit #54 (pico)</td>
          <td><strong>4.990</strong></td>
          <td>Tudo dentro. AI grammar, video embed, content search&hellip;</td>
      </tr>
      <tr>
          <td><strong>Refactor 1</strong>: Extract utilities + image picker</td>
          <td>3.249</td>
          <td>-35%, 6 módulos + 1 controller extraídos</td>
      </tr>
      <tr>
          <td><strong>Refactor 2</strong>: Extract 10 Stimulus controllers</td>
          <td>1.803</td>
          <td>-45%, 10 controllers com testes</td>
      </tr>
      <tr>
          <td>Features crescem de novo&hellip;</td>
          <td>2.739</td>
          <td>Mais código empilhado</td>
      </tr>
      <tr>
          <td><strong>Refactor 3</strong>: Extract utility modules</td>
          <td>2.684</td>
          <td>-2%, pequeno</td>
      </tr>
      <tr>
          <td><strong>Refactor 4</strong>: Extract domain controllers</td>
          <td>2.074</td>
          <td>-23%, 4 controllers novos</td>
      </tr>
      <tr>
          <td>Features crescem&hellip;</td>
          <td>2.367</td>
          <td>De volta a crescer</td>
      </tr>
      <tr>
          <td><strong>Refactor 5</strong>: Extract autosave/scroll-sync</td>
          <td>1.837</td>
          <td>-22%</td>
      </tr>
      <tr>
          <td><strong>Refactor 6</strong>: Replace custom JS with Rails 8 built-ins</td>
          <td>1.511</td>
          <td>-17%, -383 linhas líquidas</td>
      </tr>
      <tr>
          <td>Estado final</td>
          <td>1.576</td>
          <td>39 controllers + 29 lib modules</td>
      </tr>
  </tbody>
</table>
<p>O padrão é claro: <strong>construir-construir-construir-PARAR-refatorar</strong>. O arquivo cresceu 10x em 54 commits, atingiu quase 5.000 linhas, e então precisei de <strong>6 rodadas de refatoração</strong> pra trazer de volta ao aceitável. Cada parada foi um commit grande (centenas de linhas movidas, dezenas de arquivos criados), não um ajuste cirúrgico.</p>
<p>O CSS seguiu o mesmo padrão: 1.053 linhas num único <code>application.css</code>, até o commit de refactoring que explodiu em 10 arquivos de tema + 9 arquivos de componente.</p>
<h3>Testes: Antes vs. Depois<span class="hx:absolute hx:-mt-20" id="testes-antes-vs-depois"></span>
    <a href="#testes-antes-vs-depois" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>No FrankMD, o 2º commit foi &ldquo;Add comprehensive test suite&rdquo; — testes de sistema (integração browser). Parece responsável, mas esses testes cobriam <strong>o happy path e nada mais</strong>. Os testes de unidade JavaScript vieram só no commit #52, <strong>depois que o app_controller.js já tinha 5.000 linhas</strong> — porque eu precisava de testes pra refatorar com segurança. Testes retroativos.</p>
<p>A cobertura Ruby reflete isso: teve que ser empurrada de 77% pra 89% num commit explícito de &ldquo;Add tests for untested success paths&rdquo;. Não era TDD — era <strong>preencher buracos depois</strong>.</p>
<h3>Os Números Lado a Lado<span class="hx:absolute hx:-mt-20" id="os-números-lado-a-lado"></span>
    <a href="#os-n%c3%bameros-lado-a-lado" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><table>
  <thead>
      <tr>
          <th>Métrica</th>
          <th>FrankMD</th>
          <th>M.Akita Chronicles</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Commits</td>
          <td>212</td>
          <td>274</td>
      </tr>
      <tr>
          <td>Dias</td>
          <td>19</td>
          <td>8</td>
      </tr>
      <tr>
          <td>Commits/dia</td>
          <td>11</td>
          <td>34</td>
      </tr>
      <tr>
          <td>Testes</td>
          <td>1.364 JS + ~490 Ruby</td>
          <td>1.323 Ruby</td>
      </tr>
      <tr>
          <td>Coverage Ruby</td>
          <td>~89% (retroativo)</td>
          <td>82-87% (orgânico)</td>
      </tr>
      <tr>
          <td>Ratio teste/código</td>
          <td>~1.2x</td>
          <td>1.52x</td>
      </tr>
      <tr>
          <td>Refactors &ldquo;pare tudo&rdquo;</td>
          <td>6 grandes</td>
          <td>0</td>
      </tr>
      <tr>
          <td>Refactors incrementais</td>
          <td>16</td>
          <td>27</td>
      </tr>
      <tr>
          <td>Maior arquivo (pico)</td>
          <td>4.990 LOC</td>
          <td>—</td>
      </tr>
      <tr>
          <td>CI a cada commit</td>
          <td>Não</td>
          <td>Sim</td>
      </tr>
  </tbody>
</table>
<p>O número que importa: <strong>commits por dia</strong>. O M.Akita Chronicles teve 3x mais throughput — com TDD, CI, e refactoring contínuo — porque <strong>nunca precisou parar</strong>. Nenhum commit de &ldquo;parar tudo e refatorar 5.000 linhas em 10 arquivos&rdquo;. Nenhum momento de &ldquo;preciso adicionar testes retroativos pra poder mexer nesse código sem medo&rdquo;.</p>
<h3>A Lição Concreta<span class="hx:absolute hx:-mt-20" id="a-lição-concreta"></span>
    <a href="#a-li%c3%a7%c3%a3o-concreta" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Os 6 refactors grandes do FrankMD não foram &ldquo;refactoring contínuo&rdquo;. Foram <strong>cirurgias de emergência em dívida técnica acumulada</strong>. Cada um levou horas — não os 2 minutos do &ldquo;extrai um concern&rdquo; descrito acima. E cada um introduziu riscos: quando você move 1.500 linhas de um arquivo pra 10 arquivos novos, a chance de quebrar algo é proporcional ao tamanho da mudança.</p>
<p>Com TDD e refactoring contínuo, o M.Akita Chronicles nunca acumulou essa dívida. Os 27 refactors são commits pequenos — &ldquo;Extract StealthBrowser&rdquo;, &ldquo;DRY bubble methods&rdquo; — que levam minutos e são protegidos por 1.323 testes que já existiam quando o refactoring foi feito.</p>
<p><strong>Mesmo desenvolvedor. Mesmo agente de IA. Duas abordagens diferentes. Resultado: o projeto disciplinado entregou mais, em menos tempo, com menos dor.</strong></p>
<h2>Conclusão<span class="hx:absolute hx:-mt-20" id="conclusão"></span>
    <a href="#conclus%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/41MD3fXWOXL.jpg" alt="xp book"  loading="lazy" /></p>
<p>Esse projeto não é um showcase de IA. É um showcase de engenharia de software acelerada por IA. E a prova mais forte é que o <strong>mesmo desenvolvedor com o mesmo agente</strong> produziu resultados radicalmente diferentes dependendo do processo.</p>
<p>No FrankMD, sem disciplina, o resultado foi previsível: um arquivo de 5.000 linhas, 6 cirurgias de emergência, testes retroativos, e 11 commits por dia. No M.Akita Chronicles, com XP, o resultado foi 34 commits por dia, zero paradas forçadas, 1.323 testes orgânicos, e um sistema em produção em 8 dias. A variável não foi a IA — foi o processo.</p>
<p>274 commits em 8 dias não acontecem porque o agente é mágico. Acontecem porque Extreme Programming — pair programming, TDD, small releases, refactoring contínuo, integração contínua — funciona tão bem com um par de IA quanto funciona com um par humano. Talvez melhor em alguns aspectos: o agente nunca cansa, nunca discute por ego, nunca diz &ldquo;mas sempre fizemos assim&rdquo;, e lê 700 linhas de documentação antes de cada interação sem reclamar.</p>
<p>O futuro do desenvolvimento não é &ldquo;IA substituindo programadores&rdquo;. É programadores que sabem usar IA como ferramenta — com a mesma disciplina que sempre usaram com qualquer outra ferramenta — produzindo em uma semana o que equipes inteiras levam trimestres.</p>
<p>Não porque a IA é genial. Porque <strong>o processo</strong> é genial. O XP tinha razão desde o começo. Só faltava um par que nunca cansa e digita a 1000 palavras por minuto.</p>
<p>Lembrem-se:</p>
<blockquote>
  <p>&ldquo;A IA é seu espelho, ele revela mais rápido quem você é. Se for incompetente, vai produzir coisas ruins mais rápido. Se for competente, vai produzir coisas boas mais rápido.&rdquo; - by Akita</p>

</blockquote>
]]></content:encoded><category>frank</category><category>llms</category><category>inteligencia-artificial</category><category>the-m-akita-chronicles</category></item><item><title>Vibe Code: Fiz um Editor de Markdown do zero com Claude Code (FrankMD) PARTE 2</title><link>https://akitaonrails.github.io/2026/02/01/vibe-code-fiz-um-editor-de-markdown-do-zero-com-claude-code-frankmd-parte-2/</link><guid isPermaLink="true">https://akitaonrails.github.io/2026/02/01/vibe-code-fiz-um-editor-de-markdown-do-zero-com-claude-code-frankmd-parte-2/</guid><pubDate>Sun, 01 Feb 2026 19:29:18 +0000</pubDate><description>&lt;p&gt;Se ainda não leu, veja a &lt;a href="https://akitaonrails.github.io/2026/02/01/vibe-code-fiz-um-editor-de-markdown-do-zero-com-claude-code-frankmd-part-1/"&gt;Parte 1&lt;/a&gt; pra entender o que é o aplicativo que eu construí. O &lt;strong&gt;FrankMD&lt;/strong&gt; é um editor de Markdown que personalizei pra ser eficaz pro meu caso de uso pessoal: escrever blog posts. Não foi pra ser substituto de Obsidian nem de VS Code.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/akitaonrails/FrankMD"target="_blank" rel="noopener"&gt;&lt;img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_18-49-26.jpg" alt="FrankMD" loading="lazy" /&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;O &lt;strong&gt;TL;DR&lt;/strong&gt; é o seguinte: antigamente, se eu fosse fazer este web app do zero, teria me custado aproximadamente &lt;strong&gt;200 horas&lt;/strong&gt;, ao longo de 1 ou 2 meses, bem devagar, indo e voltando. E talvez não tivesse ficado tão completo quanto este.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Se ainda não leu, veja a <a href="/2026/02/01/vibe-code-fiz-um-editor-de-markdown-do-zero-com-claude-code-frankmd-part-1/">Parte 1</a> pra entender o que é o aplicativo que eu construí. O <strong>FrankMD</strong> é um editor de Markdown que personalizei pra ser eficaz pro meu caso de uso pessoal: escrever blog posts. Não foi pra ser substituto de Obsidian nem de VS Code.</p>
<p><a href="https://github.com/akitaonrails/FrankMD"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_18-49-26.jpg" alt="FrankMD"  loading="lazy" /></a></p>
<p>O <strong>TL;DR</strong> é o seguinte: antigamente, se eu fosse fazer este web app do zero, teria me custado aproximadamente <strong>200 horas</strong>, ao longo de 1 ou 2 meses, bem devagar, indo e voltando. E talvez não tivesse ficado tão completo quanto este.</p>
<blockquote>
  <p>Com Claude, fiz tudo em pouco mais de <strong>30 horas</strong>, distribuído em 3 dias de super hiper foco (não recomendo; praticamente fiquei doente fazendo isso). No final, isso gerou um projeto com aproximadamente 130 pull requests. Um total de <strong>18 mil linhas de código</strong> (cobertura de testes de cerca de 70%). Foi tudo feito em Ruby on Rails 8 e Javascript (HotWire/Stimulus).</p>

</blockquote>
<p>Existem várias vantagens neste aplicativo em particular: é um escopo muito bem conhecido e bem documentado. Existem centenas de editores de texto de todos os tipos e de complexidades. Certamente todos os LLMs foram treinados com toneladas de material sobre isso. Então, quando eu peço pra fazer uma pesquisa por nome de arquivo, ele já sabe buscar algo como o algoritmo de Levenshtein, pra fuzzy matching, por exemplo.</p>
<p>Buscar e listar imagens em um grid, fazer upload pra S3, procurar e substituir texto, preview em HTML, etc. Tudo isso é super bem documentado e trivial de fazer. Mesmo sem LLMs, qualquer estagiário encontraria no primeiro link no Google e no Stack Overflow. Portanto, eu diria que mais de 90% do escopo foi fácil. Um aplicativo com um escopo mais desconhecido — digamos, um aplicativo de uso científico — teria sido bem mais difícil. Então este meu exemplo é só um pouco acima de um Hello World: repetir um escopo bem conhecido, que já existe.</p>
<p>Diferente do que fiz no meu artigo do primeiro exemplo de Vibe Coding, desta vez eu não vou comentar commit a commit; são mais de 130! Ficaríamos aqui a semana inteira. Em vez disso, vou comentar alguns temas que me chamam mais a atenção e que podem ser relevantes pra outros projetos.</p>
<h2>Escolha bem seus planos de LLM<span class="hx:absolute hx:-mt-20" id="escolha-bem-seus-planos-de-llm"></span>
    <a href="#escolha-bem-seus-planos-de-llm" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Durante os 3 dias, eu fui interrompido pelo Claude Code umas 2 ou 3 vezes e tive que usar outra coisa por 2 ou 3 horas. Isso porque ele tem algum limite diário de tokens e me manda parar depois que eu uso tokens demais.</p>
<p>Estou usando o Plano Max (5x Pro), que acho que custa uns USD 100, ou seja, cerca de USD 6 por dia. Nesse caso, foi barato porque 3 dias custariam só USD 18.</p>
<p>Mas num desses intervalos, mudei pra usar Claude Opus via OpenRouter, e isso foi BEM mais caro. Fora do plano Max exclusivo do Claude Code, o custo por token é absurdamente maior. Em umas 2 ou 3 horas, já consumiu mais de USD 100 (o equivalente à mensalidade toda!)</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260201165118_screenshot-2026-01-31_10-19-11.png" alt="open router billing"  loading="lazy" /></p>
<p>Ou seja, no total, por usar o plano Max fora, acabei gastando mais de USD 120. Mas se tivesse só parado pra descansar entre os limites diários, teria gasto um dia a mais (não teria ficado doente &#x1f605;) e não teria ficado muito além de USD 20. Portanto, precisa pesquisar bem os planos de cada plataforma antes de sair usando sem pensar, como eu nem. &#x1f602;</p>
<p>Como já cansei de falar: na dúvida, use o Claude Code com o plano Max.</p>
<h2>Escolha bem sua arquitetura<span class="hx:absolute hx:-mt-20" id="escolha-bem-sua-arquitetura"></span>
    <a href="#escolha-bem-sua-arquitetura" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Quando comecei a codificar (sempre que falar &ldquo;eu codifiquei&rdquo;, leiam como &ldquo;eu vibe codei&rdquo;, claro), o início foi bem rápido. Eu decidi poucas coisas técnicas: queria que fosse Ruby on Rails 8, queria que usasse Tailwind CSS, queria que não usasse nenhum banco de dados e implementasse tudo com base em sistemas de arquivos, queria que não tivesse autenticação, já que seria self-hosted pra uso somente meu.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_17-01-45.jpg" alt="first commits"  loading="lazy" /></p>
<p>Os primeiros 10 commits levaram pouco mais de 1 hora e meia. Fui adicionando funcionalidades pouco a pouco pra dar &ldquo;forma&rdquo; ao editor e, nesse ponto, eu já tinha um editor mínimo, bonito, com tema dark, que editava e exibia uma preview em html. Nada mal pra um protótipo.</p>
<p>Como falei no outro artigo, esse seria um dos pontos de &ldquo;já funciona&rdquo;.</p>
<p>Mas claro, ainda faltam mais de 120 commits pra realmente funcionar e quase 30 horas pra chegar no &ldquo;funciona de verdade&rdquo;.</p>
<p>Fui adicionando várias pequenas funcionalidades, nesta ordem:</p>
<ul>
<li>Adicionar um cheat-sheet de Markdown (help)</li>
<li>Dialog box pra adicionar fenced code blocks com autocomplete de linguagem</li>
<li>Sistema de temas, já adicionando uns 10 temas (mas ainda não ajustado pro Omarchy)</li>
<li>Dialog box pra customizar fontes</li>
<li>Controle de zoom no Preview</li>
<li>Primeira tentativa de &ldquo;typewriter mode&rdquo; (isso vai dar muita dor de cabeça ainda)</li>
<li>Dialog box de pesquisar arquivos por path com fuzzy finder</li>
<li>Primeira tentativa de sincronizar o scroll do editor e preview (isso também ainda vai dar muita dor de cabeça)</li>
<li>Dialog box de pesquisar arquivos por conteúdo com suporte a regex</li>
<li>Dialog box de pesquisar e embedar videos de YouTube</li>
<li>Dialog box de pesquisar imagens, upload e inserir</li>
<li>Suporte inicial a Docker</li>
</ul>
<p>Isso completa os primeiros <a href="https://github.com/akitaonrails/FrankMD/commit/ba42c81b47a6343ba42df8705c6973d9d45506a0"target="_blank" rel="noopener">34 commits</a> e cerca de 4 horas de trabalho. Novamente, muito próximo do &ldquo;já funciona&rdquo;.</p>
<p>Mas a diversão começa no <a href="https://github.com/akitaonrails/FrankMD/commit/7bbf03a4b594203ed1a36c6735f9d23c5ffc2c91"target="_blank" rel="noopener">commit 7bbf03a</a>, intitulado: &ldquo;Refactor to Restful Architecture&rdquo;.</p>
<blockquote>
  <p>Isto é uma coisa específica de Rails e de arquitetura em geral. O Claude, sem eu ter instruído, fez exatamente o que eu esperaria que um estagiário em Rails fizesse: embutiu toda a lógica de gerenciar diretórios e arquivos, upload de imagens e tudo mais em 2 ou 3 módulos de serviço.</p>

</blockquote>
<p>Também fez todas as rotas manualmente. Ou seja, não usou a coisa mais básica de Rails: o conceito de <a href="https://guides.rubyonrails.org/routing.html"target="_blank" rel="noopener">Restful Resources</a>. Leiam o guia no link; não vou explicar o que é isso aqui, só que qualquer pessoa experiente em Rails sabe disso de olhos fechados.</p>
<p>Portanto, ele criou rotas como &ldquo;<a href="http://localhost:3000/notes"target="_blank" rel="noopener">http://localhost:3000/notes</a>&rdquo; mas não habilitou ter coisas como &ldquo;<a href="http://localhost:3000/notes/hello"target="_blank" rel="noopener">http://localhost:3000/notes/hello</a>&rdquo; automaticamente abrir o &ldquo;hello.md&rdquo; e que verbos de HTTP como PATCH pra atualizar o arquivo ou DELETE pra apagar o arquivo. E também, sem a opção de GET, eu não teria a opção de fazer um bookmark direto de um arquivo. Tudo isso sai de graça se usar o RESTful. Então esse commit foi o refactoring em que eu o instruí a refazer tudo nessa arquitetura. Só isso foi quase <strong>1000 linhas</strong> adicionadas.</p>
<blockquote>
  <p>O &ldquo;correto&rdquo; seria ter, logo no começo, configurado um arquivo CLAUDE.MD ou CONTRIBUTING.MD ou similar com as regras específicas de arquitetura e convenções de código - mas eu não fiz, então fica aqui o lembrete pra vocês. &#x1f605;</p>

</blockquote>
<h3>Refactoring e Fixes<span class="hx:absolute hx:-mt-20" id="refactoring-e-fixes"></span>
    <a href="#refactoring-e-fixes" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Depois desse primeiro grande refactor, voltei a fazer ajustes (fixes ou consertos) e continuar adicionando novas funcionalidades:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_17-17-55.jpg" alt="screenshot 2026 02 01 17 17 55"  loading="lazy" /></p>
<p>Neste ponto já cruzamos <strong>47 commits</strong> e mais de <strong>8 horas</strong> non-stop. E sim, eu não tirei o olho do monitor. Não dá pra ler 100% de tudo o que o Claude fica pensando e fazendo; é muita coisa, mas, no geral, já notei que precisa ficar batendo o olho e, principalmente, <strong>prestando muita atenção</strong>!</p>
<p>O tempo de processamento de múltiplos agentes em paralelo, muitos em modo de <em>reasoning/thinking</em> é muito demorado mesmo. O Claude é um dos mais rápidos entre os outros, mas, na prática, eu acho sofridamente demorado.</p>
<p>Depois do almoço, resolvi abrir um YouTube do lado enquanto esperava o Claude terminar as tarefas. Significa que eu não prestei atenção e só batia o olho se ele terminava com &ldquo;sucesso&rdquo; ou se me pedia alguma permissão pra executar alguma coisa.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260201185351_screenshot-2026-01-30_19-32-51.png" alt="não faça isso kkk"  loading="lazy" /></p>
<p>Não lembro se foi neste ponto, mas aí fui dar uma olhada no que ele tinha gerado e, claro, a essa altura já tinha um <code>application.css</code> de mais de 1000 linhas, que mandei consertar <a href="https://github.com/akitaonrails/FrankMD/commit/4d3d6a07e572567dbf4ddb59faf54ba5f30aa259#diff-824082aab0a95adb8b632a5c383dd6ffb733b0899d4ba594a8380882467eba82"target="_blank" rel="noopener">neste commit</a> e ele quebrou um único arquivão em todos estes aqui:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_17-25-55.jpg" alt="application.js refactoring"  loading="lazy" /></p>
<p>Até os temas: estava tudo num único arquivo!! Esse é o nível que eu chamaria de &ldquo;estagiário&rdquo;. Como tudo &ldquo;funciona&rdquo;, vai só acumulando código, tudo no mesmo lugar, e deixa pra se preocupar com isso depois. Todo estagiário faz isso no começo se ninguém ensinar, e a LLM, pelo visto, faz do mesmo jeito. Ele não tenta fazer nada proativamente que você não pediu explicitamente. De fato, eu nunca disse no prompt <em>&ldquo;lembre-se de ir quebrando em pequenos arquivos&rdquo;</em>, mas deveria.</p>
<p>Mas esse não foi o pior. Não, senhor, <a href="https://github.com/akitaonrails/FrankMD/commit/e35f6eb738b218caba1340723d016633d8ead99f"target="_blank" rel="noopener">neste commit</a> está todo o Javascript, NUM ÚNICO ARQUIVO. Eu acho que já tinha passado de <strong>4 mil ou 5 mil linhas</strong>. Olha só em quantos arquivos ele quebrou, nem cabe tudo nesta foto:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_17-29-19.jpg" alt="app_controller.js refactoring"  loading="lazy" /></p>
<p>O problema é que eu mesmo caí no que estou criticando aqui. Eu pedia pra adicionar uma nova funcionalidade, dava &ldquo;reload&rdquo; no navegador e <em>voilá</em> lá estava a funcionalidade nova! &ldquo;FUNCIONANDO&rdquo;!! (Ruby on Rails é muito produtivo!)</p>
<p>E assim fui, adicionando &ldquo;funciona já&rdquo;, adicionando, &ldquo;funciona já&rdquo;, quando fui ver, <strong>show de horror</strong>!!</p>
<p>No último refactoring estávamos na marca de umas 9 horas de trabalho. Aqui já terminamos o dia; estamos no dia seguinte e, tirando o tempo que eu dormi, já estamos em <strong>17 horas</strong>, 70 commits. Mais ou menos na METADE do projeto.</p>
<blockquote>
  <p>Tudo isso pra dizer o seguinte: a MAIOR parte de Vibe Coding, sempre vai ser REFATORAÇÃO e consertos de bugs e adicionar TESTES!</p>

</blockquote>
<h3>I18n - Internacionalização<span class="hx:absolute hx:-mt-20" id="i18n---internacionalização"></span>
    <a href="#i18n---internacionaliza%c3%a7%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Eu me adiantei um pouco; antes do mega-refactoring de Javascript, ainda tinha feito outro mega-refactoring antes: pedi pra ele extrair todas as strings em inglês e já me traduzir uma opção em português do Brasil. Mais um <a href="https://github.com/akitaonrails/FrankMD/commit/9aee048bcaaafbf83480c5c272032b5bb2941ac5"target="_blank" rel="noopener">commit gigante</a> de mais de <strong>1600 linhas</strong> modificadas.</p>
<p>Quis fazer uma seção separada só pra comentar isso porque este é um dos pontos em que eu acho que LLMs brilham. Eles foram inicialmente feitos justamente pra lidar com linguagem natural e traduções. LLMs são muito bons em línguas. Antigamente eu precisaria passar pelo Google Tradutor, que sempre foi bem ruim, e depois achar alguém nativo da língua pra ajustar termos que o Google traduziu errado.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_17-38-24.jpg" alt="i18n"  loading="lazy" /></p>
<p>Como esperado, depois de extrair tudo, mandar ajustar os testes pra levar i18n em consideração e ver que o português estava funcionando, saí adicionando outras de brincadeira e fiquei muito satisfeito com o resultado. I18n é definitivamente algo que não faz sentido fazer sem LLM hoje em dia. É de ordens de grandeza melhor do que o jeito antigo.</p>
<h2>A Última METADE<span class="hx:absolute hx:-mt-20" id="a-última-metade"></span>
    <a href="#a-%c3%baltima-metade" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Do último grande refactoring até o fim, são mais uns 70 commits e aproximadamente 14 horas, o fim do segundo e do 3º dia, com intervalo de umas 15 horas no meio onde eu fiquei com febre e tive que me jogar na cama e me entupir de remédios… mas tudo bem, na manhã seguinte melhorei um pouco e aí terminei!</p>
<p>Daqui em diante, fui adicionando o resto das features, como emoji, emoticons, find and replace dialog, jump to line dialog, ajustes no sistema de configuração (via envs, via .fed) e Docker.</p>
<p>Uma coisa que não falei antes, mas que veio antes do esforço de I18n, foi um <strong>REBRANDING</strong>. Isso é outra coisa em que LLMs são muito boas pra resolver: substituição de texto, sabendo diferenciar textos, variáveis e código em geral. Eu não tava muito inspirado e, no começo, ia fazer um editorzinho web MUITO mais simples do que o que acabou ficando, e ia chamar só de &ldquo;WebNotes&rdquo; mesmo.</p>
<p>Mas quando cresceu e decidi que valia a pena compartilhar publicamente, resolvi dar um nome de verdade pra ele. Daí nasceu &ldquo;FrankMD&rdquo;. Só que não basta só dar nome; agora tem que sair renomeando TUDO dentro do código, strings, documentação, etc. Foi <a href="https://github.com/akitaonrails/FrankMD/commit/4d370f7cbae145fe6c28c6173c723fccfb818ffe"target="_blank" rel="noopener">neste commit</a>: umas boas <strong>700 linhas</strong> de modificações.</p>
<h3>Agora &ldquo;funciona??&rdquo;<span class="hx:absolute hx:-mt-20" id="agora-funciona"></span>
    <a href="#agora-funciona" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Estou escrevendo este artigo às 17h de domingo. Às 19:25 de sábado, mandei no Discord pra uns amigos testarem, inocentemente achando que já tinha chegado à versão 1.0.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_17-50-30.jpg" alt="discord"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_17-54-03.jpg" alt="issues"  loading="lazy" /></p>
<p>E lá vou eu, com febre precisando deitar, mas querendo chegar no 1.0!!</p>
<p>As issues em si matei rápido, mas <strong>Syntax Highlight</strong> eu não sabia que ia virar um inferno na minha vida. A primeira versão saiu rapidinho, <a href="https://github.com/akitaonrails/FrankMD/commit/e063a0bb3b6f3a1ab927ca335d06fe8eb7946d25"target="_blank" rel="noopener">neste commit</a></p>
<h3>Não Reinvente a Roda (duh)<span class="hx:absolute hx:-mt-20" id="não-reinvente-a-roda-duh"></span>
    <a href="#n%c3%a3o-reinvente-a-roda-duh" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Sim, é o clichê mais óbvio de todos, mas até eu cometo esse erro. Como falei no começo, minha expectativa com esse projeto era ser pouco mais do que um &ldquo;Notepad&rdquo; Web pras minhas anotações, e acabou virando o editor principal pro meu blog.</p>
<p>A parte do editor de textos começou com a primeira coisa que funciona primeiro: um mesmo textarea mesmo.</p>
<p>Mas no sábado eu queria colocar uma coluna de contador de linhas, que leva em consideração o wrapping das linhas, controle desse wrapping, modo typewriter sempre centralizado no meio da tela e sincronização do scroll do editor de texto com o preview.</p>
<p>Sem contar todos os dialog-boxes que adicionam coisas ao texto, como imagens, vídeos, código e tabelas. Sem contar os shortcuts pra coisas como bold, italic. Sem contar rotinas de auto-save, estatísticas de contagem de linha, linha atual, etc.</p>
<p>Pra quem não sabe, o problema de syntax highlight é que não dá pra editar direto no texto colorido (porque ele tem tags escondidas que formatam o texto). Uma das formas de resolver isso é duplicar o bloco de texto: um no background, que é o texto cru e você pode editar como quiser, e outro bloco formatado e colorido com html exatamente em cima, que você não mexe, mas ele atualiza com o que for editado. Foi assim que o Claude fez.</p>
<p>Mas tem UM MONTE de &ldquo;edge cases&rdquo;: problemas de alinhamento por conta de wrapping, problemas com tamanho e posição pra ajustar o sync do auto-scroll. Levei horas e horas brigando com isso. Olha o tipo de problema que eu estava lidando:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260201183500_screenshot-2026-02-01_12-14-45.png" alt="syntax offset bug"  loading="lazy" /></p>
<p>Tudo se resolveu quando decidi: chega. Estou reinventando a roda. Obviamente esse problema já foi resolvido. Então pedi ao Claude pra remover toda a lógica ligada ao textarea customizado e substituir por <a href="https://codemirror.net/"target="_blank" rel="noopener"><strong>Code Mirror</strong></a>, que é um componente open source pra editor de código, com suporte a contagem de linha, syntax highlight e muito mais!</p>
<p><a href="https://github.com/akitaonrails/FrankMD/commit/d0d42c9b57a9d19d904023951e6b46d9a71fcba8"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_18-03-47.jpg" alt="codemirror refactoring"  loading="lazy" /></a></p>
<p>Não foram só esses 5 commits; foram os principais. Levaram umas boas <strong>3 horas</strong> ou mais pra estabilizar esse código e múltiplos ajustes pequenos ao longo de mais commits. Umas boas <strong>6.000 linhas de código modificadas</strong>. É o tipo de refactoring que, num projeto real, teria levado a semana inteira e, ainda assim, teria saído com vários bugs e efeitos colaterais. Mas, pra isso, a LLM também é muito boa. Grandes refactorings, <strong>especialmente se você preparou uma boa suite de testes automatizados</strong>, vão bastante bem com LLMs.</p>
<blockquote>
  <p>Isso é o tipo de coisa que acontece em projetos de verdade. Não posso culpar a LLM, é totalmente culpa de eu ter mudado os requerimentos do projeto: originalmente não ia ter syntax highlight!</p>

</blockquote>
<h3>Testes, testes, testes<span class="hx:absolute hx:-mt-20" id="testes-testes-testes"></span>
    <a href="#testes-testes-testes" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>As principais recomendações de como fazer vibe coding são as mesmas de como programar do jeito antigo:</p>
<ul>
<li>Cuidado com a escolha de arquitetura (sim, exige experiência)</li>
<li>Faça pequenas funcionalidades de cada vez, cada uma com abstrações corretas e imediatamente com testes</li>
<li>Refatore de tempos em tempos pra consolidar ou abstrair código nos domínios corretos, ajustando e criando mais testes</li>
</ul>
<p>Se seguir esses 3 itens, já ajuda muito. Não é mágica: tudo isso são ferramentas que dão feedback à LLM pra ela saber o que fazer.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260201183748_screenshot-2026-01-31_03-33-13.png" alt="tests"  loading="lazy" /></p>
<p>Se você é aquele tipo de cliente anta que só fala <em>&ldquo;ah, parou de funcionar&rdquo;</em> - você não serve pra falar com um programador, e sinto dizer que LLMs não vão te ajudar também:</p>
<ul>
<li>O &ldquo;QUE&rdquo; parou de funcionar?</li>
<li>Tudo parou de abrir?</li>
<li>Somente um elemento desapareceu?</li>
<li>O elemento aparece, mas ao clicar não acontece nada?</li>
<li>Dá pra clicar, mas aparece uma mensagem de erro?</li>
<li>Que mensagem exatamente?</li>
<li>O que você fez antes disso?</li>
<li>Mudou alguma coisa do teste anterior pra este?</li>
<li>etc</li>
</ul>
<p>TUDO ISSO e mais tem que estar no próximo prompt pra correções. Incluindo logs do servidor, linhas de comando pra reproduzir o erro e toda a evidência que puder ser produzida, pra facilitar o entendimento do erro.</p>
<blockquote>
  <p>Não existe <em>&ldquo;parou de funcionar&rdquo;</em> — sua cabeça que nunca funcionou.</p>

</blockquote>
<p>Uma coisa de que gostei muito do Claude foi quando apareceu um bug que antes não existia, mas não sabia se tinha sido no último commit ou em uns 3 commits pra trás. E em vez de chutar coisas aleatórias, o próprio Claude ofereceu pra fazer, manualmente, o equivalente a um <a href="https://git-scm.com/docs/git-bisect"target="_blank" rel="noopener">git bisect</a> e foi me oferecendo um commit de cada vez pra testar e continuar até acharmos em qual o erro foi introduzido:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260201183240_screenshot-2026-02-01_15-05-30.png" alt="bisect"  loading="lazy" /></p>
<h3>Otimizações e Checagens<span class="hx:absolute hx:-mt-20" id="otimizações-e-checagens"></span>
    <a href="#otimiza%c3%a7%c3%b5es-e-checagens" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Ao longo do projeto, umas 2 ou 3 vezes eu parei especificamente pra checar coisas como leaks de memória e buracos de segurança. Mesmo sendo um projeto pessoal, eu não queria buracos muito óbvios.</p>
<p>Já fiquem avisados de que Claude é campeão em fazer um monte de setTimeout em Javascript e não dar <a href="https://github.com/akitaonrails/FrankMD/commit/039f3f8d72b0bf8facd5a1f85f54523bbfc12b53"target="_blank" rel="noopener">clearTimeout</a> em nada!</p>
<p>Não é muita coisa, mas eu tive mais de <a href="https://github.com/akitaonrails/FrankMD/commit/afb669f9b96baef05bae02cd76a9731353f6eac4"target="_blank" rel="noopener">um commit</a> pra apagar <strong>&ldquo;Dead Code&rdquo;</strong>, que é código morto: código que, numa refatoração, foi movido pra outro módulo, mas se esqueceu de apagar da origem. As LLMs sempre vão deixar sobrando dead code, porque não têm como olhar 100% de todos os arquivos 100% do tempo. Eventualmente ele perde uma coisa ou outra. E não vai causar nenhum bug óbvio. Tem que ir tirando aos poucos, depois de cada refatoração.</p>
<p>Quando comecei a testar de verdade, com textos longos, notei que a interface estava com &ldquo;lag&rdquo;, meio devagar. Duas coisas pareciam estar afetando: a contagem estatística de quantas palavras e caracteres o texto tem estava muito agressiva.</p>
<blockquote>
  <p>Novamente &ldquo;Funciona já&rdquo; &hellip; mas devagar &hellip;</p>

</blockquote>
<p>Pior, quando mandei criar o editor de texto pela primeira vez, não disse exatamente como queria que fosse o &ldquo;auto-save&rdquo;. Então ele fez do jeito mais conservador, agressivo e lento de todos: pra mandar salvar tudo a cada tecla digitada!!!</p>
<p>Tinha <a href="https://github.com/akitaonrails/FrankMD/commit/76406744a3e268d7b96648a2a41c415e3781c1a4"target="_blank" rel="noopener">várias outras coisas</a> como não atualizar o Preview HTML se não estiver aberto e coisas assim.</p>
<blockquote>
  <p>LLMs não vão fazer o código mais performático logo de cara; você precisa pedir como!</p>

</blockquote>
<p>Performance é sempre um trade-off. Por exemplo, se eu deixar o mais rápido possível, o resto pode acabar super inseguro. Se eu aumentar a segurança demais, derruba a usabilidade. Se aumentar demais a otimização, derruba a manutenibilidade do código. Não existe como fazer tudo perfeito: um sempre vai afetar negativamente o outro e só um programador experiente sabe como escolher (ou ir na tentativa e erro até acertar, pro caso específico).</p>
<blockquote>
  <p>De novo, não é &ldquo;culpa&rdquo; da LLM; isso é normal em qualquer projeto e temos que ficar o tempo todo ajustando essas coisas.</p>

</blockquote>
<h3>Agora sim, chegando ao fim<span class="hx:absolute hx:-mt-20" id="agora-sim-chegando-ao-fim"></span>
    <a href="#agora-sim-chegando-ao-fim" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Ainda sobrou muito débito técnico. O arquivo <a href="https://github.com/akitaonrails/FrankMD/blob/master/app/javascript/controllers/app_controller.js"target="_blank" rel="noopener">app_controller.js</a> ainda tem mais de <strong>2.000 linhas</strong>. Isso já é DEPOIS que eu gastei umas boas 3 horas refatorando só ele. Agora tem aproximadamente 28 controllers em JavaScript, mas antes tinha somente um arquivo.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260201183730_screenshot-2026-01-31_11-05-30.png" alt="app controller"  loading="lazy" /></p>
<p>Além disso, as LLMs não conseguem ser consistentes o tempo todo, especialmente quando o código do projeto aumenta muito. Teve um tipo de funcionalidade que ele sempre fazia server-side, mas depois resolveu fazer client-side. Eu tive que interromper e falar: <em>&ldquo;ow, por que?&rdquo;</em>, daí ele refez do jeito certo:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260201183531_screenshot-2026-02-01_10-49-54.png" alt="interrupt"  loading="lazy" /></p>
<p>Todo código feito sem assistência humana nenhuma SEMPRE sai na pior qualidade de código possível. Com minha assistência, acho que o código está minimamente apresentável. Vocês mesmos podem checar no <a href="https://github.com/akitaonrails/FrankMD"target="_blank" rel="noopener">meu repositório no GitHub</a>.</p>
<p>Recomendo que fucem os commits: pelo menos uma coisa que as LLMs fazem bem é documentar o histórico do que foi modificado naquele commit, o que facilita muito pra pesquisar depois.</p>
<p>Teve um bug em que o Claude ficou em loop, fazendo tentativa e erro, pra descobrir quando meu CSS quebrou do nada. Ele mudava alguma coisa e me mandava testar; eu reiniciava o server, dava force reload e nada: quebrado.</p>
<p>Era sábado à noite; eu estava cansado e meio febril, mas aí que me caiu a ficha. Eu tinha feito uma besteira antes. Coloquei &ldquo;notes/&rdquo; no <code>.gitignore</code>, que era o diretório onde estava criando arquivos Markdown de teste no editor e não queria que commitasse.</p>
<p>Porém, isso também passou a ignorar diretórios como <code>app/views/notes/...</code>, por exemplo. E nem eu nem o Claude sabíamos disso, mas quando mandei ele pesquisar, voilá:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260201184244_screenshot-2026-01-30_19-55-28.png" alt="tailwind error"  loading="lazy" /></p>
<blockquote>
  <p>Tailwind build obedece ao <code>.gitignore</code> então tava cagando a compilação de CSS. &#x1f92c;</p>

</blockquote>
<p>Quando consertamos isso, passou a funcionar, mas aí já passaram 2 horas!</p>
<p>Como falei, você precisa estar esperto nos erros, ver por que a LLM tá fazendo tanta tentativa e erro, fazendo e desfazendo no mesmo lugar e não chegando a lugar algum. Isso não é incomum e pode passar metade do seu dia nisso, se não prestar atenção.</p>
<h2>Conclusão<span class="hx:absolute hx:-mt-20" id="conclusão"></span>
    <a href="#conclus%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Quando comecei a fazer isso sexta de manhã, imaginei:</p>
<blockquote>
  <p><em>&ldquo;Ah, um dia inteiro de trabalho deve ser suficiente.&rdquo;</em></p>

</blockquote>
<p>E como sempre, toda estimativa é subestimada. Agora já é domingo, fim do dia, e só agora eu acho que cheguei perto da versão 1.0. Ainda não está perfeito, mas acho que está minimamente apresentável.</p>
<p>Lembrando que eu fiz isso no meu ritmo de <strong>extremo hiperfoco</strong>, quando eu quase nem pisco, meus olhos ressecam e começo a enxergar tudo embaçado, até a cabeça começa a doer um pouco de tanto ficar tenso, concentrado numa coisa só. Não é um estado que eu recomendo.</p>
<p>Mesmo essas 30 horas em 3 dias, o correto seria 30 horas ao longo de, pelo menos, 5 dias.</p>
<p>Se eu tivesse deixado o Claude fazer tudo sozinho, sem checar nada, teria levado metade do tempo e, no final, teria um código &ldquo;que funciona&rdquo;, mas inusitado, com um único arquivo css de umas 2000 linhas, um único arquivo JS de umas 8 mil linhas, tudo com ZERO testes e tudo parecendo um castelo de cartas onde se mexe numa coisa pequena e quebra 10 outras coisas sem saber por quê.</p>
<p>No geral, foi um bom experimento. Sabendo como e onde usar, LLMs são excepcionais. Como disse no começo, sem a LLM eu teria levado mais de 200 horas pra fazer sozinho. Mesmo sofrendo vários perrengues no meio do caminho, levei só 30 horas. Então a LLM realmente me fez trabalhar pelo menos 6 a 7 vezes mais rápido, talvez mais.</p>
<p>Finalmente eu sinto que chegamos ao elusivo <strong>&ldquo;Developer 10x&rdquo;</strong>. Basta que você seja sênior primeiro.</p>
<p>Pra acabar, um mini-relatório do <a href="https://github.com/XAMPPRocky/tokei"target="_blank" rel="noopener">Tokei</a>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>Breakdown by language:
┌─────────────────┬───────┬────────┬────────┐
│    Language     │ Files │ Lines  │  Code  │
├─────────────────┼───────┼────────┼────────┤
│ JavaScript      │ 137   │ 26,621 │ 19,922 │
├─────────────────┼───────┼────────┼────────┤
│ Ruby            │ 60    │ 8,449  │ 5,883  │
├─────────────────┼───────┼────────┼────────┤
│ YAML            │ 10    │ 3,477  │ 2,867  │
├─────────────────┼───────┼────────┼────────┤
│ Ruby HTML (ERB) │ 33    │ 2,927  │ 2,634  │
├─────────────────┼───────┼────────┼────────┤
│ CSS             │ 29    │ 1,619  │ 1,297  │
├─────────────────┼───────┼────────┼────────┤
│ Markdown        │ 1     │ 716    │ -      │
├─────────────────┼───────┼────────┼────────┤
│ HTML            │ 5     │ 369    │ 329    │
├─────────────────┼───────┼────────┼────────┤
│ Dockerfile      │ 1     │ 88     │ 46     │
├─────────────────┼───────┼────────┼────────┤
│ Other           │ 6     │ 145    │ 76     │
└─────────────────┴───────┴────────┴────────┘
The codebase is primarily JavaScript (60%) and Ruby (17%) by code volume.</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Portanto, aproximadamente <strong>33 mil linhas de código</strong>. Isto é um projeto PEQUENO! Projetos grandes são 10 vezes maiores, com mais de 300 mil linhas de código. Eu chutaria que num iFood ou Mercado Livre da vida, com todos os subsistemas, é mais de 1 milhão de linhas de código. Arrumem suas expectativas!</p>
]]></content:encoded><category>frankmd</category><category>frank</category><category>llms</category><category>inteligencia-artificial</category></item><item><title>Vibe Code: Fiz um Editor de Markdown do zero com Claude Code (FrankMD) PARTE 1</title><link>https://akitaonrails.github.io/2026/02/01/vibe-code-fiz-um-editor-de-markdown-do-zero-com-claude-code-frankmd-part-1/</link><guid isPermaLink="true">https://akitaonrails.github.io/2026/02/01/vibe-code-fiz-um-editor-de-markdown-do-zero-com-claude-code-frankmd-part-1/</guid><pubDate>Sun, 01 Feb 2026 10:40:43 -0300</pubDate><description>&lt;p&gt;Nos últimos &lt;a href="https://akitaonrails.com/2026/01/28/vibe-code-eu-fiz-um-appzinho-100-com-glm-4-7-tv-clipboard/"target="_blank" rel="noopener"&gt;dois&lt;/a&gt; &lt;a href="https://akitaonrails.com/2026/01/29/vibe-code-qual-llm-%c3%a9-a-melhor-vamos-falar-a-real/"target="_blank" rel="noopener"&gt;posts&lt;/a&gt; eu só quis mesmo maltratar as LLMs pra mostrar a dificuldade que é fazer algo simples do zero, quem dirá &lt;em&gt;&amp;ldquo;fazer um GTA 6 do zero&amp;rdquo;&lt;/em&gt;, como alguns idiotas acreditam.&lt;/p&gt;
&lt;p&gt;Eu falo isso faz anos: eu GOSTO das LLMs, uso bastante desde que foram lançadas e nunca tive problemas com elas. Meu problema é só balizar as expectativas IRREAIS dos &amp;ldquo;normies&amp;rdquo;, não-programadores: nunca vai sair um app como este com poucos prompts e sem saber exatamente os componentes e técnicas pra usar por baixo.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Nos últimos <a href="https://akitaonrails.com/2026/01/28/vibe-code-eu-fiz-um-appzinho-100-com-glm-4-7-tv-clipboard/"target="_blank" rel="noopener">dois</a> <a href="https://akitaonrails.com/2026/01/29/vibe-code-qual-llm-%c3%a9-a-melhor-vamos-falar-a-real/"target="_blank" rel="noopener">posts</a> eu só quis mesmo maltratar as LLMs pra mostrar a dificuldade que é fazer algo simples do zero, quem dirá <em>&ldquo;fazer um GTA 6 do zero&rdquo;</em>, como alguns idiotas acreditam.</p>
<p>Eu falo isso faz anos: eu GOSTO das LLMs, uso bastante desde que foram lançadas e nunca tive problemas com elas. Meu problema é só balizar as expectativas IRREAIS dos &ldquo;normies&rdquo;, não-programadores: nunca vai sair um app como este com poucos prompts e sem saber exatamente os componentes e técnicas pra usar por baixo.</p>
<p>Desta vez, decidi resolver outro probleminha que tava no meu todo-list faz tempo: ter um web app de anotações self-hosted (pra colocar no meu <a href="/tags/homelab/">home-server</a>. Ao longo dos anos, já usei de tudo: Evernote, Notion, Obsidian, entre outros, mas nenhum deles realmente me agrada. Então, por que não fazer o meu próprio exatamente como eu quero?</p>
<p>Vou dividir este tema em 2 artigos: neste primeiro, vou falar exclusivamente sobre o meu aplicativo; <a href="/2026/02/01/vibe-code-fiz-um-editor-de-markdown-do-zero-com-claude-code-frankmd-parte-2/">no segundo</a>, vou falar sobre o processo de vibe codar com o Claude. Spoilers: levou 3 dias inteiros (aproximadamente <strong>30 horas</strong>). 137 pull requests! Aguardem o post-mortem.</p>
<h2>O Problema<span class="hx:absolute hx:-mt-20" id="o-problema"></span>
    <a href="#o-problema" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>No geral, é só frescura minha mesmo. Guardar anotações não é pra ser complicado: cria um diretório aí na sua máquina, abre o LazyVim, o VSCode ou qualquer outro, e acabou: edita os Markdown em código puro e seja feliz.</p>
<p>Enfim, recentemente estava usando apenas o Obsidian num diretório compartilhado no meu Dropbox. Assim, eu poderia acessar do meu celular, se precisasse. Mas é um saco porque se eu estiver em outro PC, preciso ter o Obsidian ou outro editor instalado. Se tiver plugins, também preciso instalar.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_11-43-09.jpg" alt="obsidian"  loading="lazy" /></p>
<p>Na prática, qualquer outro editor de textos serviria: são só arquivos de texto, mas a inconsistência de ter algo diferente em cada lugar e desconfigurado me desmotiva a escrever.</p>
<p>Em particular, estava usando Obsidian e, depois, LazyVim pra editar os posts do meu blog, que também são em Markdown. E aqui tenho um conjunto maior de problemas:</p>
<ul>
<li>tem path específico e frontMatter do Hugo; tomar cuidado com o título pra gerar o slug correto, etc.</li>
<li>tem imagens/screenshots que fico manualmente subindo no S3, pegando a URL e colando no post</li>
<li>evito usar tabelas porque é um saco &ldquo;desenhar&rdquo; tabelas direto em Markdown</li>
<li>fico com abas de Google Images, youtube, etc aberta pra poder ficar pegando links, alt-tab, e colando</li>
<li>não tem um bom preview, então eu deixo passar muito errinho (url de imagem duplicada, por exemplo)</li>
<li>não tem um bom corretor ortográfico, então passa muito errinho que depois tenho que ficar corrigindo</li>
</ul>
<p>Eu queria resolver esses problemas tudo num lugar só: um editor bonito como o Obsidian, que resolvesse todos esses inconvenientes e me deixasse só focar no meu texto.</p>
<h2>FrankMD<span class="hx:absolute hx:-mt-20" id="frankmd"></span>
    <a href="#frankmd" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Frank Rosenblatt foi o criador do Perceptron em 1957, o que deu origem às redes neurais modernas. Como eu fiz este editor totalmente com IA, resolvi homenagear o Frank.</p>
<p>Por isso, nomeei de Frank Markdown ou Frank Editor (fed). Este editor tem todo o básico que você esperaria em qualquer editor:</p>
<ul>
<li>syntax highlighting</li>
<li>HTML preview</li>
<li>facilitadores pra formatar markdown (Ctrl-B pra bold, por exemplo)</li>
<li>customizar família e tamanho de fontes</li>
</ul>
<p>Mas aí eu fui adicionando algumas coisas específicas do meu gosto pessoal e/ou que facilitariam minha vida pra editar os posts do meu blog. Vamos lá.</p>
<h3>Inserção de Imagens<span class="hx:absolute hx:-mt-20" id="inserção-de-imagens"></span>
    <a href="#inser%c3%a7%c3%a3o-de-imagens" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Esta é uma parte importante pra mim. Quando faço posts sobre código, sempre fico tirando fotos de tela do meu terminal ou do editor pra ilustrar. O processo é um print screen que manda pra <code>~/Pictures/Screenhots</code>. Eu abro o Nautilus (estou no Linux) e executo um scriptzinho que fiz pra subir essa imagem num bucket no meu AWS S3. Copio o link gerado e adiciono ao meu post.</p>
<p>É &ldquo;chatinho&rdquo; toda vez fazer assim. Além do mais, às vezes eu gostaria de redimensionar a imagem pra ficar mais otimizada. Mas isso significa ir ao terminal e rodar <code>magick screenshot.png -resize 50%</code>, o que é mais um passo manual.</p>
<p>Então decidi fazer um dialog-box com várias opções pra isso.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-03-02.jpg" alt="image dialog"  loading="lazy" /></p>
<p>Por padrão, posso configurar um diretório principal de imagens no meu PC e ele vai exibir um grid limitado a 10 dos mais recentes. Ou posso filtrar por nome. Tem opção pra linkar diretamente (caso eu queira subir a imagem junto com o html) ou servir do S3 e ele vai copiar o link gerado automaticamente (que é o que prefiro).</p>
<p>Mesma coisa pra imagens na Web (Google, Pinterest), onde posso escolher fazer download, redimensionar e subir no S3:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-06-07.jpg" alt="bing images example"  loading="lazy" /></p>
<p>E o link vai ser no formato esperado <code>...amazonaws.com/frankmd/...jpg</code>. Tudo bonito, fácil e prático! Pra mim, isso ajuda muito!</p>
<p>É um extra, já que não poderia deixar de integrar uma IA; a última aba é pro <a href="https://gemini.google/br/overview/image-generation/?hl=pt-BR"target="_blank" rel="noopener">Nano Banana</a>:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-10-24.jpg" alt="nano banana generator"  loading="lazy" /></p>
<p>Nano Banana é bem impressionante mesmo. olha o que esse prompt gerou:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/ai_1769965759787.png" alt="nano banana generated image"  loading="lazy" /></p>
<p>Mais importante ainda: posso ver o preview imediatamente (ctrl-shift-v) pra saber se inseri a imagem correta (coisa que eu errava muito editando pelo NeoVim, sem ver):</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-13-29.jpg" alt="real time preview"  loading="lazy" /></p>
<p>Pra mim, estes assistentes de inserção de imagem são os que vão me ajudar mais pra editar meus posts no blog.</p>
<h3>Facilitadores de Markdown<span class="hx:absolute hx:-mt-20" id="facilitadores-de-markdown"></span>
    <a href="#facilitadores-de-markdown" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Mais simples que uma imagem é embedar um player de vídeo. Posso ou passar um link qualquer com um arquivo .mp4 da vida, ou posso diretamente pesquisar no YouTube e embedar corretamente:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-12-34.jpg" alt="youtube searching"  loading="lazy" /></p>
<p>Ele vai inserir o HTML correto, <code>&lt;embed ...</code>, com a URL e o título corretos.</p>
<p>Também fiz um jeito fácil de adicionar e editar tabelas:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-28-00.jpg" alt="spreadsheet editor"  loading="lazy" /></p>
<p>E vai gerar o Markdown direitinho. Nunca mais preciso ficar editando essa porcaria na mão pra reajustar colunas ou linhas.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl">| Cabeçalho 1 | Cabeçalho 2 |
</span></span><span class="line"><span class="cl">| ----------- | ----------- |
</span></span><span class="line"><span class="cl">| Linguagem   | Nota        |
</span></span><span class="line"><span class="cl">| Ruby        | 10          |
</span></span><span class="line"><span class="cl">| JS          | 5           |
</span></span><span class="line"><span class="cl">| Rust        | 6 😂        |</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Mas se quiser fazer manualmente, também tem como:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-28-40.jpg" alt="code editor"  loading="lazy" /></p>
<p>Isso eu coloquei mais porque ficaria estranho omitir, mas, em geral, prefiro escrever blocos de código manualmente mesmo. Mas tá aí pra quem quiser um modo visual.</p>
<p>Daí, claro, tinha que ter um jeito fácil de editar Emojis &#x1f606; (ctrl-shift-e):</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-30-19.jpg" alt="emoji panel"  loading="lazy" /></p>
<p>Mas o que ninguém esperaria é que eu também oferecesse suporte a <strong>Emoticons</strong>!!
☆:.｡.o(≧▽≦)o.｡.:*☆</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-30-40.jpg" alt="emoticon panel"  loading="lazy" /></p>
<h3>Customização<span class="hx:absolute hx:-mt-20" id="customização"></span>
    <a href="#customiza%c3%a7%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Falando em Emoticons, me lembra que eu quis fazer este editor ser infinitamente customizável. Por exemplo, claro que adicionei internacionalização e tem japonês!!</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-34-30.jpg" alt="japanese locale"  loading="lazy" /></p>
<p>E mais outras línguas também; deem uma fuçada depois. Tem português também!</p>
<blockquote>
  <p>&ldquo;Qual a melhor fonte pra programação?&rdquo;</p>

</blockquote>
<p>Sei lá! Por isso adicionei várias pra escolher:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_15-22-35.jpg" alt="fonts"  loading="lazy" /></p>
<p>Como uso <a href="/tags/omarchy/">Omarchy</a> então também queria que meu editor tivesse suporte a exatamente os mesmos temas:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-37-05.jpg" alt="temas"  loading="lazy" /></p>
<p>No <a href="https://github.com/akitaonrails/FrankMD/blob/master/README.md"target="_blank" rel="noopener">README</a> tem documentado tudo o que dá pra customizar. Dá pra customizar várias coisas via variável e ambiente e também via um arquivo <code>.fed</code>, criado automaticamente no diretório onde se abre este editor. Tem bastante coisa, então recomendo fuçar.</p>
<h3>Foco no Texto<span class="hx:absolute hx:-mt-20" id="foco-no-texto"></span>
    <a href="#foco-no-texto" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Obviamente eu incluí um <em>&ldquo;Typewriter Mode&rdquo;</em> que mantém meu cursor mais perto do meio da tela e centraliza o editor e, ao mesmo tempo, esconde todas as distrações.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-17-37.jpg" alt="typewriter mode"  loading="lazy" /></p>
<p>Eu normalmente prefiro TUIs, mas tenho que admitir que usar um navegador tem a vantagem de me dar acesso a extensões. A mais útil sem dúvida é <a href="https://app.grammarly.com/"target="_blank" rel="noopener">Grammarly</a>, pra qualquer língua:</p>
<p><a href="https://app.grammarly.com/"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-20-04.jpg" alt="grammarly"  loading="lazy" /></a></p>
<p>Mas, além disso, eu adicionei um botão de &ldquo;IA&rdquo; no toolbar (que é configurável via ENVs e um arquivo de configuração — leia o <a href="https://github.com/akitaonrails/FrankMD/blob/master/README.md"target="_blank" rel="noopener">README</a>). E ele faz isto:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-22-28.jpg" alt="AI grammar check"  loading="lazy" /></p>
<p>Manda pro seu LLM de preferência (Ollama local, OpenAI, Anthropic, OpenRouter, etc.) com um prompt pra não reescrever seu texto, somente checar gramática, ortografia e erros desse tipo. Daí eu devolvo uma interface de &ldquo;diff&rdquo; pra comparar e aceitar, editar ou cancelar.</p>
<p>Eu acho que o Grammarly sozinho já é suficiente, mas não custava nada fazer esse, então fiz.</p>
<h3>Hugo Blogging<span class="hx:absolute hx:-mt-20" id="hugo-blogging"></span>
    <a href="#hugo-blogging" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Este blog é feito com Hextra/Hugo. Por isso, queria já embutir um jeito fácil de criar novos artigos no Hugo. Isso começa já na opção de criar um novo documento:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-39-53.jpg" alt="new hugo note"  loading="lazy" /></p>
<p>E ele já cria com o frontMatter, geração automática de slugs, subdiretórios timestamped e tudo mais:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_14-38-55.jpg" alt="hugo frontmatter"  loading="lazy" /></p>
<p>Como falei, este editor foi feito principalmente pra facilitar o meu workflow.</p>
<h2>Self-deploy vs CLI<span class="hx:absolute hx:-mt-20" id="self-deploy-vs-cli"></span>
    <a href="#self-deploy-vs-cli" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Originalmente pensei neste projeto pra ser um Notion-light ou algo assim. Um editor web que fizesse deploy no meu home server e servisse apenas como organizador, um lugar pra colocar anotações maiores do que um post-it (tipo histórico de configurações do meu Linux, passo a passo de comandos, etc.).</p>
<p>Mas enquanto ia construindo o app, mais e mais ele foi ficando maior e mais parecido mesmo com um desktop app. Mas aí já era tarde demais. Eu queria usar Ruby on Rails 8 (pra me desenferrujar e porque eu gosto). Mas só na metade pensei que deveria ter feito em Node, porque agora não posso mais embutir num app do Electron.</p>
<p>Tudo bem, a ideia é ser um editor de Markdown pra programadores. Todo programador tem Docker. Então encapsulei numa funçãozinho de shell e posso só chegar em qualquer diretório no meu Linux digitar:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">cd</span> ~/Projects/akitaonrails-hugo
</span></span><span class="line"><span class="cl">fed .</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Leia o <a href="https://github.com/akitaonrails/FrankMD/blob/master/README.md#2-add-the-fed-function"target="_blank" rel="noopener">README</a> do projeto para saber como adicionar ao seu <code>.bashrc</code>. E fazendo assim, vai funcionar no meu Omarchy igualzinho a qualquer outro app nativo:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_15-16-29.jpg" alt="omarchy web app"  loading="lazy" /></p>
<p>Na realidade, vou usar ambos os jeitos: como se fosse um app nativo. E como self-hosted, pra organizar um lugar central com minhas anotações — que vai ser diferente de onde organizo meus posts de blog. Então tem múltiplas utilidades mesmo.</p>
<h2>Conclusão<span class="hx:absolute hx:-mt-20" id="conclusão"></span>
    <a href="#conclus%c3%a3o" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Eu resumi as funcionalidades; há várias pequenas coisinhas que vocês vão descobrir à medida que começarem a usá-las. Tudo é clicável; se clicar no título do app, tem um About:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_15-21-03.jpg" alt="about"  loading="lazy" /></p>
<p>Botão direito em cima do texto selecionado tem um sub-menu de formatação; Ctrl-I funciona pra itálico; <code>Ctrl-H</code> tem &ldquo;find and replace&rdquo; básico; tem um monte de combinações de teclado pra acessar as coisas.</p>
<p>Ah é, nem falei que tem como pesquisar rapidamente arquivos por nome ou por conteúdo usando <code>Ctrl-P</code> e <code>Ctrl-Shift-F</code>:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_15-24-36.jpg" alt="search filename"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-01_15-25-59.jpg" alt="search file content"  loading="lazy" /></p>
<p>Pra garantir que tudo está funcionando, este artigo aqui já foi escrito inteiramente com o meu novo editor. E diria que, se não está perfeito (pra mim), está nos 99%. Fiquem à vontade pra abrir Issues com bugs ou features que fariam sentido — lembrando: a ideia não é substituir o VSCode e ter TUDO. Eu preciso que tenha só o SUFICIENTE.</p>
<p>Considerando que me custou <strong>3 dias de trabalho</strong> e que não escrevi nenhuma linha de código, o resultado é impressionante. Sim, editores de texto são aplicativos muito bem documentados e com vasto material de treinamento — por isso, o Claude conseguiu fazer sem problemas. Não se impressionem demais. Mas isso dito, sim, eu queria um editor customizado pro meu uso pessoal, que fosse bonito de usar, e ele conseguiu entregar, então estou satisfeito.</p>
<p>Não deixem de ver a <a href="/2026/02/01/vibe-code-fiz-um-editor-de-markdown-do-zero-com-claude-code-frankmd-parte-2/">PARTE 2</a> pros detalhes de como foi fazer isso.</p>
]]></content:encoded><category>frankmd</category><category>frank</category><category>llms</category><category>inteligencia-artificial</category></item></channel></rss>