Assistente Inteligente de Análise de Documentos.
Veja a trilha de aprendizagem completa em ROADMAP.md e o acompanhamento das tarefas no board do projeto.
O nome aiad é publicado no GitHub Packages a cada tag de versão (gem install aiad --source https://rubygems.pkg.github.com/Hirley), mas o pacote carrega só o nome e a versão: as classes do projeto moram soltas em lib/, sem namespace, e embarcá-las poria tool.rb, tracer.rb e companhia no load path de quem instalasse. A aplicação continua sendo rodada por Docker — ver Como rodar.
Esta seção existe porque a falta dela já tinha consequência: sem escopo escrito, toda auto-revisão produzia achado de superfície de produção para um app que não sai do laptop, e cada um parecia merecer prioridade. Uma issue que só faz sentido sob um objetivo que o projeto não tem é ruído com aparência de dívida.
O objetivo é aprender construindo, e deixar cada decisão legível. A stack de RAG é escrita do zero — sem framework de orquestração — para que cada peça possa ser explicada: os cabeçalhos de classe registram a decisão tomada e a alternativa rejeitada, o comportamento entra por TDD/BDD, e o que pode falhar em silêncio ganha log e métrica. A régua é "a decisão está explicada e medida", não "sobrevive a produção".
Os não-objetivos abaixo são decisões, não esquecimentos. Cada um já está encodado em alguma escolha do código; o que faltava era dizê-lo em voz alta.
| Não-objetivo | Onde isso já está decidido |
|---|---|
| Deploy público | O compose publica só em 127.0.0.1 (API e Grafana), e a chave é digitada à mão no console. Não há modelo de ameaça com atacante anônimo na internet. |
| Multi-tenant | Uma coleção só, e os escopos são de ação (read, write, metrics), nunca de dono. Duas pessoas com a mesma chave veem o mesmo acervo, por construção. |
| Ciclo de vida de credencial | O ApiKeyStore guarda digest e compara em tempo constante: ele responde "esta chave vale?", e nunca "de quem é, quando expira, como se revoga". TLS, rotação e emissão ficam fora. |
| Alta disponibilidade e vários workers | Limite aceito, já documentado no Api::LexicalIndexWarmup: com workers > 1 cada worker atende uma ingestão diferente e as cópias do índice BM25 divergem em silêncio. Resolver exige índice compartilhado ou vetores esparsos do Qdrant. Não é bug pendente. |
| Acervo como dado sensível | O acervo é tratado como fixture, não como base de dados real — ver a ressalva abaixo, que é a única desta lista que não era óbvia. |
Duas decisões do projeto parecem se contradizer, e vale dizer por que não se contradizem — dada a linha acima.
O PrometheusEvaluationLog recusa mandar pergunta e resposta para métrica, citando cardinalidade infinita e o
risco de o conteúdo ficar guardado para sempre num sistema que ninguém trata como base de dados pessoais. Já o
LangfuseExporter, com as duas chaves configuradas, manda para um serviço externo a pergunta, a resposta e o
prompt — e o prompt carrega os trechos recuperados do acervo.
As duas convivem porque o acervo aqui é material de teste: política de férias inventada, não prontuário. Métrica e trace também têm naturezas diferentes — uma tem retenção indefinida e cardinalidade que explode, a outra é um armazenamento feito para conteúdo e opcional por configuração.
O que muda se alguém apontar isto para documento real: o Langfuse deixa de ser um detalhe de configuração e passa a ser uma decisão de tratamento de dados, e o console — que hoje não guarda nada além da chave em memória — passa a precisar de um modelo de ameaça de verdade. Nesse dia, esta seção é o que deve ser reescrito primeiro.
Issue que só existe sob um não-objetivo fecha como wontfix, com link para esta seção — em vez de ficar no
backlog dando a impressão de dívida.
Isso não torna sem valor toda proposta que encoste num não-objetivo. A #25 é o exemplo: o argumento dela era defesa em profundidade contra um XSS que ninguém tem, mas metade do trabalho era consertar documentação enganosa e um cenário de Cucumber que fingia testar segurança. Essa metade valia sob qualquer escopo. O critério é o que sobra depois de tirar o que só o não-objetivo justificava.
Não há Ruby na máquina: tudo passa por Docker.
cp .env.example .env # troque as chaves antes de subir
docker compose up --buildA API sobe em http://127.0.0.1:9292; o Qdrant fica só na rede interna do compose. Quem fala com o
mundo é a API, e ela exige chave. Gere cada uma com:
docker compose run --rm test ruby -rsecurerandom -e 'puts SecureRandom.hex(32)'Abrir http://127.0.0.1:9292/ dá o console web: ingerir, buscar e perguntar pelo navegador, com as
origens citadas e o score de cada trecho recuperado. A chave é digitada ali e vive só na memória da
página — some no reload, de propósito.
A imagem final também se constrói sozinha, que é o que um registry vai querer: docker build -t aiad .
Dá ~285 MB, sem compilador e sem gems de teste, rodando como usuário não-root. Sozinha ela responde
200 em /health e 503 no /ask, porque sem Qdrant não há o que buscar.
| Rota | Escopo | O que faz |
|---|---|---|
GET / |
público | Console web |
GET /health |
público | Verificação de saúde, não toca no Qdrant |
GET /metrics |
metrics |
Métricas no formato de texto do Prometheus |
POST /documents |
write |
Ingere um documento (content, source, format, metadata) |
POST /search |
read |
Busca trechos (query, limit, filter) |
POST /ask |
read |
Pergunta com RAG (question, filter) |
POST /agent |
read |
Pergunta ao agente, que decide o que buscar (question, session) |
Autenticação por Authorization: Bearer <chave>, com escopo por rota: read consulta, write ingere,
metrics raspa o /metrics. Rota não mapeada exige o escopo mais restritivo — rota nova nasce
protegida.
curl -X POST http://127.0.0.1:9292/documents \
-H 'Authorization: Bearer SUA-CHAVE' -H 'Content-Type: application/json' \
-d '{"content":"A política de férias garante trinta dias por ano.","source":"politica.txt"}'
curl -X POST http://127.0.0.1:9292/ask \
-H 'Authorization: Bearer SUA-CHAVE' -H 'Content-Type: application/json' \
-d '{"question":"quantos dias de férias por ano"}'{"answer": "...", "sources": ["politica.txt"], "cached": false,
"usage": {"prompt_tokens": 96, "completion_tokens": 12, "total_tokens": 108, "measured": false}}measured diz se os tokens vieram do provedor ou da estimativa do TokenCounter.
/ask contra /agent. O /ask faz RAG direto: uma recuperação, um prompt, uma resposta. O /agent
põe um ReactAgent na frente, com a busca como ferramenta e memória por sessão — ele decide se e
quantas vezes consultar o acervo. Custa mais chamadas de modelo, e serve para pergunta que uma
recuperação só não resolve. A resposta traz answer, session, iterations, finished e tools;
mande a session de volta na próxima pergunta e o agente continua de onde parou.
Sem ANTHROPIC_API_KEY o /ask responde de forma extrativa — recorta o trecho em vez de gerar texto — e
o /agent devolve 503 nomeando a variável que falta.
O PlanAndSolveAgent e o AgentCrew continuam sem rota, como biblioteca exercitada por RSpec e Cucumber.
Tudo por variável de ambiente, no .env ao lado do docker-compose.yml. O .env.example
traz cada uma comentada; abaixo o que muda comportamento.
| Variável | Padrão | O que faz |
|---|---|---|
AIAD_API_KEYS |
— (obrigatória) | nome:chave:escopos, separadas por ; |
QDRANT_URL |
http://qdrant:6333 |
Onde está o Qdrant |
AIAD_COLLECTION |
documentos |
Coleção usada pela API |
AIAD_TOP_K |
4 |
Trechos recuperados por pergunta |
AIAD_RELEVANCE_FLOOR |
0.45 |
Piso de relevância; 0 desliga e a API volta a responder pergunta que o acervo não cobre |
AIAD_RERANK |
ligado | Re-ranking dos candidatos |
AIAD_CACHE |
ligado | Cache semântico, um por filtro de metadados |
AIAD_HYDE |
desligado | Busca por resposta hipotética; custa uma chamada a mais por pergunta |
AIAD_PARENT_DOCUMENTS |
desligado | Busca no chunk, entrega o documento inteiro |
AIAD_CONTEXT_BUDGET |
1500 |
Orçamento de tokens do contexto |
AIAD_EVALUATE |
ligado | Avaliação contínua das respostas |
AIAD_LEXICAL_INDEX_MAX |
50000 |
Teto de trechos no aquecimento do índice léxico |
AIAD_LEXICAL_INDEX_TIMEOUT |
30 |
Teto de segundos do mesmo aquecimento |
ANTHROPIC_API_KEY |
— | Liga o modelo de verdade no /ask e no /agent |
AIAD_MODEL |
claude-sonnet-5 |
Qual modelo usar |
AIAD_MODEL_PRICES |
— | modelo:entrada:saida em dólares por milhão de tokens; sem isso o custo sai zero |
AIAD_ANSWER_JUDGE |
— | llm troca a heurística de sustentação pelo LlmJudge; exige ANTHROPIC_API_KEY |
AIAD_HISTORY_BUDGET |
400 |
Tokens de histórico que vão no prompt do agente a cada pergunta |
AIAD_MAX_SESSIONS |
500 |
Conversas guardadas em memória |
LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY / LANGFUSE_URL |
— | Trace para o Langfuse; sem o par, o exportador não é montado |
AIAD_METRICS_TOKEN / GRAFANA_PASSWORD |
— | Profile observabilidade |
Três tetos governam a conversa do /agent, e é fácil confundi-los: o orçamento de histórico mede o
custo de cada pergunta (o histórico inteiro vai no prompt toda vez), a retenção de turnos (100, fixa)
mede a memória de uma conversa, e o teto de conversas mede a memória do processo. O primeiro é
apertado e o segundo folgado de propósito: um se paga em toda chamada, o outro só ocupa RAM.
GET /metrics no formato de texto do Prometheus, e uma linha JSON por requisição na saída padrão.
curl -H 'Authorization: Bearer SUA-CHAVE-DE-METRICAS' http://127.0.0.1:9292/metrics| Métrica | Tipo | O que responde |
|---|---|---|
aiad_http_requests_total |
contador | Throughput e taxa de erro, por método, rota e status |
aiad_http_request_duration_seconds |
histograma | Latência — o percentil se escolhe na consulta |
aiad_http_requests_in_flight |
medidor | Requisições sendo atendidas agora |
aiad_http_exceptions_total |
contador | Requisições que morreram sem devolver status |
aiad_process_resident_memory_bytes |
medidor | Memória residente |
aiad_process_cpu_seconds_total |
contador | CPU acumulada |
aiad_process_threads |
medidor | Threads vivas (o Puma atende com cinco) |
aiad_process_uptime_seconds |
medidor | Tempo desde a subida |
aiad_lexical_index_documents |
medidor | Trechos no índice BM25 — zero com acervo cheio é busca pela metade |
aiad_lexical_index_complete |
medidor | 1 se o índice cobre o acervo inteiro; 0 se parou no teto ou falhou |
aiad_llm_calls_total |
contador | Chamadas ao modelo, por modelo |
aiad_llm_prompt_tokens_total / aiad_llm_completion_tokens_total |
contador | Tokens gastos |
aiad_llm_cost_usd_total |
contador | Custo acumulado — zero sem AIAD_MODEL_PRICES |
aiad_llm_latency_seconds |
histograma | Duração das chamadas ao modelo |
aiad_llm_groundedness |
histograma | Fração das frases sustentadas pelo contexto |
aiad_llm_answer_relevancy |
histograma | Quanto a resposta trata da pergunta feita |
aiad_llm_context_relevancy |
histograma | Sobreposição média entre pergunta e trechos recuperados |
aiad_llm_unsupported_sentences_total |
contador | Afirmações sem apoio que saíram para o usuário |
O log traz ts, request_id, method, path, route, status, duration_ms e principal — pronto
para Loki ou jq, sem regex. Nunca entram o corpo (que em /documents é um documento inteiro e em
/ask é a pergunta do usuário) nem a credencial: da chave vai só o nome do principal. O x-request-id
volta na resposta. Além dele saem duas linhas de partida, lexical_index_warmup e model_prices, porque
ninguém olha painel durante um boot.
Prometheus, Grafana, Loki e Promtail sobem juntos, atrás de um profile — não é preciso tê-los de pé para trabalhar na API, e custam memória:
docker compose --profile observabilidade up -dO Grafana fica em http://127.0.0.1:3000 (admin, senha em GRAFANA_PASSWORD), com dois painéis
provisionados: Infraestrutura e API (throughput, latência, memória, CPU, recusas de credencial e o log
ao vivo) e LLM: custo e qualidade (custo por pergunta, tokens por minuto, as três notas ao longo do
tempo, o tamanho de cada corcova da sustentação e frases sem apoio).
Quatro decisões de infraestrutura, que não têm cabeçalho de classe onde morar:
- Fontes de dados e painéis são provisionados por arquivo, não clicados na interface. Painel que só existe no banco do Grafana morre com o volume, e ninguém revisa num pull request o que foi configurado.
- A chave do Prometheus não mora no
prometheus.yml. O arquivo é versionado, e chave em arquivo versionado é chave vazada. Ela chega porAIAD_METRICS_TOKENe vira arquivo no boot, porque a configuração do Prometheus não expande variável de ambiente. - O Promtail descobre containers pelo socket do Docker, e não por arquivo em
/var/lib/docker/containers: no Docker Desktop esse diretório vive dentro da VM. É um privilégio grande — quem lê o socket do Docker manda no Docker — aceitável numa stack local, e que num deploy real seria trocado por um agente sem esse acesso. - Só o Grafana publica porta. Prometheus e Loki ficam na rede interna do compose, como o Qdrant. E no
Loki a disciplina de cardinalidade é a mesma do Prometheus: viram rótulo só
container,leveleroute; o resto do JSON continua na linha, pesquisável, sem virar índice.
O Prometheus responde que o custo subiu ontem às 3h; não responde por quê. Para isso é preciso a requisição individual — qual pergunta chegou, qual prompt foi montado, qual span demorou — e é o que vai para o Langfuse, quando as duas chaves estão configuradas. Sem elas o exportador não é montado, o trace vai só para o Prometheus e a API sobe igual.
O formato do payload nunca foi verificado contra uma instância real. Tudo o que o
LangfuseBatchmonta — nomes de campo, tipos de evento, a forma do lote — é o melhor entendimento da API de ingestão, escrito sem um servidor para confirmar. O Qdrant roda em container neste projeto e a API da Anthropic é exercitada de verdade quando há chave; o Langfuse, não. Por isso a tradução mora numa classe separada do transporte: se o formato estiver errado, o conserto é emlib/langfuse_batch.rbe em nenhum outro lugar.
Não há Ruby na máquina — a suíte roda pelo serviço test do compose, que fica atrás de um profile e não
sobe junto com a API. O código entra por bind mount, então editar e rodar de novo não exige rebuild:
docker compose run --rm test # rspec (comando padrão)
docker compose run --rm test bundle exec cucumber
docker compose run --rm test bundle exec rubocopA suíte inteira, na mesma ordem do CI:
docker compose run --rm test bash -lc "bundle exec rubocop && bundle exec rspec && bundle exec cucumber"Depois de mexer no Gemfile, refaça a imagem com docker compose --profile test build test.
| Classe | Responsabilidade |
|---|---|
DocumentIngestor |
Ingestão e normalização do conteúdo bruto do documento |
ContentCleaner |
Limpeza por formato: texto, log (remove timestamp/nível) e PDF (marcadores de página, hifenização) |
DocumentChunker |
Divisão do conteúdo em chunks com sobreposição configurável |
EmbeddingGenerator |
Vetorização de texto (embeddings) e similaridade de cosseno |
QdrantClient |
CRUD de coleções e pontos no Qdrant, busca por similaridade com filtro de metadados e tuning de índice |
EtlPipeline |
Orquestra ingestão → limpeza → chunking → embeddings → indexação, e a busca semântica |
Bm25Index |
Índice léxico BM25 em memória (braço de palavra-chave da busca híbrida) |
HybridRetriever |
Funde o braço vetorial e o léxico por Reciprocal Rank Fusion |
MetadataFilter |
Avalia filtro de metadados no formato do Qdrant sobre um payload local |
PromptBuilder |
Monta o prompt do RAG com contexto numerado e origem de cada trecho |
RagPipeline |
Recuperação → prompt → geração, devolvendo resposta, trechos e origens |
HttpQdrantTransport |
Transporte HTTP real para o Qdrant, montado a partir do ambiente |
ExtractiveLlm |
Resposta extrativa, usada enquanto nenhum modelo real está configurado |
AnthropicLlm |
Modelo de verdade pela API de mensagens, com transporte injetável e chave fora do inspect |
ApiKeyStore |
Chaves de API e escopos, guardadas como digest e comparadas em tempo constante |
Api::AccessPolicy |
Escopo exigido por rota; rota não mapeada exige o escopo mais restritivo |
Api::Authentication |
Middleware Rack de autenticação e autorização |
Api::App |
API HTTP: /health, /documents, /search, /ask, /agent |
Reranker |
Reordena os candidatos recuperados, com scorer injetável |
ParentDocumentRetriever |
Busca no chunk, entrega o documento inteiro |
ParentStore |
Guarda o documento inteiro de cada origem |
HydeRetriever |
Busca com uma resposta hipotética gerada pelo modelo |
TokenCounter |
Estimativa de tokens, com tokenizador injetável |
UsageMeter |
Acumula tokens e custo por modelo |
PromptCompressor |
Encaixa o contexto num orçamento de tokens |
SemanticCache |
Cache por similaridade de embedding |
CachedRag |
Decorador de cache na frente do RAG, um por filtro |
ModelRouter |
Roteia a pergunta entre modelo barato e modelo forte — classe pronta e testada, fora da pilha da API (#37) |
Tool |
Ferramenta do agente: nome, descrição, parâmetros e validação estrita dos argumentos |
ToolRegistry |
Catálogo de ferramentas do prompt e despacho da chamada, com erro virando observação |
RetrievalTool |
Liga o agente ao acervo: recebe um termo, devolve trechos com a origem |
ReactParser |
Leitura da saída do modelo no formato ReAct |
ReactAgent |
Laço ReAct: pensamento → ação → observação, com teto de iterações e trajeto registrado |
PlanParser |
Leitura do plano numerado que o modelo escreveu |
PlanAndSolveAgent |
Planeja antes de agir, executa passo a passo alimentando o seguinte e sintetiza a resposta |
SpecialistTool |
Embrulha um agente inteiro como ferramenta, para o time tratar especialista e ferramenta igual |
StateGraph |
Grafo de estado: nós que transformam o estado e arestas (fixas ou condicionais) que decidem o próximo |
AgentCrew |
Time multi-agente sobre o grafo: rotear → executar → revisar, com a revisão devolvendo o trabalho |
ConversationStore |
Guarda a conversa em memória, com teto de sessões e descarte da menos usada |
FileConversationStore |
Mesmo contrato, em disco: o histórico sobrevive ao processo |
ConversationMemory |
Turnos da conversa e o pedaço do histórico que cabe no orçamento de tokens |
ConversationalAgent |
Dá memória a qualquer agente: histórico junto da pergunta nova, resposta registrada |
Tracer |
Spans aninhados com duração, entrada, saída, tokens e erro; Tracer.null desliga tudo a custo zero |
SessionMetrics |
Latência, custo e tokens por sessão, com média, máximo e p95 |
MetricsExporter |
Liga o tracer ao SessionMetrics: o que já é instrumentado vira medição |
AnswerEvaluator |
Sustentação no contexto (alucinação) e relevância de resposta e de contexto, com juiz injetável |
LlmJudge |
Juiz de sustentação por modelo, injetável no AnswerEvaluator; entende sinônimo e paráfrase |
EvaluationLog |
Média corrente das notas e a lista das respostas que pontuaram pior |
EvaluatedRag |
Decorador que pontua toda resposta assim que ela sai e alimenta o log |
MetricRegistry |
Contadores, medidores e histogramas, com rótulo declarado e sob mutex |
PrometheusExposition |
Escreve o registro no formato de texto que o Prometheus raspa |
ProcessCollector |
Memória residente, CPU, threads e uptime, amostrados no momento do scrape |
Api::Instrumentation |
Middleware que conta e cronometra requisições, com a rota normalizada |
Api::MetricsEndpoint |
Serve GET /metrics, dentro do controle de acesso |
Api::RequestLogger |
Uma linha JSON por requisição, sem corpo e sem credencial |
Api::Observability |
Monta o registro de métricas e envolve a aplicação com log e instrumentação |
PrometheusTraceExporter |
Publica tokens, custo e latência de modelo no registro, span a span |
PrometheusEvaluationLog |
Publica as notas de avaliação no registro, sem levar o texto junto |
Stemmer |
Reduz a palavra ao radical em português, para "trabalhar" casar com "trabalho" |
RelevanceFloor |
Descarta o trecho que não tem a ver com a pergunta, para o assistente recusar em vez de errar com convicção |
LangfuseExporter |
Manda o trace para o Langfuse: autenticação, timeout e tratamento de erro |
LangfuseBatch |
Traduz o trace para os eventos da ingestão — a única parte não verificada contra o serviço real |
CompositeExporter |
Entrega o mesmo trace a vários destinos, sem que a queda de um corte os outros |
Este README diz o que existe e como usar. A razão de cada decisão — com a alternativa que foi
rejeitada e o motivo — mora no cabeçalho da classe que a implementa, que é onde ela é lida por quem vai
mexer no código. Vinte e três classes carregam um cabeçalho desses; o AnswerEvaluator tem cinquenta
linhas de decisão numerada.
Isto aqui já foi uma segunda cópia dessas mesmas decisões, em prosa. Duas cópias de uma decisão divergem, e nada testa prosa — a errada seria a que a pessoa lê primeiro.
| Para entender | Leia |
|---|---|
| Por que existe um piso de relevância, e por que 0,45 | lib/relevance_floor.rb |
| Por que a nota de sustentação não distingue paráfrase de alucinação | lib/answer_evaluator.rb |
| Por que o juiz por modelo não é o padrão | lib/llm_judge.rb |
| Por que os baldes de cada nota são diferentes | lib/prometheus_evaluation_log.rb |
Por que o stemmer é um subconjunto do RSLP, e por que -am/-em ficaram de fora |
lib/stemmer.rb |
| Por que a fusão é por Reciprocal Rank Fusion | lib/hybrid_retriever.rb |
| Por que o índice léxico se reconstrói na partida, e o que acontece com vários workers | lib/api/lexical_index_warmup.rb |
| Por que a varredura pagina e tem dois tetos | lib/lexical_index_loader.rb |
| Por que o preço vem do ambiente e não há tabela embutida | lib/usage_meter.rb |
| Por que o uso medido volta junto com o texto, e nunca no objeto | lib/anthropic_llm.rb |
| Por que a chave vira digest e a comparação é em tempo constante | lib/api_key_store.rb |
| Por que o console é servido pela própria API, e não guarda a chave | lib/api/console.rb, public/index.html |
| Por que o cache fica por fora da avaliação | lib/cached_rag.rb, lib/evaluated_rag.rb |
| Por que a queda do Langfuse não leva a métrica junto | lib/composite_exporter.rb |
| Por que a rota do rótulo é normalizada antes de virar métrica | lib/api/instrumentation.rb |
| Por que o log não leva corpo nem credencial | lib/api/request_logger.rb |
| Como o transporte e o modelo entram por injeção de dependência | lib/qdrant_client.rb, lib/embedding_generator.rb |
Como trabalhar neste repositório — TDD/BDD, lint, convenção de commit, o que o CI cobre — está no CLAUDE.md. A trilha de aprendizagem, fase a fase, está na ROADMAP.md.