Lição 11 · Curso de Fusão · Mergulhos profundos · Web profunda
Alembic × Hermes · O Curso de Fusão · Mergulho profundo · subsistema 5 de 7

Web profunda — webSearch / webExtract, ports sobre fetch

Duas ferramentas — buscar na web, extrair uma página — construídas como um kernel de despacho fino sobre um WebBackend injetado, com um seam Compressor opcional para resumo via 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 devolve um Result. O backend de produção é uma implementação fetch sem dependências. É um CLONE do web_tools.py do Hermes (1378 linhas).

Leia primeiro (fonte primária)
packages/hermes/src/web/ — types.ts, web.ts, fetch-backend.ts, web.test.ts

Esta lição destila o subsistema web do @alembic/hermes ancorado linha a linha no código real do repositório. A proveniência do CLONE está em docs/hermes-complete-map.md §3.1. Nada aqui é inventado: cada afirmação é citável de arquivo ou de teste.

Leia a versão simples, ou abra a camada técnica em qualquer seção.
O que você vai conseguir fazer
  • Explicar por que o módulo web não importa nenhum SDK e nenhum backend concreto — só dois ports: WebBackend e Compressor.
  • Descrever a defesa em profundidade: o backend fino mapeia de forma defensiva, e o kernel re-valida cada linha com Zod, falhando tudo se uma linha for inválida.
  • Distinguir vazio (ok([])) de erro (err) — e por que confundir os dois esconde uma queda real do provider.
  • Dizer quando o Compressor roda: só quando existe e o conteúdo cruza o piso DEFAULT_COMPRESS_MIN_LENGTH = 5000.
  • Apontar por que createFetchBackend nunca abre um socket nos testes — o fetch é um campo injetável.
Suposições tolas (o que presumimos de você — bem pouco)
  • Você sabe que "buscar na web" significa mandar uma consulta e receber uma lista de resultados (título, link, trechinho).
  • Você lembra do tipo Result das lições anteriores: ou ok(valor), ou err(erro) — nunca uma exceção atravessando a fronteira.
  • Você não precisa saber o que é Zod, port, ou injeção de dependência. A gente constrói cada termo aqui, do zero.
1

A grande ideia


A web é o mundo lá fora: caótica, não confiável, às vezes fora do ar. A sabedoria deste subsistema é tratar tudo que vem de fora como suspeito — e deixar o contrato (o que é uma "linha de busca válida") na mão de uma camada só, o kernel.

Imagine duas ferramentas que um agente usa: buscar ("o que existe sobre X?") e extrair ("me dá o texto desta página"). O ingênuo costura cada uma direto numa biblioteca de busca. Aqui, em vez disso, as duas falam com um port — um buraco em forma de tomada onde você pluga qualquer implementação: um fetch de verdade em produção, um dublê (fake) nos testes. O código que você vai ler não conhece nenhum provider específico.

Pense como… a recepção de um prédio. A recepcionista (o kernel) não conhece nenhum entregador pessoalmente; ela tem uma regra de quem pode subir (o contrato). Qualquer entregador (o backend) pode aparecer — o que importa é passar pela regra. Trocar de transportadora não muda a recepção. A analogia quebra num ponto: a recepcionista confia no crachá; aqui o kernel re-confere tudo, mesmo o que o entregador jurou já ter conferido.

Por baixo do capô

O Hermes expõe web_search / web_extract sobre um registry de backends de provider intercambiáveis (Exa / Firecrawl / Parallel / Tavily / …), cada um implementando um par search()/extract(), mais uma passada opcional de LLM que comprime o conteúdo cru (process_content_with_llm, com piso DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION). Veja o cabeçalho de packages/hermes/src/web/types.ts.

Esta é a estrutura daquele subsistema portada para o estilo ports-and-injection do motor — não uma integração de API ao vivo. Os dois seams viram dois ports injetados. O cascade de auto-detecção de 7 backends, o split por capacidade, a descoberta de plugins, o log de debug-session, o filtro de SSRF/URL secreta e o registro de tool-schema OpenAI ficam de fora: são a camada de tool/segurança/transporte ainda não conectada. A saída não confiável do backend é validada com Zod na fronteira, no lugar dos normalizadores de resposta em Python; todo caminho falível é um Result<T, Error>, nunca lançado.

