Curso / Lição 11  ·  EN
Lição 11 · Mergulho profundo · subsistema 5 de 7

webSearch / webExtract — portas sobre fetch

Duas ferramentas — buscar na web, extrair uma página — construídas como um kernel de despacho fino sobre uma porta WebBackend injetada, com uma costura opcional Compressor para sumarização LLM que economiza tokens. O kernel não resolve nada sozinho: valida o pedido, chama o backend injetado, re-valida o resultado não confiável com Zod, e retorna um Result. O backend de produção é uma impl fetch sem dependências. Um CLONE do web_tools.py do Hermes (1378 LOC).

Duas portas, um kernel

A fonte se constrói ao redor de duas costuras — um registro de provedores e um passo opcional de compressão LLM. Ambas viram portas injetadas; este módulo não importa nenhum SDK e nenhum backend concreto:

// packages/hermes/src/web/types.ts:120-140
export interface WebBackend {
  search(query: WebSearchQuery): Promise<Result<readonly WebSearchResult[], Error>>;
  extract(url: string): Promise<Result<WebExtractResult, Error>>;
}
export type Compressor = (
  text: string, instruction: string,
) => Promise<Result<string, Error>>;        // opcional; ausente ⇒ conteúdo bruto

Uma falha de rede é err; uma busca vazia é ok([]) — a diferença é preservada no tipo, exatamente como o data.web vazio da fonte. O Compressor espelha a costura ReviewProposer do loop de aprendizado: "uma chamada de modelo em prod, um fake em testes".

Defesa em profundidade: mapear defensivamente, depois re-validar

O fluxo tem duas fronteiras. O backend fetch mapeia JSON não confiável defensivamente (formas desconhecidas → strings vazias); depois o kernel re-valida cada linha com Zod, falhando a chamada inteira fechada numa única linha ruim:

backend fetch POST · mapear linhas JSON NÃO CONFIÁVEL entra webSearch / webExtract Zod re-valida · clamp · comprime Result<rows> ou err (1 linha ruim) linhas aparadas backend nunca lança no lixo · kernel rejeita lixo estruturalmente inválido (ex.: um url não-URL)
// packages/hermes/src/web/web.ts:69-90 — webSearch
const parsedQuery = webSearchQuerySchema.safeParse(query);
if (!parsedQuery.success) return err(new Error(`Invalid web search query: …`));
const found = await deps.backend.search(clampQuery(parsedQuery.data)); // clamp [1,100]
if (!found.ok) return found;
const validated: WebSearchResult[] = [];
for (const raw of found.value) {
  const parsed = webSearchResultSchema.safeParse(raw);   // linha de backend não confiável
  if (!parsed.success) return err(new Error(`Invalid web search result: …`)); // 1 ruim ⇒ falha tudo
  validated.push(parsed.data);
}
return ok(validated);
Por que re-validar o que o backend já mapeou? O backend é "fino por design" — ele eleva falhas de transporte e mapeia payloads, mas não é dono do contrato. O kernel é. Então um backend que retorna uma linha com um url não-URL (que o mapeador defensivo alegremente passa adiante como string) ainda é rejeitado pelo z.string().url() do webSearchResultSchema. Dois testes fixam isto: um url ausente e um url não-URL ambos produzem "Invalid web search result".

O clamp, e a distinção vazio-vs-erro

maxResults é limitado a [1, 100] antes do backend ver — o min(max(limit, 1), 100) da fonte:

// packages/hermes/src/web/web.ts:134-139 — clampQuery
const clampQuery = (query) => {
  if (query.maxResults === undefined) return query;   // ausente ⇒ passa direto
  const clamped = Math.min(Math.max(query.maxResults, 1), 100);
  return { ...query, maxResults: clamped };
};

O teste "clamps maxResults into [1, 100]" envia 9999 e 1 e afirma que o backend vê exatamente [100, 1]. Um conjunto de resultados vazio é ok([]), não um erro — "sem resultados" é uma busca bem-sucedida, distinta de "o provedor caiu" (err).

Compressão opcional — limitada por um piso de tamanho

Para extracts, um Compressor injetado pode encolher páginas grandes para economizar tokens. É pulado abaixo de um piso de tamanho (comprimir conteúdo pequeno desperdiça uma chamada de modelo) e ausente por padrão:

// packages/hermes/src/web/web.ts:118-132 — maybeCompress
const minLength = deps.compressMinLength ?? DEFAULT_COMPRESS_MIN_LENGTH;  // 5000
if (compressor === undefined || result.content.length < minLength) {
  return ok(result);                            // sem compressor / pequeno demais ⇒ bruto
}
const compressed = await compressor(result.content, instruction);
if (!compressed.ok) return compressed;          // falha do compressor ⇒ err (falha fechado)
return ok({ ...result, content: compressed.value });
O backend de produção tem zero dependências — e nunca abre um socket nos testes

createFetchBackend fala uma API JSON genérica sobre o fetch global do Node (Node 18+) — sem node-fetch, sem SDK, sem dep nova. O endpoint e a key são injetados (nunca hardcoded), e o próprio fetch é um campo de config injetável com padrão no global. Então os testes passam um fetch fake e exercem o mapeamento request/response inteiro sem uma chamada de rede (espelhando a injeção de idFactory no clarify). Um throw de rede, um status não-2xx, e JSON não parseável cada um vira err — provado por três testes de transporte. E o rename de campo é real: o description da fonte mapeia para o snippet idiomático do motor em exatamente um lugar, com um teste afirmando snippet:'d' a partir da entrada description:'d'.

1. O backend retorna uma linha cujo url é a string "not-a-url". O que webSearch retorna?
Correto: b. Defesa em profundidade: o backend fino mapeia payloads defensivamente, mas o kernel é dono do contrato e re-valida cada linha. Um url não-URL falha o z.string().url(), e uma linha ruim falha a chamada inteira (nunca lança).
2. Uma busca não encontra nada. Como isso é representado, e como difere de uma queda do provedor?
Correto: d. O tipo preserva a distinção: um conjunto de resultados vazio é sucesso, uma falha de backend/rede é falha. Confundi-los esconderia uma queda real atrás de "sem resultados".
3. webExtract recebe uma página de 3000 chars e um Compressor (piso padrão 5000). O que acontece?
Correto: c. maybeCompress retorna a linha inalterada quando não há compressor ou o conteúdo está abaixo de compressMinLength (padrão DEFAULT_COMPRESS_MIN_LENGTH = 5000). Comprimir conteúdo minúsculo desperdiça uma chamada de modelo.

Confusões comuns

"Isto é uma integração web ao vivo." Não — é a ESTRUTURA da fonte portada para portas-e-injeção, não um provedor plugado. O kernel não importa SDK; o único módulo que toca a rede é o createFetchBackend, e mesmo ele recebe um fetch injetável para que os testes nunca abram um socket.
"Dupla validação é desperdício." É camada deliberada: o backend fica fino e nunca lança no lixo; o kernel é dono do contrato e rejeita dados estruturalmente inválidos. Cada camada tem um trabalho, e a divisão é o que deixa o mesmo kernel rodar contra qualquer backend.