Pular para o conteúdo principal
Agentes de pesquisa são úteis quando você quer mais do que um único resultado de busca ou uma resposta rápida de modelo. Um bom agente de pesquisa pode transformar um tópico amplo em queries de busca, coletar fontes, extrair as evidências importantes, fazer follow-up sobre lacunas e escrever um briefing citado que você pode inspecionar depois. Neste tutorial, construiremos um agente de pesquisa privado usando Python e a API Venice. Ao final, você terá um CLI que pode pesquisar um tópico, fazer scrape de páginas públicas para Markdown, sumarizar chunks de fontes, executar passos de pesquisa de follow-up cientes de lacunas e gerar um relatório citado com artefatos JSONL locais opcionais. Interessado na implementação completa do código? Confira o repositório no GitHub. Antes de continuarmos, você precisará de uma chave de API Venice:
export VENICE_API_KEY=<my-key>

O que vamos construir

A implementação de referência é um pequeno projeto Python com algumas partes claras:
ParteO que faz
CLIAceita um tópico de pesquisa, modelo, provedores, configurações de profundidade, caminho de saída e diretório de artefatos
Cliente VeniceChama chat completions, streaming chat completions e POST /augment/scrape
Camada de buscaPesquisa no DuckDuckGo por padrão, com descoberta opcional de papers do arXiv
Modelos de dadosRastreia URLs de origem, URLs canônicas, chunks, evidências, notas, erros e relatórios
Agente de pesquisaPlaneja buscas, lê fontes, extrai evidências, analisa lacunas, gera queries de follow-up e escreve o relatório final
Escritor de artefatosArmazena registros JSONL auditáveis para queries, lacunas de pesquisa, resultados, fetches, chunks, notas de origem, rascunhos de relatório, erros e relatórios
O fluxo se parece com isto: Pipeline do agente de pesquisa privado
  1. Pedir à Venice para gerar queries de busca diversas para o tópico.
  2. Pesquisar na web com um ou mais provedores.
  3. Deduplicar URLs antes de lê-las.
  4. Usar o endpoint de scrape da Venice para transformar cada página de origem pública em Markdown.
  5. Dividir páginas longas em chunks.
  6. Pedir à Venice para extrair evidências de cada chunk.
  7. Pedir à Venice para transformar evidências de chunks em notas de origem.
  8. Identificar lacunas de pesquisa e problemas de equilíbrio de origens antes de gerar queries de follow-up.
  9. Pedir à Venice para sintetizar o relatório final com citações no estilo de notas de rodapé.
Isso é “privado” no sentido prático de que o agente mantém a orquestração, notas de origem, artefatos e relatórios finais na sua máquina. A Venice lida com as chamadas de modelo e o scraping através de sua API. A implementação de referência padrão ainda envia queries de busca ao DuckDuckGo ou arXiv, então trate a escolha do provedor como parte do seu design de privacidade.

Configurando o projeto

O projeto de referência usa Python 3.13 e uv, mas o mesmo código funciona com um ambiente virtual normal também. Crie um novo projeto:
mkdir venice-research-agent
cd venice-research-agent
uv init
Instale as dependências:
uv add httpx beautifulsoup4 python-dotenv
Se preferir pip, crie um ambiente virtual e instale os mesmos pacotes:
python -m venv .venv
source .venv/bin/activate
pip install "httpx>=0.28.0" "beautifulsoup4>=4.13.0" "python-dotenv>=1.0.0"
Crie um arquivo .env para desenvolvimento local:
VENICE_API_KEY=your_venice_api_key_here
VENICE_MODEL=openai-gpt-55
Usamos VENICE_MODEL para que você possa alterar o modelo sem editar código. A implementação de referência atualmente usa openai-gpt-55 como padrão, mas você pode trocá-lo por outro modelo de chat disponível na sua conta Venice.

Criando os modelos de dados

Antes de escrever a lógica do agente, definiremos os objetos que se movem pelo pipeline. Esses modelos mantêm o resto do código mais fácil de raciocinar porque cada fonte carrega proveniência: de onde veio, qual query a encontrou, quando foi recuperada e como foi dividida em chunks. Crie research_agent/models.py:
from __future__ import annotations

import hashlib
from dataclasses import dataclass, field
from datetime import UTC, datetime
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse

TRACKING_PARAMS = {
    "fbclid",
    "gclid",
    "igshid",
    "mc_cid",
    "mc_eid",
    "msclkid",
    "ref",
    "ref_src",
}


@dataclass(frozen=True)
class SearchResult:
    title: str
    url: str
    snippet: str
    query: str = ""
    rank: int = 0
    provider: str = "duckduckgo"
    canonical_url: str = ""

    def __post_init__(self) -> None:
        if not self.canonical_url:
            object.__setattr__(self, "canonical_url", canonicalize_url(self.url))


@dataclass(frozen=True)
class ScrapeResult:
    url: str
    content: str
    title: str = ""
    final_url: str = ""
    content_type: str = "text/markdown"


@dataclass(frozen=True)
class TextChunk:
    chunk_id: str
    text: str
    start: int
    end: int
    content_hash: str


@dataclass(frozen=True)
class WebPage:
    title: str
    url: str
    text: str
    final_url: str = ""
    canonical_url: str = ""
    content_type: str = ""
    retrieved_at: str = ""
    content_hash: str = ""
    chunks: tuple[TextChunk, ...] = field(default_factory=tuple)

    def __post_init__(self) -> None:
        final_url = self.final_url or self.url
        object.__setattr__(self, "final_url", final_url)
        if not self.canonical_url:
            object.__setattr__(self, "canonical_url", canonicalize_url(final_url))
        if not self.retrieved_at:
            object.__setattr__(self, "retrieved_at", utc_now())
        if not self.content_hash:
            object.__setattr__(self, "content_hash", content_hash(self.text))


@dataclass(frozen=True)
class EvidenceChunk:
    chunk_id: str
    text: str
    summary: str
    quotes: tuple[str, ...] = field(default_factory=tuple)


@dataclass(frozen=True)
class SourceNote:
    source_id: str
    title: str
    url: str
    query: str
    summary: str
    canonical_url: str = ""
    final_url: str = ""
    rank: int = 0
    snippet: str = ""
    provider: str = "duckduckgo"
    retrieved_at: str = ""
    content_type: str = ""
    content_hash: str = ""
    chunks: tuple[EvidenceChunk, ...] = field(default_factory=tuple)
Os campos importantes aqui são canonical_url, content_hash e chunks. canonical_url permite ao agente evitar ler a mesma fonte repetidamente quando os resultados de busca diferem apenas por parâmetros de tracking ou fragmentos. content_hash ajuda a capturar páginas duplicadas mesmo quando vivem em URLs diferentes. chunks nos permite sumarizar páginas longas em pedaços menores em vez de perder evidência útil para limites de contexto. Adicione as funções helper abaixo das dataclasses:
def utc_now() -> str:
    return datetime.now(UTC).isoformat()


