Imported from frederico-kluser/newsletter-crawler (
.agents/skills/searching-the-corpus/SKILL.md). Install upstream withnpx skills add frederico-kluser/newsletter-crawler --skill searching-the-corpus. Copyright stays with the author.
Searching the corpus (+ PT-BR summaries)
When to use
Editing src/search.js, src/summarize.js, src/web.js, the search/web commands, or the search UIs (SearchConfig/ResultsView na TUI, src/web-ui/ no navegador); adding tag-based retrieval.
Procedure / injected knowledge
-
The tag system ALREADY EXISTS — reuse it. 9 facets,
config/taxonomy.json(8 domains, aliases, limits, mandatory), classification insrc/classify.js/src/taxonomy.js, persisted toarticle_tags(article_id, facet, tag, rank), auto post-crawl. Do NOT rebuild the taxonomy. -
PT-BR summaries keep the original.
articles.contentstays original (search/tags read it); thesummarizestage (Flash high) writesarticles.title_pt+articles.summary_pt(a readable PT-BR summary, NOT a literal full translation).summarizePendingis idempotent (summary_pt IS NULL) + a post-crawl hook (SUMMARIZE_AFTER_CRAWL).src/summarize.js@b7ee2f7. -
Search Mode A (exhaustive).
searchRelevance= Flash high,pLimit(stageWindow(SEARCH_FLASH_CONCURRENCY))over the scope;judgeRelevancereturns{relation: direct|similar|none, kind: news|tool}— json_schema has NO enum; zod clamps + per-article fail-open tonone. O miolo (gate + fail-open + BUDGET_EXCEEDED→skipped) vive emjudgeRowsIndividually(query, rows, verdicts, label)— compartilhado pelo modo A e pela busca profunda da web. Cost guard: acima deSEARCH_MODE_A_CONFIRMo comando exige--yes.src/search.js@6b1d77d,src/llm.js@6b1d77d. -
O ESCOPO delta agora VALE (bug corrigido).
cmdSearchcalculagetSearchScope(flags)= {all, runId, count} e REPASSAall/runIdarunSearch(antes só o guard usava o escopo e a varredura era sempre o acervo todo). A âncora do delta éstmts.maxArticleRunId(=MAX(articles.run_id), a última run QUE TROUXE ARTIGOS) — nãoMAX(runs.id): search/verify/web-search também abrem runs e zerariam o "apenas o novo". O confirmA da TUI usa a MESMA conta +estimateStageCallUsd(US$) e oferece trocar p/ acervo quando o delta está vazio.src/commands.js@6b1d77d(getSearchScope),src/db.js@6b1d77d(maxArticleRunId),src/ui/screens.js@6b1d77d. -
Search Mode B (tag-based, cheap). Exactly 5 Pro calls, one per
RETRIEVAL_FACETS;buildFacetQueryPromptmaps the query to that facet's vocab, validated byvalidateFacetTags; union;stmts.articlesByTagsretrieves viajson_each(@tags)ranked by match count. Requires classification.src/taxonomy.js@b7ee2f7,src/db.js@b7ee2f7. -
Web search agora é IA-FIRST (
POST /api/search). A busca digitada da web usasearchWeb(query, {deep, sources, from, to})(src/search.js@6b1d77d): soft (default) =judgeRelevanceBatch— 1 Flash xhigh por lote deSEARCH_BATCH_SIZE(40) artigos, entrada título +summary_pt|blurb|content_head(400)(stmtwebSearchCandidatesLite), fusão tolerantemergeBatchVerdicts(id faltando→none fail-open, desconhecido ignora, duplicado 1º vence); deep (toggle "Busca profunda") =judgeRowsIndividuallypor artigo (content 8000, stmtwebSearchCandidates). Escopo porSEARCH_SCOPE_WHERE(sources via json_each + from/to via iso_date). Retorno = hits CRUS {id,relation,kind} capados emSEARCH_WEB_MAX_ITEMS; oweb.jsre-seleciona porwebArticlesByIds+ decoração de tags/kind (cards idênticos ao browse, +relation/judge_kind). Verified: busca soft real 362 artigos/10 lotes (US$ 0.013) e deep 7 artigos em 7s;test/web.search.test.js+test/search.batch.test.js. -
Guard/serialização/key no servidor. Preflight
GET /api/search/scopedevolve {count, calls, estimatedUsd (média real viaestimateStageCallUsd), threshold, needsConfirm}; o POST re-valida (acima deSEARCH_MODE_A_CONFIRMdeep /SEARCH_SOFT_CONFIRMsoft semconfirm:true→ 428). UMA busca por processo (_searchBusy→ 409): o run do ledger é global. Envelope própriowithSearchRun=initGovernor({profile:'llm-only'})+beginRun({command:'web-search'})+ finally — NÃO importerunWithLimitsno web.js (ciclo de import com commands.js + process.exit). Sem key → 400code:'NO_KEY'e o cliente abre o KeyModal:POST /api/keyproba (probeOpenRouterKey) e só entãoupsertEnvVar+setRuntimeKey(live binding — vale sem reiniciar). O body do POST é consumido ANTES do trabalho, então o requestTimeout do Node não derruba a resposta longa.src/web.js@6b1d77d. -
Streaming SSE = 2ª rota da busca da web (
GET /api/search/stream).searchWeb/judgeRowsIndividually(deep) e o loop soft aceitamonEventOPCIONAL → emitem{type:'progress',scanned,total,relevant,failed}(nível-ARTIGO nos DOIS modos — o soft agora conta artigos, não lotes) e{type:'hit',hit}(relevante AO VIVO);_progressganhoufailed(429 esgotado ≠ 'none' legítimo → "não analisados"). O modo A do CLI segue SEMonEvent— nada muda lá. Noweb.jsa rota SSE reusa os MESMOS guards do POST (428/409/_searchBusy/NO_KEYviawithSearchRun), escreveevent: progress|hit|done|error, decora cada hit comenrichHit(card idêntico ao POST) e injeta custo ao vivo viagetBudgetState(). Clienteweb-ui/app.js:streamSearch(fetch + parse SSE manual, ABORTÁVEL) — NÃOEventSource(não lê status 428/409 nem cancela); cards ao vivo (streamItems), loader com barra + %, X/Y artigos, relevantes, custo, ETA, "não analisados". OPOST /api/searchcontinua p/ compat.src/search.js@df126c0,src/web.js@df126c0,src/web-ui/app.js@df126c0. Verified: SSE real (15 artigos) —progress→hit×4→progress→done, US$ 0,0003. -
Browse sem query continua SQL (e SÓ ele).
WEB_WHEREperdeu a cláusula@q(a função SQLfoldfoi removida — busca por palavras morreu de propósito); filtros fonte/período/facetas/verify seguem, ekindagora aceita release (CASE de 3 vias: release = coluna exata; news/tool mantêmisToolByTags, release segue contando como tool no bucket amplo).src/db.js@6b1d77d,src/web.js@6b1d77d(buildSearchParams). Verified:test/web.api.test.js. -
Buckets news vs tool. Mode A/soft/deep usam o
kinddo juiz; Mode B usaisToolByTags; a web em modo IA filtra o Segmented porjudge_kind(paridade com os buckets do CLI).src/taxonomy.js@b7ee2f7. -
2º consumidor da busca: o webapp ESTÁTICO (
webapp/). Buscador Vite+React+Motion (JS puro, deploy Vercel, 100% SEM backend) que lê um SNAPSHOT JSON commitado (webapp/public/data/{meta,articles}.json+contents.partN.json— ocontents.jsonÚNICO morreu ao passar de 100 MB, gerado porncrawl export --format web—src/export-web.js, stmtswebExportArticles/Contents/Tags, sempre o acervo COMPLETO). A busca IA roda no NAVEGADOR (BYOK — OpenRouter ou DeepSeek direto, provedor escolhido no KeyModal; sem servidor nosso):webapp/src/lib/{search,openrouter}.jssão CÓPIA VERBATIM da rubrica/schemas/prompts dejudgeRelevance/judgeRelevanceBatch(src/llm.js) + da fusãomergeBatchVerdicts/chunkBatches(src/search.js), com clamp manual no lugar do zod;webapp/src/lib/filters.jsreproduz oWEB_WHERE. GOTCHA de sincronia: editou a rubrica/relevanceSchema/relevanceBatchSchemanollm.js, a fusão de lote nosearch.js, ou oWEB_WHEREnodb.js? ATUALIZE TAMBÉM a cópia nowebapp/— NÃO há import compartilhado (o browser não roda Node). Modelos/effort/lote/tetos chegam viameta.searchno snapshot: mudouconfig/models.json/thresholds → RE-EXPORTE (senão o site usa o valor antigo).src/export-web.js@1b914d1,webapp/src/lib/search.js@1b914d1. Verified: 204 testes CLI (+test/export-web.test.js, byte-determinismo) + E2E da busca no browser com OpenRouter mockado (KeyModal→soft com badges/custo→confirm profunda→600 chamadas) + build Vite. CONCORRÊNCIA ADAPTATIVA (AIMD) — só faltava no webapp. AasyncPoolfixa (soft 2 / deep 4) virouadaptivePool(webapp/src/lib/pool.js— largura relêgetLimit()a cada folga; aasyncPoolFICOU intacta, travada por teste) lendowebapp/src/lib/lane.js(semáforo módulo-level: começa no tetometa.search.concurrency {soft:6,deep:10}, corta ½ no 429, recupera +1 por 10s limpos — espelha o governor do CLI;openrouter.jsbumpPenaltychamanoteRateLimit()).runSearchtambém streama hits (onHit) + contafailed+ progresso nível-artigo (paridade com a web).meta.search.concurrencyé NOVO no export (src/export-web.js+config.js) — RE-EXPORTE p/ o site deployado ler (defaults 6/10 embutidos no bundle).webapp/src/lib/lane.js@c0047f8,webapp/src/lib/pool.js@c0047f8,webapp/src/lib/search.js@c0047f8,src/export-web.js@c0047f8. Verified:webapp/test/lane.test.js(8 casos: corte/recuperação/piso/ordem/abort) + webapp build. Os FILTROS DE TAG divergem de propósito doWEB_WHERE(SÓ o webapp) — NÃO 'ressincronize' p/ OR. Dentro da faceta o site público faz INTERSEÇÃO (AND: duas tags = itens com AS DUAS;applyFiltersusatags.every, nãosome), e ganhou o que o SQL não tem: contagens de CO-OCORRÊNCIA ao vivo —computeFacetCounts(conjuntoJÁfiltrado)tallya as tags do resultado atual, então cada chip mostra quantos itens do filtro corrente também têm aquela tag (a SELECIONADA = |R|); a UI ordena por essa contagem e DESABILITA as zeradas (chip sem clique). Fluxo:App.jsxcomputafacetCountssobre ofiltered→ Sidebar/FilterDrawer → FilterPanel →FacetGroup.jsx(stringfacetTagUnavailablenos 2 dicts,.chip:disabled).webapp/src/lib/filters.js,webapp/src/components/FacetGroup.jsx. Verified: 48 testes webapp (filters.test.jscobre AND +computeFacetCounts) + build Vite + drive Playwright real (reactjs∩frontend=286 = a co-ocorrência exibida; 3 chips zerados sem clique; repouso idêntico ao total estático). ORDEM DE EXIBIÇÃO + fonte MULTI-SELEÇÃO (as outras duas divergências doWEB_WHERE, SÓ no webapp).sortForDisplay(articles, {mix, sourceName})é data DESC SEMPRE; o desempate DENTRO da data é RODÍZIO alfabético entre fontes, com fila poridASC (= ordem editorial da issue). Por quê: o snapshot é gravado na ordem de COLETA (fonte por fonte), então desempatar poridfazia um dia inteiro sair em BLOCOS (84 itens de uma newsletter, depois 67 de outra).mix:falseagrupa por fonte — é o toggle "misturar fontes" (nc-mix-sources, LIGADO por default). Os hits da busca IA passam pelo MESMO sort só quando a busca TERMINA (aiActive): durante o streaming a ordem é a de CHEGADA DE PROPÓSITO (reordenar ao vivo faz o card pular sob o cursor de quem está lendo); toggle desligado = ordem por relevância. O filtro de fonte ésourceIds: []em UNIÃO/OR (vazio = todas; AND entre fontes daria sempre vazio, fonte é atributo único do artigo) enormalizeScope()converte osourceIdLEGADO dos escopos já salvos (histórico/checkpoint no localStorage).webapp/src/lib/filters.js@562ead3,webapp/src/App.jsx@562ead3. Verified: 66 testes webapp + drive Playwright real (rodízio em 25/07 = llmnews→Node→React→llmnews…, filtro de 2 fontes em união, toggle persistido no reload) + resultado IA CONGELADO reaberto do histórico (zero LLM). -
Histórico de buscas = a busca DEIXOU de ser efêmera (persistência congelada). Toda busca IA concluída é gravada na tabela
searches(SQLite) porpersistSearch(src/search.js, fail-open — histórico NUNCA derruba busca que já custou) no fim derunSearch(CLI/TUI,origincli|tui) esearchWeb(web local,originweb). Guarda LEVE: query/mode/scope/stats + os hits como{id,relation,kind[,bucket]}(NÃO o conteúdo); o custo real vem do JOINrun_id→llm_usage (1 run por busca), não de um campo salvo. Reabrir = ZERO LLM: re-hidrata a ficha de cada hit do acervo (getSearchHistoryEntry/stmtsearchArticlesByIdsna TUI;enrichHitno web viaGET /api/searches/:id), remontando os buckets no MESMO shape derunSearch(ResultsView consome direto) — ids que sumiram (purge) virammissing, contados, nunca quebram. Re-rodar restaura o escopo e passa pela confirmação de custo usual (SearchConfig aceitainitial; webdoSearchaceita overrides explícitos — o setState é assíncrono, então re-rodar NÃO pode depender do estado recém-setado). Web local:GET|DELETE /api/searches[/:id]+ dropdown de recentes no campo + painel Histórico. TUI: telahistory(src/ui/HistoryView.js, um único useInput: Enter abre/rre-roda/dapaga/x×2 limpa). Webapp ESTÁTICO: histórico no NAVEGADOR (webapp/src/lib/history.js, localStorage via storage.js, payload versionado{v:1,items:[novo→antigo]}; auto-save SEM limite, só poda o rabo se a quota do localStorage recusar —trySetHistorydistingue quota de storage-indisponível).src/db.js(tabelasearches+ stmts insert/list/get/delete/clear/searchArticlesByIds),src/commands.js(listSearchHistory/getSearchHistoryEntry/deleteSearchHistory),src/web.js(apiSearches/apiSearchDetail),src/web-ui/app.js,webapp/src/hooks/useAiSearch.js. Verified:test/search-history.test.js+test/ui.history.test.js+webapp/test/history.test.js; e2e no web local com busca soft real (US$0.0004) — persistiu, re-hidratou congelado (missing=0) e apagou. -
API pública dedicada
/api/v1/corpus.json(irmã do snapshot web, desacoplada de propósito). O MESMOexport --format webgera tambémwebapp/public/api/v1/corpus.jsonviasrc/export-api.js(buildPublicApi/exportPublicApi, reusawebExportArticles/Tags+webMeta*; só no destino default docmdExport,!flags.out): 1 arquivo self-contained, acervo COMPLETO, metadados+resumos+tags camelCase +verifyStatus(consumidor filtra), SEM corpo (~5 MB). Contrato v1 aditivo (breaking→/api/v2), docs emwebapp/public/api/v1/{README.md,schema.json}, CORS viawebapp/vercel.json(SÓheaders, escopo/api/(.*)— não mexe no zero-config Vite). Determinístico como o export-web (sógeneratedAtvolátil); mudou o shape → atualize README+schema.src/export-api.js,src/commands.js. Verified:test/export-api.test.js+ export real 3061 (byKind soma, 0 com content) + 299 testes. -
Results to the UI.
cmdSearchRETURNS the results object;RunViewcaptures it and App swaps toResultsView— agora NAVEGÁVEL: seleção ↑/↓ com auto-scroll, Enter → preview (conteúdo completo viagetArticle(id)injetado;stmts.webGetArticle),oabre a URL (openBrowserinjetado comoonOpen), Esc/b volta. Itens de busca trazemsource_name/date_iso(join nos 4 stmts de busca +toItem).renderResults(print CLI) ficou INTOCADO — paridade. Progresso viagetSearchProgress().src/ui/ResultsView.js@6b1d77d,src/commands.js@6b1d77d(getArticle). Verified:test/ui.results.test.js. -
Busca PRECISÃO-PRIMEIRO (spec + estrito) — o soft/deep "entende" a consulta ANTES de julgar.
compileQuerySpec(src/llm.js, stagesearchSpecPro/high — TEM que estar emSTAGE_KEYSde config.js, senãostageModelcai no default Pro/xhigh e IGNORA o models.json) devolve{must_have, nice_to_have, query_en, terms}(critérios OBRIGATÓRIOS/desejáveis + tradução PT→EN; small-output). TODO lote/artigo julga contra o spec viabuildBatchJudgePrompt({query,items,spec})— a FONTE ÚNICA do prompt de lote, que oeval/prompts.mjsIMPORTA (eval == produção; não duplique a rubrica).searchWebcompila o spec 1× (fail-open → query crua), emiteonEvent({type:'spec'})(oweb.jsencaminha como evento SSEspec;apiSearchScopesoma +1 chamada de spec ao custo) e o persiste no histórico (stats.spec). A UI (app.js) tem toggle Estrito(default)/Amplo que RE-FILTRA o MESMO scan sem repagar (sódirectvsdirect∪similar) + banner do "entendimento".searchWebtambém PRIORIZA os pendentes por overlap dos termos EN do spec (prioritizeBySpec, grátis, cross-lingual, varre TUDO — só reordena). Paralelismo agora é ESCOLHÍVEL pelo usuário: paramconcurrency(POST/SSE/scope, clamp[1, SEARCH_UI_CONCURRENCY_CEILING]) →stageWindow(AIMD por baixo);meta.searchexpõe{default,ceiling}. Webapp: mesmo desenho portado emwebapp/src/lib/search.js+ banner noApp.jsx;meta.searchganhousearchSpec/uiConcurrency— RE-EXPORTE. UX do webapp DIVERGE do web-ui do CLI: o Estrito virou switch DESLIGADO por padrão (Amplo =direct∪similar; Estrito opt-in — o default-ligado escondia quase tudo na prática) e os resultados IA não re-paginam nodone(App passadisplayItems.lengthcomovisiblenumérico →ArticleGridrenderiza TODOS, sem encolher/pular ao terminar; browse SQL segue paginado); o fim da busca é apresentado COMO a entrada recém-salva do histórico (frozen+ banner "salvo em…" + item ativo viaactiveIdemHistoryPanel/recentes;history.jspersistespec).webapp/src/App.jsx/useAiSearch.js/history.js. PORTÃO:node eval/run-eval.mjs --batchmede o modo LOTE (antes só media 1-artigo), scoring ESTRITO(direct) vs AMPLO(direct∪similar) + latência (eval/score.mjs); o golden foi REFEITO contra o DB atual (o antigo tinha ids reatribuídos).src/search.js@f98220e,src/llm.js@f98220e,src/web.js@f98220e,src/web-ui/app.js@f98220e,eval/@f98220e. Verified: 245 testes CLI + 40 webapp + busca real emitiuspec; eval ESTRITO precisão 0,75→0,87. -
O EXPORT é o único escritor do snapshot — e o guard anti-encolhimento vive DENTRO dele.
exportWebSnapshotmonta o snapshot INTEIRO em MEMÓRIA, chamaassertSnapshotAllowede só então escreve: baseline = high-water dototals.articlesdo meta.json em até 1000 commits do git (publishedHighWater, lido em lotes) ∪ meta em disco ∪ oliveque o chamador trouxe; publicar MENOS que isso LANÇASnapshotShrinkErrore o outDir fica INTACTO (bloquear depois deixaria os JSONs já esvaziados na árvore). A decisão pura mora emsrc/snapshot-guard.js(evaluateSnapshotChange) e é a MESMA doncrawl deploye do.githooks/pre-push— fail-SAFE ao contrário do resto do projeto: total ilegível BLOQUEIA. Escrita ATÔMICA (tmp por arquivo + rename;meta.jsonpromovido POR ÚLTIMO, porque é ele o baseline — promovê-lo antes envenenaria o high-water numa escrita interrompida). Tetos DUROS:articles.json≤ 95 MB (blob > 100 MB = GH001 no push) e o contents fatiado emcontents.partN.jsoncom o índice emmeta.contentsParts[{file,from,to}]. Opt-in explícito:--allow-shrink/--allow-shrink wipe— com ESPAÇO, NUNCA=(o parseFlags de src/index.js não quebra em=e a hint viraria um loop de bloqueio); no hook éNC_ALLOW_SHRINK=1|wipe, que ele REPASSA ao export.src/export-web.js@7ab2080,src/snapshot-guard.js@a3c59b1,.githooks/pre-push@7ab2080. Verified:test/export-guard.test.js,test/export-web.atomic.test.js,test/export-cli.allow-shrink.test.js,test/pre-push.no-bootstrap.test.js. -
issue_url+blurbviajam no snapshot E na API v1 (proveniência p/ o RESTORE).stmts.webExportArticlestraz os dois numa query só (a varredura fonte-a-fonte anterior PERDIA o artigo comsource_idNULO — exatamente o que um restore produz). Noarticles.jsonvale a regra anti-duplicação: artigo COM blurb ⇒blurbcompleto +snippet:null; SEM blurb ⇒snippet+blurb:null(o webapp refaz o snippet emhydrateSnippet; poupou 2,1 MiB por deploy). Na API v1 oblurbsai SEMPRE (contrato público não pode ter campo condicional) comoissueUrl/blurb, documentado em README+schema como ADITIVO. Sem esses campos o restore não alimentarestorePage/isUrlKnowne a 1ª coleta re-cura ~745 issues por IA. ATENÇÃO: oarticles.jsonCOMMITADO hoje ainda é do exportador antigo (0issue_url) — o gap fecha a cada crawl+deploy; mudou o shape do snapshot/API? atualizewebapp/public/api/v1/{README.md,schema.json}e o leitorwebapp/src/lib/data.js.src/export-web.js@7ab2080,src/export-api.js@7ab2080,src/db.js:586-594@7ab2080.
On completion, update this skill only for an important, externally-verified change (a green npm test + a live search/web run). See meta-skill-evolution.