o mundo lá fora é não confiável — o kernel é a fronteira que o domestica A WEB ✱ JSON de formas variadas ✱ campos faltando, urls tortas ✱ provider cai sem avisar O KERNEL webSearch · webExtract valida na fronteira ok(linhas válidas) err (fail-closed)
A web entra suja por um port; o kernel é a única fronteira que decide o que sai limpo — ou recusa.
Pipeline horizontal em duas fronteiras: JSON nao confiavel entra no fetch backend fino que mapeia defensivamente, segue para o kernel que re-valida cada linha com Zod, e termina em ok validado ou err fail-closed

As duas fronteiras do subsistema: o backend fino mapeia de forma defensiva; o kernel valida com Zod e é o dono do contrato.

0
ferramentas no kernel (search + extract)
0
ports injetados (WebBackend + Compressor)
0
SDKs/backends concretos importados pelo kernel
0
testes que provam o comportamento
CLONE — a estrutura da fonte, não uma cópia literal nem uma integração ao vivo Hermes · web_tools.py 1378 linhas · registry de 7 providers + process_content_with_llm portado p/ ports @alembic/hermes · web/ kernel + 2 ports + fetch backend Zod na fronteira · Result em tudo
A proveniência (docs/hermes-complete-map.md §3.1): as 1378 linhas viram a estrutura portada — o cascade de 7 backends, SSRF e tool-schema ficam de fora, na camada ainda não conectada.
2

Dois ports, um kernel


A fonte se constrói em torno de dois seams — um registry de providers e uma passada opcional de compressão por LLM. Ambos viram ports injetados; este módulo importa nenhum SDK e nenhum backend concreto:

packages/hermes/src/web/types.ts
export interface WebBackend {
  /* ok([]) p/ nenhum hit · err p/ falha de provider/rede */
  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 cru
Por que um "port"? Um port é só uma interface: um contrato de "quem encaixa aqui precisa ter estes métodos". O kernel depende do contrato, não da implementação. Em produção você pluga um fetch real; nos testes, um dublê que devolve Results fixos. O mesmo kernel roda contra os dois sem mudar uma linha.
WebBackend ↔ o registry de providers
WebBackend qualquer um destes encaixa: fetch (prod) fake (teste) outro provider o kernel não conhece nenhum deles
Compressor ↔ process_content_with_llm
conteúdo cru Compressorpresente? resumo ausente ⇒ devolve cru

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 o seam ReviewProposer do loop de aprendizado: "uma chamada de modelo em produção, um fake nos testes".

a linha de busca é aparada — e description vira snippet linha do Hermes (Python) title · url · description · position 4 campos por hit aparada + renomeada WebSearchResult (motor) title · url · snippet description ⇒ snippet · position descartado
O dispatcher devolve só o essencial; o description da fonte vira o idiomático snippet — um teste fixa snippet:'d' vindo de description:'d'.
o error por-URL vira o err da própria linha — nunca um campo inline o anti-padrão { url, title, content, error? } uma falha se passa por "content vazio" o padrão do motor Result<{ url, title, content }> falha = err da linha · sucesso = núcleo limpo
O WebExtractResult guarda só o núcleo de sucesso { url, title, content }; uma extração falha é o err da linha, então nunca se disfarça de conteúdo vazio.
Preveja antes de virar

O Compressor recebe text e instruction e devolve Promise<Result<string, Error>>. Se a chamada de modelo der erro (timeout do resumidor), o que webExtract deveria devolver — o conteúdo cru como plano B, ou propagar a falha?

Propaga a falha (err), fail-closed. Em maybeCompress, if (!compressed.ok) return compressed; — uma compressão que falhou vira err, não "devolve o cru por garantia". Devolver o cru em silêncio esconderia que o resumo que o chamador pediu não aconteceu. O teste "propagates a compressor error as err (fail closed)" fixa exatamente isto.
3

Defesa em profundidade: mapeia defensivamente, depois re-valida


O fluxo tem duas fronteiras. O backend fetch mapeia o JSON não confiável de forma defensiva (formas desconhecidas → strings vazias); depois o kernel re-valida cada linha com Zod, falhando a chamada inteira no primeiro erro:

backend fetch POST · mapeia linhas JSON NÃO CONFIÁVEL entra webSearch / webExtract Zod re-valida · clamp · comprime Result<linhas> ou err (1 linha ruim) linhas aparadas o backend nunca lança em lixo · o kernel recusa lixo estruturalmente inválido (ex.: uma url que não é URL)
Duas camadas, uma responsabilidade cada: o backend lifta falhas de transporte e mapeia; o kernel é o dono do contrato.
packages/hermes/src/web/web.ts — 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 não confiável do backend
  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 de propósito" — ele lifta falhas de transporte e mapeia payloads, mas não é o dono do contrato. O kernel é. Então um backend que devolve uma linha com uma url que não é URL (que o mapeador defensivo passa adiante feliz, como string) ainda é recusado pelo z.string().url() do webSearchResultSchema. Dois testes fixam isto: uma url faltando e uma url não-URL produzem ambos "Invalid web search result".
packages/hermes/src/web/types.ts — o contrato que o kernel impõe
export const webSearchResultSchema = z.object({
  title: z.string(),                       // pode ser vazio
  url: z.string().url('result url must be a valid URL'), // ⇐ o portão
  snippet: z.string(),                     // o `description` da fonte, renomeado
});
Dica"Defesa em profundidade" é um termo de segurança: você não confia em uma barreira. O backend coa o grosso (formas desconhecidas viram vazio) e o kernel coa o fino (a url precisa ser uma URL de verdade). Se uma barreira falhar, a outra ainda segura.
for (raw of linhas) — uma ruim aborta TODA a chamada (fail-closed) linha 1 linha 2 linha 3 safeParse por linha todas válidas ⇒ ok(validated) 1 ruim ⇒ return erraborta no ato, não termina o laço
O return err dentro do laço aborta na hora — não há "valida o resto e junta o que deu". Tudo passa, ou nada passa.
duas camadas, UM trabalho cada backend (fino) ✓ lifta falha de transporte ⇒ err ✓ mapeia payload · ✗ NÃO valida contrato kernel (dono do contrato) ✓ valida cada linha com Zod ✓ clamp · compressão · ✗ NÃO toca a rede
A divisão de trabalho é o que deixa o mesmo kernel rodar contra qualquer backend — fake, fetch, ou um provider futuro.
4

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


maxResults é "clampado" para [1, 100] antes de o backend ver — o min(max(limit, 1), 100) da fonte:

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

O teste "clamps maxResults into [1, 100]" manda 9999 e 1 e afirma que o backend vê exatamente [100, 1].

min(max(maxResults, 1), 100) — tudo é trazido para a régua 1 100 50 50 fica 9999 0 undefined ⇒ passa adiante, sem clamp
Acima de 100 cai para 100; abaixo de 1 sobe para 1; no meio fica; ausente (undefined) passa sem tocar.
Tres paineis: vazio ok([]) versus erro provider caido; o clamp de maxResults para o intervalo 1 a 100; e o piso de 5000 caracteres que decide pular ou chamar o Compressor

Três decisões que o tipo Result preserva: vazio ≠ erro, o clamp [1,100], e o piso de compressão.

Um conjunto de resultados vazio é ok([]), não um erro — "nenhum hit" é uma busca bem-sucedida, distinta de "o provider caiu" (err):

nenhum hit ok([]) busca bem-sucedida e vazia provider caiu err falha de rede/provider
Confundir os dois esconde uma queda real atrás de "sem resultados". O tipo não deixa.
Exemplo resolvido — o caminho de uma consulta com maxResults: 9999
1
webSearchQuerySchema.safeParse({ query: 'gatos', maxResults: 9999 })sucesso (a query não é vazia, maxResults é inteiro positivo).
2
clampQuery roda: min(max(9999, 1), 100) = 100. O backend recebe maxResults: 100, nunca 9999.
3
O backend devolve ok([]) (nenhum gato hoje). found.ok é true, o laço de validação não tem o que iterar.
4
webSearch devolve ok([])sucesso vazio. Quem chamou ramifica em result.ok e mostra "nenhum resultado", sem confundir com um erro.
5
Agora você: e se a consulta fosse { query: '' } (vazia)? Acompanhe o passo 1 — onde o fluxo para, e o backend chega a ser chamado? (Pista: o teste "rejects an empty query… before calling the backend" afirma called === false.)
query vazia ⇒ falha ANTES de o backend ser chamado { query: '' }entrada querySchema.safeParsemin(1) falha err (Invalid … query)backend nunca é tocado (called === false) backend.search (não alcançado)
A validação de entrada vem primeiro: uma query vazia nunca gasta uma chamada de backend — o teste afirma called === false.
5

Compressão opcional — barrada por um piso de tamanho


Para extrações, um Compressor injetado pode encolher páginas grandes para economizar tokens. Ele é 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 — maybeCompress
const minLength = deps.compressMinLength ?? DEFAULT_COMPRESS_MIN_LENGTH; // 5000
if (compressor === undefined || result.content.length < minLength) {
  return ok(result);          // sem compressor / pequeno demais ⇒ cru
}
const compressed = await compressor(result.content, instruction);
if (!compressed.ok) return compressed; // falha do compressor ⇒ err (fail-closed)
return ok({ ...result, content: compressed.value });
conteúdo extraído sem compressor⇒ ok(cru) tamanho < 5000⇒ ok(cru) existe E ≥ 5000⇒ chama o modelo
Só uma porta leva à chamada de modelo: o compressor existe e o conteúdo cruza o piso. As outras duas devolvem o cru.

O piso é configurável

O piso padrão é DEFAULT_COMPRESS_MIN_LENGTH = 5000, mas deps.compressMinLength sobrescreve. O teste "honours a custom compressMinLength" passa compressMinLength: 4 e um conteúdo de 4 chars ('abcd') — agora o piso é cruzado e o compressor roda, devolvendo 'C'. Já o teste "skips the compressor below the size floor" passa um conteúdo 'tiny' com o piso padrão e afirma called === false e content === 'tiny': o compressor nem é chamado.

A instrução padrão entregue ao compressor é DEFAULT_COMPRESS_INSTRUCTION = "Summarize this web page, preserving all key facts, quotes, and code verbatim." — sobrescrevível por deps.compressInstruction. O teste "applies the compressor when content clears the size floor" afirma que a instrução vista tem comprimento > 0.

SituaçãoCompressor roda?Resultado
Sem compressor injetadonãook conteúdo cru
Conteúdo < piso (5000)nãook conteúdo cru
Compressor + conteúdo ≥ pisosimok conteúdo comprimido
Compressor falha (timeout)simerr (fail-closed)
6

O backend de produção — zero dependências, zero sockets nos testes


createFetchBackend 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 dependência nova. O endpoint e a chave 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 falso e exercitam todo o mapeamento de pedido/resposta sem uma chamada de rede (espelhando a injeção de idFactory em clarify). Uma exceção de rede, um status não-2xx e um JSON impossível de parsear viram cada um err — provado por três testes de transporte. E o rename é real: o description da fonte vira o idiomático snippet em exatamente um lugar, com um teste afirmando snippet:'d' a partir de description:'d'.

packages/hermes/src/web/fetch-backend.ts — fetch injetável + transporte fail-closed
const doFetch = config.fetch ?? (globalThis.fetch as unknown as FetchLike); // padrão = global

const postJson = async (doFetch, url, headers, body) => {
  const responded = await tryCatchAsync(() => doFetch(url, { method: 'POST', headers, body }));
  if (!responded.ok) return responded;          // throw de rede ⇒ err
  const response = responded.value;
  if (!response.ok)                              // status não-2xx ⇒ err
    return err(new Error(`web backend HTTP ${response.status} …`));
  return tryCatchAsync(() => response.json());    // JSON inválido ⇒ err
};
mapSearchRows — defensivo: forma desconhecida vira string vazia payload bruto results[] (ou web[]) { title, url, ... } description ← legado asString / asArray linha aparada title: asString(...) url: asString(...) snippet: snippet ?? desc → ao kernel
O backend lê results ou web, puxa title/url/snippet (com fallback para description) e nunca lança — formas desconhecidas colapsam para string vazia. A validação final é do kernel.
CuidadoO backend mapear descriptionsnippet e devolver string vazia para um campo ausente não é validação — é normalização defensiva. A garantia ("a url é mesmo uma URL?") só acontece no kernel. Não confunda "mapeou sem quebrar" com "validou".
postJson — três modos de falha de transporte, todos liftados para err doFetch(url, …) throw de rede ⇒ err status ≠ 2xx ⇒ err JSON inválido ⇒ err
tryCatchAsync embrulha cada caminho; três testes (throw de rede, HTTP 503, JSON impossível) fixam que nenhum atravessa a fronteira como exceção.
packages/hermes/src/web/fetch-backend.ts — determinismo
// sem Date.now() / Math.random() (a plan VM do motor proíbe ambos);
// o pedido não carrega nonce nem timestamp.

Por isso o backend roda dentro de um alembic.plan.ts sem disparar o detector de não-determinismo: a mesma entrada produz o mesmo pedido, sempre.

determinismo — mesma entrada, mesmo pedido, sempre { query, url } monta o bodysem nonce · sem timestamp POST idêntico ✗ Date.now() ✗ Math.random()
A plan VM do motor proíbe Date.now() e Math.random(); o pedido não carrega nonce nem timestamp, então é reproduzível.
7

Experimente — o veredito do kernel, linha por linha


O backend devolveu uma linha. O kernel a re-valida. Escolha a linha e veja o veredito — e por que ele falha ou passa:

o que o backend devolveu
{ title: "Exemplo",
  url: "https://example.com",
  snippet: "um resultado" }
ok

Passa. A linha tem os três campos e a url é uma URL válida — webSearchResultSchema.safeParse sucede e a linha entra em validated.

Guarde istoRepare: o caso "url faltando" e o caso "not-a-url" produzem a mesma mensagemInvalid web search result — e uma linha ruim falha a chamada inteira. O kernel não conserta, não descarta em silêncio: ele fecha a porta.

Simples ↔ Técnico: a mesma ideia, duas alturas

Alterne entre a explicação leiga e a precisa. Use a aba "Técnico" quando quiser os nomes reais; a "Simples" quando quiser a intuição.

Em linguagem de gente: o subsistema web é um porteiro com uma regra clara. Qualquer entregador pode trazer o pacote (o backend), mas o porteiro (o kernel) confere a etiqueta de cada um — e se uma etiqueta está rasgada, ele devolve o lote inteiro em vez de deixar entrar coisa duvidosa. E há um tradutor opcional na portaria (o compressor) que só é chamado para documentos grandes, porque traduzir um bilhete de uma linha é desperdício.
Com os termos reais: webSearch faz webSearchQuerySchema.safeParseclampQuery [1,100] → backend.search → laço de webSearchResultSchema.safeParse por linha (1 falha ⇒ err). webExtract faz backend.extractwebExtractResultSchema.safeParsemaybeCompress (gated por compressMinLength ?? DEFAULT_COMPRESS_MIN_LENGTH). O backend de produção é createFetchBackend sobre globalThis.fetch com tryCatchAsync liftando throw/não-2xx/JSON-inválido para err. Nenhum SDK; fetch injetável.

Cartões de memória

Vire cada cartão (clique, ou Enter/Espaço) e tente responder antes de ver o verso. É prática de recuperação — vale mais que reler.

Ports
Quais dois ports o módulo web define?
clique para virar
WebBackend (par search/extract, o registry de providers) e Compressor (opcional, a passada de LLM). O kernel importa nenhum SDK concreto.
Defesa
Por que re-validar o que o backend já mapeou?
clique para virar
O backend é fino e nunca lança em lixo; o kernel é o dono do contrato. Uma url não-URL passa pelo mapeador mas é recusada pelo .url() do Zod.
Tipo
Como "nenhum hit" difere de "provider caiu"?
clique para virar
Nenhum hit é ok([]) (sucesso vazio); o provider caiu é err. Conflatá-los esconderia uma queda real atrás de "sem resultados".
Compressão
Quando o Compressor roda?
clique para virar
Só quando ele existe E o conteúdo é ≥ DEFAULT_COMPRESS_MIN_LENGTH (5000). Sem ele, ou abaixo do piso, devolve o conteúdo cru.
Clamp
O que acontece com maxResults: 9999?
clique para virar
min(max(9999, 1), 100) = 100. O clamp roda antes de o backend ver; o teste afirma que o backend vê [100, 1].
Testes
Por que createFetchBackend não abre socket nos testes?
clique para virar
O fetch é um campo de config injetável (padrão = global). Os testes passam um fetch falso e exercitam o mapeamento sem rede.
8

Recapitulando em 6 slides


A virada de chave

Dois ports, um kernel

Buscar e extrair falam com um WebBackend injetado e um Compressor opcional. O kernel não importa SDK nem backend concreto.

A regra de ouro

Defesa em profundidade

O backend fino mapeia de forma defensiva; o kernel re-valida cada linha com Zod. Uma linha ruim falha tudo — fail-closed, nunca lança.

A distinção

Vazio não é erro

Nenhum hit é ok([]) (sucesso vazio); o provider caiu é err. O tipo carrega a diferença que o código não pode perder.

O limite

O clamp [1, 100]

maxResults é clampado por min(max(n, 1), 100) antes de o backend ver. 9999 vira 100; ausente passa adiante.

A economia

O piso da compressão

O Compressor só roda se existe e o conteúdo cruza DEFAULT_COMPRESS_MIN_LENGTH = 5000. Comprimir conteúdo pequeno desperdiça uma chamada.

Para a próxima

Skills profundas

A lição 12 entra no subsistema de skills: como o motor descobre, valida e usa habilidades — o próximo CLONE do Hermes na trilha dos mergulhos profundos.

1 / 6setas
9

Confusões comuns


"Isto é uma integração web ao vivo." Não — é a estrutura da fonte portada para ports-and-injection, não um provider conectado. O kernel importa nenhum SDK; o único módulo que toca a rede é createFetchBackend, e mesmo ele recebe um fetch injetável para que os testes nunca abram um socket.
"Validação dupla é desperdício." É camadas deliberadas: o backend fica fino e nunca lança em lixo; o kernel é o dono do contrato e recusa dados estruturalmente inválidos. Cada camada tem um trabalho — e essa divisão é o que deixa o mesmo kernel rodar contra qualquer backend.
"Uma linha ruim deveria ser descartada, não derrubar tudo." O kernel escolhe fail-closed: 1 ruim ⇒ falha tudo. Descartar em silêncio mascararia um backend mal-comportado; falhar a chamada inteira torna o problema visível e força o conserto na fonte certa.
Pergunta para levar adiante: o WebExtractResult guarda só o núcleo de sucesso { url, title, content } — o error por-URL da fonte vira o err da própria linha, não um campo inline. Por que isso é melhor do que um campo error?: string dentro de uma linha "de sucesso"? (Pista: uma extração que falhou nunca pode se passar por "conteúdo vazio".)
10

Verifique seu entendimento


Revisão acumulada — 3 perguntas

Responda as três; o placar abaixo conta seus acertos.

1. O backend devolve uma linha cuja url é a string "not-a-url". O que webSearch retorna?
Correto: b. Defesa em profundidade: o backend fino mapeia, mas o kernel é o dono do contrato e re-valida cada linha. Uma 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 provider?
Correto: d. O tipo preserva a distinção: um conjunto vazio é sucesso, uma falha de backend/rede é falha. Conflatá-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 devolve 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.
Acertos: 0/3
As cinco verdades desta lição
  1. O kernel web importa nenhum SDK e nenhum backend concreto — só os ports WebBackend e Compressor.
  2. Duas fronteiras: o backend fino mapeia de forma defensiva; o kernel re-valida com Zod e é o dono do contrato.
  3. ok([]) (vazio) ≠ err (queda do provider) — e uma linha inválida falha a chamada inteira, fail-closed.
  4. O Compressor roda só se existe e o conteúdo cruza o piso 5000; uma falha dele propaga como err.
  5. createFetchBackend é zero-dependência e injeta o fetch — os testes provam transporte e mapeamento sem abrir um socket.

Fontes (lidas verbatim no repositório)