def content_hash(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).hexdigest()


def canonicalize_url(raw_url: str) -> str:
    if not raw_url:
        return ""

    parsed = urlparse(raw_url.strip())
    if parsed.scheme not in {"http", "https"} or not parsed.netloc:
        return ""

    scheme = parsed.scheme.lower()
    netloc = parsed.netloc.lower()
    path = parsed.path or "/"
    if path != "/":
        path = path.rstrip("/")

    query_pairs = [
        (key, value)
        for key, value in parse_qsl(parsed.query, keep_blank_values=True)
        if not _is_tracking_param(key)
    ]
    query = urlencode(sorted(query_pairs), doseq=True)
    return urlunparse((scheme, netloc, path, "", query, ""))


def chunk_text(text: str, *, chunk_chars: int = 3000, overlap: int = 250) -> tuple[TextChunk, ...]:
    clean = text.strip()
    if not clean:
        return ()
    if chunk_chars <= 0:
        raise ValueError("chunk_chars must be greater than 0")
    if overlap < 0 or overlap >= chunk_chars:
        raise ValueError("overlap must be at least 0 and smaller than chunk_chars")

    chunks: list[TextChunk] = []
    start = 0
    index = 1
    while start < len(clean):
        end = min(len(clean), start + chunk_chars)
        chunk = clean[start:end].strip()
        if chunk:
            chunks.append(
                TextChunk(
                    chunk_id=f"C{index}",
                    text=chunk,
                    start=start,
                    end=end,
                    content_hash=content_hash(chunk),
                )
            )
            index += 1
        if end == len(clean):
            break
        start = end - overlap

    return tuple(chunks)


def _is_tracking_param(key: str) -> bool:
    lowered = key.lower()
    return lowered.startswith("utm_") or lowered in TRACKING_PARAMS
O chunking é deliberadamente simples aqui: chunks de tamanho fixo de caracteres com overlap. Isso é suficiente para um agente de pesquisa demo porque o endpoint de scrape da Venice retorna Markdown, que geralmente é muito mais limpo que HTML cru. Para pesquisa em produção em documentos técnicos longos, você pode melhorar isso dividindo por headings, parágrafos ou contagem de tokens.

Construindo o cliente Venice

A seguir, criaremos um pequeno cliente Venice. Você poderia usar o SDK Python da OpenAI para chat completions porque a Venice é compatível com OpenAI, mas a implementação de referência usa httpx diretamente para que o mesmo cliente possa chamar o endpoint POST /augment/scrape da Venice. Crie research_agent/venice.py:
from __future__ import annotations

import json
import os
import time
from dataclasses import dataclass
from typing import Any

import httpx

from .models import ScrapeResult

DEFAULT_BASE_URL = "https://api.venice.ai/api/v1"
DEFAULT_MODEL = "openai-gpt-55"
RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}


class VeniceError(RuntimeError):
    """Raised when the Venice API returns an unusable response."""


@dataclass(frozen=True)
class VeniceClient:
    api_key: str
    model: str = DEFAULT_MODEL
    base_url: str = DEFAULT_BASE_URL
    timeout: float = 60.0
    max_retries: int = 2
    backoff_seconds: float = 1.0

    @classmethod
    def from_env(cls, model: str | None = None, *, max_retries: int = 2) -> "VeniceClient":
        api_key = os.getenv("VENICE_API_KEY")
        if not api_key:
            raise VeniceError("VENICE_API_KEY is required.")

        return cls(
            api_key=api_key,
            model=model or os.getenv("VENICE_MODEL", DEFAULT_MODEL),
            base_url=os.getenv("VENICE_BASE_URL", DEFAULT_BASE_URL).rstrip("/"),
            max_retries=max_retries,
        )
O helper from_env() mantém os segredos fora do seu código-fonte. Também torna o desenvolvimento local conveniente, porque python-dotenv pode carregar VENICE_API_KEY e VENICE_MODEL do .env. Agora adicione chat completions:
    def chat(
        self,
        messages: list[dict[str, str]],
        *,
        temperature: float = 0.2,
        max_tokens: int = 1600,
    ) -> str:
        payload: dict[str, Any] = {
            "model": self.model,
            "messages": messages,
            "temperature": temperature,
            "max_tokens": max_tokens,
        }

        data = self._post_json("/chat/completions", payload)
        try:
            return data["choices"][0]["message"]["content"].strip()
        except (KeyError, IndexError, TypeError) as exc:
            raise VeniceError(f"Unexpected Venice API response: {data}") from exc
Para o relatório final, queremos usar streaming porque relatórios profundos podem levar significativamente mais tempo (porque produzirão muito mais texto). Isso pode causar problemas de timeout em requisições em que pode levar um tempo extremamente longo para produzir a saída final. Usando streaming, podemos eliminar esse problema e tornar a requisição mais resistente a falhas de timeout:
    def chat_stream(
        self,
        messages: list[dict[str, str]],
        *,
        temperature: float = 0.2,
        max_tokens: int = 1600,
    ) -> str:
        payload: dict[str, Any] = {
            "model": self.model,
            "messages": messages,
            "temperature": temperature,
            "max_tokens": max_tokens,
            "stream": True,
        }
        return self._post_chat_stream("/chat/completions", payload).strip()
Depois adicione scraping:
    def scrape(self, url: str) -> ScrapeResult:
        data = self._post_json("/augment/scrape", {"url": url})
        content = _first_string(data, "content", "markdown", "text")
        if not content:
            raise VeniceError(f"Unexpected Venice scrape response: {data}")

        return ScrapeResult(
            url=url,
            final_url=_first_string(data, "final_url", "url", "source_url") or url,
            title=_first_string(data, "title"),
            content=content,
            content_type="text/markdown",
        )
