diff --git a/.env.example b/.env.example index d5e179e..457df85 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,14 @@ DOCUMENT_INGESTION_TOKEN=sinapse-dev-document-ingestion-token-2026 SEARCH_MIN_VECTOR_SIMILARITY=0.55 # Ranking textual mínimo para retornar correspondências full-text. SEARCH_MIN_TEXT_RANK=0.05 +# Aceitação combinada de similaridade (60%) e cobertura textual/sigla (40%). +SEARCH_MIN_HYBRID_SCORE=0.45 +# Similaridade forte preserva paráfrases sem exigir grande coincidência textual. +SEARCH_STRONG_VECTOR_SIMILARITY=0.6 +# Busca parcial exige pelo menos dois conceitos distintivos do projeto. +SEARCH_MIN_PARTIAL_SIMILARITY=0.5 +# Equivalências de termos também exigem dois conceitos e apoio semântico. +SEARCH_MIN_CONCEPT_SIMILARITY=0.45 # Limites do RepoAnalyzer ANALYZER_QUICK_FILES=8 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b54b50d..74c2c80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,10 @@ jobs: working-directory: backend run: npm run build + - name: Test search evaluation output and cleanup + working-directory: backend + run: npm run test:evaluation + validate-frontend: name: Build Frontend (React + Vite) runs-on: ubuntu-latest @@ -120,3 +124,7 @@ jobs: working-directory: ai-service run: python -m py_compile main.py config.py services/__init__.py services/chunker.py services/ollama_client.py harness.py + - name: Test versioned search evaluation + working-directory: ai-service + run: python -m unittest discover -s tests -p "test_search*.py" -v + diff --git a/ai-service/evaluation/README.md b/ai-service/evaluation/README.md index c0d2907..930dad8 100644 --- a/ai-service/evaluation/README.md +++ b/ai-service/evaluation/README.md @@ -45,15 +45,114 @@ O executor `run_search_suite.py` converte a resposta da rota S2-06 para este for - `Precision@5` macro: relevantes recuperados divididos por cinco nas consultas com resposta esperada; resultados ausentes contam como posições vazias. - `MRR@5`: média do inverso da posição da primeira evidência relevante, nas consultas com resposta esperada. - Acurácia de ausência: proporção das consultas sem evidência que retornaram lista vazia. -- Latência: percentis p50/p95 e proporção de consultas em até 2.000 ms, limite descrito no PBI-02.3.1. +- Latência: percentis p50/p95 e proporção de consultas abaixo de 2.000 ms, conforme RNF-06 do PRD. - Isolamento: contagem de resultados cujo `project_id` difere do projeto pedido. O objetivo de segurança é zero violações. - Métricas por categoria: repetem recuperação, acurácia sem evidência e p95 para cada tipo de consulta, permitindo localizar regressões que uma média global esconde. - Filtros de nível e tecnologia são enviados pelo executor quando declarados na consulta. A fixture PRE-06 não contém associações de tecnologia curadas; por isso a avaliação quantitativa de tecnologia requer um corpus/fixture versionado com tags revisadas, enquanto o filtro combinado já é coberto por teste PostgreSQL de integração. -O backlog não define um mínimo de Recall/Precision/MRR. Portanto, o relatório mede e registra a linha de base, sem inventar um critério de aprovação. O time/PO deve definir metas de relevância depois de observar a busca real. Para latência, os 2.000 ms são limite por consulta; reporte ambiente, hardware, concorrência e se houve aquecimento do Ollama para que comparações sejam reproduzíveis. +O backlog não define um mínimo de Recall/Precision/MRR. Portanto, o relatório mede e registra a linha de base, sem inventar um critério de aprovação. O time/PO deve definir metas de relevância depois de observar a busca real. Para latência, RNF-06 exige menos de 2.000 ms por consulta; exatamente 2.000 ms não passa. Reporte ambiente, hardware, concorrência e se houve aquecimento do Ollama para que comparações sejam reproduzíveis. ## Executar +### Execução isolada e reproduzível + +Há um orquestrador que cria PostgreSQL/pgvector e FastAPI temporários, aplica a fixture PRE-06, +gera os embeddings com o Ollama local, cria uma sessão exclusivamente de QA e executa a API real. +Ao terminar, inclusive em caso de erro, tenta encerrar todos os recursos; uma falha na +limpeza de um deles não interrompe as demais tentativas e produz saída de erro. + +Pré-requisitos: Docker, Python 3.11+, Ollama com `bge-m3` disponível na porta 11434 e imagem +`sinapse-s209-ai-test` construída a partir de `ai-service`. Validado em Windows/Docker Desktop. +Em Linux, o Ollama precisa aceitar conexões pelo gateway local usado pelo contêiner. + +```bash +# Na raiz, uma vez ou após alterar as dependências Python: +docker build -t sinapse-s209-ai-test ai-service + +# Em backend: +npm run evaluate:search -- --python python --version 2 --rounds 3 +``` + +Se o Windows resolver `python` para o atalho da Store, informe em `--python` o caminho do +executável instalado. `SEARCH_PYTHON` também define esse caminho. `--version 1` seleciona a +bateria original; nenhuma execução modifica fixtures ou gabaritos. + +A saída padrão fica em `ai-service/evaluation/results/s217-`, ignorada pelo Git; use +`--output` para preservar rodadas separadas. Cada rodada grava resultados e métricas. +`environment.json` registra hardware, runtimes, modelo/digest, imagem de IA, aquecimento e +concorrência. `summary.json` separa os gates de isolamento, latência e aceite de relevância. +O processo retorna erro se qualquer consulta atingir ou ultrapassar 2.000 ms. Violação de isolamento +é recusada pelo avaliador, inclusive depois da quinta posição. Um retorno zero não é aceite +de relevância: o backlog não define o mínimo de Recall/Precision/MRR. + +O preparo gera seis vetores do texto curado e marca os documentos dessa base descartável +como processados. Isso prepara a medição de recuperação; não é teste do upload/extração S2-01/S2-02. +Uma consulta de aquecimento fica fora da medição, e as rodadas usam concorrência 1. +Não compare números de ambientes diferentes como se fossem uma melhoria da implementação. + +Os testes unitários do avaliador e do adaptador HTTP são executados no CI, sem Docker ou Ollama. +Uma avaliação com modelo real permanece execução explícita, pois requer o runtime local. +O registro da reexecução sobre a API `9936354` está em +[QA-S2-17-2026-10-25](../../docs/QA-S2-17-2026-10-25.md). + +O diretório padrão inclui versão, data e identificador da execução. Um `--output` que já +exista é recusado, preservando as evidências anteriores; use uma pasta nova a cada execução. +O executor confere o dataset e seu corpus antes de criar recursos. O cliente HTTP recusa +redirecionamentos para que a sessão não seja encaminhada a outro destino. O avaliador +confere o nível da fonte e o filtro de nível; resultados antigos sem `entity_type` podem +usar o manifesto curado como referência. Fontes desconhecidas em consultas filtradas precisam +informar o nível. Os resultados da API atual incluem esse campo. + +Os hashes do dataset são capturados antes das consultas. Um dataset em memória sem arquivo +correspondente recebe hash do JSON canônico, identificado por `dataset_hash_kind`. +O avaliador verifica o hash quando presente; resultados históricos sem metadados continuam +compatíveis. Alterações de dataset ou fontes durante a medição invalidam a execução quando +há hashes verificáveis. Veja a [revisão complementar](../../docs/QA-S2-17-REVISAO-2026-10-25.md). + +### Correção de relevância e diagnóstico suplementar + +As quatro falhas suplementares foram tratadas depois da revisão complementar. A execução +mais recente está em [QA-S2-17-QUATRO-CONSULTAS-2026-10-25](../../docs/QA-S2-17-QUATRO-CONSULTAS-2026-10-25.md). +`search-diagnostics-draft-v2.json` preserva as 40 consultas suplementares anteriores e +acrescenta oito negativas para verificar ambiguidades dos conceitos. As versões publicadas +`search-ptbr-v1/v2.json` e suas fixtures permanecem inalteradas. + +A recuperação parcial exige pelo menos dois conceitos compartilhados que ajudem a +distinguir a fonte dentro do projeto. Termos presentes em todos os chunks do projeto não +sustentam essa etapa quando há mais de um chunk. O vocabulário versionado de autenticação, +papéis de gestão, restrição e inativação está em `backend/src/modules/search/search.concepts.ts`. +Conjugações com alternância c/qu e g/gu são normalizadas depois do stemming. Repetições e +sinônimos do mesmo conceito contam uma única vez. Os cortes independentes são +`SEARCH_MIN_PARTIAL_SIMILARITY=0.5` e `SEARCH_MIN_CONCEPT_SIMILARITY=0.45`. +Esse caminho também passa pelos filtros de projeto, acesso, nível e elegibilidade da origem. +O relatório registra a versão do vocabulário e os dois cortes adicionais. + +A reexecução após corrigir a recuperação está em +[QA-S2-17-CORRECOES-2026-10-25](../../docs/QA-S2-17-CORRECOES-2026-10-25.md). +A busca mantém o corte vetorial de 0,55 e o rank textual de 0,05. Candidatos sem +correspondência textual integral passam por uma combinação de similaridade e cobertura +dos termos (`SEARCH_MIN_HYBRID_SCORE`, padrão 0,45), preservando correspondências semânticas +fortes (`SEARCH_STRONG_VECTOR_SIMILARITY`, padrão 0,6). Quando nenhum candidato apresenta +apoio textual, permanece o fallback semântico original. Siglas em maiúsculas podem +corresponder às iniciais da expressão completa; números de 0 a 20 também são comparados +por extenso. Não há dicionário específico de respostas, siglas ou IDs do gabarito. + +`search-diagnostics-draft-v1.json` preserva as 26 perguntas da v2 e acrescenta 14 propostas +de diagnóstico. Seu gabarito suplementar ainda requer revisão do PO/time. Foi usado no +desenvolvimento do ajuste; não representa avaliação cega nem substitui a v2 publicada. +Para executar o diagnóstico sobre a fixture v2, dentro de `backend`: + +```bash +npm run evaluate:search -- --version 2 --rounds 1 --dataset ../ai-service/evaluation/datasets/search-diagnostics-draft-v1.json --output ../ai-service/evaluation/results/diagnostics +npm run test:integration:search +``` + +Use `--python caminho/do/python` caso o executável não se chame `python`. `--diagnostics` +registra os sinais de similaridade e cobertura em `diagnostics.json`, sem consultar outros +projetos. O ambiente registra as quatro configurações efetivas da busca, além das versões +independentes do corpus e do dataset. O teste de banco cobre sinais de relevância com vetores +determinísticos e verifica autorização, isolamento, arquivamento e documento não processado. + Executar as 24 consultas contra o backend local (a sessão deve ter acesso ao projeto de cada consulta): ```bash diff --git a/ai-service/evaluation/datasets/search-diagnostics-draft-v1.json b/ai-service/evaluation/datasets/search-diagnostics-draft-v1.json new file mode 100644 index 0000000..483d7f4 --- /dev/null +++ b/ai-service/evaluation/datasets/search-diagnostics-draft-v1.json @@ -0,0 +1,382 @@ +{ + "dataset": "sinapse-search-diagnostics", + "version": 1, + "corpus": { + "dataset": "pre06-historical-v2", + "file": "database/seed/fixtures/historical-v2.json", + "source_policy": "Documentação pública curada PRE-06 v2; inclui localizadores de origem GRF-01/GRF-08 e mantém consultas por conteúdo. A v1 permanece imutável." + }, + "source_projects": { + "62000000-0000-4000-8000-000000000006": "API-2", + "62000000-0000-4000-8000-000000000011": "API-3", + "62000000-0000-4000-8000-000000000001": "API-1" + }, + "source_project_ids": { + "62000000-0000-4000-8000-000000000013": "62000000-0000-4000-8000-000000000011", + "62000000-0000-4000-8000-000000000010": "62000000-0000-4000-8000-000000000006", + "62000000-0000-4000-8000-000000000008": "62000000-0000-4000-8000-000000000006", + "62000000-0000-4000-8000-000000000003": "62000000-0000-4000-8000-000000000001", + "62000000-0000-4000-8000-000000000005": "62000000-0000-4000-8000-000000000001", + "62000000-0000-4000-8000-000000000015": "62000000-0000-4000-8000-000000000011" + }, + "source_entity_types": { + "62000000-0000-4000-8000-000000000013": "documento", + "62000000-0000-4000-8000-000000000010": "documento", + "62000000-0000-4000-8000-000000000008": "documento", + "62000000-0000-4000-8000-000000000003": "documento", + "62000000-0000-4000-8000-000000000005": "documento", + "62000000-0000-4000-8000-000000000015": "documento" + }, + "queries": [ + { + "id": "Q001", + "category": "semantic_paraphrase", + "query": "Como os administradores entram na plataforma?", + "project_id": "62000000-0000-4000-8000-000000000001", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000003" + ] + }, + { + "id": "Q002", + "category": "semantic_paraphrase", + "query": "Gerenciar as permissões de acesso de quem administra o sistema", + "project_id": "62000000-0000-4000-8000-000000000001", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000003" + ] + }, + { + "id": "Q003", + "category": "semantic_paraphrase", + "query": "O RH pode criar, consultar, editar e desativar contas de pessoas?", + "project_id": "62000000-0000-4000-8000-000000000006", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000008" + ] + }, + { + "id": "Q004", + "category": "semantic_paraphrase", + "query": "Preciso manter o plano de desenvolvimento de cada colaborador por exercício", + "project_id": "62000000-0000-4000-8000-000000000006", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000010" + ] + }, + { + "id": "Q005", + "category": "semantic_paraphrase", + "query": "Acompanhar em gráficos como a equipe está sendo avaliada", + "project_id": "62000000-0000-4000-8000-000000000001", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000005" + ] + }, + { + "id": "Q006", + "category": "semantic_paraphrase", + "query": "Visualizar a evolução do saldo de crédito do país", + "project_id": "62000000-0000-4000-8000-000000000011", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000013" + ] + }, + { + "id": "Q007", + "category": "semantic_paraphrase", + "query": "Comparar os estados pela pontuação de oportunidades", + "project_id": "62000000-0000-4000-8000-000000000011", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000015" + ] + }, + { + "id": "Q008", + "category": "semantic_paraphrase", + "query": "Qual funcionalidade registra um PDI anual ligado ao funcionário?", + "project_id": "62000000-0000-4000-8000-000000000006", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000010" + ] + }, + { + "id": "Q009", + "category": "exact_identifier", + "query": "GRF-01", + "project_id": "62000000-0000-4000-8000-000000000011", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000013" + ] + }, + { + "id": "Q010", + "category": "exact_identifier", + "query": "GRF-08", + "project_id": "62000000-0000-4000-8000-000000000011", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000015" + ] + }, + { + "id": "Q011", + "category": "exact_identifier", + "query": "20539", + "project_id": "62000000-0000-4000-8000-000000000011", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000013" + ] + }, + { + "id": "Q012", + "category": "exact_identifier", + "query": "20539, 20540 e 20541", + "project_id": "62000000-0000-4000-8000-000000000011", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000013" + ] + }, + { + "id": "Q013", + "category": "exact_term", + "query": "índice de oportunidade", + "project_id": "62000000-0000-4000-8000-000000000011", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000015" + ] + }, + { + "id": "Q014", + "category": "exact_term", + "query": "login para administradores", + "project_id": "62000000-0000-4000-8000-000000000001", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000003" + ] + }, + { + "id": "Q015", + "category": "combined_intent", + "query": "Gráfico de barras para ordenar estados pelo índice de oportunidade entre zero e dez", + "project_id": "62000000-0000-4000-8000-000000000011", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000015" + ] + }, + { + "id": "Q016", + "category": "combined_intent", + "query": "Criar gráficos para analisar a avaliação e o desempenho de equipes", + "project_id": "62000000-0000-4000-8000-000000000001", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000005" + ] + }, + { + "id": "Q017", + "category": "no_relevant_result", + "query": "Integração de pagamentos via PIX com conciliação bancária", + "project_id": "62000000-0000-4000-8000-000000000011", + "expected_source_ids": [] + }, + { + "id": "Q018", + "category": "no_relevant_result", + "query": "Autenticação OAuth com refresh token e PKCE", + "project_id": "62000000-0000-4000-8000-000000000001", + "expected_source_ids": [] + }, + { + "id": "Q019", + "category": "no_relevant_result", + "query": "Upload de PDF com antivírus, limite de tamanho e retentativa", + "project_id": "62000000-0000-4000-8000-000000000006", + "expected_source_ids": [] + }, + { + "id": "Q020", + "category": "project_isolation", + "query": "Gráficos de avaliação e desempenho da equipe", + "project_id": "62000000-0000-4000-8000-000000000006", + "expected_source_ids": [] + }, + { + "id": "Q021", + "category": "project_isolation", + "query": "Acesso ao sistema pelas pessoas responsáveis pela administração", + "project_id": "62000000-0000-4000-8000-000000000001", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000003" + ] + }, + { + "id": "Q022", + "category": "project_isolation", + "query": "Saldo de crédito representado por gráfico de linha", + "project_id": "62000000-0000-4000-8000-000000000001", + "expected_source_ids": [] + }, + { + "id": "Q023", + "category": "combined_filters", + "query": "Acesso dos administradores ao sistema", + "project_id": "62000000-0000-4000-8000-000000000001", + "filters": { + "level": "documento" + }, + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000003" + ] + }, + { + "id": "Q024", + "category": "combined_filters", + "query": "Acesso dos administradores ao sistema", + "project_id": "62000000-0000-4000-8000-000000000001", + "filters": { + "level": "pbi" + }, + "expected_source_ids": [] + }, + { + "project_id": "62000000-0000-4000-8000-000000000011", + "category": "content_search", + "id": "Q025", + "query": "Como visualizar o saldo de crédito nacional em um gráfico de linha?", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000013" + ] + }, + { + "project_id": "62000000-0000-4000-8000-000000000011", + "category": "content_search", + "id": "Q026", + "query": "Como comparar oportunidades entre os estados usando o índice de oportunidade?", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000015" + ] + }, + { + "id": "H001", + "query": "De que maneira a chefia consegue entrar na área restrita?", + "project_id": "62000000-0000-4000-8000-000000000001", + "category": "diagnostic_paraphrase", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000003" + ] + }, + { + "id": "H002", + "query": "Permitir que responsáveis se identifiquem antes de utilizar os recursos", + "project_id": "62000000-0000-4000-8000-000000000001", + "category": "diagnostic_paraphrase", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000003" + ] + }, + { + "id": "H003", + "query": "Existe um painel para acompanhar os resultados de um grupo de trabalho?", + "project_id": "62000000-0000-4000-8000-000000000001", + "category": "diagnostic_paraphrase", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000005" + ] + }, + { + "id": "H004", + "query": "Ver a performance da equipe em representações visuais", + "project_id": "62000000-0000-4000-8000-000000000001", + "category": "diagnostic_paraphrase", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000005" + ] + }, + { + "id": "H005", + "query": "Manutenção das contas de acesso pela área de recursos humanos", + "project_id": "62000000-0000-4000-8000-000000000006", + "category": "diagnostic_paraphrase", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000008" + ] + }, + { + "id": "H006", + "query": "Desabilitar o cadastro de uma pessoa sem apagar seus dados", + "project_id": "62000000-0000-4000-8000-000000000006", + "category": "diagnostic_paraphrase", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000008" + ] + }, + { + "id": "H007", + "query": "Guardar a evolução profissional de cada pessoa ano a ano", + "project_id": "62000000-0000-4000-8000-000000000006", + "category": "diagnostic_paraphrase", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000010" + ] + }, + { + "id": "H008", + "query": "Cadastrar um plano de desenvolvimento individual para um empregado em determinado ano", + "project_id": "62000000-0000-4000-8000-000000000006", + "category": "diagnostic_paraphrase", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000010" + ] + }, + { + "id": "H009", + "query": "Quais séries compõem o desenho da evolução do crédito em todo o Brasil?", + "project_id": "62000000-0000-4000-8000-000000000011", + "category": "diagnostic_paraphrase", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000013" + ] + }, + { + "id": "H010", + "query": "Comparação regional das notas de oportunidade em barras", + "project_id": "62000000-0000-4000-8000-000000000011", + "category": "diagnostic_paraphrase", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000015" + ] + }, + { + "id": "H011", + "query": "Efetuar transferências TED entre contas correntes", + "project_id": "62000000-0000-4000-8000-000000000001", + "category": "diagnostic_negative", + "expected_source_ids": [] + }, + { + "id": "H012", + "query": "Agendar consultas médicas e emitir receitas", + "project_id": "62000000-0000-4000-8000-000000000006", + "category": "diagnostic_negative", + "expected_source_ids": [] + }, + { + "id": "H013", + "query": "Cobrança recorrente de assinaturas em cartão", + "project_id": "62000000-0000-4000-8000-000000000011", + "category": "diagnostic_negative", + "expected_source_ids": [] + }, + { + "id": "H014", + "query": "Ordenar unidades federativas de acordo com uma nota de 0 a 10", + "project_id": "62000000-0000-4000-8000-000000000011", + "category": "diagnostic_paraphrase", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000015" + ] + } + ], + "annotation_status": "draft_pending_product_review", + "purpose": "Diagnóstico de desenvolvimento; não substitui o gabarito publicado nem aceite independente." +} diff --git a/ai-service/evaluation/datasets/search-diagnostics-draft-v2.json b/ai-service/evaluation/datasets/search-diagnostics-draft-v2.json new file mode 100644 index 0000000..e0630bf --- /dev/null +++ b/ai-service/evaluation/datasets/search-diagnostics-draft-v2.json @@ -0,0 +1,438 @@ +{ + "dataset": "sinapse-search-diagnostics", + "version": 2, + "corpus": { + "dataset": "pre06-historical-v2", + "file": "database/seed/fixtures/historical-v2.json", + "source_policy": "Documentação pública curada PRE-06 v2; inclui localizadores de origem GRF-01/GRF-08 e mantém consultas por conteúdo. A v1 permanece imutável." + }, + "source_projects": { + "62000000-0000-4000-8000-000000000006": "API-2", + "62000000-0000-4000-8000-000000000011": "API-3", + "62000000-0000-4000-8000-000000000001": "API-1" + }, + "source_project_ids": { + "62000000-0000-4000-8000-000000000013": "62000000-0000-4000-8000-000000000011", + "62000000-0000-4000-8000-000000000010": "62000000-0000-4000-8000-000000000006", + "62000000-0000-4000-8000-000000000008": "62000000-0000-4000-8000-000000000006", + "62000000-0000-4000-8000-000000000003": "62000000-0000-4000-8000-000000000001", + "62000000-0000-4000-8000-000000000005": "62000000-0000-4000-8000-000000000001", + "62000000-0000-4000-8000-000000000015": "62000000-0000-4000-8000-000000000011" + }, + "source_entity_types": { + "62000000-0000-4000-8000-000000000013": "documento", + "62000000-0000-4000-8000-000000000010": "documento", + "62000000-0000-4000-8000-000000000008": "documento", + "62000000-0000-4000-8000-000000000003": "documento", + "62000000-0000-4000-8000-000000000005": "documento", + "62000000-0000-4000-8000-000000000015": "documento" + }, + "queries": [ + { + "id": "Q001", + "category": "semantic_paraphrase", + "query": "Como os administradores entram na plataforma?", + "project_id": "62000000-0000-4000-8000-000000000001", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000003" + ] + }, + { + "id": "Q002", + "category": "semantic_paraphrase", + "query": "Gerenciar as permissões de acesso de quem administra o sistema", + "project_id": "62000000-0000-4000-8000-000000000001", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000003" + ] + }, + { + "id": "Q003", + "category": "semantic_paraphrase", + "query": "O RH pode criar, consultar, editar e desativar contas de pessoas?", + "project_id": "62000000-0000-4000-8000-000000000006", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000008" + ] + }, + { + "id": "Q004", + "category": "semantic_paraphrase", + "query": "Preciso manter o plano de desenvolvimento de cada colaborador por exercício", + "project_id": "62000000-0000-4000-8000-000000000006", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000010" + ] + }, + { + "id": "Q005", + "category": "semantic_paraphrase", + "query": "Acompanhar em gráficos como a equipe está sendo avaliada", + "project_id": "62000000-0000-4000-8000-000000000001", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000005" + ] + }, + { + "id": "Q006", + "category": "semantic_paraphrase", + "query": "Visualizar a evolução do saldo de crédito do país", + "project_id": "62000000-0000-4000-8000-000000000011", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000013" + ] + }, + { + "id": "Q007", + "category": "semantic_paraphrase", + "query": "Comparar os estados pela pontuação de oportunidades", + "project_id": "62000000-0000-4000-8000-000000000011", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000015" + ] + }, + { + "id": "Q008", + "category": "semantic_paraphrase", + "query": "Qual funcionalidade registra um PDI anual ligado ao funcionário?", + "project_id": "62000000-0000-4000-8000-000000000006", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000010" + ] + }, + { + "id": "Q009", + "category": "exact_identifier", + "query": "GRF-01", + "project_id": "62000000-0000-4000-8000-000000000011", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000013" + ] + }, + { + "id": "Q010", + "category": "exact_identifier", + "query": "GRF-08", + "project_id": "62000000-0000-4000-8000-000000000011", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000015" + ] + }, + { + "id": "Q011", + "category": "exact_identifier", + "query": "20539", + "project_id": "62000000-0000-4000-8000-000000000011", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000013" + ] + }, + { + "id": "Q012", + "category": "exact_identifier", + "query": "20539, 20540 e 20541", + "project_id": "62000000-0000-4000-8000-000000000011", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000013" + ] + }, + { + "id": "Q013", + "category": "exact_term", + "query": "índice de oportunidade", + "project_id": "62000000-0000-4000-8000-000000000011", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000015" + ] + }, + { + "id": "Q014", + "category": "exact_term", + "query": "login para administradores", + "project_id": "62000000-0000-4000-8000-000000000001", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000003" + ] + }, + { + "id": "Q015", + "category": "combined_intent", + "query": "Gráfico de barras para ordenar estados pelo índice de oportunidade entre zero e dez", + "project_id": "62000000-0000-4000-8000-000000000011", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000015" + ] + }, + { + "id": "Q016", + "category": "combined_intent", + "query": "Criar gráficos para analisar a avaliação e o desempenho de equipes", + "project_id": "62000000-0000-4000-8000-000000000001", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000005" + ] + }, + { + "id": "Q017", + "category": "no_relevant_result", + "query": "Integração de pagamentos via PIX com conciliação bancária", + "project_id": "62000000-0000-4000-8000-000000000011", + "expected_source_ids": [] + }, + { + "id": "Q018", + "category": "no_relevant_result", + "query": "Autenticação OAuth com refresh token e PKCE", + "project_id": "62000000-0000-4000-8000-000000000001", + "expected_source_ids": [] + }, + { + "id": "Q019", + "category": "no_relevant_result", + "query": "Upload de PDF com antivírus, limite de tamanho e retentativa", + "project_id": "62000000-0000-4000-8000-000000000006", + "expected_source_ids": [] + }, + { + "id": "Q020", + "category": "project_isolation", + "query": "Gráficos de avaliação e desempenho da equipe", + "project_id": "62000000-0000-4000-8000-000000000006", + "expected_source_ids": [] + }, + { + "id": "Q021", + "category": "project_isolation", + "query": "Acesso ao sistema pelas pessoas responsáveis pela administração", + "project_id": "62000000-0000-4000-8000-000000000001", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000003" + ] + }, + { + "id": "Q022", + "category": "project_isolation", + "query": "Saldo de crédito representado por gráfico de linha", + "project_id": "62000000-0000-4000-8000-000000000001", + "expected_source_ids": [] + }, + { + "id": "Q023", + "category": "combined_filters", + "query": "Acesso dos administradores ao sistema", + "project_id": "62000000-0000-4000-8000-000000000001", + "filters": { + "level": "documento" + }, + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000003" + ] + }, + { + "id": "Q024", + "category": "combined_filters", + "query": "Acesso dos administradores ao sistema", + "project_id": "62000000-0000-4000-8000-000000000001", + "filters": { + "level": "pbi" + }, + "expected_source_ids": [] + }, + { + "project_id": "62000000-0000-4000-8000-000000000011", + "category": "content_search", + "id": "Q025", + "query": "Como visualizar o saldo de crédito nacional em um gráfico de linha?", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000013" + ] + }, + { + "project_id": "62000000-0000-4000-8000-000000000011", + "category": "content_search", + "id": "Q026", + "query": "Como comparar oportunidades entre os estados usando o índice de oportunidade?", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000015" + ] + }, + { + "id": "H001", + "query": "De que maneira a chefia consegue entrar na área restrita?", + "project_id": "62000000-0000-4000-8000-000000000001", + "category": "diagnostic_paraphrase", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000003" + ] + }, + { + "id": "H002", + "query": "Permitir que responsáveis se identifiquem antes de utilizar os recursos", + "project_id": "62000000-0000-4000-8000-000000000001", + "category": "diagnostic_paraphrase", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000003" + ] + }, + { + "id": "H003", + "query": "Existe um painel para acompanhar os resultados de um grupo de trabalho?", + "project_id": "62000000-0000-4000-8000-000000000001", + "category": "diagnostic_paraphrase", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000005" + ] + }, + { + "id": "H004", + "query": "Ver a performance da equipe em representações visuais", + "project_id": "62000000-0000-4000-8000-000000000001", + "category": "diagnostic_paraphrase", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000005" + ] + }, + { + "id": "H005", + "query": "Manutenção das contas de acesso pela área de recursos humanos", + "project_id": "62000000-0000-4000-8000-000000000006", + "category": "diagnostic_paraphrase", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000008" + ] + }, + { + "id": "H006", + "query": "Desabilitar o cadastro de uma pessoa sem apagar seus dados", + "project_id": "62000000-0000-4000-8000-000000000006", + "category": "diagnostic_paraphrase", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000008" + ] + }, + { + "id": "H007", + "query": "Guardar a evolução profissional de cada pessoa ano a ano", + "project_id": "62000000-0000-4000-8000-000000000006", + "category": "diagnostic_paraphrase", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000010" + ] + }, + { + "id": "H008", + "query": "Cadastrar um plano de desenvolvimento individual para um empregado em determinado ano", + "project_id": "62000000-0000-4000-8000-000000000006", + "category": "diagnostic_paraphrase", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000010" + ] + }, + { + "id": "H009", + "query": "Quais séries compõem o desenho da evolução do crédito em todo o Brasil?", + "project_id": "62000000-0000-4000-8000-000000000011", + "category": "diagnostic_paraphrase", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000013" + ] + }, + { + "id": "H010", + "query": "Comparação regional das notas de oportunidade em barras", + "project_id": "62000000-0000-4000-8000-000000000011", + "category": "diagnostic_paraphrase", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000015" + ] + }, + { + "id": "H011", + "query": "Efetuar transferências TED entre contas correntes", + "project_id": "62000000-0000-4000-8000-000000000001", + "category": "diagnostic_negative", + "expected_source_ids": [] + }, + { + "id": "H012", + "query": "Agendar consultas médicas e emitir receitas", + "project_id": "62000000-0000-4000-8000-000000000006", + "category": "diagnostic_negative", + "expected_source_ids": [] + }, + { + "id": "H013", + "query": "Cobrança recorrente de assinaturas em cartão", + "project_id": "62000000-0000-4000-8000-000000000011", + "category": "diagnostic_negative", + "expected_source_ids": [] + }, + { + "id": "H014", + "query": "Ordenar unidades federativas de acordo com uma nota de 0 a 10", + "project_id": "62000000-0000-4000-8000-000000000011", + "category": "diagnostic_paraphrase", + "expected_source_ids": [ + "62000000-0000-4000-8000-000000000015" + ] + }, + { + "id": "C001", + "query": "Desabilitar notificações push de aplicativo", + "project_id": "62000000-0000-4000-8000-000000000006", + "category": "concept_regression_negative", + "expected_source_ids": [] + }, + { + "id": "C002", + "query": "Desativar a cobrança automática de assinaturas", + "project_id": "62000000-0000-4000-8000-000000000006", + "category": "concept_regression_negative", + "expected_source_ids": [] + }, + { + "id": "C003", + "query": "Autenticar pacientes para emitir receitas médicas", + "project_id": "62000000-0000-4000-8000-000000000001", + "category": "concept_regression_negative", + "expected_source_ids": [] + }, + { + "id": "C004", + "query": "Restringir o limite de saque no cartão de crédito", + "project_id": "62000000-0000-4000-8000-000000000001", + "category": "concept_regression_negative", + "expected_source_ids": [] + }, + { + "id": "C005", + "query": "Desabilitar cadastro de paciente e apagar prontuários", + "project_id": "62000000-0000-4000-8000-000000000006", + "category": "concept_regression_negative", + "expected_source_ids": [] + }, + { + "id": "C006", + "query": "Quais séries mostram o faturamento de assinaturas mensais?", + "project_id": "62000000-0000-4000-8000-000000000011", + "category": "concept_regression_negative", + "expected_source_ids": [] + }, + { + "id": "C007", + "query": "Programar limpeza automática de arquivos do servidor", + "project_id": "62000000-0000-4000-8000-000000000011", + "category": "concept_regression_negative", + "expected_source_ids": [] + }, + { + "id": "C008", + "query": "Restringir a entrada de veículos no estacionamento", + "project_id": "62000000-0000-4000-8000-000000000001", + "category": "concept_regression_negative", + "expected_source_ids": [] + } + ], + "annotation_status": "draft_pending_product_review", + "purpose": "Diagnóstico suplementar com regressões de conceitos. Gabarito provisório sujeito a curadoria; não é avaliação independente." +} diff --git a/ai-service/evaluation/evaluate_search.py b/ai-service/evaluation/evaluate_search.py index 5367226..45e58bc 100644 --- a/ai-service/evaluation/evaluate_search.py +++ b/ai-service/evaluation/evaluate_search.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import hashlib import json import math import sys @@ -12,6 +13,7 @@ DEFAULT_DATASET = Path(__file__).parent / "datasets" / "search-ptbr-v1.json" DEFAULT_LATENCY_BUDGET_MS = 2000 +SEARCH_LEVELS = {"documento", "decisao", "epico", "feature", "pbi", "analise_repositorio"} def load_json(path: Path) -> dict[str, Any]: @@ -19,10 +21,37 @@ def load_json(path: Path) -> dict[str, Any]: return json.load(file) +def canonical_dataset_sha256(dataset: dict[str, Any]) -> str: + data = json.dumps(dataset, sort_keys=True, separators=(',', ':'), ensure_ascii=False).encode('utf-8') + return hashlib.sha256(data).hexdigest() + + +def verify_dataset_provenance(dataset: dict[str, Any], results: dict[str, Any], path: Path) -> None: + metadata = results.get('execution_metadata', {}) + if not isinstance(metadata, dict): + raise ValueError('execution_metadata deve ser um objeto') + fingerprint = metadata.get('dataset_sha256') + if fingerprint is not None: + kind = metadata.get('dataset_hash_kind', 'file') + if kind == 'file': + expected = hashlib.sha256(path.read_bytes()).hexdigest() + elif kind == 'canonical_json': + expected = canonical_dataset_sha256(dataset) + else: + raise ValueError('dataset_hash_kind inválido') + if fingerprint != expected: + raise ValueError('o hash do dataset dos resultados não corresponde ao arquivo avaliado') + corpus_hash = metadata.get('corpus_sha256') + if corpus_hash not in (None, 'unavailable'): + corpus = Path(__file__).resolve().parents[2] / (dataset.get('corpus') or {}).get('file', '') + if not corpus.is_file() or hashlib.sha256(corpus.read_bytes()).hexdigest() != corpus_hash: + raise ValueError('o hash do corpus dos resultados não corresponde ao corpus avaliado') + + def validate_dataset(dataset: dict[str, Any], minimum_queries: int = 20) -> None: if not isinstance(dataset, dict) or not isinstance(dataset.get("dataset"), str) or not dataset["dataset"].strip(): raise ValueError("dataset.dataset deve ser um nome não vazio") - if not isinstance(dataset.get("version"), int) or dataset["version"] < 1: + if type(dataset.get("version")) is not int or dataset["version"] < 1: raise ValueError("dataset.version deve ser um inteiro positivo") queries = dataset.get("queries") if not isinstance(queries, list) or len(queries) < minimum_queries: @@ -40,9 +69,11 @@ def validate_dataset(dataset: dict[str, Any], minimum_queries: int = 20) -> None source_entity_types = dataset.get("source_entity_types") if not isinstance(source_entity_types, dict) or set(source_entity_types) != set(source_project_ids): raise ValueError("dataset.source_entity_types deve mapear todas as evidências do corpus") - allowed_levels = {"documento", "decisao", "epico", "feature", "pbi"} - if not set(source_entity_types.values()) <= allowed_levels: + if any(not isinstance(level, str) or level not in SEARCH_LEVELS for level in source_entity_types.values()): raise ValueError("source_entity_types contém nível inválido") + corpus = dataset.get('corpus') + if corpus is not None and (not isinstance(corpus, dict) or not isinstance(corpus.get('file'), str) or not corpus['file'].strip()): + raise ValueError('dataset.corpus deve informar o arquivo do corpus') query_ids: set[str] = set() for query in queries: if not isinstance(query, dict): @@ -53,6 +84,8 @@ def validate_dataset(dataset: dict[str, Any], minimum_queries: int = 20) -> None query_ids.add(query_id) if not isinstance(query.get("query"), str) or not query["query"].strip(): raise ValueError(f"{query_id}: consulta vazia") + if 'category' in query and (not isinstance(query['category'], str) or not query['category'].strip()): + raise ValueError(f'{query_id}: category deve ser texto não vazio') expected = query.get("expected_source_ids") if not isinstance(expected, list) or any(not isinstance(item, str) or not item for item in expected): raise ValueError(f"{query_id}: expected_source_ids deve ser uma lista de ids") @@ -68,7 +101,7 @@ def validate_dataset(dataset: dict[str, Any], minimum_queries: int = 20) -> None filters = query.get("filters", {}) if not isinstance(filters, dict): raise ValueError(f"{query_id}: filters deve ser um objeto") - if filters.get("level") is not None and filters["level"] not in allowed_levels: + if filters.get("level") is not None and (not isinstance(filters["level"], str) or filters["level"] not in SEARCH_LEVELS): raise ValueError(f"{query_id}: nível de filtro inválido") if filters.get("level") and any(source_entity_types[source_id] != filters["level"] for source_id in expected): raise ValueError(f"{query_id}: evidência esperada não corresponde ao filtro de nível") @@ -91,7 +124,7 @@ def evaluate(dataset: dict[str, Any], results_document: dict[str, Any], k: int = raise ValueError("results deve ser um objeto") if results_document.get("dataset") != dataset["dataset"]: raise ValueError("os resultados não correspondem ao dataset solicitado") - if results_document.get("version") != dataset["version"]: + if type(results_document.get("version")) is not int or results_document["version"] != dataset["version"]: raise ValueError("a versão dos resultados não corresponde ao dataset") expected_by_id = {query["id"]: query for query in dataset["queries"]} @@ -103,7 +136,7 @@ def evaluate(dataset: dict[str, Any], results_document: dict[str, Any], k: int = if not isinstance(run, dict): raise ValueError("cada execução deve ser um objeto") query_id = run.get("query_id") - if query_id not in expected_by_id: + if not isinstance(query_id, str) or query_id not in expected_by_id: raise ValueError(f"resultado contém query_id desconhecido: {query_id}") if query_id in actual_by_id: raise ValueError(f"resultado contém query_id duplicado: {query_id}") @@ -126,6 +159,15 @@ def evaluate(dataset: dict[str, Any], results_document: dict[str, Any], k: int = seen.add(item["source_id"]) if item["source_id"] in dataset["source_project_ids"] and item["project_id"] != dataset["source_project_ids"][item["source_id"]]: raise ValueError(f"{query_id}: project_id retornado contradiz a origem conhecida da evidência") + known_level = dataset['source_entity_types'].get(item['source_id']) + returned_level = item.get('entity_type', known_level) + if returned_level is not None and (not isinstance(returned_level, str) or returned_level not in SEARCH_LEVELS): + raise ValueError(f'{query_id}: nível retornado inválido') + if known_level is not None and returned_level != known_level: + raise ValueError(f'{query_id}: nível retornado contradiz a origem conhecida') + level_filter = expected_by_id[query_id].get('filters', {}).get('level') + if level_filter is not None and returned_level != level_filter: + raise ValueError(f'{query_id}: resultado fora do nível solicitado') query_project_id = expected_by_id[query_id].get("project_id") if item["project_id"] != query_project_id: raise ValueError(f"{query_id}: resultado retornado fora do projeto solicitado") @@ -153,7 +195,7 @@ def evaluate(dataset: dict[str, Any], results_document: dict[str, Any], k: int = relevant_positions = [index + 1 for index, source_id in enumerate(returned_ids) if source_id in expected_ids] latency = float(run["latency_ms"]) latencies.append(latency) - within_budget += latency <= latency_budget_ms + within_budget += latency < latency_budget_ms category = query.get("category", "uncategorized") category_bucket = category_metrics.setdefault(category, { "answerable": [], "no_result": [], "latencies": [], @@ -196,7 +238,7 @@ def evaluate(dataset: dict[str, Any], results_document: dict[str, Any], k: int = "precision_at_k": precision, "reciprocal_rank_at_k": reciprocal_rank, "latency_ms": latency, - "within_latency_budget": latency <= latency_budget_ms, + "within_latency_budget": latency < latency_budget_ms, }) by_category = {} @@ -253,7 +295,9 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--latency-budget-ms", type=int, default=DEFAULT_LATENCY_BUDGET_MS) args = parser.parse_args(argv) try: - report = evaluate(load_json(args.dataset), load_json(args.results), args.k, args.latency_budget_ms) + dataset, results = load_json(args.dataset), load_json(args.results) + report = evaluate(dataset, results, args.k, args.latency_budget_ms) + verify_dataset_provenance(dataset, results, args.dataset) except (OSError, json.JSONDecodeError, ValueError) as error: print(f"Erro na avaliação: {error}", file=sys.stderr) return 2 diff --git a/ai-service/evaluation/run_search_suite.py b/ai-service/evaluation/run_search_suite.py index 8101c49..2b76014 100644 --- a/ai-service/evaluation/run_search_suite.py +++ b/ai-service/evaluation/run_search_suite.py @@ -6,6 +6,7 @@ import hashlib import ipaddress import json +import math import os import subprocess import sys @@ -15,12 +16,21 @@ from urllib.parse import urlsplit from urllib.error import HTTPError, URLError from urllib.parse import urlencode -from urllib.request import Request, urlopen +from urllib.request import Request, HTTPRedirectHandler, build_opener -from evaluate_search import DEFAULT_DATASET, load_json, validate_dataset +from evaluate_search import DEFAULT_DATASET, SEARCH_LEVELS, canonical_dataset_sha256, load_json, validate_dataset REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +EVALUATION_V2 = Path(__file__).parent / 'datasets' / 'search-ptbr-v2.json' + + +class NoRedirects(HTTPRedirectHandler): + def redirect_request(self, request, response, code, message, headers, new_url): + return None + + +urlopen = build_opener(NoRedirects()).open def validate_api_base_url(value: str) -> str: @@ -82,6 +92,7 @@ def working_tree_is_dirty(root: Path = REPOSITORY_ROOT) -> bool: def run_query(api_base_url: str, session_cookie: str, query: dict, limit: int, timeout: float) -> dict: + api_base_url = validate_api_base_url(api_base_url) parameters = { "q": query["query"], "projeto_id": query["project_id"], @@ -114,6 +125,10 @@ def run_query(api_base_url: str, session_cookie: str, query: dict, limit: int, t if not isinstance(item, dict) or not isinstance(item.get("id"), str) or not isinstance(item.get("project_id"), str): raise RuntimeError(f"{query['id']}: resultado sem id ou project_id verificável") normalized_item = {"source_id": item["id"], "project_id": item["project_id"]} + level = item.get('entity_type') + if not isinstance(level, str) or level not in SEARCH_LEVELS: + raise RuntimeError(f"{query['id']}: resultado sem entity_type verificável") + normalized_item['entity_type'] = level score = item.get("relevance_score") if isinstance(score, (int, float)) and not isinstance(score, bool): normalized_item["relevance_score"] = float(score) @@ -127,9 +142,24 @@ def run_suite(dataset: dict, api_base_url: str, session_cookie: str, limit: int api_base_url = validate_api_base_url(api_base_url) if not session_cookie.strip(): raise ValueError("SINAPSE_SESSION_COOKIE não foi configurado") - if limit < 1 or limit > 50: + if type(limit) is not int or limit < 1 or limit > 50: raise ValueError("limit deve estar entre 1 e 50") + if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not math.isfinite(timeout) or timeout <= 0: + raise ValueError('timeout deve ser um número positivo e finito') + explicit_dataset = dataset_path or (Path(os.environ['SEARCH_DATASET_PATH']) if os.getenv('SEARCH_DATASET_PATH') else None) + dataset_file = explicit_dataset or (DEFAULT_DATASET if dataset['version'] == 1 else EVALUATION_V2) + same_file = dataset_file.is_file() and load_json(dataset_file) == dataset + if explicit_dataset is not None and not same_file: + raise ValueError('o arquivo informado não corresponde ao dataset executado') + dataset_hash_kind = 'file' if same_file else 'canonical_json' + dataset_hash = _sha256_file(dataset_file) if same_file else canonical_dataset_sha256(dataset) + try: + dirty, snapshot_hash = working_tree_is_dirty(), source_snapshot_sha256() + except (OSError, subprocess.CalledProcessError, UnicodeDecodeError): + dirty, snapshot_hash = None, 'unavailable' + started_at = datetime.now(timezone.utc).isoformat() runs = [run_query(api_base_url, session_cookie, query, limit, timeout) for query in dataset["queries"]] + finished_at = datetime.now(timezone.utc).isoformat() run_by_id = {run["query_id"]: run for run in runs} categories = sorted({query.get("category", "uncategorized") for query in dataset["queries"]}) by_category = { @@ -144,15 +174,15 @@ def run_suite(dataset: dict, api_base_url: str, session_cookie: str, limit: int ).stdout.strip() except (OSError, subprocess.CalledProcessError): revision = "unknown" - dataset_file = dataset_path or Path(os.getenv("SEARCH_DATASET_PATH", "")) - if not dataset_file.is_file(): - dataset_file = DEFAULT_DATASET if dataset.get("version") == 1 else Path(__file__).parent / "datasets" / "search-ptbr-v2.json" + if same_file and _sha256_file(dataset_file) != dataset_hash: + raise RuntimeError('O dataset mudou durante a execução; repita a bateria sem editar as fontes.') corpus_path = REPOSITORY_ROOT / dataset.get("corpus", {}).get("file", "") try: - dirty = working_tree_is_dirty() - snapshot_hash = source_snapshot_sha256() + finished_snapshot = source_snapshot_sha256() except (OSError, subprocess.CalledProcessError, UnicodeDecodeError): - dirty, snapshot_hash = None, "unavailable" + finished_snapshot = 'unavailable' + if snapshot_hash != 'unavailable' and finished_snapshot != 'unavailable' and snapshot_hash != finished_snapshot: + raise RuntimeError('As fontes locais mudaram durante a execução; repita a bateria sem editar as fontes.') try: corpus_hash = _sha256_file(corpus_path) if corpus_path.is_file() else "unavailable" except OSError: @@ -163,7 +193,8 @@ def percentile(p: float) -> int: return { "dataset": dataset["dataset"], "version": dataset["version"], - "started_at": datetime.now(timezone.utc).isoformat(), + "started_at": started_at, + "finished_at": finished_at, "limit": limit, "execution_metadata": { "query_count": len(runs), @@ -174,7 +205,9 @@ def percentile(p: float) -> int: "code_revision": revision, "working_tree_dirty": dirty, "source_snapshot_sha256": snapshot_hash, - "dataset_sha256": _sha256_file(dataset_file), + "dataset_sha256": dataset_hash, + "dataset_hash_kind": dataset_hash_kind, + "source_snapshot_stable": snapshot_hash == finished_snapshot if snapshot_hash != 'unavailable' and finished_snapshot != 'unavailable' else None, "corpus_sha256": corpus_hash, "embedding_model": os.getenv("SEARCH_EMBEDDING_MODEL", "bge-m3"), "minimum_vector_similarity": float(os.getenv("SEARCH_MIN_VECTOR_SIMILARITY", "0.55")), diff --git a/ai-service/tests/test_search_evaluation.py b/ai-service/tests/test_search_evaluation.py new file mode 100644 index 0000000..23a44d8 --- /dev/null +++ b/ai-service/tests/test_search_evaluation.py @@ -0,0 +1,287 @@ +import copy +import json +from pathlib import Path +import sys +import unittest +from unittest.mock import patch + +EVALUATION = Path(__file__).resolve().parents[1] / 'evaluation' +sys.path.insert(0, str(EVALUATION)) +from evaluate_search import canonical_dataset_sha256, evaluate, load_json, validate_dataset, verify_dataset_provenance +from run_search_suite import run_query, run_suite, validate_api_base_url + + +class SearchEvaluationTests(unittest.TestCase): + def setUp(self): + self.dataset = load_json(EVALUATION / 'datasets/search-ptbr-v2.json') + self.results = {'dataset': self.dataset['dataset'], 'version': 2, 'runs': [ + {'query_id': q['id'], 'latency_ms': 100 + index, 'results': [ + {'source_id': source, 'project_id': q['project_id']} + for source in q['expected_source_ids'] + ]} for index, q in enumerate(self.dataset['queries']) + ]} + + def test_both_versioned_datasets_have_at_least_twenty_scoped_queries(self): + for version, count in [(1, 24), (2, 26)]: + dataset = load_json(EVALUATION / f'datasets/search-ptbr-v{version}.json') + validate_dataset(dataset) + self.assertEqual(len(dataset['queries']), count) + + def test_supplementary_versions_preserve_published_queries_and_previous_gold(self): + first = load_json(EVALUATION / 'datasets/search-diagnostics-draft-v1.json') + second = load_json(EVALUATION / 'datasets/search-diagnostics-draft-v2.json') + validate_dataset(first) + validate_dataset(second) + self.assertEqual(first['queries'][:26], self.dataset['queries']) + self.assertEqual(second['queries'][:40], first['queries']) + self.assertEqual(len(second['queries']), 48) + self.assertEqual(second['annotation_status'], 'draft_pending_product_review') + + def test_gold_sources_exist_in_curated_corpus(self): + corpus = load_json(EVALUATION.parents[1] / self.dataset['corpus']['file']) + chunks = {r['values']['id']: r for r in corpus['records'] if r['table'] == 'chunk'} + self.assertEqual(set(chunks), set(self.dataset['source_project_ids'])) + for q in self.dataset['queries']: + for source in q['expected_source_ids']: + self.assertEqual(chunks[source]['values']['projeto_id'], q['project_id']) + self.assertTrue(chunks[source]['source']['locator']) + + def test_perfect_retrieval_has_full_recall_mrr_and_no_result_accuracy(self): + report = evaluate(self.dataset, self.results) + self.assertEqual(report['retrieval']['recall_at_k_macro'], 1) + self.assertEqual(report['retrieval']['mrr_at_k'], 1) + self.assertEqual(report['retrieval']['no_result_accuracy'], 1) + self.assertEqual(report['project_isolation']['violations'], 0) + self.assertEqual(report['latency']['p95_ms'], 124) + + def test_precision_uses_five_positions_including_empty_ones(self): + report = evaluate(self.dataset, self.results) + self.assertAlmostEqual(report['retrieval']['precision_at_k_macro'], .2) + + def test_missing_positive_source_reduces_recall(self): + self.results['runs'][0]['results'] = [] + self.assertLess(evaluate(self.dataset, self.results)['retrieval']['recall_at_k_macro'], 1) + + def test_wrong_result_in_negative_query_is_not_treated_as_success(self): + q = next(q for q in self.dataset['queries'] if not q['expected_source_ids']) + run = next(r for r in self.results['runs'] if r['query_id'] == q['id']) + run['results'] = [{'source_id': 'unknown-noise', 'project_id': q['project_id']}] + self.assertLess(evaluate(self.dataset, self.results)['retrieval']['no_result_accuracy'], 1) + + def test_isolation_checks_every_result_even_after_k(self): + q = self.dataset['queries'][0] + other_project = next(p for p in self.dataset['source_projects'] if p != q['project_id']) + self.results['runs'][0]['results'] = [ + {'source_id': str(i), 'project_id': q['project_id']} for i in range(5) + ] + [{'source_id': 'leak', 'project_id': other_project}] + with self.assertRaises(ValueError): evaluate(self.dataset, self.results) + + def test_source_cannot_lie_about_its_known_project(self): + q = self.dataset['queries'][0] + source = next(s for s, p in self.dataset['source_project_ids'].items() if p != q['project_id']) + self.results['runs'][0]['results'] = [{'source_id': source, 'project_id': q['project_id']}] + with self.assertRaises(ValueError): evaluate(self.dataset, self.results) + + def test_missing_duplicate_and_unknown_queries_are_rejected(self): + for change in ['missing', 'duplicate', 'unknown']: + results = copy.deepcopy(self.results) + if change == 'missing': results['runs'].pop() + elif change == 'duplicate': results['runs'].append(results['runs'][0]) + else: results['runs'][0]['query_id'] = 'unknown' + with self.subTest(change=change), self.assertRaises(ValueError): evaluate(self.dataset, results) + + def test_duplicate_sources_are_rejected(self): + self.results['runs'][0]['results'] *= 2 + with self.assertRaises(ValueError): evaluate(self.dataset, self.results) + + def test_invalid_latencies_are_rejected(self): + for latency in [-1, True, float('nan'), float('inf'), '100']: + self.results['runs'][0]['latency_ms'] = latency + with self.subTest(latency=latency), self.assertRaises(ValueError): evaluate(self.dataset, self.results) + + def test_latency_budget_counts_each_query_not_only_p95(self): + self.results['runs'][0]['latency_ms'] = 2001 + report = evaluate(self.dataset, self.results) + self.assertEqual(report['latency']['within_budget_count'], 25) + self.assertEqual(report['latency']['p95_ms'], 125) + + def test_latency_must_be_below_two_seconds_as_required_by_rnf06(self): + self.results['runs'][0]['latency_ms'] = 2000 + report = evaluate(self.dataset, self.results) + self.assertEqual(report['latency']['within_budget_count'], 25) + self.assertFalse(report['per_query'][0]['within_latency_budget']) + + def test_categories_are_reported_separately(self): + report = evaluate(self.dataset, self.results) + self.assertEqual(sum(c['queries'] for c in report['by_category'].values()), 26) + self.assertEqual(report['by_category']['content_search']['queries'], 2) + + def test_dataset_and_result_version_must_match(self): + self.results['version'] = 1 + with self.assertRaises(ValueError): evaluate(self.dataset, self.results) + + def test_versions_require_integers_in_dataset_and_results(self): + for version in [True, 2.0, [], {}, '2']: + with self.subTest(version=version): + dataset = copy.deepcopy(self.dataset) + dataset['version'] = version + with self.assertRaises(ValueError): validate_dataset(dataset) + results = copy.deepcopy(self.results) + results['version'] = version + with self.assertRaises(ValueError): evaluate(self.dataset, results) + + def test_invalid_descriptors_fail_with_controlled_validation(self): + for field in ['category', 'level', 'source_entity_type', 'corpus']: + dataset = copy.deepcopy(self.dataset) + if field == 'category': dataset['queries'][0]['category'] = ['invalid'] + elif field == 'level': dataset['queries'][0]['filters'] = {'level': ['documento']} + elif field == 'corpus': dataset['corpus'] = 'invalid' + else: dataset['source_entity_types'][next(iter(dataset['source_entity_types']))] = ['documento'] + with self.subTest(field=field), self.assertRaises(ValueError): validate_dataset(dataset) + + def test_malformed_query_ids_fail_with_controlled_validation(self): + for query_id in [[], {}, True, None]: + self.results['runs'][0]['query_id'] = query_id + with self.subTest(query_id=query_id), self.assertRaises(ValueError): evaluate(self.dataset, self.results) + + def test_report_sources_are_valid_search_levels(self): + dataset = copy.deepcopy(self.dataset) + dataset['source_entity_types'] = dict.fromkeys(dataset['source_entity_types'], 'analise_repositorio') + for q in dataset['queries']: + if q.get('filters', {}).get('level') == 'documento': q['filters']['level'] = 'analise_repositorio' + validate_dataset(dataset) + + def test_returned_type_must_match_manifest_and_level_filter(self): + self.results['runs'][0]['results'][0]['entity_type'] = 'pbi' + with self.assertRaises(ValueError): evaluate(self.dataset, self.results) + + def test_redirect_does_not_forward_session_to_another_server(self): + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + from threading import Thread + received = [] + class Target(BaseHTTPRequestHandler): + def do_GET(self): + received.append(self.headers.get('Cookie')) + self.send_response(200) + self.end_headers() + self.wfile.write(b'{"items":[]}') + def log_message(self, *_): pass + target = ThreadingHTTPServer(('127.0.0.1', 0), Target) + class Redirect(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(self.server.redirect_code) + self.send_header('Location', f'http://127.0.0.1:{target.server_port}/capture') + self.end_headers() + def log_message(self, *_): pass + origin = ThreadingHTTPServer(('127.0.0.1', 0), Redirect) + workers = [Thread(target=server.serve_forever, daemon=True) for server in [origin, target]] + for worker in workers: worker.start() + try: + for code in [301, 302, 303, 307, 308]: + origin.redirect_code = code + with self.subTest(code=code), self.assertRaisesRegex(RuntimeError, f'HTTP {code}'): + run_query(f'http://127.0.0.1:{origin.server_port}', 'fake-test-session', self.dataset['queries'][0], 5, 3) + self.assertEqual(received, []) + finally: + for server in [origin, target]: server.shutdown(); server.server_close() + for worker in workers: worker.join() + + def test_runtime_parameters_are_validated_before_requests(self): + for kwargs in [{'limit': True}, {'limit': 5.5}, {'timeout': 0}, {'timeout': -1}, {'timeout': float('nan')}, {'timeout': float('inf')}]: + with self.subTest(kwargs=kwargs), patch('run_search_suite.run_query') as request: + with self.assertRaises(ValueError): run_suite(self.dataset, 'http://localhost:3001', 'fake', **kwargs) + request.assert_not_called() + + def test_provenance_rejects_changed_dataset_with_same_name_and_version(self): + self.results['execution_metadata'] = {'dataset_sha256': '0' * 64, 'dataset_hash_kind': 'file'} + with self.assertRaises(ValueError): + verify_dataset_provenance(self.dataset, self.results, EVALUATION / 'datasets/search-ptbr-v2.json') + + def test_provenance_rejects_changed_corpus_even_when_dataset_is_unchanged(self): + self.results['execution_metadata'] = {'corpus_sha256': '0' * 64} + with self.assertRaisesRegex(ValueError, 'hash do corpus'): + verify_dataset_provenance(self.dataset, self.results, EVALUATION / 'datasets/search-ptbr-v2.json') + + def test_explicit_dataset_file_must_match_executed_data(self): + dataset = copy.deepcopy(self.dataset) + dataset['queries'][0]['query'] += ' alterada' + with patch('run_search_suite.run_query') as request: + with self.assertRaises(ValueError): + run_suite(dataset, 'http://localhost:3001', 'fake', dataset_path=EVALUATION / 'datasets/search-ptbr-v2.json') + request.assert_not_called() + + def test_custom_dataset_without_file_records_its_actual_content_hash(self): + dataset = copy.deepcopy(self.dataset) + dataset['dataset'] = 'custom-diagnostic' + def query(*args): return next(r for r in self.results['runs'] if r['query_id'] == args[2]['id']) + with patch('run_search_suite.run_query', side_effect=query), \ + patch('run_search_suite.source_snapshot_sha256', return_value='a' * 64): + result = run_suite(dataset, 'http://localhost:3001', 'fake') + metadata = result['execution_metadata'] + self.assertEqual(metadata['dataset_hash_kind'], 'canonical_json') + self.assertEqual(metadata['dataset_sha256'], canonical_dataset_sha256(dataset)) + verify_dataset_provenance(dataset, result, EVALUATION / 'datasets/search-ptbr-v2.json') + + def test_source_edits_during_measurement_invalidate_execution(self): + def query(*args): return next(r for r in self.results['runs'] if r['query_id'] == args[2]['id']) + with patch('run_search_suite.run_query', side_effect=query), \ + patch('run_search_suite.source_snapshot_sha256', side_effect=['a' * 64, 'b' * 64]): + with self.assertRaisesRegex(RuntimeError, 'fontes locais mudaram'): + run_suite(self.dataset, 'http://localhost:3001', 'fake') + + def test_expected_source_must_exist_match_scope_and_level(self): + for change in ['source', 'project', 'level']: + dataset = copy.deepcopy(self.dataset) + q = dataset['queries'][0] + if change == 'source': q['expected_source_ids'] = ['unknown'] + elif change == 'project': q['project_id'] = next(p for p in dataset['source_projects'] if p != q['project_id']) + else: q['filters'] = {'level': 'pbi'} + with self.subTest(change=change), self.assertRaises(ValueError): validate_dataset(dataset) + + def test_remote_or_credentialed_urls_never_receive_session_cookie(self): + for url in ['https://example.com', 'http://host.docker.internal:3001', 'http://user:pass@localhost:3001', 'http://localhost:3001/path', 'http://localhost:3001?x=1']: + with self.subTest(url=url), self.assertRaises(ValueError): validate_api_base_url(url) + self.assertEqual(validate_api_base_url('http://127.0.0.1:3001/'), 'http://127.0.0.1:3001') + + def test_empty_session_does_not_send_request(self): + with patch('run_search_suite.urlopen') as request: + with self.assertRaises(ValueError): run_suite(self.dataset, 'http://localhost:3001', '') + request.assert_not_called() + + def test_http_adapter_preserves_source_ids_scope_filters_and_scores(self): + q = dict(self.dataset['queries'][0], filters={'level': 'documento', 'technology_id': 'technology'}) + payload = {'items': [{'id': q['expected_source_ids'][0], 'project_id': q['project_id'], 'entity_type': 'documento', 'relevance_score': .8}]} + from io import BytesIO + with patch('run_search_suite.urlopen', return_value=BytesIO(json.dumps(payload).encode())) as request: + result = run_query('http://localhost:3001', 'test-session', q, 5, 3) + sent = request.call_args.args[0] + self.assertIn('projeto_id=' + q['project_id'], sent.full_url) + self.assertIn('nivel=documento', sent.full_url) + self.assertIn('tecnologia_id=technology', sent.full_url) + self.assertEqual(result['results'][0]['source_id'], q['expected_source_ids'][0]) + self.assertEqual(result['results'][0]['relevance_score'], .8) + self.assertEqual(result['results'][0]['entity_type'], 'documento') + + def test_execution_times_surround_queries_instead_of_marking_finish_as_start(self): + from datetime import datetime, timezone + events = [] + class Clock: + @staticmethod + def now(_): + events.append('time') + return datetime.now(timezone.utc) + def query(*args): + events.append('query') + return next(r for r in self.results['runs'] if r['query_id'] == args[2]['id']) + with patch('run_search_suite.datetime', Clock), patch('run_search_suite.run_query', side_effect=query), \ + patch('run_search_suite.working_tree_is_dirty', return_value=False), \ + patch('run_search_suite.source_snapshot_sha256', return_value='a' * 64): + result = run_suite(self.dataset, 'http://localhost:3001', 'test-session') + self.assertEqual(events[0], 'time') + self.assertEqual(events[-1], 'time') + self.assertEqual(events.count('query'), 26) + self.assertLessEqual(result['started_at'], result['finished_at']) + + +if __name__ == '__main__': + unittest.main() diff --git a/backend/package.json b/backend/package.json index 558cd0d..8f91e0f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,10 +16,13 @@ "test": "node scripts/test.mjs", "test:integration:indexing": "tsx scripts/test-indexing-db.mts", "test:integration:quality": "tsx scripts/test-indexing-db.mts --quality", + "test:integration:search": "tsx scripts/test-indexing-db.mts --search", "test:integration:harness": "tsx scripts/validate-harness.mts", "test:integration:copilot": "tsx scripts/validate-harness.mts --browser", "test:integration:review": "tsx scripts/test-browser-qa.mts", "test:integration:regressions": "tsx scripts/test-main-regressions.mts && tsx scripts/test-history-and-inputs.mts", + "evaluate:search": "tsx scripts/evaluate-search.mts", + "test:evaluation": "node --test scripts/search-evaluation-utils.test.mjs", "audit:security": "npm audit --omit=dev" }, "dependencies": { diff --git a/backend/scripts/evaluate-search.mts b/backend/scripts/evaluate-search.mts new file mode 100644 index 0000000..4fee848 --- /dev/null +++ b/backend/scripts/evaluate-search.mts @@ -0,0 +1,162 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { spawn, spawnSync } from 'node:child_process'; +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { arch, cpus, platform, totalmem } from 'node:os'; +import { Client } from 'pg'; +import { applyUntil } from '../src/database/migration-test-utils.js'; +import { applyDataset, loadDataset } from '../src/database/seed-lib.js'; +const { withHarnessDeadline } = await import(new URL('./harness-deadline.mjs', import.meta.url).href); +const { cleanupEvaluation, createEvaluationOutput } = await import(new URL('./search-evaluation-utils.mjs', import.meta.url).href); + +function option(name: string, fallback: string): string { + const index = process.argv.indexOf(name); + return index < 0 ? fallback : process.argv[index + 1] ?? ''; +} +const version = option('--version', '2'); +const rounds = Number(option('--rounds', '3')); +assert.ok(['1', '2'].includes(version), 'Use --version 1 ou 2.'); +assert.ok(Number.isInteger(rounds) && rounds >= 1 && rounds <= 10, 'Use de uma a dez rodadas.'); +const python = option('--python', process.env.SEARCH_PYTHON ?? 'python'); +const revision = spawnSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).stdout.trim(); +const executionId = new Date().toISOString().replace(/[^0-9]/g, '') + '-' + randomUUID().slice(0, 8); +const output = resolve(option('--output', '../ai-service/evaluation/results/s217-' + revision.slice(0, 7) + '-v' + version + '-' + executionId)); +const name = 'sinapse-s217-' + randomUUID().slice(0, 8), token = randomUUID(); +const containers: string[] = []; +const docker = (args: string[]) => { + const result = spawnSync('docker', args, { encoding: 'utf8' }); + if (result.status !== 0) throw new Error('Docker: operação ' + args[0] + ' falhou.'); + return result.stdout.trim(); +}; +const port = (container: string, key: string) => JSON.parse(docker(['inspect', container]))[0].NetworkSettings.Ports[key][0].HostPort; +const pythonRun = (args: string[], env: NodeJS.ProcessEnv = {}) => new Promise((done, reject) => { + const child = spawn(python, args, { env: { ...process.env, ...env, PYTHONIOENCODING: 'utf-8' }, windowsHide: true }); + let stdout = '', stderr = ''; + child.stdout.on('data', data => { stdout += data; }); child.stderr.on('data', data => { stderr += data; }); + child.on('error', reject); + child.on('close', code => code === 0 ? done(stdout) : reject(new Error('Executor Python falhou: ' + stderr))); +}); +let pool: any, server: any; +try { + const datasetPath = resolve(option('--dataset', '../ai-service/evaluation/datasets/search-ptbr-v' + version + '.json')); + const dataset = JSON.parse(await readFile(datasetPath, 'utf8')); + assert.equal(dataset.corpus?.dataset, 'pre06-historical-v' + version, 'O corpus do dataset deve corresponder a --version.'); + await pythonRun(['-c', 'import sys; from pathlib import Path; sys.path.insert(0,sys.argv[1]); from evaluate_search import load_json,validate_dataset; validate_dataset(load_json(Path(sys.argv[2])))', resolve('../ai-service/evaluation'), datasetPath]); + await createEvaluationOutput(output); + const pg = name + '-db'; + docker(['run', '-d', '--name', pg, '-e', 'POSTGRES_USER=sinapse', '-e', 'POSTGRES_PASSWORD=' + token, '-e', 'POSTGRES_DB=sinapse_s217_test', '-p', '127.0.0.1::5432', 'pgvector/pgvector:pg16']); + containers.push(pg); + for (let i = 0; i < 40; i++) { + if (spawnSync('docker', ['exec', pg, 'pg_isready', '-h', '127.0.0.1', '-U', 'sinapse'], { encoding: 'utf8' }).status === 0) break; + await new Promise(done => setTimeout(done, 500)); + } + process.env.DATABASE_URL = `postgresql://sinapse:${token}@127.0.0.1:${port(pg, '5432/tcp')}/sinapse_s217_test`; + process.env.NODE_ENV = 'test'; process.env.SEED_DATASET_VERSION = version; + const migration = new Client({ connectionString: process.env.DATABASE_URL }); await migration.connect(); + try { await applyUntil(migration, true, () => false); } finally { await migration.end(); } + const ai = name + '-ai'; + docker(['run', '-d', '--name', ai, '-e', 'AI_SERVICE_TOKEN=' + token, '-e', 'DOCUMENT_INGESTION_TOKEN=' + token, + '-e', 'OLLAMA_BASE_URL=http://host.docker.internal:11434', '-e', 'OLLAMA_EMBEDDING_MODEL=bge-m3', '-e', 'NODE_ENV=test', '--add-host', 'host.docker.internal:host-gateway', + '-e', 'WORKSPACE_DIR=/tmp/analyzer', '-e', 'PYTHONDONTWRITEBYTECODE=1', '--mount', 'type=bind,source=' + resolve('../ai-service') + ',target=/app,readonly', + '-p', '127.0.0.1::8000', 'sinapse-s209-ai-test']); + containers.push(ai); + process.env.AI_SERVICE_URL = 'http://127.0.0.1:' + port(ai, '8000/tcp'); + process.env.AI_SERVICE_TOKEN = token; process.env.DOCUMENT_INGESTION_TOKEN = token; + let ready = false; + for (let i = 0; i < 60; i++) { + try { ready = (await withHarnessDeadline((signal: AbortSignal) => fetch(process.env.AI_SERVICE_URL + '/openapi.json', { headers: { 'X-Service-Token': token }, signal }), 2000)).ok; } catch {} + if (ready) break; + await new Promise(done => setTimeout(done, 500)); + } + assert.ok(ready, 'O serviço de IA temporário não iniciou.'); + const [{ pool: db }, { app }, { AuthRepository }, { SessionService }] = await Promise.all([ + import('../src/database/db.js'), import('../src/app.js'), import('../src/modules/auth/auth.repository.js'), import('../src/modules/auth/session.service.js'), + ]); + pool = db; + const data = await loadDataset(); + const client = await pool.connect(); + try { await client.query('BEGIN'); await applyDataset(client, data); await client.query('COMMIT'); } + catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } + const embed = async (text: string) => { + const response = await fetch(process.env.AI_SERVICE_URL + '/embeddings', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Service-Token': token }, body: JSON.stringify({ text }), signal: AbortSignal.timeout(120000) }); + assert.equal(response.status, 200, 'Falha ao gerar embedding local.'); + const result = await response.json() as { embedding: number[] }; + assert.equal(result.embedding.length, 1024); + return '[' + result.embedding.join(',') + ']'; + }; + for (const record of data.records.filter(row => row.table === 'chunk')) { + const vector = await embed(String(record.values.texto)); + await pool.query("UPDATE chunk SET embedding=$2::vector,metadados_json=metadados_json || '{\"embedding_status\":\"ready\",\"embedding_model\":\"bge-m3\"}'::jsonb WHERE id=$1", [record.values.id, vector]); + } + await pool.query("UPDATE documento SET status_processamento='processado'"); + await pool.query('ANALYZE chunk'); + const user = (await pool.query("INSERT INTO usuario(nome,email,senha_hash,role) VALUES ('QA S2-17',$1,'unused','admin') RETURNING id", [randomUUID() + '@example.test'])).rows[0].id; + const session = await new SessionService(new AuthRepository(pool)).createSession(user); + server = app.listen(0, '127.0.0.1'); await new Promise(done => server.once('listening', done)); + const base = 'http://127.0.0.1:' + server.address().port; + if (process.argv.includes('--diagnostics')) { + const diagnostics = []; + for (const query of dataset.queries) { + const vector = await embed(query.query); + const signals = await pool.query(`SELECT id, 1-(embedding <=> $2::vector) AS similarity, + (SELECT count(*)::double precision FROM unnest(tsvector_to_array(to_tsvector('portuguese',$1))) term + WHERE term=ANY(tsvector_to_array(to_tsvector('portuguese',texto)))) + / GREATEST(cardinality(tsvector_to_array(to_tsvector('portuguese',$1))),1) AS coverage, + tsvector_to_array(to_tsvector('portuguese',$1)) AS query_terms, + tsvector_to_array(to_tsvector('portuguese',texto)) AS source_terms + FROM chunk WHERE projeto_id=$3 ORDER BY id`, [query.query,vector,query.project_id]); + diagnostics.push({query_id:query.id,signals:signals.rows}); + } + await writeFile(resolve(output, 'diagnostics.json'), JSON.stringify(diagnostics,null,2)); + } + const warmup = new URLSearchParams({ q: dataset.queries[0].query, projeto_id: dataset.queries[0].project_id }); + assert.equal((await fetch(base + '/api/v1/search?' + warmup, { headers: { Cookie: 'sinapse_session=' + session.token }, signal: AbortSignal.timeout(30000) })).status, 200); + const reports: unknown[] = []; + for (let round = 1; round <= rounds; round++) { + console.log(`S2-17: rodada ${round}/${rounds}, ${dataset.queries.length} consultas autenticadas, bge-m3 real.`); + const results = resolve(output, `round-${round}-results.json`); + await pythonRun([resolve('../ai-service/evaluation/run_search_suite.py'), '--dataset', datasetPath, '--output', results, '--api-base-url', base], { + SINAPSE_SESSION_COOKIE: session.token, SEARCH_OLLAMA_WARMED_UP: 'true', SEARCH_EMBEDDING_MODEL: 'bge-m3', + }); + const report = JSON.parse(await pythonRun([resolve('../ai-service/evaluation/evaluate_search.py'), '--dataset', datasetPath, '--results', results])); + await writeFile(resolve(output, `round-${round}-metrics.json`), JSON.stringify(report, null, 2)); + reports.push(report); + console.log(JSON.stringify({ round, retrieval: report.retrieval, latency: report.latency, isolation: report.project_isolation })); + } + const modelInfo = await (await fetch('http://127.0.0.1:11434/api/tags', { signal: AbortSignal.timeout(10000) })).json() as { models: Array<{name:string;digest:string}> }; + const pythonVersion = (await pythonRun(['--version'])).trim(); + const { env: searchEnv } = await import('../src/config/env.js'); + const { SEARCH_CONCEPTS_VERSION } = await import('../src/modules/search/search.concepts.js'); + const searchConfiguration = { + SEARCH_MIN_VECTOR_SIMILARITY: searchEnv.SEARCH_MIN_VECTOR_SIMILARITY, + SEARCH_MIN_TEXT_RANK: searchEnv.SEARCH_MIN_TEXT_RANK, + SEARCH_MIN_HYBRID_SCORE: searchEnv.SEARCH_MIN_HYBRID_SCORE, + SEARCH_STRONG_VECTOR_SIMILARITY: searchEnv.SEARCH_STRONG_VECTOR_SIMILARITY, + SEARCH_MIN_PARTIAL_SIMILARITY: searchEnv.SEARCH_MIN_PARTIAL_SIMILARITY, + SEARCH_MIN_CONCEPT_SIMILARITY: searchEnv.SEARCH_MIN_CONCEPT_SIMILARITY, + concepts_version: SEARCH_CONCEPTS_VERSION, + }; + await writeFile(resolve(output, 'environment.json'), JSON.stringify({ revision, evaluated_at: new Date().toISOString(), dataset_version: dataset.version, corpus_version: version, search_configuration: searchConfiguration, + corpus: data.dataset, chunks: data.records.filter(row => row.table === 'chunk').length, + embedding_input: 'texto curado sem alteração do gabarito', model: modelInfo.models.filter(model => model.name.startsWith('bge-m3')).map(({name,digest}) => ({name,digest})), + platform: platform(), architecture: arch(), cpu: cpus()[0]?.model, logical_cpus: cpus().length, memory_bytes: totalmem(), + node: process.versions.node, python: pythonVersion, ai_image: docker(['image', 'inspect', 'sinapse-s209-ai-test', '--format', '{{.Id}}']), + concurrency: 1, warmup_queries: 1, rounds, reports: reports.length, database: 'PostgreSQL/pgvector descartável', relevance_acceptance: 'pendente de critérios e revisão do PO/time' }, null, 2)); + const latencyPassed = reports.every((report: any) => report.latency.within_budget_rate === 1); + await writeFile(resolve(output, 'summary.json'), JSON.stringify({ revision, dataset_version: dataset.version, corpus_version: version, search_configuration: searchConfiguration, rounds, total_queries: dataset.queries.length * rounds, + latency_gate: { budget_ms: 2000, passed: latencyPassed }, isolation_gate: { passed: true, violations: 0 }, + relevance_gate: { status: 'pending_product_acceptance', thresholds: null }, reports }, null, 2)); + if (!latencyPassed) process.exitCode = 1; + console.log('Resultados: ' + output); +} finally { + const failures = await cleanupEvaluation([ + async () => { if (server) { server.closeAllConnections(); await new Promise((done, reject) => server.close((error?: Error) => error ? reject(error) : done())); } }, + async () => { if (pool) await pool.end(); }, + ...containers.reverse().map(container => async () => { docker(['rm', '-f', '-v', container]); }), + ]); + if (failures.length) { + process.exitCode = 1; + console.error('Não foi possível encerrar todos os recursos temporários. Confira os contêineres sinapse-s217.'); + } else if (containers.length) console.log('Banco e serviço de IA descartáveis removidos.'); +} diff --git a/backend/scripts/search-evaluation-utils.mjs b/backend/scripts/search-evaluation-utils.mjs new file mode 100644 index 0000000..c625e4f --- /dev/null +++ b/backend/scripts/search-evaluation-utils.mjs @@ -0,0 +1,20 @@ +import { mkdir } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +export async function createEvaluationOutput(path) { + await mkdir(dirname(path), { recursive: true }); + try { + await mkdir(path); + } catch (error) { + if (error.code === 'EEXIST') throw new Error('A pasta de resultados já existe. Use outro --output para preservar a execução anterior.'); + throw error; + } +} + +export async function cleanupEvaluation(steps) { + const failures = []; + for (const step of steps) { + try { await step(); } catch (error) { failures.push(error); } + } + return failures; +} diff --git a/backend/scripts/search-evaluation-utils.test.mjs b/backend/scripts/search-evaluation-utils.test.mjs new file mode 100644 index 0000000..2663008 --- /dev/null +++ b/backend/scripts/search-evaluation-utils.test.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import test from 'node:test'; +import { cleanupEvaluation, createEvaluationOutput } from './search-evaluation-utils.mjs'; + +test('uma nova execução não sobrescreve relatórios existentes', async () => { + const root = await mkdtemp(join(tmpdir(), 'sinapse-s217-output-')); + try { + const output = join(root, 'results'); + await createEvaluationOutput(output); + await writeFile(join(output, 'summary.json'), 'baseline'); + await assert.rejects(createEvaluationOutput(output), /já existe/); + assert.equal(await readFile(join(output, 'summary.json'), 'utf8'), 'baseline'); + await createEvaluationOutput(join(root, 'other-results')); + } finally { + assert.equal(dirname(resolve(root)), resolve(tmpdir())); + assert.ok(root.startsWith(join(tmpdir(), 'sinapse-s217-output-'))); + await rm(root, { recursive: true, force: true }); + } +}); + +test('falha ao encerrar um recurso não impede a limpeza dos outros', async () => { + const visited = []; + const failures = await cleanupEvaluation([ + async () => { visited.push('server'); throw new Error('falha controlada'); }, + async () => { visited.push('pool'); }, + async () => { visited.push('ai'); throw new Error('falha controlada'); }, + async () => { visited.push('postgres'); }, + ]); + assert.deepEqual(visited, ['server', 'pool', 'ai', 'postgres']); + assert.equal(failures.length, 2); + assert.deepEqual(await cleanupEvaluation([async () => {}]), []); +}); diff --git a/backend/scripts/test-indexing-db.mts b/backend/scripts/test-indexing-db.mts index d07be82..94956eb 100644 --- a/backend/scripts/test-indexing-db.mts +++ b/backend/scripts/test-indexing-db.mts @@ -54,16 +54,16 @@ try { } const exitCode = await new Promise((resolve, reject) => { const child = spawn(process.execPath,["--import","tsx","--test", - ...(process.argv.includes('--quality') ? ['src/modules/quality/organization-quality.db.test.ts','src/modules/chat/rag.db.test.ts'] : ["src/modules/indexing/indexing.db.test.ts","src/modules/indexing/indexing.migration.db.test.ts","src/modules/indexing/indexing.transport.db.test.ts","src/modules/indexing/indexing.state.db.test.ts","src/modules/indexing/indexing.recovery.db.test.ts","src/modules/indexing/indexing.worker.test.ts","src/modules/indexing/indexing.integration.db.test.ts","src/modules/repo-analyses/repo-analyses.indexing.db.test.ts"])],{ - env:{...process.env,NODE_ENV:"test",DATABASE_URL:url,INDEX_TEST_DATABASE_URL:url,QUALITY_TEST_DATABASE_URL:process.argv.includes('--quality') ? url : '',INDEX_AI_ENDPOINT:realEndpoint}, + ...(process.argv.includes('--search') ? ['src/modules/search/search.relevance.db.test.ts','src/modules/search/search.access.db.test.ts'] : process.argv.includes('--quality') ? ['src/modules/quality/organization-quality.db.test.ts','src/modules/chat/rag.db.test.ts'] : ["src/modules/indexing/indexing.db.test.ts","src/modules/indexing/indexing.migration.db.test.ts","src/modules/indexing/indexing.transport.db.test.ts","src/modules/indexing/indexing.state.db.test.ts","src/modules/indexing/indexing.recovery.db.test.ts","src/modules/indexing/indexing.worker.test.ts","src/modules/indexing/indexing.integration.db.test.ts","src/modules/repo-analyses/repo-analyses.indexing.db.test.ts"])],{ + env:{...process.env,NODE_ENV:"test",DATABASE_URL:url,INDEX_TEST_DATABASE_URL:url,ARCHIVE_TEST_DATABASE_URL:process.argv.includes('--search') ? url : '',QUALITY_TEST_DATABASE_URL:process.argv.includes('--quality') ? url : '',INDEX_AI_ENDPOINT:realEndpoint}, stdio:"inherit", }); child.once("error", reject); child.once("exit", code => resolve(code ?? 1)); }); process.exitCode=exitCode; - if (exitCode === 0 && !process.argv.includes('--quality')) { - const suggestions = spawnSync(process.execPath, ["--import", "tsx", "--test", "src/modules/audit/versions.db.test.ts", "src/modules/similar-items/similar-items.db.test.ts", "src/modules/suggestions/suggestions.db.test.ts", "src/modules/pbis/pbis.relations.db.test.ts", "src/modules/documents/chunks.http.db.test.ts", "src/modules/search/search.access.db.test.ts", "src/modules/auth/auth.registration.db.test.ts"], { + if (exitCode === 0 && !process.argv.includes('--quality') && !process.argv.includes('--search')) { + const suggestions = spawnSync(process.execPath, ["--import", "tsx", "--test", "src/modules/audit/versions.db.test.ts", "src/modules/similar-items/similar-items.db.test.ts", "src/modules/suggestions/suggestions.db.test.ts", "src/modules/pbis/pbis.relations.db.test.ts", "src/modules/documents/chunks.http.db.test.ts", "src/modules/search/search.access.db.test.ts", "src/modules/search/search.relevance.db.test.ts", "src/modules/auth/auth.registration.db.test.ts"], { env: { ...process.env, NODE_ENV: "test", DATABASE_URL: url, ARCHIVE_TEST_DATABASE_URL: url }, stdio: "inherit", }); diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index f152c74..b4e94bf 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -23,6 +23,10 @@ const envSchema = z.object({ REPO_ANALYZER_URL: z.string().default('http://localhost:8000'), SEARCH_MIN_VECTOR_SIMILARITY: z.coerce.number().min(0).max(1).default(0.55), SEARCH_MIN_TEXT_RANK: z.coerce.number().min(0).max(1).default(0.05), + SEARCH_MIN_HYBRID_SCORE: z.coerce.number().min(0).max(1).default(0.45), + SEARCH_STRONG_VECTOR_SIMILARITY: z.coerce.number().min(0).max(1).default(0.6), + SEARCH_MIN_PARTIAL_SIMILARITY: z.coerce.number().min(0).max(1).default(0.5), + SEARCH_MIN_CONCEPT_SIMILARITY: z.coerce.number().min(0).max(1).default(0.45), // S2-12: limiar mais alto que o da busca para não forçar sugestões pouco relacionadas. SIMILAR_MIN_VECTOR_SIMILARITY: z.coerce.number().min(0).max(1).default(0.72), diff --git a/backend/src/modules/search/search.concepts.ts b/backend/src/modules/search/search.concepts.ts new file mode 100644 index 0000000..0c46bf2 --- /dev/null +++ b/backend/src/modules/search/search.concepts.ts @@ -0,0 +1,8 @@ +export const SEARCH_CONCEPTS_VERSION = 1; + +export const SEARCH_CONCEPTS = [ + { id: 'authentication', terms: 'login autenticação autenticar identificação identificar' }, + { id: 'management_role', terms: 'administrador chefe chefia gestor gerente responsável' }, + { id: 'restriction', terms: 'restrição restringir restrito limitar limitação limitado' }, + { id: 'deactivation', terms: 'desativar desativação inativar inativação inativo desabilitar desabilitação desabilitado' }, +] as const; diff --git a/backend/src/modules/search/search.relevance.db.test.ts b/backend/src/modules/search/search.relevance.db.test.ts new file mode 100644 index 0000000..f2872e2 --- /dev/null +++ b/backend/src/modules/search/search.relevance.db.test.ts @@ -0,0 +1,143 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import test from 'node:test'; +import { Pool } from 'pg'; +import { SearchRepository } from './search.repository.js'; +import { env } from '../../config/env.js'; + +const url = process.env.ARCHIVE_TEST_DATABASE_URL; +test('busca híbrida: relevância, siglas, números e isolamento', { skip: !url }, async t => { + assert.match(new URL(url!).pathname, /_test$/); + const db = new Pool({ connectionString: url }); + const repository = new SearchRepository(db); + const projects = [randomUUID(), randomUUID()]; + const documents = [randomUUID(), randomUUID()]; + const queryVector = [1, ...Array(1023).fill(0)]; + const search = (query: string) => repository.hybridSearch({ query, projectId: projects[0], limit: 5 }, queryVector, 0.55); + const seed = async (text: string, similarity: number | null, metadata = {}, scope = 0) => { + const id = randomUUID(); + const vector = similarity === null ? null : JSON.stringify([similarity, Math.sqrt(1 - similarity ** 2), ...Array(1022).fill(0)]); + await db.query("INSERT INTO chunk(id,projeto_id,entidade_tipo,entidade_id,texto,metadados_json,embedding) VALUES($1,$2,'documento',$3,$4,$5::jsonb,$6::vector)", + [id, projects[scope], documents[scope], text, JSON.stringify(metadata), vector]); + return id; + }; + const clean = () => db.query('DELETE FROM chunk WHERE projeto_id=ANY($1::uuid[])', [projects]); + try { + for (let i = 0; i < projects.length; i++) { + await db.query("INSERT INTO projeto(id,nome,cliente) VALUES($1,$2,'QA')", [projects[i], 'QA relevância ' + projects[i]]); + await db.query("INSERT INTO documento(id,projeto_id,nome,caminho,status_processamento) VALUES($1,$2,'qa.txt','qa.txt','processado')", [documents[i], projects[i]]); + } + await t.test('uma palavra genérica em comum não basta para incluir um assunto diferente', async () => { + await clean(); + const expected = await seed('O painel mostra consumo mensal de energia elétrica em gráficos.', 0.68); + await seed('O sistema apresenta gráficos de produtividade dos técnicos.', 0.575); + assert.deepEqual((await search('Exibir gráficos de consumo mensal de energia')).map(row => row.id), [expected]); + }); + await t.test('candidatos descartados não consomem o limite da busca', async () => { + for (const limit of [1, 5]) { + await clean(); + for (let i = 0; i < limit; i++) await seed('Gráficos de produtividade dos técnicos.', 0.59); + const expected = await seed('O consumo mensal de energia elétrica é apresentado no painel.', 0.58); + const result = await repository.hybridSearch({ query: 'Exibir gráficos de consumo mensal de energia', projectId: projects[0], limit }, queryVector, 0.55); + assert.deepEqual(result.map(row => row.id), [expected]); + } + }); + await t.test('sigla encontra a expressão completa mesmo abaixo do corte vetorial', async () => { + await clean(); + const expected = await seed('Um plano de metas estratégicas pode ser cadastrado por pessoa e por ano.', 0.49); + assert.deepEqual((await search('Qual processo registra um PME anual para cada pessoa?')).map(row => row.id), [expected]); + }); + await t.test('conceitos equivalentes recuperam formulações diferentes com apoio de dois conceitos', async () => { + await clean(); + const expected = await seed('Autenticação com restrição de funcionalidades para operadores.', 0.46); + assert.deepEqual((await search('Limitar a entrada de operadores no setor protegido')).map(row => row.id), [expected]); + }); + await t.test('inativação é recuperável como desabilitação de um cadastro', async () => { + await clean(); + const expected = await seed('Permitir inativação de cadastro funcional.', 0.46); + assert.deepEqual((await search('Desabilitar o cadastro de um funcionário')).map(row => row.id), [expected]); + }); + await t.test('o limite configurado para conceitos é respeitado quando supera o corte vetorial', async () => { + await clean(); + await seed('Inativação de cadastro funcional.', 0.56); + const previous = env.SEARCH_MIN_CONCEPT_SIMILARITY; + try { + env.SEARCH_MIN_CONCEPT_SIMILARITY = 0.9; + assert.deepEqual(await search('Desabilitar cadastro de pessoa sem apagar seus dados'), []); + } finally { env.SEARCH_MIN_CONCEPT_SIMILARITY = previous; } + }); + await t.test('dois termos distintivos permitem correspondência parcial sem exigir a frase inteira', async () => { + await clean(); + const expected = await seed('As séries descrevem o consumo hídrico por trimestre em uma curva.', 0.52); + assert.deepEqual((await search('Como observar a evolução do consumo de água em séries trimestrais?')).map(row => row.id), [expected]); + }); + await t.test('um único conceito equivalente não inclui uma fonte de outro assunto', async () => { + await clean(); + await seed('Inativação de contas de usuário.', 0.46); + assert.deepEqual(await search('Desabilitar a exportação do faturamento'), []); + }); + await t.test('entrada física não é tratada como autenticação', async () => { + await clean(); + await seed('Login com restrição das funcionalidades para administradores.', 0.52); + assert.deepEqual(await search('Restringir a entrada de veículos no estacionamento'), []); + assert.deepEqual(await search('Entrar com caminhões em área restrita do galpão'), []); + }); + await t.test('repetições e sinônimos do mesmo conceito não contam como conceitos independentes', async () => { + await clean(); + await seed('Autenticação de contas de usuário.', 0.46); + assert.deepEqual(await search('Login, autenticação e identificação'), []); + }); + await t.test('termos comuns a todo o projeto não sustentam uma correspondência parcial', async () => { + await clean(); + await seed('Análise de documentação sobre parcelas.', 0.52); + await seed('Análise de documentação sobre segurança.', 0.52); + assert.deepEqual(await search('Análise de documentação para executar sangria'), []); + }); + await t.test('expressão completa encontra a sigla da fonte com apoio semântico', async () => { + await clean(); + const expected = await seed('O RH controla contas e permissões de acesso.', 0.59); + assert.deepEqual((await search('Como recursos humanos gerencia as credenciais?')).map(row => row.id), [expected]); + }); + await t.test('algarismos também reconhecem números por extenso', async () => { + await clean(); + const expected = await seed('Cada estado tem uma nota entre zero e dez, com ordenação.', 0.58); + assert.deepEqual((await search('Ordenar estados conforme notas de 0 a 10')).map(row => row.id), [expected]); + }); + await t.test('sinal semântico forte mantém paráfrases com poucas palavras comuns', async () => { + await clean(); + const expected = await seed('O sistema conserva o histórico anual de capacitação.', 0.625); + assert.deepEqual((await search('Guardar a evolução profissional de uma pessoa ano a ano')).map(row => row.id), [expected]); + }); + await t.test('sem correspondência textual, conserva a recuperação puramente semântica', async () => { + await clean(); + const expected = await seed('Credenciais individuais são necessárias para utilizar a aplicação.', 0.58); + assert.deepEqual((await search('Exigir autenticação antes de entrar')).map(row => row.id), [expected]); + }); + await t.test('busca textual continua disponível sem embedding da fonte', async () => { + await clean(); + const expected = await seed('Consumo mensal de energia elétrica.', null); + assert.deepEqual((await search('consumo mensal')).map(row => row.id), [expected]); + }); + await t.test('identificador literal preserva a origem e não aceita fallback semântico', async () => { + await clean(); + const expected = await seed('Descrição da funcionalidade.', 0, { source_locator: 'REF-312' }); + await seed('Outra funcionalidade.', 1, { source_locator: 'REF-100' }); + assert.deepEqual((await search('REF-312')).map(row => row.id), [expected]); + assert.deepEqual(await search('REF-999'), []); + }); + await t.test('similaridade máxima não atravessa projeto nem expõe documento não processado', async () => { + await clean(); + await seed('Energia elétrica.', 1, {}, 1); + await seed('Energia elétrica.', 1); + await db.query("UPDATE documento SET status_processamento='falha' WHERE id=$1", [documents[0]]); + assert.deepEqual(await search('energia elétrica'), []); + await db.query("UPDATE documento SET status_processamento='processado' WHERE id=$1", [documents[0]]); + assert.equal((await search('energia elétrica')).length, 1); + await db.query("UPDATE projeto SET status='arquivado' WHERE id=$1", [projects[0]]); + assert.deepEqual(await search('energia elétrica'), []); + }); + } finally { + await db.query('DELETE FROM projeto WHERE id=ANY($1::uuid[])', [projects]); + await db.end(); + } +}); diff --git a/backend/src/modules/search/search.repository.ts b/backend/src/modules/search/search.repository.ts index 0a92803..ba72991 100644 --- a/backend/src/modules/search/search.repository.ts +++ b/backend/src/modules/search/search.repository.ts @@ -3,6 +3,10 @@ import { pool } from "../../database/db.js"; import type { HybridSearchInput, HybridSearchRow } from "./search.types.js"; import { knowledgeSourceSql } from '../../shared/knowledge-source-sql.js'; import { projectAccessSql } from '../projects/project-access.js'; +import { env } from '../../config/env.js'; +import { SEARCH_CONCEPTS } from './search.concepts.js'; + +const concepts = JSON.stringify(SEARCH_CONCEPTS); export class SearchRepository { private readonly pool: Pool; @@ -49,6 +53,87 @@ export class SearchRepository { )) AND ($5::text IS NULL OR c.entidade_tipo = $5) ), + query_terms AS ( + SELECT tsvector_to_array(to_tsvector('portuguese', $2 || ' ' || COALESCE(( + SELECT string_agg((ARRAY['zero','um','dois','três','quatro','cinco','seis','sete','oito','nove', + 'dez','onze','doze','treze','catorze','quinze','dezesseis','dezessete','dezoito','dezenove','vinte'])[matched[1]::integer+1], ' ') + FROM regexp_matches($2, '\\m([0-9]{1,2})\\M', 'g') matched + WHERE matched[1]::integer <= 20 + ), ''))) AS terms, + ARRAY(SELECT matched[1] FROM regexp_matches($2, '\\m([A-Z]{3,8})\\M', 'g') matched) AS acronyms, + (SELECT string_agg(lower(left(token, 1)), '' ORDER BY position) + FROM ts_debug('portuguese', $2) WITH ORDINALITY + AS words(alias, description, token, dictionaries, dictionary, lexemes, position) + WHERE alias IN ('asciiword', 'word') AND cardinality(lexemes) > 0) AS initials + ), + concept_aliases AS ( + SELECT ':' || groups.id AS concept, + regexp_replace(regexp_replace(raw.term, 'qu$', 'c'), 'gu$', 'g') AS term + FROM jsonb_to_recordset($12::jsonb) AS groups(id text, terms text) + CROSS JOIN LATERAL unnest(tsvector_to_array(to_tsvector('portuguese', groups.terms))) AS raw(term) + ), + query_concepts AS ( + SELECT ARRAY( + SELECT DISTINCT COALESCE(alias.concept, normalized.term) + FROM query_terms CROSS JOIN LATERAL unnest(terms) AS raw(term) + CROSS JOIN LATERAL (SELECT regexp_replace(regexp_replace(raw.term, 'qu$', 'c'), 'gu$', 'g') AS term) normalized + LEFT JOIN concept_aliases alias ON alias.term = normalized.term + ) AS terms + ), + source_concepts AS ( + SELECT c.id, c.projeto_id, ARRAY( + SELECT DISTINCT COALESCE(alias.concept, normalized.term) + FROM unnest(tsvector_to_array(to_tsvector('portuguese', c.texto))) AS raw(term) + CROSS JOIN LATERAL (SELECT regexp_replace(regexp_replace(raw.term, 'qu$', 'c'), 'gu$', 'g') AS term) normalized + LEFT JOIN concept_aliases alias ON alias.term = normalized.term + ) AS terms + FROM scoped_chunks c + ), + concept_frequency AS ( + SELECT projeto_id, term, count(*) AS documents + FROM source_concepts CROSS JOIN LATERAL unnest(terms) term + GROUP BY projeto_id, term + ), + project_sizes AS ( + SELECT projeto_id, count(*) AS documents FROM scoped_chunks GROUP BY projeto_id + ), + partial_signals AS ( + SELECT source.id, count(*) AS matches, bool_or(left(shared.term, 1) = ':') AS synonym_support + FROM source_concepts source CROSS JOIN query_concepts query + CROSS JOIN LATERAL unnest(source.terms) AS shared(term) + JOIN concept_frequency frequency ON frequency.projeto_id = source.projeto_id AND frequency.term = shared.term + JOIN project_sizes size ON size.projeto_id = source.projeto_id + WHERE shared.term = ANY(query.terms) AND (size.documents = 1 OR frequency.documents < size.documents) + GROUP BY source.id + ), + search_signals AS ( + SELECT c.*, to_tsvector('portuguese', c.texto) AS search_vector, + COALESCE(1 - (c.embedding <=> $3::vector), 0) AS similarity, + (SELECT count(*)::double precision FROM unnest(q.terms) term + WHERE term = ANY(tsvector_to_array(to_tsvector('portuguese', c.texto)))) + / GREATEST(cardinality(q.terms), 1) AS coverage, + CASE WHEN cardinality(q.acronyms) = 0 THEN FALSE ELSE EXISTS ( + SELECT 1 FROM regexp_split_to_table(c.texto, '[.;:!?\\n]') sentence + CROSS JOIN LATERAL ( + SELECT string_agg(lower(left(token, 1)), '' ORDER BY position) AS initials + FROM ts_debug('portuguese', sentence) WITH ORDINALITY + AS words(alias, description, token, dictionaries, dictionary, lexemes, position) + WHERE alias IN ('asciiword', 'word') AND cardinality(lexemes) > 0 + ) phrase + CROSS JOIN unnest(q.acronyms) acronym + WHERE strpos(phrase.initials, lower(acronym)) > 0 + ) END OR ( + COALESCE(1 - (c.embedding <=> $3::vector), 0) >= $7 AND EXISTS ( + SELECT 1 FROM regexp_matches(c.texto, '\\m([A-Z]{2,8})\\M', 'g') abbreviation + WHERE strpos(q.initials, lower(abbreviation[1])) > 0 + ) + ) AS acronym_match, + COALESCE(partial.matches, 0) >= 2 AND + COALESCE(1 - (c.embedding <=> $3::vector), 0) >= + CASE WHEN partial.synonym_support THEN $14::double precision ELSE $13::double precision END AS partial_match + FROM scoped_chunks c CROSS JOIN query_terms q + LEFT JOIN partial_signals partial ON partial.id = c.id + ), exact_identifier AS ( SELECT EXISTS ( SELECT 1 FROM scoped_chunks @@ -56,19 +141,36 @@ export class SearchRepository { AND lower(metadados_json->>'source_locator') = lower(btrim($2)) ) OR upper(btrim($2)) ~ '^[A-Z]{2,8}-[0-9]{1,6}$' AS is_identifier_query ), + eligible_signals AS ( + SELECT c.* FROM search_signals c + WHERE (SELECT is_identifier_query FROM exact_identifier) + OR c.search_vector @@ websearch_to_tsquery('portuguese', $2) + OR c.similarity >= $11 + OR c.partial_match + OR 0.6 * GREATEST(c.similarity, 0) + 0.4 * GREATEST(c.coverage, c.acronym_match::integer * 0.75) >= $10 + OR NOT EXISTS ( + SELECT 1 FROM search_signals signal + WHERE (signal.similarity >= $7 OR signal.acronym_match OR signal.partial_match + OR (signal.search_vector @@ websearch_to_tsquery('portuguese', $2) + AND ts_rank_cd(signal.search_vector, websearch_to_tsquery('portuguese', $2)) >= $8)) + AND (signal.coverage > 0 OR signal.acronym_match OR signal.partial_match) + ) + ), lexical AS ( SELECT id, row_number() OVER (ORDER BY rank DESC, id) AS position FROM ( SELECT id, CASE WHEN metadata_locator_match THEN 10.0 + WHEN acronym_match OR partial_match THEN 1.0 ELSE ts_rank_cd(search_vector, websearch_to_tsquery('portuguese', $2)) END AS rank - FROM scoped_chunks + FROM eligible_signals CROSS JOIN LATERAL ( - SELECT to_tsvector('portuguese', texto) AS search_vector, - COALESCE(metadados_json->>'source_locator', '') <> '' + SELECT COALESCE(metadados_json->>'source_locator', '') <> '' AND lower(metadados_json->>'source_locator') = lower(btrim($2)) AS metadata_locator_match ) signals WHERE metadata_locator_match + OR (acronym_match AND NOT (SELECT is_identifier_query FROM exact_identifier)) + OR (partial_match AND NOT (SELECT is_identifier_query FROM exact_identifier)) OR (NOT (SELECT is_identifier_query FROM exact_identifier) AND search_vector @@ websearch_to_tsquery('portuguese', $2)) @@ -81,7 +183,7 @@ export class SearchRepository { SELECT id, row_number() OVER (ORDER BY distance ASC, id) AS position FROM ( SELECT id, embedding <=> $3::vector AS distance - FROM scoped_chunks + FROM eligible_signals WHERE embedding IS NOT NULL AND NOT (SELECT is_identifier_query FROM exact_identifier) ORDER BY embedding <=> $3::vector, id @@ -122,7 +224,7 @@ export class SearchRepository { END AS source_url, fused.relevance_score FROM fused - JOIN scoped_chunks c ON c.id = fused.id + JOIN eligible_signals c ON c.id = fused.id LEFT JOIN documento doc ON c.entidade_tipo = 'documento' AND doc.id = c.entidade_id LEFT JOIN decisao dec ON c.entidade_tipo = 'decisao' AND dec.id = c.entidade_id LEFT JOIN epico e ON c.entidade_tipo = 'epico' AND e.id = c.entidade_id @@ -135,7 +237,7 @@ export class SearchRepository { LEFT JOIN feature source_decision_pbi_feature ON source_decision_pbi_feature.id = source_decision_pbi.feature_id ORDER BY fused.relevance_score DESC, c.id LIMIT $6`, - [projectIds, input.query, vector, input.technologyId ?? null, input.level ?? null, input.limit, minimumSimilarity, minimumTextRank, userId ?? null], + [projectIds, input.query, vector, input.technologyId ?? null, input.level ?? null, input.limit, minimumSimilarity, minimumTextRank, userId ?? null, env.SEARCH_MIN_HYBRID_SCORE, env.SEARCH_STRONG_VECTOR_SIMILARITY, concepts, env.SEARCH_MIN_PARTIAL_SIMILARITY, env.SEARCH_MIN_CONCEPT_SIMILARITY], ); return result.rows; } diff --git a/docs/QA-S2-17-2026-10-25.md b/docs/QA-S2-17-2026-10-25.md new file mode 100644 index 0000000..c8bfa61 --- /dev/null +++ b/docs/QA-S2-17-2026-10-25.md @@ -0,0 +1,113 @@ +# S2-17 — bateria versionada e reavaliação da busca + +Este documento preserva a linha de base anterior à correção. A execução posterior está em +[QA-S2-17-CORRECOES-2026-10-25](QA-S2-17-CORRECOES-2026-10-25.md). + +Data: 25/10/2026, America/Sao_Paulo. API avaliada: `9936354fa6c609836012381f7e399f218529a4f8`. + +A bateria foi reexecutada com backend real, autenticação, PostgreSQL/pgvector descartável, +FastAPI e embeddings locais `bge-m3`. O código de recuperação da API não foi alterado. +O executor, seus testes e esta documentação têm alterações locais posteriores ao SHA da API. +Não houve commit, envio, alteração de cartão ou aceite de relevância nesta entrega local. + +## Entrega + +- V1 preservada: 24 consultas. V2 preservada: 26 consultas, três projetos, seis chunks curados da PRE-06. +- Cada consulta mantém ID, texto PT-BR, categoria, projeto, fontes esperadas e filtros quando aplicáveis. +- As fontes do gabarito são conferidas contra o manifesto curado, incluindo origem e localizador. +- Dezenove testes Python do avaliador/adaptador HTTP, executados também no Python 3.11 em contêiner. +- Comando `npm run evaluate:search` para preparar a fixture isolada e gerar relatórios com modelo real. +- Testes Python acrescentados ao CI; não dependem de Ollama ou PostgreSQL. +- Corrigida a proveniência temporal: `started_at` agora antecede as consultas e `finished_at` marca seu término. + +Verificação adicional: typecheck do backend e do novo script aprovados; 86 testes backend aprovados +(25 condicionais ignorados na suíte geral). A primeira chamada de typecheck avulso não carregava +as opções estritas e a declaração Express do projeto; foi repetida com o contexto correto e passou. +O workflow YAML foi parseado e não há erros de whitespace. Não houve alteração de frontend nesta task. + +## Execução final + +Três rodadas sequenciais de 26 consultas autenticadas: **78 chamadas medidas**, além de uma consulta +de aquecimento. Ollama aquecido, concorrência 1, corte vetorial 0,55 e rank textual mínimo 0,05. + +| Rodada | p50 | p95 | Maior latência | Dentro de 2.000 ms | Violações de projeto | +| --- | ---: | ---: | ---: | ---: | ---: | +| 1 | 117 ms | 199 ms | 303 ms | 26/26 | 0 | +| 2 | 109 ms | 140 ms | 221 ms | 26/26 | 0 | +| 3 | 103 ms | 153 ms | 211 ms | 26/26 | 0 | + +As métricas de relevância foram iguais nas três rodadas: + +| Métrica | Resultado | +| --- | ---: | +| Consultas positivas | 20 | +| Recall@5 macro | 95% | +| Precision@5 macro | 19% | +| MRR@5 | 95% | +| Consultas negativas | 6 | +| Acurácia de ausência | 83,33% — 5/6 | + +Precision@5 usa cinco posições no denominador, incluindo posições vazias. Isso não é precisão +calculada somente sobre os resultados retornados. Os gabaritos positivos desta fixture têm uma +fonte esperada por consulta; mesmo uma execução perfeita tem Precision@5 de 20% nessa definição. + +## Problemas de relevância observados + +1. **Q008:** não recuperou a fonte `62000000-0000-4000-8000-000000000010`, sobre PDI anual por colaborador. +2. **Q022:** deveria retornar vazio no projeto API-1, mas retornou o chunk `62000000-0000-4000-8000-000000000005`, sobre gráficos de avaliação de equipes. É falso positivo no projeto correto, não vazamento entre projetos. +3. **Q006 e Q025:** recuperaram a fonte esperada, mas também retornaram `62000000-0000-4000-8000-000000000015`, fora de seus gabaritos. Esses resultados adicionais precisam de revisão de relevância. + +Os três comportamentos se repetiram em todas as rodadas. Não foram alterados limiares, corpus, +sinônimos ou gabaritos para ocultá-los. A busca por `GRF-01`/`GRF-08` (Q009/Q010) e as perguntas +por conteúdo (Q025/Q026) recuperaram as fontes esperadas; isso não dispensa a revisão do time/PO +sobre expectativas e metas de relevância. + +## Proveniência e ambiente + +- Dataset v2 SHA-256: `fbbf6c44e48663e99ef81cf9d9e57c73c74805f1d87df64dc5105eac92525870`. +- Corpus v2 SHA-256: `1da7f65bb73f78670ede7037e50f4396b481a7f25af17350c76b1053b4ed9962`. +- Snapshot de arquivos observado pelo runner, igual nas três rodadas: `6131f7f80723db941752424c314181dfdad678c7cf4bbc548a3d8011bc64ae55`. +- Working tree sinalizado como modificado; o SHA sozinho não descreve as adições locais do executor/testes. +- `bge-m3:latest`, digest `7907646426070047a77226ac3e684fbbe8410524f7b4a74d02837e43f2146bab`. +- Windows x64, Intel Core i5-12500H, 16 CPUs lógicas, aproximadamente 16 GB de RAM. +- Node 22.23.0; executor Python 3.13.15; testes também aprovados em Python 3.11 no contêiner Linux. +- Imagem IA: `sha256:93542a0102f419358bf60e64d216c2bc942003b5d3788e12874ecc692d657cc8`. + +Esta medição não comprova desempenho a frio, concorrente ou em outro hardware. Ela não é uma +comparação direta com o p95 histórico de 2.261 ms, cujo contexto não deve ser tratado como idêntico. + +## Reprodução + +Na raiz, construir a imagem de dependências e executar os testes Python: + +```bash +docker build -t sinapse-s209-ai-test ai-service +python -m unittest discover -s ai-service/tests -p "test_search*.py" -v +``` + +Em `backend`, com Python configurado no PATH ou informado por caminho absoluto: + +```bash +npm run evaluate:search -- --python python --version 2 --rounds 3 --output ../ai-service/evaluation/results/s217-9936354-final +``` + +Artefatos locais ignorados pelo Git: `environment.json`, `summary.json`, `round-N-results.json` +e `round-N-metrics.json` em `ai-service/evaluation/results/s217-9936354-final/`. +Não contêm cookie, token, senha ou URL de conexão. O corpus contém somente documentação pública curada. + +O preparo gera os vetores e marca os documentos da fixture como processados dentro do banco novo; +não mede upload/extração. Não acessa o banco de desenvolvimento. Banco e serviço IA foram removidos +ao final. O teste Python em contêiner foi repetido com o repositório e corpus montados juntos, após +uma primeira tentativa que montava somente `ai-service` e não encontrava a fixture. + +## Estado de conclusão + +O mecanismo de avaliação, a bateria versionada, os testes e a reexecução estão demonstrados. +O gate de latência passou neste ambiente; o isolamento passou. **Não há aceite de relevância**: +faltam metas acordadas de Recall/Precision/MRR, revisão dos resultados adicionais e tratamento +dos casos Q008/Q022. A avaliação quantitativa de tecnologia ainda requer fixture com tags curadas; +o adaptador envia o filtro e tem teste disso, mas as 26 consultas não demonstram qualidade por tecnologia. + +A S2-17 não autoriza, por si só, encerrar tecnicamente a Sprint 2. Revisão por outra pessoa e CI +do futuro commit permanecem necessários. As vulnerabilidades e o comportamento da tela de similares +apontados na auditoria são pendências separadas e não foram alterados nesta task. diff --git a/docs/QA-S2-17-CORRECOES-2026-10-25.md b/docs/QA-S2-17-CORRECOES-2026-10-25.md new file mode 100644 index 0000000..da53691 --- /dev/null +++ b/docs/QA-S2-17-CORRECOES-2026-10-25.md @@ -0,0 +1,128 @@ +# S2-17 — correção e reavaliação de relevância + +Esta execução foi seguida por uma [revisão complementar](QA-S2-17-REVISAO-2026-10-25.md), +com correções adicionais, cobertura ampliada e novas medições. Os números abaixo preservam +a execução anterior; não substituem os resultados mais recentes. + +25/10/2026, America/Sao_Paulo. Base Git: `9936354fa6c609836012381f7e399f218529a4f8`, +com alterações locais no mecanismo de busca e no executor. Esta execução não corresponde +à main sem modificações. Não houve commit, envio ou aceite de produto. + +## Problemas corrigidos + +- Q008: a consulta com sigla PDI não recuperava a expressão completa da fonte. A busca + compara iniciais de palavras relevantes, sem cadastrar equivalências específicas do corpus. +- Q022: uma palavra genérica compartilhada permitia um resultado de assunto diferente. + A aceitação combina similaridade e cobertura textual para candidatos sem correspondência integral. +- Q006/Q025: fontes adicionais fora do gabarito eram aceitas somente pelo corte vetorial. + O mesmo filtro combinado removeu essas fontes, preservando a fonte correta. +- Proveniência do executor: `started_at` antecede as consultas e `finished_at` marca seu término; + versões de corpus e dataset suplementar são registradas separadamente, com configuração efetiva. +- A suíte do avaliador agora é descoberta pelo unittest e tem etapa própria no workflow de CI. + +O corte vetorial permanece 0,55 e o rank textual mínimo, 0,05. Os padrões novos são +`SEARCH_MIN_HYBRID_SCORE=0.45` e `SEARCH_STRONG_VECTOR_SIMILARITY=0.6`, documentados no +`.env.example`. Correspondência textual integral, identificadores exatos e recuperação +puramente semântica sem qualquer apoio lexical continuam disponíveis. Números de 0 a 20 +também são reconhecidos por extenso. O mecanismo não contém IDs, siglas ou respostas do gabarito. + +## Bateria publicada v2 + +Backend e sessão HTTP reais, PostgreSQL/pgvector e FastAPI descartáveis, Ollama local com +`bge-m3:latest`, seis chunks da PRE-06, três projetos. Os datasets v1/v2 e as fixtures +publicadas foram preservados byte a byte. Uma consulta de aquecimento fora das métricas; +concorrência de consultas 1. Parte da execução ocorreu junto da suíte de banco no mesmo +host; os tempos representam esse ambiente local, sem ensaio de carga. + +| Rodada | p50 | p95 | Máximo | Em até 2.000 ms | Violações de projeto | +| --- | ---: | ---: | ---: | ---: | ---: | +| 1 | 104 ms | 171 ms | 182 ms | 26/26 | 0 | +| 2 | 104 ms | 142 ms | 148 ms | 26/26 | 0 | +| 3 | 120 ms | 164 ms | 198 ms | 26/26 | 0 | + +Em cada rodada: Recall@5 **100%**, MRR@5 **100%**, acurácia de ausência **100% (6/6)**, +nenhuma fonte adicional fora do gabarito. Foram 78 consultas medidas. +Precision@5 macro é **20%**: o avaliador divide por cinco posições e cada consulta +positiva tem uma única fonte esperada. Isso equivale ao máximo possível desta fixture, +não a 80% de respostas incorretas. + +Comparação com a [linha de base](QA-S2-17-2026-10-25.md): Recall/MRR passaram de 95% para +100%, ausência de 5/6 para 6/6; saídas adicionais de Q006/Q025 deixaram de ocorrer. +Identificadores GRF-01/GRF-08 e suas consultas por conteúdo continuaram corretos. + +## Diagnóstico suplementar e limitações conhecidas + +O arquivo `search-diagnostics-draft-v1.json` combina as 26 consultas preservadas com +14 paráfrases/negativas propostas para diagnóstico. Não é benchmark independente: +foi consultado durante o desenvolvimento e ainda exige curadoria do PO/time. + +| Resultado nas 40 consultas | Antes | Depois | +| --- | ---: | ---: | +| Positivas com fonte recuperada | 26/31 | 27/31 | +| Negativas corretamente vazias | 8/9 | 9/9 | +| Consultas com fontes adicionais fora do gabarito | 3 | 0 | +| Consultas com violação de projeto | 0 | 0 | +| Consultas em até 2.000 ms | 40/40 | 40/40 | + +O primeiro protótipo foi rejeitado: removia ruído, mas regredia H005/H007/H014. O ajuste +final recupera esses três casos e mantém todas as consultas anteriormente recuperadas. +A execução final suplementar teve p50 111 ms e p95 129 ms. + +Persistem quatro falhas de recuperação nessa proposta de gabarito, todas já presentes +antes do ajuste; não estão resolvidas: + +- H001: “De que maneira a chefia consegue entrar na área restrita?” — fonte 003 (API-1). +- H002: responsáveis se identificarem antes de usar os recursos — fonte 003 (API-1). +- H006: desabilitar cadastro sem apagar dados — fonte 008 (API-2). +- H009: evolução do crédito em todo o Brasil — fonte 013 (API-3). + +As similaridades medidas dessas fontes ficaram abaixo do corte vetorial e a recuperação +textual não forneceu correspondência suficiente. Uma melhoria adicional requer avaliar +recall/ruído em corpus maior e revisar essas expectativas. O corpus de seis chunks não +demonstra qualidade geral nem desempenho em escala. A fixture não possui tags de tecnologia +curadas para medir relevância por tecnologia; isso continua pendente. + +## Verificação automatizada + +- Backend: **86 passaram**, 26 condicionais ignorados na suíte geral; typecheck e build passaram. +- PostgreSQL/pgvector, executor de indexação: **99 passaram**, um teste opcional de IA real ignorado. +- Busca/acesso, execução focada: **17 passaram**; estão também dentro dos 99 acima. +- Qualidade/RAG no PostgreSQL: **30 passaram**, um teste opcional de síntese com IA real ignorado. +- Avaliador/adaptador HTTP: **19 passaram** em Python 3.13 no host e Python 3.11 em contêiner. +- Typecheck estrito do executor e validação de whitespace passaram. + +Os novos testes de banco usam outros exemplos, vetores determinísticos e não dependem do +Ollama. Cobrem sigla/expressão completa nos dois sentidos, números por extenso, ruído lexical, +paráfrases semânticas, texto sem embedding, identificador inexistente, autorização e escopo, +documento com falha e projeto arquivado. O teste de autenticação preexistente foi reexecutado. + +Durante o preparo dos testes novos, a fixture de projeto foi corrigida para incluir cliente +e nomes únicos; não eram falhas da aplicação. A primeira execução da suíte geral falhou em +`uv_os_get_passwd` no ambiente restrito; a repetição fora dessa restrição passou. Nenhum +teste com falha permaneceu na execução final. Os testes opcionais ignorados não são declarados +como executados. Não houve mudança de frontend. + +## Reprodução e evidência + +No diretório `backend`, com Docker, imagem `sinapse-s209-ai-test`, Python e Ollama disponíveis: + +```bash +npm run evaluate:search -- --version 2 --rounds 3 --output ../ai-service/evaluation/results/s217-corrections-final +npm run evaluate:search -- --version 2 --rounds 1 --dataset ../ai-service/evaluation/datasets/search-diagnostics-draft-v1.json --output ../ai-service/evaluation/results/s217-diagnostics-corrected +npm run test:integration:search +npm run test:integration:indexing +npm run test:integration:quality +``` + +Os JSONs locais ficam nas pastas acima, ignoradas pelo Git. O relatório preserva somente dados +sintéticos/curados e métricas sanitizadas, sem sessão ou senha. Os bancos e contêineres temporários +foram removidos ao final. O banco de desenvolvimento não foi alterado. + +Ambiente: Windows x64, i5-12500H, 16 CPUs lógicas, aproximadamente 16 GB RAM, +Node 22.23.0, Python host 3.13.15, Python IA 3.11. Modelo/digest: +`bge-m3:latest` / `7907646426070047a77226ac3e684fbbe8410524f7b4a74d02837e43f2146bab`. +Dataset v2 SHA-256: `fbbf6c44e48663e99ef81cf9d9e57c73c74805f1d87df64dc5105eac92525870`. +Corpus v2 SHA-256: `1da7f65bb73f78670ede7037e50f4396b481a7f25af17350c76b1053b4ed9962`. +Cada resultado registra `working_tree_dirty=true` e o hash da árvore de fontes; não é +evidência de um novo commit publicado. O aceite formal de relevância e a revisão por outra +pessoa continuam pendentes. diff --git a/docs/QA-S2-17-QUATRO-CONSULTAS-2026-10-25.md b/docs/QA-S2-17-QUATRO-CONSULTAS-2026-10-25.md new file mode 100644 index 0000000..0e2771a --- /dev/null +++ b/docs/QA-S2-17-QUATRO-CONSULTAS-2026-10-25.md @@ -0,0 +1,97 @@ +# S2-17 — correção das quatro consultas suplementares + +25/10/2026, America/Sao_Paulo. Código local sobre `9936354`, sem commit ou envio. +Este relatório sucede a revisão complementar, preservando os registros anteriores. + +## Resultado + +As quatro consultas recuperaram as fontes esperadas nas três rodadas finais: + +| Consulta | Origem recuperada | Resultado | +| --- | --- | --- | +| H001 — chefia entrando em área restrita | API-1, chunk 003: login de administradores e restrição de funcionalidades | Passou 3/3 | +| H002 — responsáveis se identificarem para usar recursos | API-1, chunk 003 | Passou 3/3 | +| H006 — desabilitar cadastro sem apagar dados | API-2, chunk 008: cadastro, consulta, edição e inativação de usuários pelo RH | Passou 3/3 | +| H009 — séries da evolução do crédito no Brasil | API-3, chunk 013: saldo nacional de crédito e séries 20539/20540/20541 | Passou 3/3 | + +Os sufixos de chunk acima pertencem ao namespace `62000000-0000-4000-8000-000000000`. +A recuperação de H006 não acrescenta afirmações à fonte: o trecho cita inativação; o RAG +continua limitado às evidências presentes no documento. + +## Ajuste implementado + +A busca ganhou uma etapa de correspondência parcial por conceitos. Ela exige dois +conceitos distintos que ajudem a identificar a fonte dentro do projeto, mais apoio semântico. +Termos repetidos ou equivalentes contam uma única vez; termos comuns a todos os chunks do +projeto não sustentam essa etapa quando existe mais de um chunk. + +O vocabulário versão 1 cobre autenticação, papéis de gestão, restrição e inativação. +Ele está em `backend/src/modules/search/search.concepts.ts`. A normalização de conjugação +c/qu e g/gu permite relacionar formas como “identifiquem” e “identificar”. Os cortes padrão +são 0,50 para correspondência parcial e 0,45 quando há equivalência do vocabulário. Eles +são configuráveis independentemente do corte de candidatos exclusivamente vetoriais. + +A elegibilidade parcial é calculada antes do limite de candidatos. Projeto, autorização, +origem processada/aprovada, arquivamento, nível e demais filtros são aplicados antes dessa etapa. +Os identificadores literais continuam exigindo a origem exata. Não houve alteração no +corpus, nos textos das fontes ou nos gabaritos anteriores para produzir esse resultado. + +## Controle de regressões e ambiguidades + +O diagnóstico v2 mantém todas as 40 consultas anteriores e acrescenta oito negativas: +notificações push, cobrança de assinaturas, receitas médicas, saque em cartão, prontuários, +faturamento em séries, limpeza de arquivos e entrada de veículos. + +O primeiro protótipo recuperou as quatro fontes, mas confundiu entrada de veículos com +login. Esse resultado foi rejeitado. Termos genéricos de entrada foram retirados do conceito +de autenticação; a correspondência de chefia passou a usar papéis de gestão e restrição. +Foram acrescentados testes para entrada física, repetição de sinônimos, um único conceito, +termos comuns ao projeto e respeito aos cortes configurados. + +Os testes usam também exemplos de operadores, cadastro funcional e consumo hídrico, com +vetores determinísticos. Não dependem dos IDs ou da redação das quatro consultas. + +## Execução final com modelo real + +Dataset `search-diagnostics-draft-v2.json`, 48 consultas por rodada: as 26 publicadas, +14 do diagnóstico anterior e oito negativas adicionais. PostgreSQL/pgvector e FastAPI +descartáveis, API autenticada e embeddings `bge-m3` reais. Aquecimento excluído das métricas, +concorrência de consultas 1, demais suítes encerradas durante a medição final. + +| Rodada | Consultas | p50 | p95 | Máximo | Abaixo de 2.000 ms | Violações de projeto | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| 1 | 48 | 115 ms | 157 ms | 255 ms | 48/48 | 0 | +| 2 | 48 | 117 ms | 164 ms | 172 ms | 48/48 | 0 | +| 3 | 48 | 103 ms | 138 ms | 156 ms | 48/48 | 0 | + +Em cada rodada: **31/31 positivas** recuperadas, **17/17 negativas** vazias, Recall@5 e +MRR@5 de 100%, nenhuma fonte adicional fora do gabarito. Precision@5 é 20%, o máximo +dessa fixture com uma fonte relevante esperada por consulta e cinco posições no denominador. +Foram **144 consultas medidas** e nenhuma ultrapassou o limite. + +Arquivos locais: `ai-service/evaluation/results/s217-four-cases-validated-20261025/`. +Cada execução registra modelo/digest, ambiente, configuração, versão do vocabulário e +hash da árvore modificada. A revisão não corresponde à main sem alterações. + +## Testes automatizados + +- Backend: 86 passaram; 26 condicionais ignorados na suíte geral. +- PostgreSQL/pgvector, indexação e regressões relacionadas: 108 passaram; um teste opcional de IA real ignorado. +- Qualidade/RAG no PostgreSQL: 30 passaram; um teste opcional de síntese com IA real ignorado. +- Avaliador/cliente HTTP Python: 33 passaram no host 3.13 e no contêiner 3.11. +- Typecheck, build e checagem estrita do executor: passaram. +- Teste de integridade: as 26 consultas publicadas e as 40 do diagnóstico anterior permanecem iguais. +- Datasets publicados e fixtures PRE-06: preservados byte a byte. + +Os bancos e contêineres temporários foram removidos. O banco de desenvolvimento foi preservado. + +## Limites da conclusão + +As quatro falhas não se repetiram nas baterias executadas. A avaliação suplementar continua +sendo diagnóstico de desenvolvimento, com gabarito provisório, e não uma avaliação independente +aprovada pelo PO/time. O corpus contém somente seis chunks e não comprova qualidade em escala. +A curadoria e as metas de relevância ainda dependem da revisão do time. + +O pico histórico de 3.352 ms permanece registrado na revisão anterior; não ocorreu nas +144 chamadas finais. Ele não é declarado corrigido ou apagado por esta mudança de recuperação. +O aceite formal e a revisão por outra pessoa continuam pendentes. Não houve publicação no GitHub. diff --git a/docs/QA-S2-17-REVISAO-2026-10-25.md b/docs/QA-S2-17-REVISAO-2026-10-25.md new file mode 100644 index 0000000..48f08b3 --- /dev/null +++ b/docs/QA-S2-17-REVISAO-2026-10-25.md @@ -0,0 +1,110 @@ +# S2-17 — revisão complementar de toda a entrega local + +As quatro falhas suplementares descritas nesta revisão foram corrigidas posteriormente. +Veja a [execução mais recente](QA-S2-17-QUATRO-CONSULTAS-2026-10-25.md). Este documento +preserva as falhas e as medições do momento em que foi produzido. + +25/10/2026, America/Sao_Paulo. Base Git `9936354`, com alterações locais não publicadas. +Foram revisados recuperação SQL, avaliador, cliente HTTP, preparação/limpeza dos recursos, +datasets publicados e suplementares, configuração e documentação. Frontend não foi alterado. + +## Achados corrigidos + +| Nº | Problema e efeito | Correção e evidência | +| --- | --- | --- | +| 1 | A filtragem de relevância acontecia depois do limite de candidatos. Fontes válidas desapareciam quando candidatos descartados ocupavam as vagas. | Aplicar elegibilidade antes do ranking/limite. Teste PostgreSQL com limites 1 e 5, ruído com similaridade superior e uma fonte válida abaixo dele; falhava antes e passa depois. | +| 2 | O cliente HTTP seguia redirecionamentos com o cookie de sessão. | Recusar redirecionamentos. Teste usa dois servidores loopback e uma credencial fictícia: o destino não recebe sessão em respostas 301/302/303/307/308. O teste com 302 reproduziu o comportamento anterior. | +| 3 | Versões booleanas/decimais e descritores malformados não eram sempre recusados de forma controlada; IDs/listas/categorias podiam causar TypeError. Limite/timeout também admitiam tipos inválidos. | Validar versões inteiras, categorias, IDs, corpus, níveis e parâmetros antes de executar. Casos inválidos retornam erro de validação, sem requisições. | +| 4 | `analise_repositorio` era um nível válido da busca, mas era rejeitado pelo avaliador. | Incluir o nível no contrato e testar corpus/consultas desse tipo. | +| 5 | O adaptador descartava `entity_type` e o avaliador não conferia a coerência do nível retornado com manifesto/filtro. | Preservar tipo, conferir sua validade e recusar contradições. Resultados antigos sem esse campo usam o manifesto quando possível. | +| 6 | A proveniência podia associar um dataset em memória ao hash de outro arquivo; o avaliador ignorava hashes divergentes e mudanças durante a medição não eram identificadas. | Conferir arquivo/dataset antes da execução, distinguir hash de arquivo e JSON canônico, verificar o hash na avaliação e comparar snapshots antes/depois. Testes cobrem arquivo divergente, hash incorreto, dataset customizado e mudança de fontes durante execução. | +| 7 | Repetir o diretório de saída sobrescrevia evidências da execução anterior. | Diretório padrão único por execução e criação exclusiva; saída existente é recusada. Testes preservam um relatório sentinela, inclusive pela CLI real. | +| 8 | Uma exceção na limpeza podia impedir a liberação dos recursos seguintes. | Tentar todas as etapas de encerramento; falhas são contabilizadas e geram saída de erro. Teste simula falhas no servidor e IA e comprova tentativa de fechar pool e banco. | +| 9 | O avaliador aceitava exatamente 2.000 ms, mas RNF-06 exige menos de dois segundos; o README atribuía o requisito ao PBI incorreto. | Usar comparação estrita e corrigir a referência para RNF-06 do PRD. Teste de fronteira recusa 2.000 ms. | + +Os achados estão agrupados por causa; subcasos não são contados como defeitos independentes. +Os nove pontos acima estão corrigidos localmente. Não foi feito commit ou envio. + +## Testes e regressões + +| Verificação | Resultado final | +| --- | --- | +| Backend geral | 86 passaram; 26 condicionais ignorados | +| Backend typecheck/build | Passaram | +| Executor TypeScript, checagem estrita | Passou | +| PostgreSQL/pgvector: indexação e regressões relacionadas | 100 passaram; um caso opcional de IA real ignorado | +| Busca/acesso focados | 18 passaram; incluídos nos 100 acima | +| PostgreSQL: qualidade/RAG | 30 passaram; um caso opcional de síntese com IA real ignorado | +| Python: avaliador e cliente HTTP | 32 passaram no host Python 3.13 e no contêiner Python 3.11 | +| Utilitários de saída/limpeza | Dois passaram | +| Whitespace e integridade das fixtures publicadas | Passaram | + +Antes das correções, os testes novos reproduziram falhas do SQL e do avaliador/cliente HTTP. +Não são testes que apenas repetem o mecanismo corrigido: usam exemplos diferentes do corpus, +tipos inválidos, servidores HTTP reais, falhas de encerramento e preservação de arquivos. +Os testes novos de utilitários e Python foram adicionados ao workflow de CI; isso é configuração +local, não uma afirmação de que o GitHub já executou este código. + +## Busca real: bateria publicada + +Resultado final em `ai-service/evaluation/results/s217-review-validated-20261025`: +API autenticada, FastAPI, PostgreSQL/pgvector descartável e embeddings `bge-m3` reais. +Uma consulta de aquecimento fora da medição; concorrência de consultas 1. Outras suítes +locais executaram no mesmo host durante essa rodada; não é ensaio de carga controlado. + +| Rodada | Consultas | p50 | p95 | Máximo | Abaixo de 2.000 ms | Violações de projeto | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| 1 | 26 | 113 ms | 175 ms | 185 ms | 26/26 | 0 | +| 2 | 26 | 148 ms | 280 ms | 362 ms | 26/26 | 0 | +| 3 | 26 | 240 ms | 542 ms | 790 ms | 26/26 | 0 | + +Nas três rodadas: Recall@5 e MRR@5 de 100%, ausência correta 6/6 e nenhuma fonte adicional +fora do gabarito. Precision@5 de 20% é o máximo dessa fixture com uma fonte esperada e +cinco posições no denominador. Os hashes de fontes ficaram estáveis durante as medições. +Foram preservados byte a byte os datasets v1/v2 e os corpora publicados PRE-06. + +## Diagnóstico suplementar: falhas que permanecem + +O gabarito suplementar continua provisório, foi usado no desenvolvimento e não representa +avaliação independente ou aceite do PO/time. Permanecem quatro consultas sem a fonte esperada: + +- H001: chefia entrando na área restrita — fonte 003, API-1. +- H002: responsáveis se identificarem antes de usar recursos — fonte 003, API-1. +- H006: desabilitar cadastro sem apagar dados — fonte 008, API-2. +- H009: séries da evolução do crédito no Brasil — fonte 013, API-3. + +Todas já estavam presentes na versão anterior ao ajuste. A revisão não as marca como corrigidas. +São 27/31 positivas recuperadas e 9/9 negativas corretamente vazias; nenhuma fonte adicional +fora do gabarito e nenhuma violação de projeto. + +Uma execução suplementar registrou **H007 com 3.352 ms**: pasta +`s217-review-diagnostics-20261025`, código de saída 1 e 39/40 consultas dentro do limite. +Ela ocorreu enquanto havia outros testes no host; a causa específica do pico não foi confirmada. +O resultado foi preservado e não é considerado aprovado em latência. + +Depois de encerrar as demais suítes, a repetição suplementar de três rodadas ficou em +`s217-review-diagnostics-alone-20261025`: + +| Rodada | Consultas | p50 | p95 | Máximo | Abaixo de 2.000 ms | +| --- | ---: | ---: | ---: | ---: | ---: | +| 1 | 40 | 120 ms | 153 ms | 169 ms | 40/40 | +| 2 | 40 | 111 ms | 151 ms | 226 ms | 40/40 | +| 3 | 40 | 117 ms | 153 ms | 161 ms | 40/40 | + +O pico não se repetiu nessas 120 chamadas. Isso não prova desempenho sob concorrência, +nem elimina o registro anterior. As quatro falhas de recuperação se repetiram; não houve +regressões novas nessa bateria suplementar. + +## Limites e conclusão da revisão + +A implementação e os defeitos de código enumerados foram verificados e corrigidos localmente. +Não é correto declarar ausência total de falhas: há quatro casos suplementares de recuperação +e um pico de latência ainda sem causa confirmada. O corpus contém somente seis chunks; +não comprova qualidade em escala e não possui tags de tecnologia curadas para avaliação +quantitativa desse filtro. O aceite de relevância pelo PO/time, a curadoria suplementar +e a revisão por outra pessoa permanecem necessários. + +Nenhum dado do banco de desenvolvimento foi alterado. Os contêineres e bancos temporários +foram removidos. Não houve alteração de funcionalidades de frontend, publicação de PR ou merge. +Ambiente e hashes do corpus/modelo permanecem descritos no relatório anterior; cada execução +nova contém seus metadados próprios, com árvore local modificada explicitamente identificada.