O endpoint de scrape da Venice aceita uma URL publicamente acessível e retorna a página como Markdown. Isso significa que o modelo não precisa fazer parsing de HTML cru, e seus prompts de extração de fonte podem trabalhar com texto mais limpo. O helper restante lida com retries e parsing de resposta:
    def _post_json(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
        for attempt in range(self.max_retries + 1):
            try:
                response = httpx.post(
                    f"{self.base_url}{path}",
                    headers={
                        "Authorization": f"Bearer {self.api_key}",
                        "Content-Type": "application/json",
                    },
                    json=payload,
                    timeout=self.timeout,
                )
                if response.status_code in RETRYABLE_STATUS_CODES and attempt < self.max_retries:
                    time.sleep(self.backoff_seconds * (2**attempt))
                    continue
                response.raise_for_status()
                data = response.json()
                if not isinstance(data, dict):
                    raise VeniceError(f"Unexpected Venice API response: {data}")
                return data
            except httpx.HTTPError as exc:
                if attempt < self.max_retries:
                    time.sleep(self.backoff_seconds * (2**attempt))
                    continue
                raise VeniceError(f"Could not reach Venice API: {exc}") from exc

        raise VeniceError("Could not reach Venice API")


def _first_string(data: dict[str, Any], *keys: str) -> str:
    for key in keys:
        value = data.get(key)
        if isinstance(value, str) and value.strip():
            return value.strip()

    for nested_key in ("data", "result", "scrape"):
        nested = data.get(nested_key)
        if isinstance(nested, dict):
            value = _first_string(nested, *keys)
            if value:
                return value

    return ""
O repositório completo também inclui um helper robusto _post_chat_stream() que lê server-sent events de chat completions em streaming. Você pode começar sem streaming e adicioná-lo quando o restante do fluxo de pesquisa funcionar.

Adicionando provedores de busca

A camada de busca tem dois trabalhos: encontrar URLs de origem e buscar essas URLs através do scraper da Venice. A implementação de referência usa o endpoint HTML do DuckDuckGo para busca geral na web e a API Atom do arXiv para papers. Crie research_agent/web.py:
from __future__ import annotations

import re
import xml.etree.ElementTree as ET
from collections.abc import Callable, Iterable
from urllib.parse import parse_qs, unquote, urlparse

import httpx
from bs4 import BeautifulSoup

from .models import ScrapeResult, SearchResult, TextChunk, WebPage, canonicalize_url, chunk_text, content_hash, utc_now

USER_AGENT = "venice-research-agent-demo/0.1 (+https://venice.ai)"


class SearchProvider:
    name = "provider"

    def search(self, web: "WebSearch", query: str, limit: int) -> list[SearchResult]:
        raise NotImplementedError
Agora adicione o DuckDuckGo:
class DuckDuckGoProvider(SearchProvider):
    name = "duckduckgo"

    def search(self, web: "WebSearch", query: str, limit: int) -> list[SearchResult]:
        response = web.get("https://duckduckgo.com/html/", params={"q": query})
        soup = BeautifulSoup(response.text, "html.parser")
        results: list[SearchResult] = []
        seen_urls: set[str] = set()

        for node in soup.select(".result"):
            link = node.select_one(".result__a")
            if link is None:
                continue

            url = _normalize_duckduckgo_url(link.get("href", ""))
            canonical_url = canonicalize_url(url)
            if not canonical_url or canonical_url in seen_urls:
                continue

            snippet = node.select_one(".result__snippet")
            results.append(
                SearchResult(
                    title=_clean_text(link.get_text(" ", strip=True)),
                    url=url,
                    snippet=_clean_text(snippet.get_text(" ", strip=True) if snippet else ""),
                    query=query,
                    rank=len(results) + 1,
                    provider=self.name,
                    canonical_url=canonical_url,
                )
            )
            seen_urls.add(canonical_url)

            if len(results) >= limit:
                break

        return results
E o arXiv:
class ArxivProvider(SearchProvider):
    name = "arxiv"

    def search(self, web: "WebSearch", query: str, limit: int) -> list[SearchResult]:
        response = web.get(
            "https://export.arxiv.org/api/query",
            params={
                "search_query": f"all:{query}",
                "start": 0,
                "max_results": limit,
                "sortBy": "relevance",
            },
        )
        namespace = {"atom": "http://www.w3.org/2005/Atom"}
        root = ET.fromstring(response.text)
        results: list[SearchResult] = []

        for entry in root.findall("atom:entry", namespace):
            title = _clean_text(_xml_text(entry.find("atom:title", namespace)))
            summary = _clean_text(_xml_text(entry.find("atom:summary", namespace)))
            url = _xml_text(entry.find("atom:id", namespace)).strip()
            canonical_url = canonicalize_url(url)
            if not url or not canonical_url:
                continue

            results.append(
                SearchResult(
                    title=title or url,
                    url=url,
                    snippet=summary,
                    query=query,
                    rank=len(results) + 1,
                    provider=self.name,
                    canonical_url=canonical_url,
                )
            )

            if len(results) >= limit:
                break

        return results
A classe WebSearch coordena provedores e busca páginas:
class WebSearch:
    def __init__(
        self,
        timeout: float = 15.0,
        *,
        providers: Iterable[SearchProvider] | None = None,
        chunk_chars: int = 3000,
        scraper: Callable[[str], ScrapeResult] | None = None,
    ) -> None:
        self._client = httpx.Client(
            timeout=timeout,
            follow_redirects=True,
            headers={"User-Agent": USER_AGENT},
        )
        self.providers = tuple(providers or (DuckDuckGoProvider(),))
        self.chunk_chars = chunk_chars
        self.scraper = scraper

    @classmethod
    def from_provider_names(cls, provider_names: Iterable[str], **kwargs: object) -> "WebSearch":
        providers = [_provider_from_name(name) for name in provider_names]
        return cls(providers=providers, **kwargs)

    def search(self, query: str, limit: int = 5) -> list[SearchResult]:
        results: list[SearchResult] = []
        seen_urls: set[str] = set()

        for provider in self.providers:
            for result in provider.search(self, query, limit):
                if result.canonical_url in seen_urls:
                    continue
                results.append(result)
                seen_urls.add(result.canonical_url)

        return results

    def fetch(self, result: SearchResult) -> WebPage:
        if self.scraper is None:
            raise RuntimeError("WebSearch.fetch requires a Venice scrape function.")

        scraped = self.scraper(result.url)
        text = scraped.content.strip() or result.snippet
        chunks = self._chunk_text(text)
        return WebPage(
            title=scraped.title or result.title,
            url=result.url,
            final_url=scraped.final_url or scraped.url or result.url,
            canonical_url=canonicalize_url(scraped.final_url or result.url),
            text=text,
            content_type=scraped.content_type or "text/markdown",
            retrieved_at=utc_now(),
            content_hash=content_hash(text),
            chunks=chunks,
        )

    def get(self, url: str, *, params: dict[str, object] | None = None) -> httpx.Response:
        response = self._client.get(url, params=params)
        response.raise_for_status()
        return response

    def close(self) -> None:
        self._client.close()

    def __enter__(self) -> "WebSearch":
        return self

    def __exit__(self, *_: object) -> None:
        self.close()

    def _chunk_text(self, text: str) -> tuple[TextChunk, ...]:
        overlap = min(250, max(0, self.chunk_chars // 10))
        return chunk_text(text, chunk_chars=self.chunk_chars, overlap=overlap)
A implementação de referência completa adiciona retries, atrasos de requisição por host e erros mais amigáveis. Vale a pena manter isso porque agentes de pesquisa passam muito tempo lidando com páginas que bloqueiam automação, redirecionam inesperadamente ou retornam erros transitórios. Adicione os pequenos helpers de provedor no final:
def _normalize_duckduckgo_url(raw_url: str) -> str:
    if not raw_url:
        return ""

    parsed = urlparse(raw_url)
    if parsed.netloc.endswith("duckduckgo.com") and parsed.path == "/l/":
        target = parse_qs(parsed.query).get("uddg", [""])[0]
        return unquote(target)

    if parsed.scheme in {"http", "https"}:
        return raw_url

    return ""


def _provider_from_name(name: str) -> SearchProvider:
    normalized = name.strip().lower()
    if normalized in {"duckduckgo", "ddg", "web"}:
        return DuckDuckGoProvider()
    if normalized == "arxiv":
        return ArxivProvider()
    raise ValueError(f"Unknown source provider: {name}")


def _clean_text(value: str) -> str:
    return re.sub(r"\s+", " ", value).strip()


def _xml_text(node: ET.Element | None) -> str:
    return "" if node is None or node.text is None else node.text

Escrevendo artefatos locais

Para workflows de pesquisa, a auditabilidade importa. Se o relatório final disser algo surpreendente, você deve ser capaz de inspecionar qual fonte levou a isso. Crie research_agent/artifacts.py:
from __future__ import annotations

import json
from dataclasses import asdict, is_dataclass
from pathlib import Path
from typing import Any


class ArtifactWriter:
    def __init__(self, root: Path | None = None) -> None:
        self.root = root
        if self.root is not None:
            self.root.mkdir(parents=True, exist_ok=True)

    @property
    def enabled(self) -> bool:
        return self.root is not None

    def write(self, kind: str, record: object) -> None:
        if self.root is None:
            return

        path = self.root / f"{kind}.jsonl"
        payload = json.dumps(_to_jsonable(record), ensure_ascii=False, sort_keys=True)
        with path.open("a", encoding="utf-8") as file:
            file.write(f"{payload}\n")


def _to_jsonable(value: object) -> Any:
    if is_dataclass(value):
        return _to_jsonable(asdict(value))
    if isinstance(value, Path):
        return str(value)
    if isinstance(value, dict):
        return {str(key): _to_jsonable(item) for key, item in value.items()}
    if isinstance(value, (list, tuple)):
        return [_to_jsonable(item) for item in value]
    return value
Isso escreve um objeto JSON por linha, o que torna os artefatos fáceis de adicionar, inspecionar e processar com ferramentas de linha de comando depois.

Construindo o agente de pesquisa

Agora que temos Venice, busca, modelos e artefatos, podemos construir o agente em si. Crie research_agent/agent.py:
from __future__ import annotations

import json
from collections.abc import Callable
from textwrap import dedent

from .artifacts import ArtifactWriter
from .models import CollectionError, EvidenceChunk, ResearchReport, SearchResult, SourceNote, WebPage, utc_now
from .venice import VeniceClient, VeniceError
from .web import WebSearch

SYSTEM_PROMPT = """You are a careful research assistant.
Use the supplied source material only when making factual claims.
Flag uncertainty, contradictions, and missing context instead of filling gaps."""

ProgressCallback = Callable[[str], None]

DEFAULT_ITERATIONS = 3
DEFAULT_QUERY_COUNT = 6
DEFAULT_RESULTS_PER_QUERY = 4
DEFAULT_MAX_SOURCES = 40
DEFAULT_MAX_CHUNKS_PER_SOURCE = 6
O system prompt é o guardrail comportamental central. Não queremos que o modelo produza um relatório impressionante a partir da memória. Queremos que ele use o material de origem e destaque incertezas quando a evidência for fraca. Também precisamos de duas dataclasses finais em models.py se você ainda não as adicionou:
@dataclass(frozen=True)
class CollectionError:
    stage: str
    message: str
    query: str = ""
    url: str = ""
    source_id: str = ""
    provider: str = ""


@dataclass(frozen=True)
class ResearchReport:
    topic: str
    markdown: str
    sources: list[SourceNote]
    artifacts_dir: str | None = None
A seguir, defina o ResearchAgent:
class ResearchAgent:
    def __init__(
        self,
        venice: VeniceClient,
        web: WebSearch | None = None,
        artifacts: ArtifactWriter | None = None,
        progress: ProgressCallback | None = None,
        max_sources: int | None = DEFAULT_MAX_SOURCES,
        max_chunks_per_source: int = DEFAULT_MAX_CHUNKS_PER_SOURCE,
    ) -> None:
        self.venice = venice
        self.web = web or WebSearch(scraper=venice.scrape)
        self.artifacts = artifacts or ArtifactWriter()
        self.progress = progress or (lambda _: None)
        self.max_sources = max_sources
        self.max_chunks_per_source = max_chunks_per_source
O método run() coordena os passos de pesquisa:
    def run(
        self,
        topic: str,
        *,
        iterations: int = DEFAULT_ITERATIONS,
        query_count: int = DEFAULT_QUERY_COUNT,
        results_per_query: int = DEFAULT_RESULTS_PER_QUERY,
    ) -> ResearchReport:
        notes: list[SourceNote] = []
        seen_source_keys: set[str] = set()
        seen_content_hashes: set[str] = set()
        queries = self._initial_queries(topic, query_count)

        self.artifacts.write("queries", {"stage": "initial", "topic": topic, "queries": queries})

        for iteration in range(1, iterations + 1):
            self.progress(f"Research pass {iteration}/{iterations}: {', '.join(queries)}")
            self._collect_notes(
                topic,
                queries,
                results_per_query,
                seen_source_keys,
                seen_content_hashes,
                notes,
                iteration,
            )

            if iteration < iterations:
                gaps, queries = self._gap_follow_up_queries(topic, notes, query_count)
                self.artifacts.write(
                    "research_gaps",
                    {
                        "topic": topic,
                        "after_iteration": iteration,
                        "source_balance": _source_cluster_counts(notes),
                        "gaps": gaps,
                        "queries": queries,
                    },
                )
                self.artifacts.write(
                    "queries",
                    {
                        "stage": "follow_up",
                        "topic": topic,
                        "iteration": iteration + 1,
                        "gap_count": len(gaps),
                        "queries": queries,
                    },
                )

        report = self._write_report(topic, notes)
        self.artifacts.write(
            "reports",
            {
                "topic": topic,
                "source_count": len(notes),
                "generated_at": utc_now(),
                "markdown": report,
            },
        )

        return ResearchReport(
            topic=topic,
            markdown=report,
            sources=notes,
            artifacts_dir=str(self.artifacts.root) if self.artifacts.root is not None else None,
        )
Os dois conjuntos seen_* são o que impedem o agente de desperdiçar tempo em fontes duplicadas. Dedup de URL captura links repetidos. Dedup por hash de conteúdo captura espelhos, posts sindicalizados e páginas que redirecionam para o mesmo conteúdo final.

Planejando buscas iniciais e de follow-up

A primeira chamada de modelo transforma o tópico em queries de busca:
    def _initial_queries(self, topic: str, count: int) -> list[str]:
        prompt = dedent(
            f"""
            Create {count} diverse web search queries for researching this topic:
            {topic}

            Cover background, recent developments, primary sources, criticism, and data.
            Include at least one query likely to find primary sources or datasets.
            Return JSON only in this shape: {{"queries": ["..."]}}
            """
        ).strip()
        return self._query_list(prompt, count, fallback=[topic])
Após cada passo de pesquisa, o agente atualizado faz um passo de análise de lacunas mais deliberado. Ele olha as notas atuais, conta clusters de origens por domínio, pergunta à Venice qual cobertura está faltando, escreve essas lacunas nos artefatos e depois usa as queries resultantes para o próximo passo. Loop de análise de lacunas Comece rastreando o equilíbrio de origens:
from urllib.parse import urlparse


def _source_cluster_counts(notes: list[SourceNote]) -> list[dict[str, object]]:
    total = len(notes)
    if total == 0:
        return []

    clusters: dict[str, list[str]] = {}
    for note in notes:
        cluster = _source_cluster(note)
        clusters.setdefault(cluster, []).append(note.source_id)

    return [
        {
            "cluster": cluster,
            "source_count": len(source_ids),
            "source_share": round(len(source_ids) / total, 3),
            "source_ids": source_ids,
        }
        for cluster, source_ids in sorted(
            clusters.items(), key=lambda item: (-len(item[1]), item[0])
        )
    ]


def _source_cluster(note: SourceNote) -> str:
    url = note.canonical_url or note.final_url or note.url
    host = urlparse(url).netloc.lower()
    if host.startswith("www."):
        host = host[4:]
    return host or "unknown"


def _source_balance_digest(notes: list[SourceNote], limit: int = 8) -> str:
    clusters = _source_cluster_counts(notes)
    if not clusters:
        return "No source clusters yet."

    total = len(notes)
    lines = [
        f"- {cluster['cluster']}: {cluster['source_count']}/{total} sources "
        f"({cluster['source_share']:.0%}); IDs: {', '.join(cluster['source_ids'])}"
        for cluster in clusters[:limit]
    ]
    return "\n".join(lines)
Isso dá ao agente uma maneira simples de notar a captura por cluster de origens. Se cada fonte está vindo de uma empresa, um framework ou um domínio, queries de follow-up devem deliberadamente ampliar o conjunto de fontes em vez de coletar mais do mesmo. Agora use essa informação de equilíbrio ao criar buscas de follow-up:
    def _follow_up_queries(self, topic: str, notes: list[SourceNote], count: int) -> list[str]:
        digest = _source_digest(notes, max_chars=9000)
        source_balance = _source_balance_digest(notes)
        prompt = dedent(
            f"""
            We are researching: {topic}

            Current notes:
            {digest}

            Source balance:
            {source_balance}

            Create {count} follow-up web search queries that fill gaps, verify important claims,
            find primary evidence, and look for dissenting evidence.
            If one source domain, vendor, framework, product, or perspective is overrepresented,
            deliberately broaden beyond it unless the topic explicitly asks for that focus.
            Return JSON only in this shape: {{"queries": ["..."]}}
            """
        ).strip()
        return self._query_list(prompt, count, fallback=[topic])
A implementação de referência mais recente envolve isso em _gap_follow_up_queries(), que pede à Venice para retornar registros de lacunas e queries:
    def _gap_follow_up_queries(
        self, topic: str, notes: list[SourceNote], count: int
    ) -> tuple[list[dict[str, str]], list[str]]:
        if not notes:
            return [], [topic]

        digest = _source_digest(notes, max_chars=12000)
        source_balance = _source_balance_digest(notes)
        prompt = dedent(
            f"""
            Identify coverage gaps before the next research pass.

            Research topic:
            {topic}

            Current source notes:
            {digest}

            Source balance:
            {source_balance}

            Find important missing coverage that would improve a deep research report.
            Look specifically for primary sources, technical concepts, dissenting views,
            overrepresented source clusters, and claims that need verification.

            Return JSON only in this shape:
            {{"gaps": [{{"missing": "...", "why_it_matters": "...", "query": "..."}}],
              "queries": ["targeted web search query"]}}
            """
        ).strip()
        response = self.venice.chat(
            [
                {"role": "system", "content": SYSTEM_PROMPT},
                {"role": "user", "content": prompt},
            ],
            temperature=0.3,
            max_tokens=900,
        )

        data = json.loads(response)
        gaps = _clean_gap_records(data.get("gaps"))
        queries = _clean_string_list(data.get("queries"))
        if not queries:
            queries = [gap["query"] for gap in gaps if gap.get("query")]
        return gaps, queries[:count]
Quando --artifacts está habilitado, esses registros são escritos em research_gaps.jsonl. Isso te dá uma trilha de auditoria útil para entender por que o agente buscou uma query de segundo passo em particular. O parser deve ser tolerante. Se o modelo retornar JSON malformado, o agente volta ao tópico original:
    def _query_list(self, prompt: str, count: int, fallback: list[str]) -> list[str]:
        response = self.venice.chat(
            [
                {"role": "system", "content": SYSTEM_PROMPT},
                {"role": "user", "content": prompt},
            ],
            temperature=0.4,
            max_tokens=500,
        )
        try:
            data = json.loads(response)
            queries = data.get("queries", [])
        except (json.JSONDecodeError, AttributeError):
            queries = []

        clean_queries = [
            query.strip()
            for query in queries
            if isinstance(query, str) and query.strip()
        ]
        return (clean_queries or fallback)[:count]
Esse padrão vale a pena usar em todo o código de agente: peça saída estruturada, faça parsing dela e forneça um fallback simples quando a saída não for utilizável.

Lendo e sumarizando fontes

Agora coletamos notas de origem. O agente busca cada query, busca cada resultado através do scrape da Venice, divide o Markdown em chunks e sumariza a evidência útil.
    def _collect_notes(
        self,
        topic: str,
        queries: list[str],
        results_per_query: int,
        seen_source_keys: set[str],
        seen_content_hashes: set[str],
        notes: list[SourceNote],
        iteration: int,
    ) -> None:
        for query in queries:
            if self.max_sources is not None and len(notes) >= self.max_sources:
                return

            self.progress(f"Searching: {query}")
            try:
                results = self.web.search(query, limit=results_per_query)
            except Exception as exc:
                self._record_error("search", exc, query=query)
                continue

            self.artifacts.write(
                "search_results",
                {"iteration": iteration, "query": query, "results": results},
            )

            for result in results:
                if self.max_sources is not None and len(notes) >= self.max_sources:
                    return

                source_key = result.canonical_url or result.url
                if source_key in seen_source_keys:
                    self.artifacts.write("dedupe", {"reason": "canonical_url", "url": result.url})
                    continue

                seen_source_keys.add(source_key)
                source_id = f"S{len(notes) + 1}"
                note = self._read_source(topic, query, source_id, result, seen_source_keys, seen_content_hashes)
                if note is not None:
                    notes.append(note)
Falhas individuais de busca e fetch não devem parar toda a execução. A web pública é bagunçada. Algumas páginas bloqueiam scraping, algumas retornam PDFs, algumas estão fora do ar e algumas redirecionam para lugares inesperados. Um agente de pesquisa deve continuar se movendo e registrar o que falhou. Aqui está o método de leitura de fonte:
    def _read_source(
        self,
        topic: str,
        query: str,
        source_id: str,
        result: SearchResult,
        seen_source_keys: set[str],
        seen_content_hashes: set[str],
    ) -> SourceNote | None:
        self.progress(f"Reading {source_id}: {result.title}")
        try:
            page = self.web.fetch(result)
        except Exception as exc:
            self._record_error("fetch", exc, query=query, url=result.url, source_id=source_id)
            return None

        if page.content_hash in seen_content_hashes:
            self.artifacts.write(
                "dedupe",
                {"reason": "content_hash", "source_id": source_id, "url": result.url},
            )
            return None
        seen_content_hashes.add(page.content_hash)

        chunks = self._summarize_chunks(topic, query, source_id, page)
        if not chunks:
            self._record_error("summarize_chunk", VeniceError("no chunks could be summarized"), url=result.url)
            return None

        summary = self._summarize_source(topic, query, source_id, page, chunks)
        note = SourceNote(
            source_id=source_id,
            title=page.title,
            url=result.url,
            canonical_url=page.canonical_url,
            final_url=page.final_url,
            query=query,
            rank=result.rank,
            snippet=result.snippet,
            provider=result.provider,
            retrieved_at=page.retrieved_at,
            content_type=page.content_type,
            content_hash=page.content_hash,
            chunks=chunks,
            summary=summary,
        )
        self.artifacts.write("source_notes", note)
        return note
Para cada chunk de origem, peça à Venice por um resumo curto de evidências e citações exatas:
    def _summarize_chunks(
        self,
        topic: str,
        query: str,
        source_id: str,
        page: WebPage,
    ) -> tuple[EvidenceChunk, ...]:
        evidence: list[EvidenceChunk] = []
        for chunk in page.chunks[: self.max_chunks_per_source]:
            prompt = dedent(
                f"""
                Topic: {topic}
                Search query: {query}
                Source ID: {source_id}
                Chunk ID: {chunk.chunk_id}
                Source title: {page.title}
                Source URL: {page.final_url}

                Source chunk:
                {chunk.text}

                Extract only evidence relevant to the topic.
                Return JSON only in this shape:
                {{"summary": "...", "quotes": ["short exact quote", "..."]}}
                """
            ).strip()

            try:
                response = self.venice.chat(
                    [
                        {"role": "system", "content": SYSTEM_PROMPT},
                        {"role": "user", "content": prompt},
                    ],
                    temperature=0.1,
                    max_tokens=600,
                )
                data = json.loads(response)
                evidence.append(
                    EvidenceChunk(
                        chunk_id=chunk.chunk_id,
                        text=chunk.text,
                        summary=str(data.get("summary", "")).strip(),
                        quotes=tuple(
                            quote.strip()
                            for quote in data.get("quotes", [])
                            if isinstance(quote, str) and quote.strip()
                        ),
                    )
                )
            except Exception as exc:
                self._record_error("summarize_chunk", exc, query=query, url=page.final_url, source_id=source_id)
                continue

        return tuple(evidence)
Depois reduza os resumos de chunks em uma nota de origem:
    def _summarize_source(
        self,
        topic: str,
        query: str,
        source_id: str,
        page: WebPage,
        chunks: tuple[EvidenceChunk, ...],
    ) -> str:
        chunk_digest = _chunk_digest(chunks, max_chars=9000)
        prompt = dedent(
            f"""
            Topic: {topic}
            Search query: {query}
            Source ID: {source_id}
            Source title: {page.title}
            Source URL: {page.final_url}

            Chunk evidence:
            {chunk_digest}

            Synthesize a source note using only the chunk evidence. Include:
            - key facts with dates/numbers where present
            - any limitations or bias in the source
            - useful exact wording from quotes if it is short

            Keep the note under 180 words and refer to the source as [{source_id}].
            """
        ).strip()
        return self.venice.chat(
            [
                {"role": "system", "content": SYSTEM_PROMPT},
                {"role": "user", "content": prompt},
            ],
            temperature=0.1,
            max_tokens=500,
        )
Essa sumarização em duas etapas é a parte que faz o agente parecer mais confiável que um script básico de “sumarize essas URLs”. O modelo lê chunks de origem primeiro, depois escreve uma nota em nível de origem a partir desses pedaços extraídos de evidência.

Escrevendo o relatório final

Quando o agente tem notas de origem, ele pode escrever o relatório. Comece com um escritor de relatório de uma passada:
    def _write_report(self, topic: str, notes: list[SourceNote]) -> str:
        if not notes:
            return (
                f"# Research report: {topic}\n\n"
                "No usable web sources were collected. Check your network connection or try a narrower topic."
            )

        prompt = dedent(
            f"""
            Research topic:
            {topic}

            Source notes:
            {_source_digest(notes, max_chars=45000)}

            Write a detailed source-backed Markdown research survey.

            Requirements:
            - Start with a precise H1 title.
            - Open with "## Overview".
            - Use topic-specific sections.
            - Use footnote-style citation markers like [^1] and [^2].
            - Do not cite with internal source IDs like [S1] in the report body.
            - Do not include uncited factual claims.
            - Avoid source-cluster capture from one vendor, domain, framework, or viewpoint.
            - Include uncertainty, contradictions, and missing context where relevant.
            - End with "## References" as a numbered list ordered by first citation.
            """
        ).strip()

        return self.venice.chat_stream(
            [
                {"role": "system", "content": SYSTEM_PROMPT},
                {"role": "user", "content": prompt},
            ],
            temperature=0.2,
            max_tokens=7000,
        )
A implementação de referência vai além para relatórios profundos: ela pede à Venice por um outline, esboça cada seção do relatório separadamente e depois pede um passo final de editor para montar o relatório terminado e converter IDs internos de origem em citações no estilo de notas de rodapé. Essa abordagem em estágios é útil quando você quer saída de pesquisa em forma longa porque um único prompt gigante geralmente comprime demais. Os prompts atualizados também empurram o relatório para uma pesquisa ampla e baseada em fontes, em vez de um guia de decisão fino. Se a base de fontes está enviesada para um cluster, o prompt do editor diz à Venice para reconhecer esse viés e evitar apresentá-lo como representativo de todo o campo. Adicione os helpers de digest:
def _chunk_digest(chunks: tuple[EvidenceChunk, ...], max_chars: int) -> str:
    parts = []
    for chunk in chunks:
        quote_text = "; ".join(chunk.quotes)
        parts.append(
            f"{chunk.chunk_id}: {chunk.summary}"
            + (f"\nQuotes: {quote_text}" if quote_text else "")
        )
    return "\n\n".join(parts)[:max_chars]


def _source_digest(notes: list[SourceNote], max_chars: int) -> str:
    chunks = [
        "\n".join(
            [
                f"[{note.source_id}] {note.title}",
                f"URL: {note.final_url or note.url}",
                f"Canonical URL: {note.canonical_url}",
                f"Found via: {note.query}",
                f"Provider/rank: {note.provider}/{note.rank}",
                f"Retrieved: {note.retrieved_at}",
                f"Content hash: {note.content_hash}",
                f"Note: {note.summary}",
                f"Chunk evidence: {_chunk_digest(note.chunks, max_chars=1000)}",
            ]
        )
        for note in notes
    ]
    return "\n\n".join(chunks)[:max_chars]
Finalmente, adicione o registro de erros:
    def _record_error(
        self,
        stage: str,
        exc: Exception,
        *,
        query: str = "",
        url: str = "",
        source_id: str = "",
        provider: str = "",
    ) -> None:
        message = str(exc)
        self.progress(f"{stage.replace('_', ' ').title()} failed: {message}")
        self.artifacts.write(
            "errors",
            CollectionError(
                stage=stage,
                message=message,
                query=query,
                url=url,
                source_id=source_id,
                provider=provider,
            ),
        )
Neste ponto, o loop central de pesquisa está pronto.

Adicionando o CLI

Agora precisamos de um ponto de entrada de linha de comando. Crie main.py:
from __future__ import annotations

import argparse
from pathlib import Path

from dotenv import load_dotenv

from research_agent.agent import (
    DEFAULT_ITERATIONS,
    DEFAULT_MAX_CHUNKS_PER_SOURCE,
    DEFAULT_MAX_SOURCES,
    DEFAULT_QUERY_COUNT,
    DEFAULT_REPORT_STYLE,
    DEFAULT_RESULTS_PER_QUERY,
    ResearchAgent,
)
from research_agent.artifacts import ArtifactWriter
from research_agent.venice import VeniceClient, VeniceError
from research_agent.web import WebSearch


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Run a minimal deep research agent powered by Venice AI.",
    )
    parser.add_argument("topic", nargs="+", help="Research topic, wrapped in quotes for best results.")
    parser.add_argument("--model", help="Venice model name. Defaults to VENICE_MODEL or openai-gpt-55.")
    parser.add_argument("--iterations", type=int, default=DEFAULT_ITERATIONS)
    parser.add_argument("--queries", type=int, default=DEFAULT_QUERY_COUNT)
    parser.add_argument("--results", type=int, default=DEFAULT_RESULTS_PER_QUERY)
    parser.add_argument("--output", "--markdown-output", dest="output", type=Path)
    parser.add_argument("--artifacts", type=Path, help="Optional directory for JSONL research artifacts.")
    parser.add_argument("--providers", default="duckduckgo", help="Comma-separated providers: duckduckgo, arxiv.")
    parser.add_argument("--max-sources", type=int, default=DEFAULT_MAX_SOURCES)
    parser.add_argument("--chunk-chars", type=int, default=3000)
    parser.add_argument("--max-chunks-per-source", type=int, default=DEFAULT_MAX_CHUNKS_PER_SOURCE)
    parser.add_argument(
        "--report-style",
        choices=["brief", "standard", "deep"],
        default=DEFAULT_REPORT_STYLE,
        help=f"Final report depth. Default: {DEFAULT_REPORT_STYLE}.",
    )
    parser.add_argument("--quiet", action="store_true", help="Hide progress messages.")
    return parser.parse_args()
O CLI expõe os knobs que você de fato vai ajustar durante a pesquisa:
OpçãoO que controla
--iterationsNúmero de passos de pesquisa
--queriesQueries de busca geradas por passo
--resultsResultados lidos por provedor para cada query
--providersProvedores de busca, como duckduckgo ou duckduckgo,arxiv
--max-sourcesMáximo de fontes utilizáveis a coletar
--chunk-charsTamanho aproximado do chunk antes da extração de evidência de origem
--max-chunks-per-sourceNúmero de chunks sumarizados por fonte
--report-styleProfundidade do relatório final: brief, standard ou deep
--artifactsDiretório para registros JSONL de auditoria
--outputCaminho para o relatório Markdown final
Agora conecte tudo:
def main() -> int:
    load_dotenv()
    args = parse_args()
    topic = " ".join(args.topic)

    try:
        venice = VeniceClient.from_env(model=args.model)
        progress = None if args.quiet else lambda message: print(f"[agent] {message}")
        provider_names = [name.strip() for name in args.providers.split(",") if name.strip()]

        with WebSearch.from_provider_names(
            provider_names,
            chunk_chars=args.chunk_chars,
            scraper=venice.scrape,
        ) as web:
            agent = ResearchAgent(
                venice=venice,
                web=web,
                artifacts=ArtifactWriter(args.artifacts),
                progress=progress,
                max_sources=args.max_sources,
                max_chunks_per_source=args.max_chunks_per_source,
                report_style=args.report_style,
            )
            report = agent.run(
                topic,
                iterations=args.iterations,
                query_count=args.queries,
                results_per_query=args.results,
            )
    except ValueError as exc:
        print(f"Configuration error: {exc}")
        return 1
    except VeniceError as exc:
        print(f"Venice API error: {exc}")
        return 1

    if args.output:
        args.output.parent.mkdir(parents=True, exist_ok=True)
        args.output.write_text(report.markdown, encoding="utf-8")
        print(f"\nSaved report to {args.output}")
    else:
        print()
        print(report.markdown)

    if report.artifacts_dir:
        print(f"Saved research artifacts to {report.artifacts_dir}")

    return 0


if __name__ == "__main__":
    raise SystemExit(main())
Isso nos dá um CLI de pesquisa local funcional.

Executando o agente

Execute um passo de pesquisa rápido:
uv run python main.py "How are AI agents changing software engineering workflows?"
Escreva o relatório em um arquivo Markdown:
uv run python main.py "state of open source LLM inference in 2026" \
  --output reports/inference.md
Use mais fontes e múltiplos provedores:
uv run python main.py "agentic coding research" \
  --providers duckduckgo,arxiv \
  --iterations 3 \
  --queries 5 \
  --results 4 \
  --max-sources 12
Escolha o estilo do relatório final:
uv run python main.py "AI agents in software engineering" --report-style deep
Use brief para um briefing conciso baseado em fontes, standard para uma pesquisa mais completa e deep para o fluxo em estágios outline/section/editor. Salve artefatos auditáveis:
uv run python main.py "privacy tradeoffs in hosted LLM APIs" \
  --output reports/privacy.md \
  --artifacts runs/privacy
Quando os artefatos estão habilitados, você verá arquivos como:
runs/privacy/
  queries.jsonl
  research_gaps.jsonl
  search_results.jsonl
  fetches.jsonl
  source_chunks.jsonl
  chunk_summaries.jsonl
  source_notes.jsonl
  dedupe.jsonl
  errors.jsonl
  report_outline.jsonl
  report_sections.jsonl
  report_editor.jsonl
  reports.jsonl
Esses arquivos são úteis quando você quer entender como o agente chegou a uma conclusão. Por exemplo, source_notes.jsonl mostra a evidência de origem sumarizada, research_gaps.jsonl mostra por que buscas de follow-up foram geradas e errors.jsonl mostra páginas que falharam durante busca, scraping ou sumarização.

Notas de privacidade e confiabilidade

Um agente de pesquisa toca vários sistemas, então ajuda ser preciso sobre o que vai para onde: Limites de dados do agente de pesquisa privado
CamadaO que vê os dados
CLI localTópico, configuração, notas de origem, artefatos e relatórios finais permanecem na sua máquina
Provedor de buscaQueries de busca são enviadas ao provedor que você escolher, como DuckDuckGo ou arXiv
Scrape da VeniceURLs de origem pública são enviadas ao endpoint de scrape da Venice
Chat completions da VenicePrompts, chunks de origem, notas de origem e instruções de geração de relatório são enviadas à Venice
Arquivos de saídaRelatórios Markdown e artefatos JSONL são escritos localmente
Se você quiser manter mais do caminho de busca dentro da Venice, pode adaptar a camada de provedor para chamar o endpoint POST /augment/search da Venice em vez de consultar o DuckDuckGo diretamente. A implementação de referência usa provedores públicos leves para que a demo permaneça fácil de executar e entender. Para confiabilidade, mantenha esses padrões conservadores:
  • Use retries para chamadas Venice e requisições web.
  • Adicione um pequeno --request-delay se estiver lendo muitas páginas do mesmo host.
  • Limite --max-sources para que tópicos amplos não rodem indefinidamente.
  • Salve --artifacts para relatórios importantes para poder auditar a saída final.
  • Trate o relatório como um briefing, não como verdade absoluta. Siga as citações até a fonte original quando a precisão importar.

Testando as peças

Você não precisa de requisições web reais ou chamadas Venice para testar a maior parte do sistema. O repo de referência usa classes Venice e web falsas para testar o loop de pesquisa, comportamento de dedup, artefatos e prompts de relatório. Um primeiro teste útil é a canonização de URL:
from research_agent.models import canonicalize_url


def test_canonicalize_url_removes_tracking_params():
    url = "https://example.com/post?utm_source=x&b=2&a=1#section"
    assert canonicalize_url(url) == "https://example.com/post?a=1&b=2"
Depois teste que conteúdo duplicado é pulado:
from research_agent.models import SearchResult, WebPage, chunk_text


class FakeWeb:
    def search(self, query: str, limit: int = 5) -> list[SearchResult]:
        return [
            SearchResult(title="First source", url="https://example.com/a", snippet="snippet"),
            SearchResult(title="Mirror", url="https://example.com/b", snippet="snippet"),
        ]

    def fetch(self, result: SearchResult) -> WebPage:
        text = "This page contains relevant evidence. " * 5
        return WebPage(
            title=result.title,
            url=result.url,
            final_url=result.url,
            text=text,
            content_hash="same-content",
            chunks=chunk_text(text, chunk_chars=80, overlap=10),
        )
Fakes tornam os testes de agente muito mais rápidos e menos instáveis. Você pode verificar a lógica de orquestração sem depender de resultados de busca ao vivo, condições de rede ou saída do modelo.

Benchmarking

Muitos provedores de IA agora têm seus próprios fluxos de pesquisa profunda, então o repo de referência inclui um benchmark simples contra a ferramenta Deep Research da Perplexity. Ambos os agentes foram solicitados a escrever um relatório sobre arquitetura de framework de agente de IA, e depois os relatórios gerados foram comitados no repositório do GitHub. Isso não pretende ser um benchmark formal. É uma maneira prática de inspecionar estrutura de relatório, cobertura de fontes, qualidade de citação e se o agente foca demais em um cluster de origem. Por isso também a implementação atualizada rastreia research_gaps.jsonl e o equilíbrio de origens antes das buscas de follow-up.

Estendendo este exemplo

Quando o agente baseline funcionar, aqui estão maneiras práticas de melhorá-lo:
  • Adicionar um provedor de busca Venice usando POST /augment/search.
  • Armazenar relatórios e artefatos em um pequeno banco SQLite em vez de arquivos JSONL.
  • Adicionar allowlists ou blocklists de origem para domínios de pesquisa confiáveis.
  • Adicionar suporte a PDF combinando scrape da Venice com parsing de documento para fontes que não expõem HTML limpo.
  • Adicionar um conjunto de avaliação de tópicos e tipos de fonte esperados para comparar a qualidade da pesquisa após mudanças de prompt.
  • Adicionar um passo de revisão que peça à Venice para encontrar alegações sem suporte no relatório final antes de salvá-lo.
O maior upgrade geralmente é uma melhor seleção de fontes. A geração de queries ajuda, mas você também pode melhorar a qualidade preferindo fontes primárias, documentos de padrões, docs oficiais, papers, changelogs e páginas de datasets em vez de resumos de baixo sinal.

Encerrando

Obrigado por ler! Espero que isso te ajude a construir um agente de pesquisa privado prático com Python e a API Venice. O padrão útil aqui não é só “pedir a um modelo para pesquisar algo”. É dividir a pesquisa em passos auditáveis: planejar buscas, coletar fontes, extrair evidências, escrever notas de origem, fazer follow-up em lacunas e sintetizar com citações. Mantendo esses passos explícitos, obtemos um workflow de pesquisa que é mais fácil de inspecionar, testar e melhorar ao longo do tempo.